=== 记忆系统 === - 新增 remember(记录)和 recall(搜索)工具,持久化到 ~/Library/Application Support/VoiceInput/memory/memory.json - 新增 set_identity 工具,独立管理助手名字/用户称呼/语气 - prompt 重写为个人助手身份,注入 macOS 能力 + 记忆规则 - prompt 中积极调用记忆工具,按需搜索、自然融入回答 - 禁止相对时间,待办必须绝对日期 === 对话持久化 === - 集成 conversation_store,done 前自动写入对话文件 - token 记录:prompt/completion 累加,total 覆盖为最新上下文长度 - 启动时加载最新对话继续(使用 eagent /resume 逻辑) - 去重:末尾连续 assistant 消息自动合并 - 修复消息加载顺序 bug(旧对话覆盖用户新消息) === 新建对话 === - 手动/自动两种模式(设置窗口可切换) - 自动模式:total > 阈值时隐藏按钮,下次输入自动新建 - 自动阈值在设置窗口可配置(默认 40000) === 弹窗 UI 统一 === - 语音输入按 Fn 即显示刘海弹窗(和打字输入统一) - 语音识别内容自动填入并发送(可关闭自动发送) - 工作期间(thinking/running)隐藏输入框,idle/done 时显示 - 工具显示三种模式:全部显示/最后隐藏/一直不显示 === 设置窗口增强 === - 助手名字 / 用户称呼 / 语气可配置 - 工具显示模式 / 新建对话模式 / 自动阈值 - 语音输入后自动发送开关(默认开) - 记忆管理窗口可视化编辑(Dock 可访问) - 窗口高度 840→860 === 菜单增强 === - Agent LLM 二级菜单切换语音/打字输入 - 助手设置和记忆管理入口(Dock 可访问) === 修复 === - Fn 按下时停止正在进行的朗读 - 弹窗已存在时不再覆盖 isTextInputMode - voiceInputText 加 @Published 以触发 UI 更新
530 lines
22 KiB
Swift
530 lines
22 KiB
Swift
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() }
|
||
}
|