fix: Agent 配置路径统一到用户目录,修复工具异常和权限错误

- 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(已迁至用户目录)
This commit is contained in:
JOJO 2026-04-29 20:16:20 +08:00
parent 76741877a8
commit 26bf147adf
7 changed files with 127 additions and 61 deletions

1
.gitignore vendored
View File

@ -3,4 +3,3 @@
*.app
.DS_Store
Agent/bin/node
agent_debug.log

View File

@ -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 });

View File

@ -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: '',

View File

@ -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) {

View File

@ -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 HIDSession 退
private func canCreateHIDEventTapSimple() -> Bool {
let mask = (1 << CGEventType.flagsChanged.rawValue)
guard let tap = CGEvent.tapCreate(tap: .cghidEventTap, place: .headInsertEventTap,
options: .defaultTap, eventsOfInterest: CGEventMask(mask),

View File

@ -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)")
}
}
}

View File

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