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