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:
parent
76741877a8
commit
26bf147adf
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,4 +3,3 @@
|
|||||||
*.app
|
*.app
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Agent/bin/node
|
Agent/bin/node
|
||||||
agent_debug.log
|
|
||||||
|
|||||||
@ -31,8 +31,12 @@ const { applyUsage, normalizeTokenUsage, normalizeUsagePayload } = require('./ut
|
|||||||
const { buildStartLine, buildFinalLine, formatResultLines } = require('./ui/tool_display');
|
const { buildStartLine, buildFinalLine, formatResultLines } = require('./ui/tool_display');
|
||||||
|
|
||||||
// --- 配置 ---
|
// --- 配置 ---
|
||||||
const parentDir = path.dirname(__dirname);
|
// 优先使用 --config 参数指定的路径(用户目录),回退到 Agent 目录
|
||||||
const configPath = path.join(parentDir, 'models.json');
|
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`);
|
process.stderr.write(`[bridge] starting, configPath=${configPath}\n`);
|
||||||
const config = ensureConfig({ path: configPath });
|
const config = ensureConfig({ path: configPath });
|
||||||
process.stderr.write(`[bridge] config loaded, validModels=${config.valid_models?.length || 0}\n`);
|
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 controller = new AbortController();
|
||||||
const result = await executeTool({
|
let result;
|
||||||
|
try {
|
||||||
|
result = await executeTool({
|
||||||
workspace: persistentWorkspace || process.cwd(),
|
workspace: persistentWorkspace || process.cwd(),
|
||||||
config,
|
config,
|
||||||
allowMode: state.allowMode,
|
allowMode: state.allowMode,
|
||||||
toolCall: call,
|
toolCall: call,
|
||||||
abortSignal: controller.signal,
|
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 });
|
const resultLines = formatResultLines(call.function.name, args, result.raw || { success: result.success });
|
||||||
|
|
||||||
|
|||||||
@ -98,8 +98,8 @@ function buildConfig(raw, filePath) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureConfig() {
|
function ensureConfig(opts = {}) {
|
||||||
const file = DEFAULT_CONFIG_PATH;
|
const file = opts.path || DEFAULT_CONFIG_PATH;
|
||||||
if (!fs.existsSync(file)) {
|
if (!fs.existsSync(file)) {
|
||||||
const template = {
|
const template = {
|
||||||
tavily_api_key: '',
|
tavily_api_key: '',
|
||||||
|
|||||||
@ -33,7 +33,12 @@ async function searchWorkspaceTool(workspace, args) {
|
|||||||
const maxFileSize = args.max_file_size || null;
|
const maxFileSize = args.max_file_size || null;
|
||||||
|
|
||||||
if (mode === 'file') {
|
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 matcher = useRegex ? new RegExp(query, caseSensitive ? '' : 'i') : null;
|
||||||
const matches = [];
|
const matches = [];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
@ -51,7 +56,7 @@ async function searchWorkspaceTool(workspace, args) {
|
|||||||
|
|
||||||
if (mode === 'content') {
|
if (mode === 'content') {
|
||||||
if (hasRg()) {
|
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 (!useRegex) argsList.push('--fixed-strings');
|
||||||
if (!caseSensitive) argsList.push('-i');
|
if (!caseSensitive) argsList.push('-i');
|
||||||
if (maxMatchesPerFile) argsList.push(`--max-count=${maxMatchesPerFile}`);
|
if (maxMatchesPerFile) argsList.push(`--max-count=${maxMatchesPerFile}`);
|
||||||
@ -82,7 +87,12 @@ async function searchWorkspaceTool(workspace, args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fallback
|
// 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 matcher = useRegex ? new RegExp(query, caseSensitive ? '' : 'i') : null;
|
||||||
const results = [];
|
const results = [];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
|
|||||||
@ -49,45 +49,50 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSSpeechSynthesizerDel
|
|||||||
// MARK: - Permissions
|
// MARK: - Permissions
|
||||||
|
|
||||||
private func beginPermissionFlow() {
|
private func beginPermissionFlow() {
|
||||||
guard CGPreflightListenEventAccess() else {
|
let hasAccessibility = CGPreflightListenEventAccess()
|
||||||
openPrefsAndWait(panel: "Privacy_Accessibility", label: "辅助功能") { [weak self] in
|
let hasInputMonitoring = canCreateHIDEventTapSimple()
|
||||||
self?.beginPermissionFlow()
|
|
||||||
}
|
// 两个权限都 OK → 直接启动
|
||||||
return
|
if hasAccessibility && hasInputMonitoring {
|
||||||
}
|
|
||||||
guard canCreateHIDEventTap() else {
|
|
||||||
openPrefsAndWait(panel: "Privacy_InputMonitoring", label: "输入监控") { [weak self] in
|
|
||||||
self?.beginPermissionFlow()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
startKeyMonitor()
|
startKeyMonitor()
|
||||||
showReadyAlert()
|
showReadyAlert()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openPrefsAndWait(panel: String, label: String, onGranted: @escaping () -> Void) {
|
// 打开系统设置隐私主页
|
||||||
NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?\(panel)")!)
|
NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security")!)
|
||||||
|
|
||||||
|
// 列出缺失的权限
|
||||||
|
var missing: [String] = []
|
||||||
|
if !hasAccessibility { missing.append("辅助功能") }
|
||||||
|
if !hasInputMonitoring { missing.append("输入监控") }
|
||||||
|
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
alert.messageText = "需要\(label)权限"
|
alert.messageText = "需要系统权限"
|
||||||
alert.informativeText = "点击 + 号,选择 VoiceInput 应用,点击打开。"
|
alert.informativeText = "VoiceInput 需要以下权限才能正常工作:\n\n• \(missing.joined(separator: "\n• "))\n\n请在左侧列表中找到对应项目,点击 + 添加 VoiceInput,然后打开开关。"
|
||||||
alert.alertStyle = .informational
|
alert.alertStyle = .informational
|
||||||
alert.addButton(withTitle: "继续")
|
alert.addButton(withTitle: "已授权,继续")
|
||||||
alert.addButton(withTitle: "退出")
|
alert.addButton(withTitle: "退出")
|
||||||
guard alert.runModal() == .alertFirstButtonReturn else { NSApp.terminate(nil); return }
|
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) { [weak self] timer in
|
||||||
Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { timer in
|
guard let self = self else { return }
|
||||||
guard check() else { return }
|
let ok1 = CGPreflightListenEventAccess()
|
||||||
|
let ok2 = self.canCreateHIDEventTapSimple()
|
||||||
|
if ok1 && ok2 {
|
||||||
timer.invalidate()
|
timer.invalidate()
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
print("[Permission] ✅ \(label) granted")
|
print("[Permission] ✅ All permissions granted")
|
||||||
onGranted()
|
self.startKeyMonitor()
|
||||||
|
self.showReadyAlert()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func canCreateHIDEventTap() -> Bool {
|
/// 简单检测输入监控(仅用于引导,KeyMonitor 内部有 HID→Session 回退)
|
||||||
|
private func canCreateHIDEventTapSimple() -> Bool {
|
||||||
let mask = (1 << CGEventType.flagsChanged.rawValue)
|
let mask = (1 << CGEventType.flagsChanged.rawValue)
|
||||||
guard let tap = CGEvent.tapCreate(tap: .cghidEventTap, place: .headInsertEventTap,
|
guard let tap = CGEvent.tapCreate(tap: .cghidEventTap, place: .headInsertEventTap,
|
||||||
options: .defaultTap, eventsOfInterest: CGEventMask(mask),
|
options: .defaultTap, eventsOfInterest: CGEventMask(mask),
|
||||||
|
|||||||
@ -26,9 +26,13 @@ final class AgentBridge {
|
|||||||
|
|
||||||
init(agentDir: String) {
|
init(agentDir: String) {
|
||||||
self.agentDir = agentDir
|
self.agentDir = agentDir
|
||||||
// 日志写到项目根目录
|
// 日志写到用户目录(app bundle 内无写权限)
|
||||||
let projectRoot = (agentDir as NSString).deletingLastPathComponent
|
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||||
self.logPath = projectRoot + "/agent_debug.log"
|
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)
|
try? "".write(toFile: logPath, atomically: true, encoding: .utf8)
|
||||||
log("======== AgentBridge 日志开始 ========")
|
log("======== AgentBridge 日志开始 ========")
|
||||||
@ -62,7 +66,7 @@ final class AgentBridge {
|
|||||||
|
|
||||||
let process = Process()
|
let process = Process()
|
||||||
process.executableURL = URL(fileURLWithPath: nodePath)
|
process.executableURL = URL(fileURLWithPath: nodePath)
|
||||||
process.arguments = [bridgePath]
|
process.arguments = [bridgePath, "--config", Config.shared.userAgentModelsPath]
|
||||||
process.currentDirectoryURL = URL(fileURLWithPath: agentDir)
|
process.currentDirectoryURL = URL(fileURLWithPath: agentDir)
|
||||||
|
|
||||||
// stdin → 向 bridge 发送输入
|
// stdin → 向 bridge 发送输入
|
||||||
@ -91,11 +95,13 @@ final class AgentBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 读取 stderr
|
// 读取 stderr
|
||||||
stderrPipe.fileHandleForReading.readabilityHandler = { handle in
|
stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
|
||||||
let data = handle.availableData
|
let data = handle.availableData
|
||||||
if !data.isEmpty, let str = String(data: data, encoding: .utf8) {
|
if !data.isEmpty, let str = String(data: data, encoding: .utf8) {
|
||||||
if !str.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -343,24 +343,55 @@ final class Config {
|
|||||||
|
|
||||||
var agentModelsPath: String { agentDir + "/models.json" }
|
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 读写
|
// MARK: - models.json 读写
|
||||||
|
|
||||||
func loadAgentModels() -> AgentModelsFile {
|
func loadAgentModels() -> AgentModelsFile {
|
||||||
let path = agentModelsPath
|
let userPath = userAgentModelsPath
|
||||||
guard FileManager.default.fileExists(atPath: path),
|
|
||||||
let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
|
// 用户目录已有配置 → 直接读取
|
||||||
return AgentModelsFile(tavilyApiKey: "", defaultModel: "", models: [])
|
if FileManager.default.fileExists(atPath: userPath),
|
||||||
}
|
let data = try? Data(contentsOf: URL(fileURLWithPath: userPath)) {
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||||
guard let models = try? decoder.decode(AgentModelsFile.self, from: data) else {
|
if let models = try? decoder.decode(AgentModelsFile.self, from: data) {
|
||||||
return AgentModelsFile(tavilyApiKey: "", defaultModel: "", models: [])
|
|
||||||
}
|
|
||||||
return models
|
return 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 AgentModelsFile(tavilyApiKey: "", defaultModel: "", models: [])
|
||||||
|
}
|
||||||
|
|
||||||
func saveAgentModels(_ models: AgentModelsFile) {
|
func saveAgentModels(_ models: AgentModelsFile) {
|
||||||
let path = agentModelsPath
|
let path = userAgentModelsPath
|
||||||
let encoder = JSONEncoder()
|
let encoder = JSONEncoder()
|
||||||
encoder.keyEncodingStrategy = .convertToSnakeCase
|
encoder.keyEncodingStrategy = .convertToSnakeCase
|
||||||
guard let data = try? encoder.encode(models) else { return }
|
guard let data = try? encoder.encode(models) else { return }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user