VoiceInput/Sources/UI/MemoryWindow.swift
JOJO 9f3ea09b06 feat(agent): Better Siri 改造 — 个人助手 + 记忆系统 + 统一弹窗 + 多项优化
=== 记忆系统 ===
- 新增 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 更新
2026-04-30 18:16:58 +08:00

530 lines
22 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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() }
}