VoiceInput/Sources/Core/AgentBridge.swift
JOJO 967da1b889 debug: 添加 Reminder 文件日志排查 DMG 定时提醒失效问题
- AppDelegate: 新增 reminderLog() 写入 ~/Library/Logs/VoiceInput/reminder.log
- AppDelegate: 修复 agentLog 路径使用用户 Logs 目录
- AppDelegate: 启动时记录 bundlePath/agentDir/memoryPath 等诊断信息
- AgentBridge: init 不清空历史日志,start() 失败时输出详细错误
- checkReminders/triggerReminder 全链路覆盖文件日志
2026-05-01 01:19:12 +08:00

273 lines
10 KiB
Swift
Raw Permalink 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)
case reminderTriggered(memoryId: String, content: 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"
// 便
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 {
log("start() 开始agentDir=\(agentDir)")
guard let nodePath = findNode() else {
let msg = "❌ Node.js not found. agentDir=\(agentDir), bundledNode=\(agentDir)/bin/node"
print("[AgentBridge] \(msg)")
log("start() \(msg)")
return false
}
let bridgePath = "\(agentDir)/src/bridge.js"
guard FileManager.default.fileExists(atPath: bridgePath) else {
let msg = "❌ bridge.js not found at \(bridgePath). agentDir contents: \((try? FileManager.default.contentsOfDirectory(atPath: agentDir)) ?? [])"
print("[AgentBridge] \(msg)")
log("start() \(msg)")
return false
}
let userConfigPath = Config.shared.userAgentModelsPath
log("start() nodePath=\(nodePath) bridgePath=\(bridgePath) userConfigPath=\(userConfigPath) configExists=\(FileManager.default.fileExists(atPath: userConfigPath))")
let process = Process()
process.executableURL = URL(fileURLWithPath: nodePath)
process.arguments = [bridgePath, "--config", userConfigPath]
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
let msg = "✅ Started (pid \(process.processIdentifier))"
print("[AgentBridge] \(msg)")
log("start() \(msg)")
return true
} catch {
let msg = "❌ Failed to start: \(error.localizedDescription)"
print("[AgentBridge] \(msg)")
log("start() \(msg)")
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))
case "reminder_triggered":
let memoryId = json["memory_id"] as? String ?? ""
let content = json["content"] as? String ?? ""
log(" → reminder_triggered id=\(memoryId)")
onEvent?(.reminderTriggered(memoryId: memoryId, content: content))
default:
log(" → 未知 type: \(type)")
break
}
}
}