From 26bf147adf1a49a124822663cae2372f5b8809a3 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Wed, 29 Apr 2026 20:16:20 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20Agent=20=E9=85=8D=E7=BD=AE=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E7=BB=9F=E4=B8=80=E5=88=B0=E7=94=A8=E6=88=B7=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=B7=A5=E5=85=B7=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E5=92=8C=E6=9D=83=E9=99=90=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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(已迁至用户目录) --- .gitignore | 1 - Agent/src/bridge.js | 33 +++++++++++---- Agent/src/config.js | 4 +- Agent/src/tools/search_workspace.js | 16 +++++-- Sources/App/AppDelegate.swift | 65 ++++++++++++++++------------- Sources/Core/AgentBridge.swift | 18 +++++--- Sources/Utils/Config.swift | 51 +++++++++++++++++----- 7 files changed, 127 insertions(+), 61 deletions(-) diff --git a/.gitignore b/.gitignore index 927d6bd..5981d8e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,3 @@ *.app .DS_Store Agent/bin/node -agent_debug.log diff --git a/Agent/src/bridge.js b/Agent/src/bridge.js index 78c815e..a2d923c 100644 --- a/Agent/src/bridge.js +++ b/Agent/src/bridge.js @@ -31,8 +31,12 @@ const { applyUsage, normalizeTokenUsage, normalizeUsagePayload } = require('./ut const { buildStartLine, buildFinalLine, formatResultLines } = require('./ui/tool_display'); // --- 配置 --- -const parentDir = path.dirname(__dirname); -const configPath = path.join(parentDir, 'models.json'); +// 优先使用 --config 参数指定的路径(用户目录),回退到 Agent 目录 +let configPath = path.join(path.dirname(__dirname), 'models.json'); +const configArgIdx = process.argv.indexOf('--config'); +if (configArgIdx >= 0 && configArgIdx + 1 < process.argv.length) { + configPath = path.resolve(process.argv[configArgIdx + 1]); +} process.stderr.write(`[bridge] starting, configPath=${configPath}\n`); const config = ensureConfig({ path: configPath }); process.stderr.write(`[bridge] config loaded, validModels=${config.valid_models?.length || 0}\n`); @@ -221,13 +225,24 @@ async function runAgent(input) { }); const controller = new AbortController(); - const result = await executeTool({ - workspace: persistentWorkspace || process.cwd(), - config, - allowMode: state.allowMode, - toolCall: call, - abortSignal: controller.signal, - }); + let result; + try { + result = await executeTool({ + workspace: persistentWorkspace || process.cwd(), + config, + allowMode: state.allowMode, + toolCall: call, + abortSignal: controller.signal, + }); + } catch (err) { + result = { + success: false, + tool: call.function.name, + raw: { success: false, error: err.message || String(err) }, + formatted: `失败: ${err.message || err}`, + tool_content: null, + }; + } const resultLines = formatResultLines(call.function.name, args, result.raw || { success: result.success }); diff --git a/Agent/src/config.js b/Agent/src/config.js index 78538b9..fa8568e 100644 --- a/Agent/src/config.js +++ b/Agent/src/config.js @@ -98,8 +98,8 @@ function buildConfig(raw, filePath) { }; } -function ensureConfig() { - const file = DEFAULT_CONFIG_PATH; +function ensureConfig(opts = {}) { + const file = opts.path || DEFAULT_CONFIG_PATH; if (!fs.existsSync(file)) { const template = { tavily_api_key: '', diff --git a/Agent/src/tools/search_workspace.js b/Agent/src/tools/search_workspace.js index bb24ae5..2cfd303 100644 --- a/Agent/src/tools/search_workspace.js +++ b/Agent/src/tools/search_workspace.js @@ -33,7 +33,12 @@ async function searchWorkspaceTool(workspace, args) { const maxFileSize = args.max_file_size || null; if (mode === 'file') { - const files = await fg(includeGlob, { cwd: root, dot: true, ignore: excludeGlob, onlyFiles: true, absolute: true }); + let files; + try { + files = await fg(includeGlob, { cwd: root, dot: true, ignore: excludeGlob, onlyFiles: true, absolute: true, followSymbolicLinks: false, suppressErrors: true }); + } catch (err) { + return { success: false, error: err.message || String(err) }; + } const matcher = useRegex ? new RegExp(query, caseSensitive ? '' : 'i') : null; const matches = []; for (const file of files) { @@ -51,7 +56,7 @@ async function searchWorkspaceTool(workspace, args) { if (mode === 'content') { if (hasRg()) { - const argsList = ['--line-number', '--no-heading', '--color=never']; + const argsList = ['--line-number', '--no-heading', '--color=never', '--no-follow']; if (!useRegex) argsList.push('--fixed-strings'); if (!caseSensitive) argsList.push('-i'); if (maxMatchesPerFile) argsList.push(`--max-count=${maxMatchesPerFile}`); @@ -82,7 +87,12 @@ async function searchWorkspaceTool(workspace, args) { } // fallback - const files = await fg(includeGlob, { cwd: root, dot: true, ignore: excludeGlob, onlyFiles: true, absolute: true }); + let files; + try { + files = await fg(includeGlob, { cwd: root, dot: true, ignore: excludeGlob, onlyFiles: true, absolute: true, followSymbolicLinks: false, suppressErrors: true }); + } catch (err) { + return { success: false, error: err.message || String(err) }; + } const matcher = useRegex ? new RegExp(query, caseSensitive ? '' : 'i') : null; const results = []; for (const file of files) { diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index 9d1d861..02a663d 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -49,45 +49,50 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSSpeechSynthesizerDel // MARK: - Permissions private func beginPermissionFlow() { - guard CGPreflightListenEventAccess() else { - openPrefsAndWait(panel: "Privacy_Accessibility", label: "辅助功能") { [weak self] in - self?.beginPermissionFlow() - } - return - } - guard canCreateHIDEventTap() else { - openPrefsAndWait(panel: "Privacy_InputMonitoring", label: "输入监控") { [weak self] in - self?.beginPermissionFlow() - } - return - } - startKeyMonitor() - showReadyAlert() - } + let hasAccessibility = CGPreflightListenEventAccess() + let hasInputMonitoring = canCreateHIDEventTapSimple() + + // 两个权限都 OK → 直接启动 + if hasAccessibility && hasInputMonitoring { + startKeyMonitor() + showReadyAlert() + return + } + + // 打开系统设置隐私主页 + NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security")!) + + // 列出缺失的权限 + var missing: [String] = [] + if !hasAccessibility { missing.append("辅助功能") } + if !hasInputMonitoring { missing.append("输入监控") } - private func openPrefsAndWait(panel: String, label: String, onGranted: @escaping () -> Void) { - NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?\(panel)")!) let alert = NSAlert() - alert.messageText = "需要\(label)权限" - alert.informativeText = "点击 + 号,选择 VoiceInput 应用,点击打开。" + alert.messageText = "需要系统权限" + alert.informativeText = "VoiceInput 需要以下权限才能正常工作:\n\n• \(missing.joined(separator: "\n• "))\n\n请在左侧列表中找到对应项目,点击 + 添加 VoiceInput,然后打开开关。" alert.alertStyle = .informational - alert.addButton(withTitle: "继续") + alert.addButton(withTitle: "已授权,继续") alert.addButton(withTitle: "退出") guard alert.runModal() == .alertFirstButtonReturn else { NSApp.terminate(nil); return } - let check: () -> Bool = { - panel == "Privacy_Accessibility" ? CGPreflightListenEventAccess() : self.canCreateHIDEventTap() - } - Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { timer in - guard check() else { return } - timer.invalidate() - DispatchQueue.main.async { - print("[Permission] ✅ \(label) granted") - onGranted() + + // 轮询检测 + Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] timer in + guard let self = self else { return } + let ok1 = CGPreflightListenEventAccess() + let ok2 = self.canCreateHIDEventTapSimple() + if ok1 && ok2 { + timer.invalidate() + DispatchQueue.main.async { + print("[Permission] ✅ All permissions granted") + self.startKeyMonitor() + self.showReadyAlert() + } } } } - private func canCreateHIDEventTap() -> Bool { + /// 简单检测输入监控(仅用于引导,KeyMonitor 内部有 HID→Session 回退) + private func canCreateHIDEventTapSimple() -> Bool { let mask = (1 << CGEventType.flagsChanged.rawValue) guard let tap = CGEvent.tapCreate(tap: .cghidEventTap, place: .headInsertEventTap, options: .defaultTap, eventsOfInterest: CGEventMask(mask), diff --git a/Sources/Core/AgentBridge.swift b/Sources/Core/AgentBridge.swift index 180b80d..98213a9 100644 --- a/Sources/Core/AgentBridge.swift +++ b/Sources/Core/AgentBridge.swift @@ -26,9 +26,13 @@ final class AgentBridge { init(agentDir: String) { self.agentDir = agentDir - // 日志写到项目根目录 - let projectRoot = (agentDir as NSString).deletingLastPathComponent - self.logPath = projectRoot + "/agent_debug.log" + // 日志写到用户目录(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 日志开始 ========") @@ -62,7 +66,7 @@ final class AgentBridge { let process = Process() process.executableURL = URL(fileURLWithPath: nodePath) - process.arguments = [bridgePath] + process.arguments = [bridgePath, "--config", Config.shared.userAgentModelsPath] process.currentDirectoryURL = URL(fileURLWithPath: agentDir) // stdin → 向 bridge 发送输入 @@ -91,11 +95,13 @@ final class AgentBridge { } // 读取 stderr - stderrPipe.fileHandleForReading.readabilityHandler = { handle in + 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 { - print("[Agent] \(str.trimmingCharacters(in: .newlines))") + let trimmed = str.trimmingCharacters(in: .newlines) + print("[Agent] \(trimmed)") + self?.log("stderr: \(trimmed)") } } } diff --git a/Sources/Utils/Config.swift b/Sources/Utils/Config.swift index 0e33ca6..285ac0c 100644 --- a/Sources/Utils/Config.swift +++ b/Sources/Utils/Config.swift @@ -343,24 +343,55 @@ final class Config { var agentModelsPath: String { agentDir + "/models.json" } + /// 用户数据目录(配置持久化,覆盖安装不丢失) + private var userDataDir: String { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let dir = appSupport.appendingPathComponent("VoiceInput").path + // 确保目录存在 + if !FileManager.default.fileExists(atPath: dir) { + try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + } + return dir + } + + /// 用户目录下的 models.json(覆盖安装不丢失) + var userAgentModelsPath: String { + userDataDir + "/models.json" + } + // MARK: - models.json 读写 func loadAgentModels() -> AgentModelsFile { - let path = agentModelsPath - guard FileManager.default.fileExists(atPath: path), - let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { - return AgentModelsFile(tavilyApiKey: "", defaultModel: "", models: []) + let userPath = userAgentModelsPath + + // 用户目录已有配置 → 直接读取 + if FileManager.default.fileExists(atPath: userPath), + let data = try? Data(contentsOf: URL(fileURLWithPath: userPath)) { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + if let models = try? decoder.decode(AgentModelsFile.self, from: data) { + return models + } } - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - guard let models = try? decoder.decode(AgentModelsFile.self, from: data) else { - return AgentModelsFile(tavilyApiKey: "", defaultModel: "", models: []) + + // 首次启动:从 bundle 模板复制到用户目录 + let bundlePath = agentModelsPath + if FileManager.default.fileExists(atPath: bundlePath), + let data = try? Data(contentsOf: URL(fileURLWithPath: bundlePath)) { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + if let models = try? decoder.decode(AgentModelsFile.self, from: data) { + // 复制到用户目录 + try? data.write(to: URL(fileURLWithPath: userPath), options: .atomic) + return models + } } - return models + + return AgentModelsFile(tavilyApiKey: "", defaultModel: "", models: []) } func saveAgentModels(_ models: AgentModelsFile) { - let path = agentModelsPath + let path = userAgentModelsPath let encoder = JSONEncoder() encoder.keyEncodingStrategy = .convertToSnakeCase guard let data = try? encoder.encode(models) else { return }