import Cocoa // MARK: - 记忆条目(与 memory.json 对应) struct MemoryEntry: Codable, Identifiable { var id: String var type: String var content: String var keywords: [String] var createdAt: String var updatedAt: String enum CodingKeys: String, CodingKey { case id, type, content, keywords case createdAt = "created_at" case updatedAt = "updated_at" } var typeDisplayName: String { switch type { case "preference": return "偏好" case "habit": return "习惯" case "event": return "事件" case "todo": return "待办" case "identity": return "身份" case "relationship": return "关系" default: return "其他" } } var keywordsString: String { keywords.joined(separator: ", ") } var dateDisplay: String { String((updatedAt.isEmpty ? createdAt : updatedAt).prefix(10)) } } // MARK: - 记忆管理窗口 final class MemoryWindow: NSWindow { private let searchField: NSTextField private let typeFilterPopup: NSPopUpButton private let tableView: NSTableView private let addButton: NSButton private let editButton: NSButton private let deleteButton: NSButton private let refreshButton: NSButton private let statusLabel: NSTextField private var allEntries: [MemoryEntry] = [] private var filteredEntries: [MemoryEntry] = [] private let memoryPath: String = { let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! return appSupport.appendingPathComponent("VoiceInput/memory/memory.json").path }() init() { searchField = NSTextField(frame: .zero) typeFilterPopup = NSPopUpButton(frame: .zero, pullsDown: false) tableView = NSTableView(frame: .zero) addButton = NSButton(title: "+", target: nil, action: nil) editButton = NSButton(title: "编辑", target: nil, action: nil) deleteButton = NSButton(title: "删除", target: nil, action: nil) refreshButton = NSButton(title: "刷新", target: nil, action: nil) statusLabel = NSTextField(labelWithString: "") let rect = NSRect(x: 0, y: 0, width: 680, height: 480) super.init( contentRect: rect, styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false ) configureWindow() buildUI() loadMemories() } private func configureWindow() { title = "记忆管理" isReleasedWhenClosed = false center() minSize = NSSize(width: 520, height: 360) } override func makeKeyAndOrderFront(_ sender: Any?) { loadMemories() super.makeKeyAndOrderFront(sender) } // MARK: - 快捷键 override func performKeyEquivalent(with event: NSEvent) -> Bool { guard event.modifierFlags.contains(.command) else { return super.performKeyEquivalent(with: event) } let selector: Selector? switch event.charactersIgnoringModifiers?.lowercased() { case "a": selector = #selector(NSText.selectAll(_:)) case "c": selector = Selector(("copy:")) case "v": selector = Selector(("paste:")) case "x": selector = Selector(("cut:")) default: selector = nil } guard let sel = selector else { return super.performKeyEquivalent(with: event) } if NSApp.sendAction(sel, to: nil, from: self) { return true } return super.performKeyEquivalent(with: event) } private func makeLabel(_ text: String) -> NSTextField { let label = NSTextField(labelWithString: text) label.font = .systemFont(ofSize: 11, weight: .semibold) label.textColor = .secondaryLabelColor label.alignment = .right label.translatesAutoresizingMaskIntoConstraints = false return label } private func buildUI() { guard let content = contentView else { return } // 搜索区域 let searchLabel = makeLabel("搜索:") searchField.placeholderString = "输入关键词搜索记忆..." searchField.font = .systemFont(ofSize: 13) searchField.bezelStyle = .roundedBezel searchField.translatesAutoresizingMaskIntoConstraints = false searchField.target = self searchField.action = #selector(searchChanged) let filterLabel = makeLabel("类型:") typeFilterPopup.translatesAutoresizingMaskIntoConstraints = false typeFilterPopup.addItems(withTitles: ["全部", "偏好", "习惯", "事件", "待办", "身份", "关系", "其他"]) typeFilterPopup.target = self typeFilterPopup.action = #selector(filterChanged) // 表格 let scrollView = NSScrollView(frame: .zero) scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.hasVerticalScroller = true scrollView.borderType = .bezelBorder tableView.translatesAutoresizingMaskIntoConstraints = false tableView.headerView = nil tableView.selectionHighlightStyle = .regular tableView.dataSource = self tableView.delegate = self tableView.rowHeight = 28 tableView.intercellSpacing = NSSize(width: 4, height: 2) tableView.usesAlternatingRowBackgroundColors = true let colType = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("type")) colType.title = "类型"; colType.width = 50; colType.minWidth = 40 let colContent = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("content")) colContent.title = "内容"; colContent.width = 280; colContent.minWidth = 150 let colKeywords = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("keywords")) colKeywords.title = "关键词"; colKeywords.width = 140; colKeywords.minWidth = 80 let colDate = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("date")) colDate.title = "更新时间"; colDate.width = 100; colDate.minWidth = 80 tableView.addTableColumn(colType) tableView.addTableColumn(colContent) tableView.addTableColumn(colKeywords) tableView.addTableColumn(colDate) scrollView.documentView = tableView // 按钮 addButton.bezelStyle = .rounded addButton.font = .systemFont(ofSize: 13, weight: .bold) addButton.translatesAutoresizingMaskIntoConstraints = false addButton.target = self addButton.action = #selector(addMemory) editButton.bezelStyle = .rounded editButton.translatesAutoresizingMaskIntoConstraints = false editButton.target = self editButton.action = #selector(editMemory) deleteButton.bezelStyle = .rounded deleteButton.translatesAutoresizingMaskIntoConstraints = false deleteButton.target = self deleteButton.action = #selector(deleteMemory) refreshButton.bezelStyle = .rounded refreshButton.translatesAutoresizingMaskIntoConstraints = false refreshButton.target = self refreshButton.action = #selector(refreshMemories) let buttonStack = NSStackView(views: [addButton, editButton, deleteButton, refreshButton]) buttonStack.translatesAutoresizingMaskIntoConstraints = false buttonStack.orientation = .horizontal buttonStack.spacing = 8 statusLabel.font = .systemFont(ofSize: 11) statusLabel.textColor = .secondaryLabelColor statusLabel.alignment = .center statusLabel.translatesAutoresizingMaskIntoConstraints = false content.addSubview(searchLabel) content.addSubview(searchField) content.addSubview(filterLabel) content.addSubview(typeFilterPopup) content.addSubview(scrollView) content.addSubview(buttonStack) content.addSubview(statusLabel) NSLayoutConstraint.activate([ searchLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), searchLabel.topAnchor.constraint(equalTo: content.topAnchor, constant: 20), searchLabel.widthAnchor.constraint(equalToConstant: 40), searchField.leadingAnchor.constraint(equalTo: searchLabel.trailingAnchor, constant: 6), searchField.centerYAnchor.constraint(equalTo: searchLabel.centerYAnchor), searchField.widthAnchor.constraint(equalToConstant: 240), filterLabel.leadingAnchor.constraint(equalTo: searchField.trailingAnchor, constant: 16), filterLabel.centerYAnchor.constraint(equalTo: searchLabel.centerYAnchor), filterLabel.widthAnchor.constraint(equalToConstant: 40), typeFilterPopup.leadingAnchor.constraint(equalTo: filterLabel.trailingAnchor, constant: 6), typeFilterPopup.centerYAnchor.constraint(equalTo: searchLabel.centerYAnchor), typeFilterPopup.widthAnchor.constraint(equalToConstant: 100), scrollView.topAnchor.constraint(equalTo: searchField.bottomAnchor, constant: 14), scrollView.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), scrollView.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20), scrollView.bottomAnchor.constraint(equalTo: buttonStack.topAnchor, constant: -10), buttonStack.centerXAnchor.constraint(equalTo: content.centerXAnchor), buttonStack.bottomAnchor.constraint(equalTo: statusLabel.topAnchor, constant: -8), statusLabel.centerXAnchor.constraint(equalTo: content.centerXAnchor), statusLabel.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -16), statusLabel.widthAnchor.constraint(equalToConstant: 640) ]) } // MARK: - 数据加载/保存 private func loadMemories() { guard let data = try? Data(contentsOf: URL(fileURLWithPath: memoryPath)) else { allEntries = [] applyFilter() return } let decoder = JSONDecoder() if let entries = try? decoder.decode([MemoryEntry].self, from: data) { allEntries = entries.sorted { $0.updatedAt > $1.updatedAt } } else { allEntries = [] } applyFilter() } private func saveMemories() { let encoder = JSONEncoder() encoder.outputFormatting = .prettyPrinted guard let data = try? encoder.encode(allEntries) else { return } let dir = (memoryPath as NSString).deletingLastPathComponent if !FileManager.default.fileExists(atPath: dir) { try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) } try? data.write(to: URL(fileURLWithPath: memoryPath), options: .atomic) } // MARK: - 过滤 private func applyFilter() { let query = searchField.stringValue.trimmingCharacters(in: .whitespaces).lowercased() let typeIndex = typeFilterPopup.indexOfSelectedItem let typeMap = ["preference", "habit", "event", "todo", "identity", "relationship", "other"] filteredEntries = allEntries.filter { entry in if typeIndex > 0 && typeIndex <= typeMap.count { if entry.type != typeMap[typeIndex - 1] { return false } } if !query.isEmpty { let matchContent = entry.content.lowercased().contains(query) let matchKeywords = entry.keywords.contains { $0.lowercased().contains(query) } if !matchContent && !matchKeywords { return false } } return true } tableView.reloadData() statusLabel.stringValue = "共 \(filteredEntries.count) 条记忆" } @objc private func searchChanged() { applyFilter() } @objc private func filterChanged() { applyFilter() } // MARK: - 操作 @objc private func addMemory() { showEditor(for: nil) } @objc private func editMemory() { let row = tableView.selectedRow guard row >= 0, row < filteredEntries.count else { statusLabel.stringValue = "请先选择一条记忆" return } showEditor(for: filteredEntries[row]) } @objc private func deleteMemory() { let row = tableView.selectedRow guard row >= 0, row < filteredEntries.count else { statusLabel.stringValue = "请先选择一条记忆" return } let entry = filteredEntries[row] let alert = NSAlert() alert.messageText = "删除记忆" alert.informativeText = "确定要删除「\(String(entry.content.prefix(50)))」吗?" alert.alertStyle = .warning alert.addButton(withTitle: "删除") alert.addButton(withTitle: "取消") if alert.runModal() == .alertFirstButtonReturn { allEntries.removeAll { $0.id == entry.id } saveMemories() applyFilter() statusLabel.stringValue = "已删除" } } @objc private func refreshMemories() { loadMemories() statusLabel.stringValue = "已刷新,共 \(filteredEntries.count) 条记忆" statusLabel.textColor = .systemGreen DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in self?.statusLabel.stringValue = "共 \(self?.filteredEntries.count ?? 0) 条记忆" self?.statusLabel.textColor = .secondaryLabelColor } } // MARK: - 编辑器 private func showEditor(for entry: MemoryEntry?) { let isEdit = entry != nil let panel = NSPanel( contentRect: NSRect(x: 0, y: 0, width: 420, height: 300), styleMask: [.titled, .closable], backing: .buffered, defer: false ) panel.title = isEdit ? "编辑记忆" : "添加记忆" panel.isReleasedWhenClosed = false panel.center() guard let contentView = panel.contentView else { return } let typeLabel = makeLabel("类型:") let typePopup = NSPopUpButton(frame: .zero, pullsDown: false) typePopup.translatesAutoresizingMaskIntoConstraints = false typePopup.addItems(withTitles: ["偏好", "习惯", "事件", "待办", "身份", "关系", "其他"]) let typeMap = ["preference", "habit", "event", "todo", "identity", "relationship", "other"] if let e = entry, let idx = typeMap.firstIndex(of: e.type) { typePopup.selectItem(at: idx) } let contentLabel = makeLabel("内容:") let contentField = NSTextField(frame: .zero) contentField.placeholderString = "记忆内容..." contentField.stringValue = entry?.content ?? "" contentField.font = .systemFont(ofSize: 13) contentField.bezelStyle = .roundedBezel contentField.translatesAutoresizingMaskIntoConstraints = false let keywordsLabel = makeLabel("关键词:") let keywordsField = NSTextField(frame: .zero) keywordsField.placeholderString = "用逗号分隔,如:咖啡, 美式" keywordsField.stringValue = entry?.keywords.joined(separator: ", ") ?? "" keywordsField.font = .systemFont(ofSize: 13) keywordsField.bezelStyle = .roundedBezel keywordsField.translatesAutoresizingMaskIntoConstraints = false let saveBtn = NSButton(title: isEdit ? "保存" : "添加", target: nil, action: nil) saveBtn.bezelStyle = .rounded saveBtn.translatesAutoresizingMaskIntoConstraints = false saveBtn.keyEquivalent = "\r" let cancelBtn = NSButton(title: "取消", target: nil, action: nil) cancelBtn.bezelStyle = .rounded cancelBtn.translatesAutoresizingMaskIntoConstraints = false let btnStack = NSStackView(views: [cancelBtn, saveBtn]) btnStack.translatesAutoresizingMaskIntoConstraints = false btnStack.orientation = .horizontal btnStack.spacing = 12 btnStack.distribution = .fillEqually contentView.addSubview(typeLabel) contentView.addSubview(typePopup) contentView.addSubview(contentLabel) contentView.addSubview(contentField) contentView.addSubview(keywordsLabel) contentView.addSubview(keywordsField) contentView.addSubview(btnStack) NSLayoutConstraint.activate([ typeLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), typeLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 24), typeLabel.widthAnchor.constraint(equalToConstant: 60), typePopup.leadingAnchor.constraint(equalTo: typeLabel.trailingAnchor, constant: 8), typePopup.centerYAnchor.constraint(equalTo: typeLabel.centerYAnchor), typePopup.widthAnchor.constraint(equalToConstant: 160), contentLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), contentLabel.topAnchor.constraint(equalTo: typeLabel.bottomAnchor, constant: 18), contentLabel.widthAnchor.constraint(equalToConstant: 60), contentField.leadingAnchor.constraint(equalTo: contentLabel.trailingAnchor, constant: 8), contentField.centerYAnchor.constraint(equalTo: contentLabel.centerYAnchor), contentField.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), keywordsLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), keywordsLabel.topAnchor.constraint(equalTo: contentLabel.bottomAnchor, constant: 18), keywordsLabel.widthAnchor.constraint(equalToConstant: 60), keywordsField.leadingAnchor.constraint(equalTo: keywordsLabel.trailingAnchor, constant: 8), keywordsField.centerYAnchor.constraint(equalTo: keywordsLabel.centerYAnchor), keywordsField.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), btnStack.topAnchor.constraint(equalTo: keywordsLabel.bottomAnchor, constant: 22), btnStack.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), btnStack.widthAnchor.constraint(equalToConstant: 200) ]) // 保存/取消动作 let saveAction = { [weak self] in guard let self = self else { return } let typeIdx = typePopup.indexOfSelectedItem guard typeIdx >= 0, typeIdx < typeMap.count else { return } let selectedType = typeMap[typeIdx] let newContent = contentField.stringValue.trimmingCharacters(in: .whitespaces) guard !newContent.isEmpty else { return } let keywords = keywordsField.stringValue .components(separatedBy: ",") .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } let now = ISO8601DateFormatter().string(from: Date()) if let e = entry { if let idx = self.allEntries.firstIndex(where: { $0.id == e.id }) { self.allEntries[idx].type = selectedType self.allEntries[idx].content = newContent self.allEntries[idx].keywords = keywords self.allEntries[idx].updatedAt = now } } else { let newId = UUID().uuidString.prefix(8).lowercased() let newEntry = MemoryEntry( id: String(newId), type: selectedType, content: newContent, keywords: keywords, createdAt: now, updatedAt: now ) self.allEntries.append(newEntry) } self.saveMemories() self.applyFilter() self.statusLabel.stringValue = isEdit ? "已更新" : "已添加" self.statusLabel.textColor = .systemGreen panel.close() } // 使用 helper 类绑定 action let helper = MemoryEditorHelper(panel: panel, onSave: saveAction) saveBtn.target = helper saveBtn.action = #selector(MemoryEditorHelper.saveAction) cancelBtn.target = helper cancelBtn.action = #selector(MemoryEditorHelper.cancelAction) objc_setAssociatedObject(saveBtn, "helper", helper, .OBJC_ASSOCIATION_RETAIN) objc_setAssociatedObject(cancelBtn, "helper", helper, .OBJC_ASSOCIATION_RETAIN) panel.makeKeyAndOrderFront(nil) } } // MARK: - NSTableView extension MemoryWindow: NSTableViewDataSource { func numberOfRows(in tableView: NSTableView) -> Int { filteredEntries.count } } extension MemoryWindow: NSTableViewDelegate { func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { guard row < filteredEntries.count, let column = tableColumn else { return nil } let entry = filteredEntries[row] let textField: NSTextField if let reused = tableView.makeView(withIdentifier: column.identifier, owner: nil) as? NSTextField { textField = reused } else { textField = NSTextField(labelWithString: "") textField.identifier = column.identifier textField.font = .systemFont(ofSize: 12) textField.lineBreakMode = .byTruncatingTail textField.maximumNumberOfLines = 1 } switch column.identifier.rawValue { case "type": textField.stringValue = entry.typeDisplayName case "content": textField.stringValue = entry.content case "keywords": textField.stringValue = entry.keywordsString case "date": textField.stringValue = entry.dateDisplay default: textField.stringValue = "" } return textField } } // MARK: - 编辑器 Helper private class MemoryEditorHelper: NSObject { weak var panel: NSPanel? let onSave: () -> Void init(panel: NSPanel, onSave: @escaping () -> Void) { self.panel = panel self.onSave = onSave } @objc func saveAction() { onSave() } @objc func cancelAction() { panel?.close() } }