## Agent 核心功能 - **流式输出刘海弹窗**:实时展示模型思考内容,支持自动滚动 - **工具调用进度**:最多可见3条 + 可滚动查看全部历史 + 防抖并行滚动 - **打字/语音双输入**:Agent 模式可选手动打字或语音输入 - **快速/思考模式切换**:支持 thinking mode 开关 - **多轮对话连续性**:同一会话内持续对话,新建对话按钮 ⊕ 开启新会话 - **工作时间显示**:工作完成状态栏显示耗时,如「工作完成 · 00:42」 ## Bug 修复 - **SIGTERM 优雅退出**:bridge.js 处理 SIGTERM,避免 code 15 误报异常 - **弹窗复用不重建**:输入内容继续对话时弹窗内部刷新,不再闪出闪进 - **关闭按钮立刻生效**:ignoreMouse=true 绕过 DynamicNotchKit 延迟 - **设置窗口 Agent UI 构建时机**:从延迟构建改为 init 中直接构建 - **模型编辑器**:修复 grid column nil 崩溃 + 改为独立 KeyEquivalentPanel 窗口支持 Cmd+A/C/V/X - **双击 Control 三向轮换**:语音输入 ↔ AI助手 ↔ 智能体 - **Fn 长按冲突处理**:非 Agent 模式下自动关闭 Agent 刘海弹窗 - **Save 后刷新下拉**:保存配置后默认模型下拉框即时同步 ## UI 交互优化 - **工具列表无灰色背景**:工具执行期间不再有底色变化 - **工作时隐藏新建对话按钮**:thinking/running 期间 ⊕ 自动隐藏 - **工具调用去 timeout 显示**:运行指令行不再显示 (timeout=30s) ## 文件拆分(单文件≤500行) - AgentNotchView → AgentProgressTracker + AppKitTextField + AgentNotchView - SettingsWindow → AgentModelEditor + KeyEquivalentSecureTextField + KeyEquivalentPanel + SettingsWindow ## 底层改动 - DynamicNotchKit: 新增 contentVersion + notifyContentChanged 机制 - NotchView: 动画绑定 contentVersion 支持弹窗内容变化弹性动画
242 lines
8.4 KiB
Swift
242 lines
8.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)
|
||
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? {
|
||
// 尝试常见路径
|
||
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
|
||
}
|
||
}
|
||
}
|