- config.js: ensureConfig 接受 opts.path,避免始终读 bundle 内空配置 - bridge.js: executeTool 异常时也写入 tool 消息,防止 tool_calls 历史不一致 - search_workspace.js: 加 followSymbolicLinks:false + suppressErrors + try-catch - AgentBridge: 日志写用户目录,传 --config 给 bridge.js - Config: Agent models.json 存储到 ~/Library/Application Support/VoiceInput - AppDelegate: 合并权限请求为一次弹窗,适配 macOS 15 - .gitignore: 移除 agent_debug.log(已迁至用户目录)
253 lines
9.1 KiB
Swift
253 lines
9.1 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)
|
||
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) {
|
||
sendInternal(message: message, workspace: workspace, allowMode: allowMode, modelKey: modelKey)
|
||
}
|
||
|
||
/// 追加消息(同一对话)
|
||
func sendContinue(message: String) {
|
||
sendInternal(message: message, workspace: "", allowMode: "", modelKey: nil)
|
||
}
|
||
|
||
private func sendInternal(message: String, workspace: String, allowMode: String, modelKey: String?) {
|
||
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
|
||
|
||
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
|
||
log(" → done contentLen=\(content.count) elapsedMs=\(elapsed)")
|
||
onEvent?(.done(content: content, elapsedMs: elapsed))
|
||
case "error":
|
||
let message = json["message"] as? String ?? "未知错误"
|
||
log(" → error message=\(message)")
|
||
onEvent?(.error(message: message))
|
||
default:
|
||
log(" → 未知 type: \(type)")
|
||
break
|
||
}
|
||
}
|
||
}
|