VoiceInput/Sources/Core/AgentBridge.swift
JOJO 76741877a8 feat: 朗读功能、弹窗优化、Node.js 打包、Prompt 更新
- Assist/Agent 模式新增朗读开关 + NSSpeechSynthesizer 语音合成
- 朗读完成后弹窗延迟5秒带动画关闭(不再写死15/20秒)
- 打字模式下不自动关闭弹窗
- 二次输入时取消旧朗读/旧延迟关闭,避免竞争问题
- Agent 进度计时器第二次输入时正确归零
- DMG 打包自动清空 models.json API Key
- Node.js 运行时打包进 Agent/bin/,用户无需安装
- Agent system prompt 更新为 VoiceInput 身份 + 语音容错说明
- AGENTS.md 全面更新,补全 Agent 模式文档
2026-04-29 18:22:14 +08:00

247 lines
8.6 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)
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
//
let projectRoot = (agentDir as NSString).deletingLastPathComponent
self.logPath = projectRoot + "/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]
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 = { handle in
let data = handle.availableData
if !data.isEmpty, let str = String(data: data, encoding: .utf8) {
if !str.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
print("[Agent] \(str.trimmingCharacters(in: .newlines))")
}
}
}
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? {
// 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
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
}
}
}