=== 记忆系统 === - 新增 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 更新
255 lines
9.4 KiB
Swift
255 lines
9.4 KiB
Swift
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? {
|
||
// 优先级 0:bundle 内打包的 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
|
||
}
|
||
}
|
||
}
|