VoiceInput/Sources/Core/AgentBridge.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

255 lines
9.4 KiB
Swift
Raw 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 Foundation
// MARK: - Agent
enum AgentEvent {
case status(mode: String)
case toolStart(tool: String, display: String)
case toolEnd(tool: String, resultLines: [String])
case assistantChunk(content: String)
case done(content: String, elapsedMs: Int, totalTokens: Int)
case error(message: String)
}
// MARK: - AgentBridge
final class AgentBridge {
private var process: Process?
private var stdin: Pipe?
private let agentDir: String
private let logPath: String
private var eventSeq = 0
var onEvent: ((AgentEvent) -> Void)?
var onExit: ((Int32) -> Void)?
init(agentDir: String) {
self.agentDir = agentDir
// app bundle
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let logDir = appSupport.appendingPathComponent("VoiceInput").path
if !FileManager.default.fileExists(atPath: logDir) {
try? FileManager.default.createDirectory(atPath: logDir, withIntermediateDirectories: true)
}
self.logPath = logDir + "/agent_debug.log"
//
try? "".write(toFile: logPath, atomically: true, encoding: .utf8)
log("======== AgentBridge 日志开始 ========")
log("agentDir: \(agentDir)")
}
private func log(_ msg: String) {
let ts = ISO8601DateFormatter().string(from: Date())
let line = "[\(ts)] \(msg)\n"
if let handle = FileHandle(forWritingAtPath: logPath) {
handle.seekToEndOfFile()
handle.write(line.data(using: .utf8)!)
handle.closeFile()
} else {
try? line.write(toFile: logPath, atomically: true, encoding: .utf8)
}
}
/// Node.js
func start() -> Bool {
guard let nodePath = findNode() else {
print("[AgentBridge] ❌ Node.js not found")
return false
}
let bridgePath = "\(agentDir)/src/bridge.js"
guard FileManager.default.fileExists(atPath: bridgePath) else {
print("[AgentBridge] ❌ bridge.js not found at \(bridgePath)")
return false
}
let process = Process()
process.executableURL = URL(fileURLWithPath: nodePath)
process.arguments = [bridgePath, "--config", Config.shared.userAgentModelsPath]
process.currentDirectoryURL = URL(fileURLWithPath: agentDir)
// stdin bridge
let stdinPipe = Pipe()
process.standardInput = stdinPipe
self.stdin = stdinPipe
// stdout JSON
let stdoutPipe = Pipe()
process.standardOutput = stdoutPipe
// stderr
let stderrPipe = Pipe()
process.standardError = stderrPipe
// stdout
stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard !data.isEmpty, let self = self else { return }
if let str = String(data: data, encoding: .utf8) {
let lines = str.components(separatedBy: "\n").filter { !$0.isEmpty }
for line in lines {
self.parseLine(line)
}
}
}
// stderr
stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let data = handle.availableData
if !data.isEmpty, let str = String(data: data, encoding: .utf8) {
if !str.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let trimmed = str.trimmingCharacters(in: .newlines)
print("[Agent] \(trimmed)")
self?.log("stderr: \(trimmed)")
}
}
}
process.terminationHandler = { [weak self] proc in
let code = proc.terminationStatus
let reason = proc.terminationReason
self?.log("进程退出: status=\(code), reason=\(reason.rawValue)")
self?.onExit?(code)
}
do {
try process.run()
self.process = process
print("[AgentBridge] ✅ Started (pid \(process.processIdentifier))")
return true
} catch {
print("[AgentBridge] ❌ Failed to start: \(error)")
return false
}
}
///
func send(message: String, workspace: String, allowMode: String = "full_access", modelKey: String? = nil, newConversation: Bool = false) {
sendInternal(message: message, workspace: workspace, allowMode: allowMode, modelKey: modelKey, newConversation: newConversation)
}
///
func sendContinue(message: String) {
sendInternal(message: message, workspace: "", allowMode: "", modelKey: nil)
}
private func sendInternal(message: String, workspace: String, allowMode: String, modelKey: String?, newConversation: Bool = false) {
var payload: [String: Any] = [
"action": "chat",
"message": message,
]
if !workspace.isEmpty { payload["workspace"] = workspace }
if !allowMode.isEmpty { payload["allowMode"] = allowMode }
if let mk = modelKey, !mk.isEmpty { payload["modelKey"] = mk }
payload["thinkingMode"] = Config.shared.agentThinkingMode
if newConversation { payload["new_conversation"] = true }
guard let data = try? JSONSerialization.data(withJSONObject: payload),
let json = String(data: data, encoding: .utf8) else {
log("send() ❌ JSON 编码失败")
print("[AgentBridge] ❌ Failed to encode message")
return
}
log("send() JSON: \(json.prefix(200))")
if let handle = stdin?.fileHandleForWriting {
handle.write((json + "\n").data(using: .utf8)!)
log("send() ✅ 已写入 stdin")
} else {
log("send() ❌ stdin handle 为 nil")
}
}
///
func stop() {
log("stop() 终止进程")
process?.terminate()
process = nil
}
// MARK: - Private
private func findNode() -> String? {
// 0bundle Node.js
let bundledNode = agentDir + "/bin/node"
if FileManager.default.fileExists(atPath: bundledNode) {
return bundledNode
}
// 退
let paths = [
"/opt/homebrew/bin/node",
"/usr/local/bin/node",
"/usr/bin/node"
]
for p in paths where FileManager.default.fileExists(atPath: p) {
return p
}
// which
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/bin/env")
task.arguments = ["which", "node"]
let pipe = Pipe()
task.standardOutput = pipe
do {
try task.run()
task.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
if let path = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
!path.isEmpty {
return path
}
} catch {
print("[AgentBridge] which node failed: \(error)")
}
return nil
}
private func parseLine(_ line: String) {
eventSeq += 1
guard let data = line.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let type = json["type"] as? String else {
log("parseLine #\(eventSeq) 解析失败: \(line.prefix(100))")
return
}
log("parseLine #\(eventSeq) type=\(type)")
switch type {
case "status":
let mode = json["mode"] as? String ?? "unknown"
log(" → status mode=\(mode)")
onEvent?(.status(mode: mode))
case "tool_start":
let tool = json["tool"] as? String ?? "unknown"
log(" → tool_start tool=\(tool)")
onEvent?(.toolStart(
tool: tool,
display: json["display"] as? String ?? ""
))
case "tool_end":
let tool = json["tool"] as? String ?? "unknown"
let resultLines = json["result"] as? [String] ?? []
log(" → tool_end tool=\(tool) resultLines=\(resultLines.count)")
onEvent?(.toolEnd(tool: tool, resultLines: resultLines))
case "assistant_chunk":
let content = json["content"] as? String ?? ""
log(" → assistant_chunk len=\(content.count)")
onEvent?(.assistantChunk(content: content))
case "done":
let content = json["content"] as? String ?? ""
let elapsed = json["elapsed_ms"] as? Int ?? 0
let totalTokens = json["total_tokens"] as? Int ?? 0
log(" → done contentLen=\(content.count) elapsedMs=\(elapsed) totalTokens=\(totalTokens)")
onEvent?(.done(content: content, elapsedMs: elapsed, totalTokens: totalTokens))
case "error":
let message = json["message"] as? String ?? "未知错误"
log(" → error message=\(message)")
onEvent?(.error(message: message))
default:
log(" → 未知 type: \(type)")
break
}
}
}