diff --git a/Agent/src/bridge.js b/Agent/src/bridge.js new file mode 100644 index 0000000..78c815e --- /dev/null +++ b/Agent/src/bridge.js @@ -0,0 +1,311 @@ +#!/usr/bin/env node +'use strict'; + +/** + * VoiceInput Bridge — JSON 事件输出模式 + * + * 用法: node bridge.js + * 从 stdin 读取 JSON 指令,通过 stdout 输出 JSON 事件。 + * + * 输入格式(stdin,每行一个 JSON): + * {"action":"chat","message":"用户问题","workspace":"/path/to/dir"} + * + * 输出格式(stdout,每行一个 JSON): + * {"type":"tool_start","tool":"read_file","args":{"path":"..."}} + * {"type":"tool_end","tool":"read_file","lines":120} + * {"type":"assistant_chunk","content":"..."} + * {"type":"done","elapsed_ms":12345} + * {"type":"error","message":"..."} + */ + +const fs = require('fs'); +const path = require('path'); +const readline = require('readline'); + +const { ensureConfig } = require('./config'); +const { createState } = require('./core/state'); +const { buildSystemPrompt } = require('./core/context'); +const { streamChat } = require('./model/client'); +const { executeTool } = require('./tools/dispatcher'); +const { applyUsage, normalizeTokenUsage, normalizeUsagePayload } = require('./utils/token_usage'); +const { buildStartLine, buildFinalLine, formatResultLines } = require('./ui/tool_display'); + +// --- 配置 --- +const parentDir = path.dirname(__dirname); +const configPath = path.join(parentDir, 'models.json'); +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`); + +if (!config.valid_models || config.valid_models.length === 0) { + process.stderr.write(`[bridge] no valid models, exiting\n`); + process.stdout.write(JSON.stringify({ type: 'error', message: '未找到可用模型配置' }) + '\n'); + process.exit(1); +} + +const PROMPTS_DIR = path.join(__dirname, '..', 'prompts'); +const TOOLS_JSON = path.join(__dirname, '..', 'doc', 'tools.json'); +const systemPrompt = fs.readFileSync(path.join(PROMPTS_DIR, 'system.txt'), 'utf8'); +const tools = JSON.parse(fs.readFileSync(TOOLS_JSON, 'utf8')); + +// --- 输出辅助 --- +function emit(obj) { + process.stdout.write(JSON.stringify(obj) + '\n'); +} + +// --- 工作区解析 --- +function resolveWorkspace(raw) { + if (!raw) return process.cwd(); + const p = path.resolve(raw); + return fs.existsSync(p) ? p : process.cwd(); +} + +// --- 构建消息 --- +function buildApiMessages(state, workspace) { + const system = buildSystemPrompt(systemPrompt, { + workspace, + allowMode: state.allowMode, + modelId: state.modelId || state.modelKey, + }); + const messages = [{ role: 'system', content: system }]; + for (const msg of state.messages) { + const m = { role: msg.role }; + if (msg.role === 'tool') { + m.tool_call_id = msg.tool_call_id; + m.content = msg.content; + } else if (msg.role === 'assistant' && Array.isArray(msg.tool_calls)) { + m.content = msg.content || null; + m.tool_calls = msg.tool_calls; + if (Object.prototype.hasOwnProperty.call(msg, 'reasoning_content')) { + m.reasoning_content = msg.reasoning_content; + } else if (state.thinkingMode) { + m.reasoning_content = ''; + } + } else { + m.content = msg.content; + if (msg.role === 'assistant' && Object.prototype.hasOwnProperty.call(msg, 'reasoning_content')) { + m.reasoning_content = msg.reasoning_content; + } + } + messages.push(m); + } + return messages; +} + +// --- 持久化会话状态(跨多轮对话) --- +let persistentState = null; +let persistentWorkspace = null; +let persistentThinkingMode = false; + +// --- 主循环 --- +async function runAgent(input) { + // 首次调用或 state 丢失时重建 + if (!persistentState) { + persistentState = createState(config, persistentWorkspace || process.cwd()); + persistentState.thinkingMode = persistentThinkingMode; + } + + const state = persistentState; + process.stderr.write(`[bridge] runAgent start, msgs=${state.messages.length}, thinkingMode=${state.thinkingMode}\n`); + + // 用户消息 + state.messages.push({ role: 'user', content: input }); + const startTime = Date.now(); + + let continueLoop = true; + let lastAssistantContent = ''; + + while (continueLoop) { + let assistantContent = ''; + let fullThinkingBuffer = ''; + let toolCalls = {}; + let usageTotal = null; + + emit({ type: 'status', mode: 'thinking' }); + + const streamController = new AbortController(); + const messages = buildApiMessages(state, persistentWorkspace || process.cwd()); + + try { + for await (const chunk of streamChat({ + config, + modelKey: state.modelKey, + messages, + tools, + thinkingMode: state.thinkingMode, + currentContextTokens: normalizeTokenUsage(state.tokenUsage).total || 0, + abortSignal: streamController.signal, + })) { + const choice = chunk.choices?.[0]; + if (!choice) continue; + + const delta = choice.delta || {}; + + // Tool calls + if (delta.tool_calls) { + delta.tool_calls.forEach((tc) => { + const idx = tc.index; + if (!toolCalls[idx]) toolCalls[idx] = { id: tc.id, type: tc.type, function: { name: '', arguments: '' } }; + if (tc.function?.name) toolCalls[idx].function.name = tc.function.name; + if (tc.function?.arguments) toolCalls[idx].function.arguments += tc.function.arguments; + }); + } + + // Content + if (delta.content) { + assistantContent += delta.content; + emit({ type: 'assistant_chunk', content: delta.content }); + } + + // Reasoning(思考模式) + if (delta.reasoning_content || delta.reasoning_details || choice.reasoning_details) { + let rc = ''; + if (delta.reasoning_content) { + rc = delta.reasoning_content; + } else if (delta.reasoning_details) { + if (Array.isArray(delta.reasoning_details)) { + rc = delta.reasoning_details.map((d) => d.text || '').join(''); + } else if (typeof delta.reasoning_details === 'string') { + rc = delta.reasoning_details; + } else if (delta.reasoning_details && typeof delta.reasoning_details.text === 'string') { + rc = delta.reasoning_details.text; + } + } else if (choice.reasoning_details) { + if (Array.isArray(choice.reasoning_details)) { + rc = choice.reasoning_details.map((d) => d.text || '').join(''); + } else if (typeof choice.reasoning_details === 'string') { + rc = choice.reasoning_details; + } else if (choice.reasoning_details && typeof choice.reasoning_details.text === 'string') { + rc = choice.reasoning_details.text; + } + } + fullThinkingBuffer += rc; + } + + // Usage + const usage = chunk.usage || choice.usage || delta.usage; + const normalized = normalizeUsagePayload(usage); + if (normalized && Number.isFinite(normalized.total_tokens)) { + usageTotal = normalized.total_tokens; + } + } + } catch (err) { + process.stderr.write(`[bridge] streamChat error: ${err.message || err}\n`); + emit({ type: 'error', message: err.message || '流式请求失败' }); + return; + } + + // Tool calls + const toolCallList = Object.keys(toolCalls) + .sort((a, b) => a - b) + .map((k) => toolCalls[k]); + + if (toolCallList.length) { + const assistantMsg = { + role: 'assistant', + content: assistantContent || null, + tool_calls: toolCallList, + }; + if (state.thinkingMode) assistantMsg.reasoning_content = fullThinkingBuffer || ''; + state.messages.push(assistantMsg); + + for (const call of toolCallList) { + let args = {}; + try { args = JSON.parse(call.function.arguments || '{}'); } catch (_) {} + + emit({ + type: 'tool_start', + tool: call.function.name, + args: args, + display: buildStartLine(call.function.name, args), + }); + + const controller = new AbortController(); + const result = await executeTool({ + workspace: persistentWorkspace || process.cwd(), + config, + allowMode: state.allowMode, + toolCall: call, + abortSignal: controller.signal, + }); + + const resultLines = formatResultLines(call.function.name, args, result.raw || { success: result.success }); + + emit({ + type: 'tool_end', + tool: call.function.name, + result: resultLines, + success: result.success, + }); + + state.messages.push({ + role: 'tool', + tool_call_id: call.id, + content: result.tool_content || result.formatted, + tool_raw: result.raw, + }); + } + + continueLoop = true; + continue; + } + + // Final + if (assistantContent) { + const msg = { role: 'assistant', content: assistantContent }; + if (state.thinkingMode) msg.reasoning_content = fullThinkingBuffer || ''; + state.messages.push(msg); + } + lastAssistantContent = assistantContent; + continueLoop = false; + } + + const elapsed = Date.now() - startTime; + emit({ type: 'done', content: lastAssistantContent, elapsed_ms: elapsed }); + // 不退出,等待下一轮输入继续对话 +} + +// --- 主入口 --- +const rl = readline.createInterface({ input: process.stdin }); + +rl.on('line', (line) => { + (async () => { + try { + const msg = JSON.parse(line.trim()); + if (msg.action === 'chat') { + // 首轮:设置工作区和模型 + if (!persistentState) { + persistentWorkspace = resolveWorkspace(msg.workspace); + persistentState = createState(config, persistentWorkspace); + persistentState.allowMode = msg.allowMode || 'full_access'; + if (msg.modelKey && config.model_map.has(msg.modelKey)) { + const model = config.model_map.get(msg.modelKey); + persistentState.modelKey = model.key; + persistentState.modelId = model.model_id || model.name || model.key; + // 优先使用消息中指定的 thinkingMode,否则用模型默认 + if (msg.thinkingMode !== undefined) { + persistentThinkingMode = !!msg.thinkingMode; + } else { + persistentThinkingMode = model.modes === 'thinking' || model.modes.includes('思考'); + } + persistentState.thinkingMode = persistentThinkingMode; + } + } + await runAgent(msg.message); + } + } catch (err) { + emit({ type: 'error', message: err.message }); + } + })(); +}); + +rl.on('close', () => { + process.stderr.write(`[bridge] stdin closed, exiting\n`); + process.exit(0); +}); + +// 优雅处理 SIGTERM:Swift 端 stop() 会发 SIGTERM,这里转为 code 0 退出 +process.on('SIGTERM', () => { + process.stderr.write(`[bridge] received SIGTERM, exiting gracefully\n`); + process.exit(0); +}); diff --git a/Agent/src/ui/tool_display.js b/Agent/src/ui/tool_display.js new file mode 100644 index 0000000..615d084 --- /dev/null +++ b/Agent/src/ui/tool_display.js @@ -0,0 +1,223 @@ +'use strict'; + +const readline = require('readline'); +const { blue, gray, red, green } = require('../utils/colors'); + +const DOT_ON = '•'; +const DOT_OFF = '◦'; +const { stripAnsi, visibleWidth, truncatePlain, truncateVisible } = require('../utils/text_width'); + +function toolNameMap(name) { + const map = { + read_file: '读取文件', + edit_file: '修改文件', + run_command: '运行指令', + web_search: '网络搜索', + extract_webpage: '提取网页', + search_workspace: '文件搜索', + read_mediafile: '读取媒体文件', + }; + return map[name] || name; +} + + +function normalizePreviewLine(line) { + return String(line ?? '').replace(/^\s+/, ''); +} + +function splitCommandLines(command) { + const text = String(command ?? ''); + const parts = text.split(/\r?\n/); + if (parts.length > 1 && parts[parts.length - 1] === '') parts.pop(); + return parts.length ? parts : ['']; +} + +function buildRunCommandLines(label, command, suffix = '') { + const width = Number(process.stdout.columns) || 80; + const parts = splitCommandLines(command); + const firstLine = parts[0] ?? ''; + const labelLen = visibleWidth(label); + const suffixLen = visibleWidth(suffix); + const available = width - labelLen - 1 - suffixLen; + const commandLine = available > 0 ? truncatePlain(firstLine, available) : ''; + const startLine = truncateVisible(`${label} ${commandLine}${suffix}`, width); + if (parts.length <= 1) { + return { startLine, finalLine: startLine }; + } + const preview = parts.slice(1, 3).map(normalizePreviewLine); + const lines = [startLine, ...preview.map((line) => ` ${truncatePlain(line, Math.max(0, width - 2))}`), ` 总指令${parts.length}行`]; + return { startLine, finalLine: lines.join('\n') }; +} + +function buildStartLine(name, args) { + const label = blue(toolNameMap(name)); + if (name === 'run_command') { + const command = args && Object.prototype.hasOwnProperty.call(args, 'command') ? args.command : ''; + const { startLine } = buildRunCommandLines(label, command); + return startLine; + } + if (name === 'web_search') { + return `${label} ${args.query}`; + } + if (name === 'search_workspace') { + const root = args.root || '.'; + const mode = args.mode === 'content' ? '内容搜索' : '文件搜索'; + return `${blue(mode)} 在 ${root} 搜索 ${args.query}`; + } + if (name === 'read_file') { + return `${label} ${args.path}`; + } + if (name === 'edit_file') { + return `${label} ${args.file_path}`; + } + if (name === 'extract_webpage') { + return `${label} ${args.url}`; + } + if (name === 'read_mediafile') { + return `${label} ${args.path}`; + } + return `${label}`; +} + +function buildFinalLine(name, args) { + if (name === 'run_command') { + const label = blue(toolNameMap(name)); + const command = args && Object.prototype.hasOwnProperty.call(args, 'command') ? args.command : ''; + const { finalLine } = buildRunCommandLines(label, command); + return finalLine; + } + return buildStartLine(name, args); +} + +function startToolDisplay(line) { + let on = false; + const rawLine = String(line ?? '').replace(/\r?\n/g, ' '); + let lastLines = 1; + function clearPrevious() { + if (lastLines > 1) readline.moveCursor(process.stdout, 0, -(lastLines - 1)); + for (let i = 0; i < lastLines; i += 1) { + readline.clearLine(process.stdout, 0); + if (i < lastLines - 1) readline.moveCursor(process.stdout, 0, 1); + } + if (lastLines > 1) readline.moveCursor(process.stdout, 0, -(lastLines - 1)); + readline.cursorTo(process.stdout, 0); + } + function render() { + clearPrevious(); + const dot = on ? DOT_ON : DOT_OFF; + const width = Number(process.stdout.columns) || 80; + const maxLine = Math.max(0, width - 3); + const displayLine = truncateVisible(rawLine, maxLine); + const lineText = `${dot} ${displayLine}`; + process.stdout.write(lineText); + const lineLen = visibleWidth(lineText); + lastLines = Math.max(1, Math.ceil(lineLen / Math.max(1, width))); + } + render(); + const timer = setInterval(() => { + on = !on; + render(); + }, 120); + return { + stop(finalLine) { + clearInterval(timer); + clearPrevious(); + process.stdout.write(`${DOT_ON} ${finalLine}\n`); + }, + }; +} + +function formatResultLines(name, args, raw) { + if (!raw || raw.success === false) { + const msg = raw && raw.error ? raw.error : '执行失败'; + if (msg === '任务被用户取消') return [red(msg)]; + return [red(`失败: ${msg}`)]; + } + if (name === 'run_command') { + const output = raw.output || ''; + const lines = output.split(/\r?\n/).filter((l) => l !== ''); + const tail = lines.slice(-5); + const summary = '运行结果'; + return [summary, ...tail]; + } + if (name === 'web_search') { + const results = raw.results || []; + const title = results[0]?.title || ''; + return [`浏览 ${results.length} 个网站 | ${title}`]; + } + if (name === 'search_workspace') { + if (raw.mode === 'file') { + const total = raw.matches.length; + const top = raw.matches.slice(0, 3); + const lines = [`搜索到 ${total} 个结果`, ...top]; + if (total > 3) lines.push(gray(`更多 ${total - 3} 个结果`)); + return lines; + } + if (raw.mode === 'content') { + const total = raw.results.length; + const top = raw.results.slice(0, 3).map((item) => { + const first = item.matches && item.matches[0]; + return first ? `${item.file}: L${first.line} ${first.snippet}` : item.file; + }); + const lines = [`搜索到 ${total} 个文件`, ...top]; + if (total > 3) lines.push(gray(`更多 ${total - 3} 个结果`)); + return lines; + } + } + if (name === 'read_file') { + if (raw.type === 'search') { + const count = (raw.matches || []).length; + return [`搜索到 ${count} 个结果`]; + } + if (raw.type === 'extract') { + const count = (raw.segments || []).length; + return [`抽取了 ${count} 个片段`]; + } + const lines = raw.line_end && raw.line_start ? raw.line_end - raw.line_start + 1 : 0; + const chars = raw.content ? raw.content.length : 0; + return [`读取了 ${lines} 行 / ${chars} 字符`]; + } + if (name === 'extract_webpage') { + if (args.mode === 'save') { + return [`已保存 ${args.target_path}`]; + } + const preview = (raw.content || '').slice(0, 50); + return [`${preview}${preview.length ? '...' : ''}`]; + } + if (name === 'edit_file') { + const diff = raw.diff || { added: 0, removed: 0, hunks: [] }; + const lines = [`减少了 ${diff.removed} 行 增加了 ${diff.added} 行`]; + diff.hunks.forEach((hunk, idx) => { + if (idx > 0) lines.push(' ⋮'); + hunk.forEach((ln) => { + if (ln.lineNum == null) return; + const num = String(ln.lineNum).padStart(4, ' '); + const mark = ln.prefix === ' ' ? ' ' : ln.prefix; + let text = `${num} ${mark} ${ln.content}`; + if (ln.prefix === '+') text = green(text); + if (ln.prefix === '-') text = red(text); + lines.push(text); + }); + }); + return lines; + } + if (name === 'read_mediafile') { + if (raw.type === 'image') return ['已附加图片']; + if (raw.type === 'video') return ['已附加视频']; + } + return ['完成']; +} + +function printResultLines(lines) { + if (!lines || !lines.length) return; + lines.forEach((line, idx) => { + if (idx === 0) { + process.stdout.write(` └ ${line}\n`); + } else { + process.stdout.write(` ${line}\n`); + } + }); + process.stdout.write('\n'); +} + +module.exports = { buildStartLine, buildFinalLine, startToolDisplay, formatResultLines, printResultLines }; diff --git a/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotch.swift b/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotch.swift index c17774a..0f2885b 100644 --- a/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotch.swift +++ b/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/DynamicNotch.swift @@ -23,6 +23,9 @@ public class DynamicNotch: ObservableObject where Content: View { @Published var notchWidth: CGFloat = 0 @Published var notchHeight: CGFloat = 0 + // Content animation trigger — increment to animate layout changes + @Published var contentVersion: Int = 0 + // Notch Closing Properties @Published var isMouseInside: Bool = false // If the mouse is inside, the notch will not auto-hide private var timer: Timer? @@ -135,6 +138,11 @@ public extension DynamicNotch { } } + /// Call when content layout changes to trigger animated resize + func notifyContentChanged() { + contentVersion += 1 + } + /// Check if the cursor is inside the screen's notch area. /// - Returns: If the cursor is inside the notch area. static func checkIfMouseIsInNotch() -> Bool { diff --git a/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NotchView.swift b/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NotchView.swift index 9ff411d..8016893 100644 --- a/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NotchView.swift +++ b/Dependencies/DynamicNotchKit/Sources/DynamicNotchKit/NotchView.swift @@ -57,7 +57,7 @@ struct NotchView: View where Content: View { } } .shadow(color: .black.opacity(0.5), radius: dynamicNotch.isVisible ? 10 : 0) - .animation(dynamicNotch.animation, value: dynamicNotch.contentID) + .animation(dynamicNotch.animation, value: dynamicNotch.contentVersion) Spacer() } diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index 696eb6a..3788034 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -1,6 +1,8 @@ import Cocoa import AVFoundation import Speech +import SwiftUI +import DynamicNotchKit /// 应用主控制器,协调 Fn 键(长按录音)和 Control 键(双击切换模式) final class AppDelegate: NSObject, NSApplicationDelegate { @@ -14,11 +16,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private let recordingPanel = RecordingPanel() private let menuBarController = MenuBarController() private let llmRefiner = LLMRefiner() + private let agentBridge: AgentBridge + private let agentTracker = AgentProgressTracker() + private var agentNotch: DynamicNotch? private var isRecording = false private var finalTranscription: String = "" + private var isTextInputActive = false + private var agentActive = false + private var agentIsTextMode = false + private var agentExitingCleanly = false // 主动关闭标记,避免误报退出错误 + private var autoHideWorkItem: DispatchWorkItem? override init() { + let agentPath = Bundle.main.bundlePath + "/../../../Agent" + agentBridge = AgentBridge(agentDir: agentPath) super.init() Self.retainedInstance = self } @@ -29,55 +41,37 @@ final class AppDelegate: NSObject, NSApplicationDelegate { print("[App] Ready") } - // MARK: - Permissions — 顺序引导 + // MARK: - Permissions private func beginPermissionFlow() { - // 步骤1:辅助功能 guard CGPreflightListenEventAccess() else { openPrefsAndWait(panel: "Privacy_Accessibility", label: "辅助功能") { [weak self] in self?.beginPermissionFlow() } return } - // 步骤2:输入监控 guard canCreateHIDEventTap() else { openPrefsAndWait(panel: "Privacy_InputMonitoring", label: "输入监控") { [weak self] in self?.beginPermissionFlow() } return } - // 全部就绪 startKeyMonitor() showReadyAlert() } - /// 打开系统设置面板,轮询等待权限,完成后回调 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?\(panel)")!) let alert = NSAlert() alert.messageText = "需要\(label)权限" alert.informativeText = "点击 + 号,选择 VoiceInput 应用,点击打开。" alert.alertStyle = .informational 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 = { - if panel == "Privacy_Accessibility" { - return CGPreflightListenEventAccess() - } else { - return self.canCreateHIDEventTap() - } + panel == "Privacy_Accessibility" ? CGPreflightListenEventAccess() : self.canCreateHIDEventTap() } - Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { timer in guard check() else { return } timer.invalidate() @@ -88,15 +82,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } - /// 检查输入监控权限 private func canCreateHIDEventTap() -> Bool { - let eventMask = (1 << CGEventType.flagsChanged.rawValue) - guard let tap = CGEvent.tapCreate( - tap: .cghidEventTap, place: .headInsertEventTap, - options: .defaultTap, eventsOfInterest: CGEventMask(eventMask), - callback: { _, _, _, _ in return nil }, - userInfo: nil - ) else { return false } + let mask = (1 << CGEventType.flagsChanged.rawValue) + guard let tap = CGEvent.tapCreate(tap: .cghidEventTap, place: .headInsertEventTap, + options: .defaultTap, eventsOfInterest: CGEventMask(mask), + callback: { _, _, _, _ in return nil }, userInfo: nil) else { return false } CFMachPortInvalidate(tap) return true } @@ -109,8 +99,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { alert.runModal() } - // MARK: - 麦克风 / 语音识别权限 - private func showMicrophonePermissionAlert() { let alert = NSAlert() alert.messageText = "需要麦克风权限" @@ -138,29 +126,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - Key Monitor private func startKeyMonitor() { - let started = keyMonitor.start() - if !started { - print("[App] Failed to start key monitor") - return - } - - // Fn 键:长按录音 - keyMonitor.onFnDown = { [weak self] in - self?.startRecording() - } - keyMonitor.onFnUp = { [weak self] in - self?.stopRecording() - } - // Control 键:双击切换模式 - keyMonitor.onControlDoubleClick = { [weak self] in - self?.toggleModeViaDoubleClick() - } + guard keyMonitor.start() else { print("[App] Failed to start key monitor"); return } + keyMonitor.onFnDown = { [weak self] in self?.startRecording() } + keyMonitor.onFnUp = { [weak self] in self?.stopRecording() } + keyMonitor.onControlDoubleClick = { [weak self] in self?.toggleModeViaDoubleClick() } } - // MARK: - Double-Click Mode Switch - private func toggleModeViaDoubleClick() { - let newMode: TriggerMode = Config.shared.triggerMode == .input ? .assist : .input + let newMode: TriggerMode + switch Config.shared.triggerMode { + case .input: newMode = .assist + case .assist: newMode = .agent + case .agent: newMode = .input + } Config.shared.triggerMode = newMode menuBarController.refreshModeMenu() print("[App] Control double-click: mode switched to \(newMode)") @@ -170,34 +148,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private func startRecording() { guard !isRecording else { return } + + // 如果当前模式不是智能体但刘海弹窗还在 → 关闭 + if agentNotch != nil && Config.shared.triggerMode != .agent { + hideAgentNotch() + } + + // 智能体 + 打字输入模式 → 直接显示文本输入刘海 + if Config.shared.triggerMode == .agent && Config.shared.agentInputMode == "text" { + isTextInputActive = true + recordingPanel.updateText("Type your question...") + recordingPanel.showAnimated() + showTextInputNotch() + return + } + isRecording = true finalTranscription = "" - // 检查麦克风权限 let micStatus = AVCaptureDevice.authorizationStatus(for: .audio) - if micStatus == .denied || micStatus == .restricted { - showMicrophonePermissionAlert() - isRecording = false - return - } + if micStatus == .denied || micStatus == .restricted { showMicrophonePermissionAlert(); isRecording = false; return } - // 检查语音识别权限 let speechStatus = SFSpeechRecognizer.authorizationStatus() - if speechStatus == .denied || speechStatus == .restricted { - showSpeechPermissionAlert() - isRecording = false - return - } + if speechStatus == .denied || speechStatus == .restricted { showSpeechPermissionAlert(); isRecording = false; return } if speechStatus == .notDetermined { - // 触发系统弹窗 SFSpeechRecognizer.requestAuthorization { [weak self] status in DispatchQueue.main.async { self?.isRecording = false - guard status == .authorized else { - self?.showSpeechPermissionAlert() - return - } - // 重新触发 + guard status == .authorized else { self?.showSpeechPermissionAlert(); return } self?.startRecording() } } @@ -205,13 +183,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } recordingPanel.showAnimated() - - // 根据模式显示不同提示 - let promptText = Config.shared.triggerMode == .input ? "Listening..." : "Ask AI..." + let promptText: String = { + switch Config.shared.triggerMode { + case .input: return "Listening..." + case .assist: return "Ask AI..." + case .agent: return "Ask Agent..." + } + }() recordingPanel.updateText(promptText) let language = Config.shared.language.rawValue - Task { @MainActor in let audioOK = audioMonitor.start() if audioOK { @@ -219,7 +200,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self?.recordingPanel.waveformView.updateLevel(level) } } - let speechOK = await speechRecognizer.start(language: language) if !speechOK { recordingPanel.updateText("Recognition error") @@ -228,115 +208,43 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } return } - speechRecognizer.onResult = { [weak self] text in - guard let self = self, self.isRecording else { return } - self.finalTranscription = text - self.recordingPanel.updateText(text) + guard let self, isRecording else { return } + finalTranscription = text + recordingPanel.updateText(text) } - speechRecognizer.onError = { error in - let nsError = error as NSError - if nsError.domain == "kAFAssistantErrorDomain" && nsError.code == 1110 { - return - } + let ns = error as NSError + if ns.domain == "kAFAssistantErrorDomain", ns.code == 1110 { return } print("[App] Speech error: \(error)") } } } private func stopRecording() { + // 打字模式:仅隐藏录音胶囊,刘海保持打开等待输入 + if isTextInputActive { + isTextInputActive = false + recordingPanel.hideAnimated() + return + } + guard isRecording else { return } isRecording = false - audioMonitor.stop() _ = speechRecognizer.stop() let text = finalTranscription.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { - recordingPanel.hideAnimated() - finalTranscription = "" - return + guard !text.isEmpty else { recordingPanel.hideAnimated(); finalTranscription = ""; return } + + switch Config.shared.triggerMode { + case .input: handleInputMode(text: text, config: Config.shared.inputLLM) + case .assist: handleAssistMode(text: text, config: Config.shared.assistLLM) + case .agent: handleAgentMode(text: text) } - - // 根据 triggerMode 选择配置和逻辑 - let mode = Config.shared.triggerMode - let config = mode == .input ? Config.shared.inputLLM : Config.shared.assistLLM - - switch mode { - case .input: - handleInputMode(text: text, config: config) - case .assist: - handleAssistMode(text: text, config: config) - } - finalTranscription = "" } - // MARK: - Input Mode (Fn) - - private func handleInputMode(text: String, config: LLMConfig) { - guard config.enabled && config.isConfigured else { - recordingPanel.hideAnimated { - TextInjector.inject(text) - } - return - } - - recordingPanel.showRefining() - - Task { @MainActor in - do { - let result = try await llmRefiner.refine(text: text, config: config) - recordingPanel.hideAnimated { - TextInjector.inject(result.text) - } - } catch { - print("[App] LLM refine error: \(error)") - recordingPanel.hideAnimated { - TextInjector.inject(text) - } - } - } - } - - // MARK: - Assist Mode (Control) - - private func handleAssistMode(text: String, config: LLMConfig) { - guard config.enabled && config.isConfigured else { - recordingPanel.updateText("AI 助手未配置") - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in - self?.recordingPanel.hideAnimated() - } - return - } - - recordingPanel.showThinking() - - Task { @MainActor in - do { - let result = try await llmRefiner.chat(text: text, config: config) - - if Config.shared.assistOutputMode == .notch { - // 刘海弹窗模式 - recordingPanel.hideAnimated() - NotchDisplayManager.shared.show(text: result.text) - } else { - // 直接输入模式(默认) - recordingPanel.hideAnimated { - TextInjector.inject(result.text) - } - } - } catch { - print("[App] LLM chat error: \(error)") - recordingPanel.updateText("请求失败,请检查配置") - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in - self?.recordingPanel.hideAnimated() - } - } - } - } - private func cancelRecording() { audioMonitor.stop() speechRecognizer.cancel() @@ -345,6 +253,206 @@ final class AppDelegate: NSObject, NSApplicationDelegate { finalTranscription = "" } + // MARK: - Input Mode + + private func handleInputMode(text: String, config: LLMConfig) { + guard config.enabled, config.isConfigured else { + recordingPanel.hideAnimated { TextInjector.inject(text) } + return + } + recordingPanel.showRefining() + Task { @MainActor in + do { + let result = try await llmRefiner.refine(text: text, config: config) + recordingPanel.hideAnimated { TextInjector.inject(result.text) } + } catch { + print("[App] LLM refine error: \(error)") + recordingPanel.hideAnimated { TextInjector.inject(text) } + } + } + } + + // MARK: - Assist Mode + + private func handleAssistMode(text: String, config: LLMConfig) { + guard config.enabled, config.isConfigured else { + recordingPanel.updateText("AI 助手未配置") + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in self?.recordingPanel.hideAnimated() } + return + } + recordingPanel.showThinking() + Task { @MainActor in + do { + let result = try await llmRefiner.chat(text: text, config: config) + if Config.shared.assistOutputMode == .notch { + recordingPanel.hideAnimated() + NotchDisplayManager.shared.show(text: result.text) + } else { + recordingPanel.hideAnimated { TextInjector.inject(result.text) } + } + } catch { + print("[App] LLM chat error: \(error)") + recordingPanel.updateText("请求失败,请检查配置") + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in self?.recordingPanel.hideAnimated() } + } + } + } + + // MARK: - Agent Mode + + private func handleAgentMode(text: String) { + let modelsFile = Config.shared.loadAgentModels() + let validModels = modelsFile.models.filter { !$0.url.isEmpty && !$0.name.isEmpty && !$0.apikey.isEmpty && !$0.modes.isEmpty } + let defaultModel = modelsFile.defaultModel.isEmpty ? validModels.first?.name : modelsFile.defaultModel + guard !validModels.isEmpty, defaultModel != nil else { + recordingPanel.updateText("智能体未配置") + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in self?.recordingPanel.hideAnimated() } + return + } + recordingPanel.hideAnimated() + + // 如果对话已在进行中 → 追加消息 + if agentActive { + autoHideWorkItem?.cancel() + agentTracker.start() + showAgentNotchInternal(isTextInput: agentIsTextMode) + agentBridge.sendContinue(message: text) + return + } + + agentActive = true + agentIsTextMode = Config.shared.agentInputMode == "text" + agentTracker.start() + showAgentNotchInternal(isTextInput: agentIsTextMode) + + agentBridge.onEvent = { [weak self] event in + DispatchQueue.main.async { self?.handleAgentEvent(event) } + } + agentBridge.onExit = { [weak self] code in + DispatchQueue.main.async { + guard let self = self else { return } + self.agentLog("[App] onExit code=\(code) currentMode=\(self.agentTracker.mode)") + self.agentActive = false + self.agentIsTextMode = false + if !self.agentExitingCleanly && code != 0 && self.agentTracker.mode != "done" { + self.agentTracker.fail("进程异常退出 (code \(code))") + } + self.agentExitingCleanly = false + } + } + guard agentBridge.start() else { + agentActive = false + agentTracker.fail("无法启动智能体进程") + return + } + agentBridge.send(message: text, workspace: Config.shared.agentWorkspace, + allowMode: Config.shared.agentAllowMode, + modelKey: defaultModel!) + } + + private func hideAgentNotch() { + agentNotch?.hide(ignoreMouse: true) + agentNotch = nil + } + + private func handleAgentEvent(_ event: AgentEvent) { + switch event { + case .status: break // no-op,内容切换由 toolPhaseActive 控制 + case .toolStart(let tool, let display): + agentLog("[App] handleEvent toolStart tool=\(tool)") + agentTracker.addTool(tool, display: display) + case .toolEnd(let tool, let resultLines): + agentLog("[App] handleEvent toolEnd tool=\(tool) lines=\(resultLines.count)") + agentTracker.updateToolResult(tool, resultLines: resultLines) + case .assistantChunk(let content): + agentTracker.appendChunk(content) + case .done(let content, _): + agentLog("[App] handleEvent done contentLen=\(content.count)") + agentTracker.finish(content: content) + autoHideWorkItem?.cancel() + // 打字模式下,输入栏有内容时不自动收回 + if !agentIsTextMode { + let workItem = DispatchWorkItem { [weak self] in self?.hideAgentNotch() } + autoHideWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 20, execute: workItem) + } + case .error(let message): + agentLog("[App] handleEvent error message=\(message)") + agentTracker.fail(message) + } + } + + private func agentLog(_ msg: String) { + let logPath = (Bundle.main.bundlePath as NSString).deletingLastPathComponent + "/../../../agent_debug.log" + 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() + } + } + + private func showAgentNotch() { + showAgentNotchInternal(isTextInput: false) + } + + private func showTextInputNotch() { + // 重置显示状态,但不进入 thinking(等用户发送后再开始) + agentTracker.resetForInput() + agentIsTextMode = true + showAgentNotchInternal(isTextInput: true) + } + + private func showAgentNotchInternal(isTextInput: Bool) { + agentTracker.isTextInputMode = isTextInput + + // 弹窗已存在 → 复用,只刷新显示 + if agentNotch != nil { + agentNotch?.show() + return + } + + // 创建新弹窗 + let view = AgentNotchView( + tracker: agentTracker, + onCopy: { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(self.agentTracker.finalContent, forType: .string) + }, + onStop: { [weak self] in + self?.autoHideWorkItem?.cancel() + self?.agentExitingCleanly = true + self?.agentBridge.stop() + self?.hideAgentNotch() + }, + onClose: { [weak self] in + self?.autoHideWorkItem?.cancel() + self?.hideAgentNotch() + }, + onNewConversation: { [weak self] in + self?.autoHideWorkItem?.cancel() + self?.agentExitingCleanly = true + self?.agentBridge.stop() + self?.agentActive = false + self?.agentIsTextMode = false + self?.agentTracker.resetForInput() + }, + onSendText: { [weak self] text in + self?.handleTextInput(text) + }, + onContentSizeChanged: { [weak self] in + self?.agentNotch?.notifyContentChanged() + } + ) + agentNotch = DynamicNotch(style: .notch) { view } + agentNotch?.show() + } + + private func handleTextInput(_ text: String) { + handleAgentMode(text: text) + } + func applicationWillTerminate(_ notification: Notification) { keyMonitor.stop() audioMonitor.stop() diff --git a/Sources/App/MenuBarController.swift b/Sources/App/MenuBarController.swift index 5ad2714..009cbda 100644 --- a/Sources/App/MenuBarController.swift +++ b/Sources/App/MenuBarController.swift @@ -17,6 +17,8 @@ final class MenuBarController: NSObject { private let inputSettingsWindow: SettingsWindow private let assistSettingsWindow: SettingsWindow + private let agentSettingsWindow: SettingsWindow + private let agentLLMEnabledItem: NSMenuItem override init() { statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) @@ -35,6 +37,12 @@ final class MenuBarController: NSObject { inputSettingsWindow = SettingsWindow(mode: .input) assistSettingsWindow = SettingsWindow(mode: .assist) + agentSettingsWindow = SettingsWindow(mode: .agent) + agentLLMEnabledItem = NSMenuItem( + title: "Enable", + action: #selector(toggleAgentLLM), + keyEquivalent: "" + ) super.init() @@ -117,6 +125,17 @@ final class MenuBarController: NSObject { modeMenuItems.append(assistModeItem) modeMenu.addItem(assistModeItem) + let agentModeItem = NSMenuItem( + title: "🤖 智能体", + action: #selector(selectMode(_:)), + keyEquivalent: "" + ) + agentModeItem.target = self + agentModeItem.representedObject = TriggerMode.agent + agentModeItem.state = Config.shared.triggerMode == .agent ? .on : .off + modeMenuItems.append(agentModeItem) + modeMenu.addItem(agentModeItem) + // 禁用 autoenablesItems,防止系统自动禁用菜单项 modeMenu.autoenablesItems = false @@ -174,6 +193,28 @@ final class MenuBarController: NSObject { menu.addItem(.separator()) + // ===== Agent LLM ===== + let agentLLMSubmenu = NSMenu() + agentLLMEnabledItem.target = self + agentLLMSubmenu.addItem(agentLLMEnabledItem) + updateAgentLLMMenuState() + + let agentSettingsItem = NSMenuItem( + title: "Settings...", + action: #selector(openAgentSettings), + keyEquivalent: "" + ) + agentSettingsItem.target = self + agentSettingsItem.isEnabled = true + agentLLMSubmenu.addItem(agentSettingsItem) + + let agentSubmenuItem = NSMenuItem() + agentSubmenuItem.title = "🤖 Agent LLM" + agentSubmenuItem.submenu = agentLLMSubmenu + menu.addItem(agentSubmenuItem) + + menu.addItem(.separator()) + // 退出 let quitItem = NSMenuItem( title: "Quit", @@ -189,6 +230,9 @@ final class MenuBarController: NSObject { modeMenu.autoenablesItems = false inputLLMSubmenu.autoenablesItems = false assistLLMSubmenu.autoenablesItems = false + agentLLMSubmenu.autoenablesItems = false + + print("[Menu Debug] Agent Settings item: title=\(agentSettingsItem.title) enabled=\(agentSettingsItem.isEnabled) target=\(String(describing: agentSettingsItem.target)) action=\(String(describing: agentSettingsItem.action)) submenu=\(String(describing: agentSettingsItem.menu))") statusItem.menu = menu } @@ -213,6 +257,12 @@ final class MenuBarController: NSObject { assistLLMEnabledItem.state = Config.shared.assistLLM.enabled ? .on : .off } + private func updateAgentLLMMenuState() { + let modelsFile = Config.shared.loadAgentModels() + let validModels = modelsFile.models.filter { !$0.url.isEmpty && !$0.name.isEmpty && !$0.apikey.isEmpty && !$0.modes.isEmpty } + agentLLMEnabledItem.state = validModels.isEmpty ? .off : .on + } + private func updateModeMenuState() { for item in modeMenuItems { guard let mode = item.representedObject as? TriggerMode else { continue } @@ -268,6 +318,15 @@ final class MenuBarController: NSObject { print("[Menu] Assist LLM: \(config.enabled ? "ON" : "OFF")") } + @objc private func toggleAgentLLM() { + let modelsFile = Config.shared.loadAgentModels() + let validModels = modelsFile.models.filter { !$0.url.isEmpty && !$0.name.isEmpty && !$0.apikey.isEmpty && !$0.modes.isEmpty } + // 只要有有效模型就视为已配置,点击时打开 Settings + if validModels.isEmpty { + openAgentSettings() + } + } + @objc private func openInputSettings() { inputSettingsWindow.makeKeyAndOrderFront(nil) NSApplication.shared.activate(ignoringOtherApps: true) @@ -278,6 +337,13 @@ final class MenuBarController: NSObject { NSApplication.shared.activate(ignoringOtherApps: true) } + @objc private func openAgentSettings() { + print("[MenuBar] openAgentSettings called") + agentSettingsWindow.refreshAgentModelTable() + agentSettingsWindow.makeKeyAndOrderFront(nil) + NSApplication.shared.activate(ignoringOtherApps: true) + } + @objc private func quit() { NSApplication.shared.terminate(nil) } diff --git a/Sources/Core/AgentBridge.swift b/Sources/Core/AgentBridge.swift new file mode 100644 index 0000000..bb851ee --- /dev/null +++ b/Sources/Core/AgentBridge.swift @@ -0,0 +1,241 @@ +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 + } + } +} diff --git a/Sources/UI/AgentModelEditor.swift b/Sources/UI/AgentModelEditor.swift new file mode 100644 index 0000000..0dbdc41 --- /dev/null +++ b/Sources/UI/AgentModelEditor.swift @@ -0,0 +1,193 @@ +import Cocoa + +// MARK: - Agent 模型编辑弹窗 + +/// 显示添加/编辑模型的独立窗口,通过回调返回结果 +final class AgentModelEditor { + static func show(for model: AgentModelConfig?, onSave: @escaping (AgentModelConfig) -> Void) { + let isEdit = model != nil + let panel = KeyEquivalentPanel( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 340), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + panel.title = isEdit ? "编辑模型" : "添加模型" + panel.isReleasedWhenClosed = false + panel.center() + + guard let content = panel.contentView else { return } + + let nameField = KeyEquivalentTextField(frame: .zero) + nameField.placeholderString = "kimi-k2.5" + nameField.stringValue = model?.name ?? "" + + let urlField = KeyEquivalentTextField(frame: .zero) + urlField.placeholderString = "https://api.example.com/v1" + urlField.stringValue = model?.url ?? "" + + let keyField = KeyEquivalentSecureTextField(frame: .zero) + keyField.placeholderString = "sk-..." + keyField.stringValue = model?.apikey ?? "" + + let modesField = KeyEquivalentTextField(frame: .zero) + modesField.placeholderString = "快速,思考" + modesField.stringValue = model?.modes ?? "快速,思考" + + let multimodalField = KeyEquivalentTextField(frame: .zero) + multimodalField.placeholderString = "图片,视频(可留空)" + multimodalField.stringValue = model?.multimodal ?? "" + + let maxOutputField = KeyEquivalentTextField(frame: .zero) + maxOutputField.placeholderString = "32000" + maxOutputField.stringValue = model.map { String($0.maxOutput) } ?? "32000" + + let maxContextField = KeyEquivalentTextField(frame: .zero) + maxContextField.placeholderString = "256000" + maxContextField.stringValue = model.map { String($0.maxContext) } ?? "256000" + + let fields: [(String, NSTextField)] = [ + ("模型名称:", nameField), + ("API URL:", urlField), + ("API Key:", keyField), + ("模式:", modesField), + ("多模态:", multimodalField), + ("最大输出:", maxOutputField), + ("最大上下文:", maxContextField), + ] + + let grid = NSGridView(frame: .zero) + grid.translatesAutoresizingMaskIntoConstraints = false + grid.columnSpacing = 8 + grid.rowSpacing = 10 + + for (labelText, field) in fields { + let label = makeLabel(labelText) + field.font = .systemFont(ofSize: 13) + field.bezelStyle = .roundedBezel + field.translatesAutoresizingMaskIntoConstraints = false + grid.addRow(with: [label, field]) + } + + grid.column(at: 0).xPlacement = .trailing + grid.column(at: 1).width = 260 + + let saveBtn = NSButton(title: isEdit ? "保存" : "添加", target: nil, action: nil) + saveBtn.bezelStyle = .rounded + saveBtn.translatesAutoresizingMaskIntoConstraints = false + saveBtn.keyEquivalent = "\r" + + let cancelBtn = NSButton(title: "取消", target: nil, action: nil) + cancelBtn.bezelStyle = .rounded + cancelBtn.translatesAutoresizingMaskIntoConstraints = false + + let buttonStack = NSStackView(views: [cancelBtn, saveBtn]) + buttonStack.translatesAutoresizingMaskIntoConstraints = false + buttonStack.orientation = .horizontal + buttonStack.spacing = 12 + buttonStack.distribution = .fillEqually + + content.addSubview(grid) + content.addSubview(buttonStack) + + NSLayoutConstraint.activate([ + grid.topAnchor.constraint(equalTo: content.topAnchor, constant: 20), + grid.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + grid.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20), + buttonStack.topAnchor.constraint(equalTo: grid.bottomAnchor, constant: 16), + buttonStack.centerXAnchor.constraint(equalTo: content.centerXAnchor), + buttonStack.widthAnchor.constraint(equalToConstant: 200) + ]) + + let helper = ModelEditorHelper( + panel: panel, + nameField: nameField, urlField: urlField, keyField: keyField, + modesField: modesField, multimodalField: multimodalField, + maxOutputField: maxOutputField, maxContextField: maxContextField, + isEdit: isEdit, onSave: onSave + ) + + saveBtn.target = helper + saveBtn.action = #selector(ModelEditorHelper.saveAction(_:)) + cancelBtn.target = helper + cancelBtn.action = #selector(ModelEditorHelper.cancelAction(_:)) + + objc_setAssociatedObject(saveBtn, "helper", helper, .OBJC_ASSOCIATION_RETAIN) + objc_setAssociatedObject(cancelBtn, "helper", helper, .OBJC_ASSOCIATION_RETAIN) + + panel.makeKeyAndOrderFront(nil) + } + + private static func makeLabel(_ text: String) -> NSTextField { + let label = NSTextField(labelWithString: text) + label.font = .systemFont(ofSize: 11, weight: .semibold) + label.textColor = .secondaryLabelColor + label.alignment = .right + label.translatesAutoresizingMaskIntoConstraints = false + return label + } +} + +// MARK: - Helper + +private class ModelEditorHelper: NSObject { + weak var panel: KeyEquivalentPanel? + let nameField: NSTextField + let urlField: NSTextField + let keyField: KeyEquivalentSecureTextField + let modesField: NSTextField + let multimodalField: NSTextField + let maxOutputField: NSTextField + let maxContextField: NSTextField + let isEdit: Bool + let onSave: (AgentModelConfig) -> Void + + init(panel: KeyEquivalentPanel, + nameField: NSTextField, urlField: NSTextField, keyField: KeyEquivalentSecureTextField, + modesField: NSTextField, multimodalField: NSTextField, + maxOutputField: NSTextField, maxContextField: NSTextField, + isEdit: Bool, onSave: @escaping (AgentModelConfig) -> Void) { + self.panel = panel + self.nameField = nameField + self.urlField = urlField + self.keyField = keyField + self.modesField = modesField + self.multimodalField = multimodalField + self.maxOutputField = maxOutputField + self.maxContextField = maxContextField + self.isEdit = isEdit + self.onSave = onSave + } + + @objc func saveAction(_ sender: Any) { + let name = nameField.stringValue.trimmingCharacters(in: .whitespaces) + let url = urlField.stringValue.trimmingCharacters(in: .whitespaces) + let key = keyField.stringValue.trimmingCharacters(in: .whitespaces) + let modes = modesField.stringValue.trimmingCharacters(in: .whitespaces) + let multimodal = multimodalField.stringValue.trimmingCharacters(in: .whitespaces) + let maxOutput = Int(maxOutputField.stringValue) ?? 32000 + let maxContext = Int(maxContextField.stringValue) ?? 256000 + + guard !name.isEmpty, !url.isEmpty, !key.isEmpty, !modes.isEmpty else { + let alert = NSAlert() + alert.messageText = "请填写模型名称、API URL、API Key 和模式" + alert.alertStyle = .warning + alert.addButton(withTitle: "确定") + alert.runModal() + return + } + + let model = AgentModelConfig( + url: url, name: name, apikey: key, + modes: modes, multimodal: multimodal, + maxOutput: maxOutput, maxContext: maxContext + ) + + panel?.close() + onSave(model) + } + + @objc func cancelAction(_ sender: Any) { + panel?.close() + } +} diff --git a/Sources/UI/AgentNotchView.swift b/Sources/UI/AgentNotchView.swift new file mode 100644 index 0000000..4ec0fa4 --- /dev/null +++ b/Sources/UI/AgentNotchView.swift @@ -0,0 +1,298 @@ +import SwiftUI +import DynamicNotchKit + +// MARK: - AgentNotchView + +struct AgentNotchView: View { + @ObservedObject var tracker: AgentProgressTracker + var onCopy: () -> Void + var onStop: () -> Void + var onClose: () -> Void + var onNewConversation: (() -> Void)? = nil + var onSendText: ((String) -> Void)? = nil + var onContentSizeChanged: (() -> Void)? = nil + + private let maxHeight: CGFloat = 250 + private let maxStreamingHeight: CGFloat = 120 + + @State private var textInput: String = "" + @State private var scrollWorkItem: DispatchWorkItem? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + statusHeader + + if !tracker.streamingContent.isEmpty { + streamingContentView + } else if !tracker.isTextInputMode && tracker.mode == "thinking" && tracker.toolEntries.isEmpty { + thinkingView + } + + if !tracker.toolEntries.isEmpty { + toolProgressList + } + + if tracker.mode == "done" { + doneView + } else if tracker.mode == "error" { + errorView + } + + if tracker.isTextInputMode && tracker.mode != "thinking" && tracker.mode != "running" { + textInputBar + } + } + .frame(width: 340) + .colorScheme(.dark) + .animation(.spring(response: 0.35, dampingFraction: 0.65), value: tracker.mode) + .animation(.spring(response: 0.35, dampingFraction: 0.65), value: tracker.toolEntries.count) + .animation(.spring(response: 0.35, dampingFraction: 0.65), value: tracker.isTextInputMode) + .onChange(of: tracker.mode) { _, _ in onContentSizeChanged?() } + .onChange(of: tracker.toolEntries.count) { _, _ in onContentSizeChanged?() } + .onChange(of: tracker.isTextInputMode) { _, _ in onContentSizeChanged?() } + .onChange(of: tracker.streamingContent.isEmpty) { _, _ in onContentSizeChanged?() } + } + + // MARK: - 状态头 + + private var statusHeader: some View { + HStack(spacing: 6) { + Image(systemName: statusIcon) + .font(.system(size: 10)) + .foregroundColor(statusColor) + .opacity(tracker.mode == "thinking" ? 1 : (tracker.mode == "running" ? 0.6 : 0.4)) + + Text(tracker.summary) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.white.opacity(0.7)) + + Spacer() + + if tracker.mode == "running" || tracker.mode == "thinking" { + Button("停止") { + onStop() + } + .font(.system(size: 10)) + .foregroundColor(.white.opacity(0.6)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.white.opacity(0.1)) + .clipShape(Capsule()) + .buttonStyle(.plain) + } + + if tracker.mode != "thinking" && tracker.mode != "running", + let onNew = onNewConversation { + Button(action: onNew) { + Image(systemName: "plus.bubble") + .font(.system(size: 9, weight: .medium)) + .foregroundColor(.white.opacity(0.5)) + .frame(width: 20, height: 20) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("新建对话") + .padding(.leading, 2) + } + + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .medium)) + .foregroundColor(.white.opacity(0.5)) + .frame(width: 20, height: 20) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.leading, 2) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + } + + // MARK: - 思考中 + + private var thinkingView: some View { + HStack(spacing: 6) { + ProgressView() + .scaleEffect(0.6) + Text("正在分析你的问题...") + .font(.system(size: 11)) + .foregroundColor(.white.opacity(0.4)) + } + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 12) + } + + // MARK: - 实时流式输出 + + private var streamingContentView: some View { + let hasTools = !tracker.toolEntries.isEmpty + + return ScrollViewReader { proxy in + ScrollView(.vertical, showsIndicators: false) { + Text(tracker.streamingContent) + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.85)) + .lineSpacing(3) + .frame(maxWidth: .infinity, alignment: .leading) + .id("streaming_bottom") + } + .frame(height: hasTools ? maxStreamingHeight : nil) + .frame(maxHeight: maxStreamingHeight) + .padding(.horizontal, 14) + .padding(.vertical, 6) + .onChange(of: tracker.streamingContent) { _, _ in + withAnimation { + proxy.scrollTo("streaming_bottom", anchor: .bottom) + } + } + } + } + + // MARK: - 工具进度列表(可见 3 条,可滚动查看全部) + + private var toolProgressList: some View { + ScrollViewReader { proxy in + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(tracker.toolEntries.enumerated()), id: \.element.id) { idx, entry in + compactToolRow(entry: entry) + .id(entry.id) + .transition( + .asymmetric( + insertion: .move(edge: .bottom).combined(with: .opacity), + removal: .move(edge: .top).combined(with: .opacity) + ) + ) + .zIndex(Double(-idx)) + } + } + } + .frame(maxHeight: 60) + .clipped() + .padding(.horizontal, 4) + .onReceive(tracker.$toolEntries) { entries in + // 并行工具调用防抖:0.1s 内连续出现只滚一次到底 + scrollWorkItem?.cancel() + let workItem = DispatchWorkItem { + withAnimation { + if let lastID = entries.last?.id { + proxy.scrollTo(lastID, anchor: .bottom) + } + } + } + scrollWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem) + } + } + } + + private func compactToolRow(entry: AgentProgressTracker.ToolEntry) -> some View { + let hasResult = !entry.resultLines.isEmpty + let isActive = !hasResult && entry.id == tracker.toolEntries.last?.id && tracker.mode == "running" + + return HStack(spacing: 4) { + Circle() + .fill(isActive ? Color.white : Color.white.opacity(0.3)) + .frame(width: 5, height: 5) + Text(entry.display) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(hasResult ? .white.opacity(0.6) : .white.opacity(0.85)) + .lineLimit(1) + .truncationMode(.tail) + if isActive { + Circle() + .fill(.white.opacity(0.5)) + .frame(width: 3, height: 3) + .opacity(blinkingOpacity) + } + Spacer() + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + } + + @State private var blinkToggle = false + private var blinkingOpacity: Double { + blinkToggle ? 1 : 0.2 + } + + // MARK: - 完成视图 + + private var doneView: some View { + EmptyView() + } + + // MARK: - 错误视图 + + private var errorView: some View { + Text("❌ \(tracker.errorMessage)") + .font(.system(size: 12)) + .foregroundColor(.red.opacity(0.8)) + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + + // MARK: - 辅助 + + private var statusIcon: String { + switch tracker.mode { + case "thinking": return "brain" + case "running": return "gearshape.2" + case "done": return "checkmark.circle" + case "error": return "xmark.circle" + default: return "circle" + } + } + + private var statusColor: Color { + switch tracker.mode { + case "thinking": return .blue + case "running": return .orange + case "done": return .green + case "error": return .red + default: return .gray + } + } + + // MARK: - 文本输入栏 + + private var textInputBar: some View { + let isBusy = tracker.mode == "thinking" || tracker.mode == "running" + + return VStack(spacing: 0) { + Divider() + .overlay(.white.opacity(0.1)) + .padding(.horizontal, 10) + + HStack(spacing: 8) { + AppKitTextField( + text: $textInput, + placeholder: isBusy ? "等待回复中..." : "输入你的问题...", + isEditable: !isBusy, + onSubmit: { if !isBusy { sendText() } } + ) + .frame(height: 28) + .opacity(isBusy ? 0.5 : 1) + + Button(action: { if !isBusy { sendText() } }) { + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 20)) + .foregroundColor(!isBusy && !textInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? .blue : .white.opacity(0.2)) + } + .buttonStyle(.plain) + .disabled(isBusy || textInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + } + } + + private func sendText() { + let text = textInput.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + onSendText?(text) + textInput = "" + } +} diff --git a/Sources/UI/AgentProgressTracker.swift b/Sources/UI/AgentProgressTracker.swift new file mode 100644 index 0000000..e74f122 --- /dev/null +++ b/Sources/UI/AgentProgressTracker.swift @@ -0,0 +1,105 @@ +import SwiftUI + +// MARK: - Agent 进度跟踪 + +final class AgentProgressTracker: ObservableObject { + struct ToolEntry: Identifiable { + let id = UUID() + let tool: String + let display: String + var resultLines: [String] = [] + } + + @Published var mode: String = "idle" // idle / thinking / running / done / error + @Published var elapsedSeconds: Int = 0 + @Published var toolEntries: [ToolEntry] = [] + @Published var finalContent: String = "" + @Published var errorMessage: String = "" + @Published var streamingContent: String = "" // 实时流式输出 + @Published var isTextInputMode: Bool = false // 打字输入模式标记(外部控制) + + private var timer: Timer? + private var startTime: Date = Date() + private var toolPhaseActive = false // 工具后首段内容应替换 + + func start() { + startTime = Date() + mode = "thinking" + toolEntries = [] + finalContent = "" + errorMessage = "" + streamingContent = "" + toolPhaseActive = false + + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + guard let self = self, self.mode != "done", self.mode != "error" else { return } + self.elapsedSeconds = Int(Date().timeIntervalSince(self.startTime)) + } + } + + /// 打字模式:清空状态但不进入 thinking + func resetForInput() { + mode = "idle" + elapsedSeconds = 0 + toolEntries = [] + finalContent = "" + errorMessage = "" + streamingContent = "" + toolPhaseActive = false + timer?.invalidate() + timer = nil + } + + func appendChunk(_ chunk: String) { + if toolPhaseActive { + streamingContent = chunk + toolPhaseActive = false + } else { + streamingContent += chunk + } + } + + func addTool(_ tool: String, display: String) { + mode = "running" + toolPhaseActive = true // 标记:下一段内容应替换 + toolEntries.append(ToolEntry(tool: tool, display: display)) + } + + func updateToolResult(_ tool: String, resultLines: [String]) { + if let idx = toolEntries.lastIndex(where: { $0.tool == tool && $0.resultLines.isEmpty }) { + toolEntries[idx].resultLines = resultLines + } + } + + func finish(content: String) { + mode = "done" + finalContent = content + timer?.invalidate() + timer = nil + } + + func fail(_ message: String) { + mode = "error" + errorMessage = message + timer?.invalidate() + timer = nil + } + + var formattedTime: String { + let m = elapsedSeconds / 60 + let s = elapsedSeconds % 60 + return String(format: "%02d:%02d", m, s) + } + + var summary: String { + if mode == "done" { + return "工作完成 · \(formattedTime)" + } else if mode == "error" { + return "错误: \(errorMessage)" + } else if mode == "idle" { + return "准备就绪" + } else { + return "工作中 \(formattedTime)" + } + } +} diff --git a/Sources/UI/AppKitTextField.swift b/Sources/UI/AppKitTextField.swift new file mode 100644 index 0000000..702b79c --- /dev/null +++ b/Sources/UI/AppKitTextField.swift @@ -0,0 +1,84 @@ +import SwiftUI +import AppKit + +// MARK: - AppKit NSTextField 桥接(支持 Cmd+A/C/V/X) + +struct AppKitTextField: NSViewRepresentable { + @Binding var text: String + var placeholder: String + var isEditable: Bool + var onSubmit: () -> Void + + func makeNSView(context: Context) -> KeyEquivalentTextField { + let textField = KeyEquivalentTextField() + textField.isBordered = false + textField.isBezeled = false + textField.drawsBackground = false + textField.focusRingType = .none + textField.font = .systemFont(ofSize: 12) + textField.textColor = .white + textField.placeholderString = placeholder + textField.isEditable = isEditable + textField.delegate = context.coordinator + textField.cell?.wraps = false + textField.cell?.isScrollable = true + textField.lineBreakMode = .byClipping + return textField + } + + func updateNSView(_ nsView: KeyEquivalentTextField, context: Context) { + if nsView.stringValue != text { + nsView.stringValue = text + } + nsView.placeholderString = placeholder + nsView.isEditable = isEditable + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, NSTextFieldDelegate { + let parent: AppKitTextField + + init(_ parent: AppKitTextField) { + self.parent = parent + } + + func controlTextDidChange(_ obj: Notification) { + guard let textField = obj.object as? NSTextField else { return } + parent.text = textField.stringValue + } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + if commandSelector == #selector(NSResponder.insertNewline(_:)) { + parent.onSubmit() + return true + } + return false + } + } +} + +// MARK: - Cmd+A/C/V/X 支持 + +final class KeyEquivalentTextField: NSTextField { + override func performKeyEquivalent(with event: NSEvent) -> Bool { + guard event.modifierFlags.contains(.command), + let chars = event.charactersIgnoringModifiers?.lowercased() else { + return super.performKeyEquivalent(with: event) + } + switch chars { + case "a": + return NSApp.sendAction(#selector(NSText.selectAll(_:)), to: currentEditor() ?? self, from: self) + case "c": + return NSApp.sendAction(#selector(NSText.copy(_:)), to: currentEditor() ?? self, from: self) + case "v": + return NSApp.sendAction(#selector(NSText.paste(_:)), to: currentEditor() ?? self, from: self) + case "x": + return NSApp.sendAction(#selector(NSText.cut(_:)), to: currentEditor() ?? self, from: self) + default: + return super.performKeyEquivalent(with: event) + } + } +} diff --git a/Sources/UI/KeyEquivalentPanel.swift b/Sources/UI/KeyEquivalentPanel.swift new file mode 100644 index 0000000..a2944ef --- /dev/null +++ b/Sources/UI/KeyEquivalentPanel.swift @@ -0,0 +1,26 @@ +import AppKit + +/// 支持 Cmd+A/C/V/X 快捷键的 NSPanel(用于独立弹窗如模型编辑器) +final class KeyEquivalentPanel: NSPanel { + override func performKeyEquivalent(with event: NSEvent) -> Bool { + guard event.modifierFlags.contains(.command), + let chars = event.charactersIgnoringModifiers?.lowercased() else { + return super.performKeyEquivalent(with: event) + } + let selector: Selector? + switch chars { + case "a": selector = #selector(NSText.selectAll(_:)) + case "c": selector = #selector(NSText.copy(_:)) + case "v": selector = #selector(NSText.paste(_:)) + case "x": selector = #selector(NSText.cut(_:)) + default: selector = nil + } + guard let sel = selector else { + return super.performKeyEquivalent(with: event) + } + if NSApp.sendAction(sel, to: nil, from: self) { + return true + } + return super.performKeyEquivalent(with: event) + } +} diff --git a/Sources/UI/KeyEquivalentSecureTextField.swift b/Sources/UI/KeyEquivalentSecureTextField.swift new file mode 100644 index 0000000..3614c48 --- /dev/null +++ b/Sources/UI/KeyEquivalentSecureTextField.swift @@ -0,0 +1,19 @@ +import AppKit + +// MARK: - Cmd+A/C/V/X 安全输入框 + +final class KeyEquivalentSecureTextField: NSSecureTextField { + override func performKeyEquivalent(with event: NSEvent) -> Bool { + guard event.modifierFlags.contains(.command), + let chars = event.charactersIgnoringModifiers?.lowercased() else { + return super.performKeyEquivalent(with: event) + } + switch chars { + case "a": return NSApp.sendAction(#selector(NSText.selectAll(_:)), to: currentEditor() ?? self, from: self) + case "c": return NSApp.sendAction(#selector(NSText.copy(_:)), to: currentEditor() ?? self, from: self) + case "v": return NSApp.sendAction(#selector(NSText.paste(_:)), to: currentEditor() ?? self, from: self) + case "x": return NSApp.sendAction(#selector(NSText.cut(_:)), to: currentEditor() ?? self, from: self) + default: return super.performKeyEquivalent(with: event) + } + } +} diff --git a/Sources/UI/SettingsWindow.swift b/Sources/UI/SettingsWindow.swift index 64cb0b6..831dd6c 100644 --- a/Sources/UI/SettingsWindow.swift +++ b/Sources/UI/SettingsWindow.swift @@ -16,6 +16,21 @@ final class SettingsWindow: NSWindow { private let outputModeLabel: NSTextField private let outputModePopup: NSPopUpButton + // Agent 模式专属 + private let workspaceField: NSTextField + private let browseButton: NSButton + private let modelPopup: NSPopUpButton + private let allowModePopup: NSPopUpButton + private let inputModePopup: NSPopUpButton + private let thinkingModePopup: NSPopUpButton + private let tavilyKeyField: NSSecureTextField + private let modelTableView: NSTableView + private let addModelButton: NSButton + private let editModelButton: NSButton + private let deleteModelButton: NSButton + private var agentModelList: [AgentModelConfig] = [] + private var agentUIBuilt = false + init(mode: LLMMode) { self.mode = mode @@ -28,8 +43,21 @@ final class SettingsWindow: NSWindow { statusLabel = NSTextField(labelWithString: "") outputModeLabel = NSTextField(labelWithString: "") outputModePopup = NSPopUpButton(frame: .zero, pullsDown: false) + workspaceField = NSTextField(frame: .zero) + browseButton = NSButton(title: "浏览...", target: nil, action: nil) + modelPopup = NSPopUpButton(frame: .zero, pullsDown: false) + allowModePopup = NSPopUpButton(frame: .zero, pullsDown: false) + inputModePopup = NSPopUpButton(frame: .zero, pullsDown: false) + thinkingModePopup = NSPopUpButton(frame: .zero, pullsDown: false) + tavilyKeyField = NSSecureTextField(frame: .zero) + modelTableView = NSTableView(frame: .zero) + addModelButton = NSButton(title: "+", target: nil, action: nil) + editModelButton = NSButton(title: "编辑", target: nil, action: nil) + deleteModelButton = NSButton(title: "删除", target: nil, action: nil) - let windowRect = NSRect(x: 0, y: 0, width: 420, height: mode == .assist ? 360 : 320) + let height: CGFloat = mode == .agent ? 660 : (mode == .assist ? 360 : 320) + let width: CGFloat = mode == .agent ? 500 : 420 + let windowRect = NSRect(x: 0, y: 0, width: width, height: height) super.init( contentRect: windowRect, styleMask: [.titled, .closable, .miniaturizable], @@ -43,15 +71,18 @@ final class SettingsWindow: NSWindow { } private func configureWindow() { - title = mode == .input ? "Input LLM Settings (Fn)" : "Assist LLM Settings (Control)" + switch mode { + case .input: title = "Input LLM Settings (Fn)" + case .assist: title = "Assist LLM Settings (Control)" + case .agent: title = "Agent LLM Settings" + } isReleasedWhenClosed = false center() } - // MARK: - 快捷键支持(LSUIElement 无 Edit 菜单,需手动转发) + // MARK: - 快捷键支持 override func performKeyEquivalent(with event: NSEvent) -> Bool { - // 仅拦截 Cmd 组合键 guard event.modifierFlags.contains(.command) else { return super.performKeyEquivalent(with: event) } @@ -69,7 +100,6 @@ final class SettingsWindow: NSWindow { return super.performKeyEquivalent(with: event) } - // 发送到第一响应者链 if NSApp.sendAction(sel, to: nil, from: self) { return true } @@ -77,18 +107,23 @@ final class SettingsWindow: NSWindow { return super.performKeyEquivalent(with: event) } - private func buildUI() { - guard let content = contentView else { return } + private func makeLabel(_ text: String) -> NSTextField { + let label = NSTextField(labelWithString: text) + label.font = .systemFont(ofSize: 11, weight: .semibold) + label.textColor = .secondaryLabelColor + label.alignment = .right + label.translatesAutoresizingMaskIntoConstraints = false + return label + } - func makeLabel(_ text: String) -> NSTextField { - let label = NSTextField(labelWithString: text) - label.font = .systemFont(ofSize: 11, weight: .semibold) - label.textColor = .secondaryLabelColor - label.alignment = .right - label.translatesAutoresizingMaskIntoConstraints = false - return label + private func buildUI() { + if mode == .agent { + buildAgentUI() + return } + guard let content = contentView else { return } + let baseURLLabel = makeLabel("API Base URL:") let apiKeyLabel = makeLabel("API Key:") let modelLabel = makeLabel("Model:") @@ -154,7 +189,6 @@ final class SettingsWindow: NSWindow { content.addSubview(copyFromOtherButton) content.addSubview(statusLabel) - // Assist 模式专属:输出方式选择 let outputLabel: NSTextField? if mode == .assist { let label = makeLabel("输出方式:") @@ -171,7 +205,6 @@ final class SettingsWindow: NSWindow { label.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), label.topAnchor.constraint(equalTo: grid.bottomAnchor, constant: 16), label.widthAnchor.constraint(equalToConstant: 90), - outputModePopup.leadingAnchor.constraint(equalTo: label.trailingAnchor, constant: 8), outputModePopup.centerYAnchor.constraint(equalTo: label.centerYAnchor), outputModePopup.widthAnchor.constraint(equalToConstant: 160) @@ -181,7 +214,6 @@ final class SettingsWindow: NSWindow { outputLabel = nil } - // 按钮位置:assist 模式下在输出选择下方,否则在 grid 下方 let buttonTopItem = (outputLabel ?? grid)! NSLayoutConstraint.activate([ @@ -284,4 +316,366 @@ final class SettingsWindow: NSWindow { self?.statusLabel.stringValue = "" } } + + // MARK: - Agent UI + + override func makeKeyAndOrderFront(_ sender: Any?) { + if mode == .agent && !agentUIBuilt { + refreshAgentModelTable() + } + super.makeKeyAndOrderFront(sender) + } + + func refreshAgentModelTable() { + buildAgentUI() + let modelsFile = Config.shared.loadAgentModels() + print("[SettingsWindow] loaded models count: \(modelsFile.models.count)") + agentModelList = modelsFile.models + modelTableView.reloadData() + + modelPopup.removeAllItems() + let validModels = modelsFile.models.filter { + !$0.url.isEmpty && !$0.name.isEmpty && !$0.apikey.isEmpty && !$0.modes.isEmpty + } + for m in validModels { + modelPopup.addItem(withTitle: m.name) + } + if let idx = validModels.firstIndex(where: { $0.name == modelsFile.defaultModel }) { + modelPopup.selectItem(at: idx) + } else if !validModels.isEmpty { + modelPopup.selectItem(at: 0) + } + + tavilyKeyField.stringValue = modelsFile.tavilyApiKey + workspaceField.stringValue = Config.shared.agentWorkspace + } + + private func buildAgentUI() { + guard !agentUIBuilt else { return } + guard let content = contentView else { return } + agentUIBuilt = true + + let modelLabel = makeLabel("默认模型:") + modelPopup.translatesAutoresizingMaskIntoConstraints = false + modelPopup.target = self + modelPopup.action = #selector(agentModelChanged) + + let allowLabel = makeLabel("运行模式:") + allowModePopup.translatesAutoresizingMaskIntoConstraints = false + allowModePopup.addItems(withTitles: ["Read Only(只读)", "Full Access(无限制)"]) + allowModePopup.selectItem(at: Config.shared.agentAllowMode == "read_only" ? 0 : 1) + allowModePopup.target = self + allowModePopup.action = #selector(agentAllowModeChanged) + + let inputLabel = makeLabel("输入方式:") + inputModePopup.translatesAutoresizingMaskIntoConstraints = false + inputModePopup.addItems(withTitles: ["🎤 语音输入", "⌨️ 打字输入"]) + inputModePopup.selectItem(at: Config.shared.agentInputMode == "text" ? 1 : 0) + inputModePopup.target = self + inputModePopup.action = #selector(agentInputModeChanged) + + let thinkLabel = makeLabel("思考模式:") + thinkingModePopup.translatesAutoresizingMaskIntoConstraints = false + thinkingModePopup.addItems(withTitles: ["⚡ 快速", "🧠 思考"]) + thinkingModePopup.selectItem(at: Config.shared.agentThinkingMode ? 1 : 0) + thinkingModePopup.target = self + thinkingModePopup.action = #selector(agentThinkingModeChanged) + + let tavilyLabel = makeLabel("Tavily Key:") + tavilyKeyField.placeholderString = "tvly-...(网络搜索用)" + tavilyKeyField.font = .systemFont(ofSize: 13) + tavilyKeyField.bezelStyle = .roundedBezel + tavilyKeyField.translatesAutoresizingMaskIntoConstraints = false + + let wsLabel = makeLabel("工作目录:") + workspaceField.placeholderString = NSHomeDirectory() + workspaceField.font = .systemFont(ofSize: 12) + workspaceField.bezelStyle = .roundedBezel + workspaceField.translatesAutoresizingMaskIntoConstraints = false + + browseButton.bezelStyle = .rounded + browseButton.translatesAutoresizingMaskIntoConstraints = false + browseButton.target = self + browseButton.action = #selector(browseWorkspace) + + let tableHeaderLabel = NSTextField(labelWithString: "模型列表") + tableHeaderLabel.font = .systemFont(ofSize: 12, weight: .semibold) + tableHeaderLabel.translatesAutoresizingMaskIntoConstraints = false + + addModelButton.bezelStyle = .rounded + addModelButton.font = .systemFont(ofSize: 13, weight: .bold) + addModelButton.translatesAutoresizingMaskIntoConstraints = false + addModelButton.target = self + addModelButton.action = #selector(addAgentModel) + + let scrollView = NSScrollView(frame: .zero) + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.hasVerticalScroller = true + scrollView.borderType = .bezelBorder + + modelTableView.translatesAutoresizingMaskIntoConstraints = false + modelTableView.headerView = nil + modelTableView.selectionHighlightStyle = .regular + modelTableView.dataSource = self + modelTableView.delegate = self + modelTableView.rowHeight = 24 + modelTableView.intercellSpacing = NSSize(width: 4, height: 2) + + let colName = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("name")) + colName.title = "模型名称"; colName.width = 120; colName.minWidth = 80 + let colURL = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("url")) + colURL.title = "API URL"; colURL.width = 150; colURL.minWidth = 100 + let colKey = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("key")) + colKey.title = "API Key"; colKey.width = 90; colKey.minWidth = 70 + let colModes = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("modes")) + colModes.title = "模式"; colModes.width = 70; colModes.minWidth = 50 + + modelTableView.addTableColumn(colName) + modelTableView.addTableColumn(colURL) + modelTableView.addTableColumn(colKey) + modelTableView.addTableColumn(colModes) + + scrollView.documentView = modelTableView + + editModelButton.bezelStyle = .rounded + editModelButton.translatesAutoresizingMaskIntoConstraints = false + editModelButton.target = self + editModelButton.action = #selector(editAgentModel) + + deleteModelButton.bezelStyle = .rounded + deleteModelButton.translatesAutoresizingMaskIntoConstraints = false + deleteModelButton.target = self + deleteModelButton.action = #selector(deleteAgentModel) + + let editDeleteStack = NSStackView(views: [editModelButton, deleteModelButton]) + editDeleteStack.translatesAutoresizingMaskIntoConstraints = false + editDeleteStack.orientation = .horizontal + editDeleteStack.spacing = 8 + + saveButton.bezelStyle = .rounded + saveButton.translatesAutoresizingMaskIntoConstraints = false + saveButton.keyEquivalent = "\r" + saveButton.target = self + saveButton.action = #selector(saveAgentConfig) + + statusLabel.font = .systemFont(ofSize: 11) + statusLabel.textColor = .secondaryLabelColor + statusLabel.alignment = .center + statusLabel.maximumNumberOfLines = 2 + statusLabel.translatesAutoresizingMaskIntoConstraints = false + + content.addSubview(modelLabel) + content.addSubview(modelPopup) + content.addSubview(allowLabel) + content.addSubview(allowModePopup) + content.addSubview(inputLabel) + content.addSubview(inputModePopup) + content.addSubview(thinkLabel) + content.addSubview(thinkingModePopup) + content.addSubview(tavilyLabel) + content.addSubview(tavilyKeyField) + content.addSubview(wsLabel) + content.addSubview(workspaceField) + content.addSubview(browseButton) + content.addSubview(tableHeaderLabel) + content.addSubview(addModelButton) + content.addSubview(scrollView) + content.addSubview(editDeleteStack) + content.addSubview(saveButton) + content.addSubview(statusLabel) + + NSLayoutConstraint.activate([ + modelLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + modelLabel.topAnchor.constraint(equalTo: content.topAnchor, constant: 24), + modelLabel.widthAnchor.constraint(equalToConstant: 85), + modelPopup.leadingAnchor.constraint(equalTo: modelLabel.trailingAnchor, constant: 8), + modelPopup.centerYAnchor.constraint(equalTo: modelLabel.centerYAnchor), + modelPopup.widthAnchor.constraint(equalToConstant: 200), + + allowLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + allowLabel.topAnchor.constraint(equalTo: modelLabel.bottomAnchor, constant: 16), + allowLabel.widthAnchor.constraint(equalToConstant: 85), + allowModePopup.leadingAnchor.constraint(equalTo: allowLabel.trailingAnchor, constant: 8), + allowModePopup.centerYAnchor.constraint(equalTo: allowLabel.centerYAnchor), + allowModePopup.widthAnchor.constraint(equalToConstant: 200), + + inputLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + inputLabel.topAnchor.constraint(equalTo: allowLabel.bottomAnchor, constant: 16), + inputLabel.widthAnchor.constraint(equalToConstant: 85), + inputModePopup.leadingAnchor.constraint(equalTo: inputLabel.trailingAnchor, constant: 8), + inputModePopup.centerYAnchor.constraint(equalTo: inputLabel.centerYAnchor), + inputModePopup.widthAnchor.constraint(equalToConstant: 200), + + thinkLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + thinkLabel.topAnchor.constraint(equalTo: inputLabel.bottomAnchor, constant: 16), + thinkLabel.widthAnchor.constraint(equalToConstant: 85), + thinkingModePopup.leadingAnchor.constraint(equalTo: thinkLabel.trailingAnchor, constant: 8), + thinkingModePopup.centerYAnchor.constraint(equalTo: thinkLabel.centerYAnchor), + thinkingModePopup.widthAnchor.constraint(equalToConstant: 200), + + tavilyLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + tavilyLabel.topAnchor.constraint(equalTo: thinkLabel.bottomAnchor, constant: 16), + tavilyLabel.widthAnchor.constraint(equalToConstant: 85), + tavilyKeyField.leadingAnchor.constraint(equalTo: tavilyLabel.trailingAnchor, constant: 8), + tavilyKeyField.centerYAnchor.constraint(equalTo: tavilyLabel.centerYAnchor), + tavilyKeyField.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20), + + wsLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + wsLabel.topAnchor.constraint(equalTo: tavilyLabel.bottomAnchor, constant: 16), + wsLabel.widthAnchor.constraint(equalToConstant: 85), + workspaceField.leadingAnchor.constraint(equalTo: wsLabel.trailingAnchor, constant: 8), + workspaceField.centerYAnchor.constraint(equalTo: wsLabel.centerYAnchor), + workspaceField.trailingAnchor.constraint(equalTo: browseButton.leadingAnchor, constant: -6), + browseButton.centerYAnchor.constraint(equalTo: wsLabel.centerYAnchor), + browseButton.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20), + browseButton.widthAnchor.constraint(equalToConstant: 60), + + tableHeaderLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 22), + tableHeaderLabel.topAnchor.constraint(equalTo: wsLabel.bottomAnchor, constant: 22), + addModelButton.centerYAnchor.constraint(equalTo: tableHeaderLabel.centerYAnchor), + addModelButton.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20), + addModelButton.widthAnchor.constraint(equalToConstant: 28), + + scrollView.topAnchor.constraint(equalTo: tableHeaderLabel.bottomAnchor, constant: 6), + scrollView.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + scrollView.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20), + scrollView.heightAnchor.constraint(equalToConstant: 170), + + editDeleteStack.topAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: 8), + editDeleteStack.centerXAnchor.constraint(equalTo: content.centerXAnchor), + + saveButton.topAnchor.constraint(equalTo: editDeleteStack.bottomAnchor, constant: 12), + saveButton.centerXAnchor.constraint(equalTo: content.centerXAnchor), + saveButton.widthAnchor.constraint(equalToConstant: 140), + + statusLabel.topAnchor.constraint(equalTo: saveButton.bottomAnchor, constant: 8), + statusLabel.centerXAnchor.constraint(equalTo: content.centerXAnchor), + statusLabel.widthAnchor.constraint(equalToConstant: 460) + ]) + + refreshAgentModelTable() + } + + @objc private func browseWorkspace() { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.message = "选择智能体工作目录" + panel.begin { [weak self] response in + guard response == .OK, let url = panel.url else { return } + self?.workspaceField.stringValue = url.path + Config.shared.agentWorkspace = url.path + } + } + + // MARK: - Agent 事件 + + @objc private func agentModelChanged() {} + @objc private func agentAllowModeChanged() { + Config.shared.agentAllowMode = allowModePopup.indexOfSelectedItem == 0 ? "read_only" : "full_access" + } + @objc private func agentInputModeChanged() { + Config.shared.agentInputMode = inputModePopup.indexOfSelectedItem == 0 ? "voice" : "text" + } + @objc private func agentThinkingModeChanged() { + Config.shared.agentThinkingMode = thinkingModePopup.indexOfSelectedItem == 1 + } + + @objc private func saveAgentConfig() { + var modelsFile = Config.shared.loadAgentModels() + modelsFile.tavilyApiKey = tavilyKeyField.stringValue.trimmingCharacters(in: .whitespaces) + modelsFile.defaultModel = modelPopup.titleOfSelectedItem ?? "" + modelsFile.models = agentModelList + Config.shared.saveAgentModels(modelsFile) + Config.shared.agentWorkspace = workspaceField.stringValue + refreshAgentModelTable() + statusLabel.stringValue = "✅ Agent 配置已保存" + statusLabel.textColor = .systemGreen + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + self?.statusLabel.stringValue = "" + } + } + + @objc private func addAgentModel() { + AgentModelEditor.show(for: nil) { [weak self] newModel in + guard let self = self else { return } + self.agentModelList.append(newModel) + self.modelTableView.reloadData() + self.statusLabel.stringValue = "✅ 模型已添加(点击 Save 保存到文件)" + self.statusLabel.textColor = .systemGreen + } + } + + @objc private func editAgentModel() { + let selected = modelTableView.selectedRow + guard selected >= 0, selected < agentModelList.count else { + statusLabel.stringValue = "请先选中一个模型" + statusLabel.textColor = .systemRed + return + } + let original = agentModelList[selected] + AgentModelEditor.show(for: original) { [weak self] newModel in + guard let self = self else { return } + if let idx = self.agentModelList.firstIndex(where: { $0.name == original.name }) { + self.agentModelList[idx] = newModel + } + self.modelTableView.reloadData() + self.statusLabel.stringValue = "✅ 模型已更新(点击 Save 保存到文件)" + self.statusLabel.textColor = .systemGreen + } + } + + @objc private func deleteAgentModel() { + let selected = modelTableView.selectedRow + guard selected >= 0, selected < agentModelList.count else { + statusLabel.stringValue = "请先选中一个模型" + statusLabel.textColor = .systemRed + return + } + let model = agentModelList[selected] + let alert = NSAlert() + alert.messageText = "删除模型" + alert.informativeText = "确定要删除模型「\(model.name)」吗?" + alert.alertStyle = .warning + alert.addButton(withTitle: "删除") + alert.addButton(withTitle: "取消") + if alert.runModal() == .alertFirstButtonReturn { + agentModelList.remove(at: selected) + modelTableView.reloadData() + statusLabel.stringValue = "✅ 已删除(点击 Save 保存到文件)" + statusLabel.textColor = .systemGreen + } + } + +} + +// MARK: - NSTableView + +extension SettingsWindow: NSTableViewDataSource { + func numberOfRows(in tableView: NSTableView) -> Int { agentModelList.count } +} + +extension SettingsWindow: NSTableViewDelegate { + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + guard row < agentModelList.count, let column = tableColumn else { return nil } + let model = agentModelList[row] + let textField: NSTextField + if let reused = tableView.makeView(withIdentifier: column.identifier, owner: nil) as? NSTextField { + textField = reused + } else { + textField = NSTextField(labelWithString: "") + textField.identifier = column.identifier + textField.font = .systemFont(ofSize: 11) + textField.lineBreakMode = .byTruncatingTail + textField.maximumNumberOfLines = 1 + } + switch column.identifier.rawValue { + case "name": textField.stringValue = model.name + case "url": textField.stringValue = model.url + case "key": textField.stringValue = model.maskedKey + case "modes": textField.stringValue = model.modes + default: textField.stringValue = "" + } + return textField + } } diff --git a/Sources/Utils/Config.swift b/Sources/Utils/Config.swift index 2e746a3..83b6c80 100644 --- a/Sources/Utils/Config.swift +++ b/Sources/Utils/Config.swift @@ -1,5 +1,44 @@ import Foundation +// MARK: - Agent 模型配置(对应 models.json) + +struct AgentModelConfig: Codable, Identifiable { + var id: String { name } + var url: String + var name: String + var apikey: String + var modes: String + var multimodal: String + var maxOutput: Int + var maxContext: Int + + init(url: String = "", name: String = "", apikey: String = "", + modes: String = "快速,思考", multimodal: String = "", maxOutput: Int = 32000, maxContext: Int = 256000) { + self.url = url + self.name = name + self.apikey = apikey + self.modes = modes + self.multimodal = multimodal + self.maxOutput = maxOutput + self.maxContext = maxContext + } + + /// 是否支持思考模式 + var supportsThinking: Bool { modes.contains("思考") || modes.contains("thinking") } + /// 是否支持快速模式 + var supportsFast: Bool { modes.contains("快速") || modes.contains("fast") } + /// 脱敏显示 API Key + var maskedKey: String { + apikey.count <= 8 ? apikey : "\(apikey.prefix(3))...\(apikey.suffix(3))" + } +} + +struct AgentModelsFile: Codable { + var tavilyApiKey: String + var defaultModel: String + var models: [AgentModelConfig] +} + enum RecognitionLanguage: String, CaseIterable { case chineseSimplified = "zh-CN" case english = "en-US" @@ -21,11 +60,13 @@ enum RecognitionLanguage: String, CaseIterable { enum LLMMode { case input // 语音输入纠错 case assist // AI 助手对话 + case agent // 智能体调用 } enum TriggerMode: String { case input = "input" // 语音输入 case assist = "assist" // AI 助手 + case agent = "agent" // 智能体调用 } enum AssistOutputMode: String, CaseIterable { @@ -76,6 +117,13 @@ final class Config { // 助手模式输出方式 static let assistOutputMode = "assist_output_mode" + + // 智能体模式 + static let agentLLMEnabled = "agent_llm_enabled" + static let agentLLMBaseURL = "agent_llm_base_url" + static let agentLLMAPIKey = "agent_llm_api_key" + static let agentLLMModel = "agent_llm_model" + static let agentWorkspace = "agent_workspace" } var language: RecognitionLanguage { @@ -197,18 +245,130 @@ final class Config { defaults.set(newValue.rawValue, forKey: Keys.assistOutputMode) } } - + + // MARK: - 智能体模式配置 + + var agentLLM: LLMConfig { + get { + LLMConfig( + enabled: defaults.bool(forKey: Keys.agentLLMEnabled), + baseURL: defaults.string(forKey: Keys.agentLLMBaseURL) ?? "", + apiKey: defaults.string(forKey: Keys.agentLLMAPIKey) ?? "", + model: defaults.string(forKey: Keys.agentLLMModel) ?? "" + ) + } + set { + defaults.set(newValue.enabled, forKey: Keys.agentLLMEnabled) + defaults.set(newValue.baseURL, forKey: Keys.agentLLMBaseURL) + defaults.set(newValue.apiKey, forKey: Keys.agentLLMAPIKey) + defaults.set(newValue.model, forKey: Keys.agentLLMModel) + } + } + + var agentWorkspace: String { + get { defaults.string(forKey: Keys.agentWorkspace) ?? NSHomeDirectory() } + set { defaults.set(newValue, forKey: Keys.agentWorkspace) } + } + + // Agent 运行模式 + var agentAllowMode: String { + get { defaults.string(forKey: "agent_allow_mode") ?? "full_access" } + set { defaults.set(newValue, forKey: "agent_allow_mode") } + } + + // Agent 输入方式 + var agentInputMode: String { + get { defaults.string(forKey: "agent_input_mode") ?? "voice" } + set { defaults.set(newValue, forKey: "agent_input_mode") } + } + + // Agent 思考模式 + var agentThinkingMode: Bool { + get { defaults.bool(forKey: "agent_thinking_mode") } + set { defaults.set(newValue, forKey: "agent_thinking_mode") } + } + + // MARK: - models.json 路径 + + /// Agent 目录路径:优先查找 bundle Resources,其次逐级往上搜索包含 models.json 的 Agent/ + var agentDir: String { + // 优先级 0:bundle Resources/Agent(Release 打包后) + if let resourcePath = Bundle.main.resourcePath { + let bundledAgent = resourcePath + "/Agent/models.json" + if FileManager.default.fileExists(atPath: bundledAgent) { + print("[Config] found Agent in bundle Resources: \(resourcePath)/Agent") + return resourcePath + "/Agent" + } + } + + // 优先级 1:从 bundlePath 开始逐级往上搜索(Debug 开发模式) + var dir = Bundle.main.bundlePath + print("[Config] bundlePath: \(Bundle.main.bundlePath)") + for i in 0..<6 { + let candidate = dir + "/Agent/models.json" + let exists = FileManager.default.fileExists(atPath: candidate) + print("[Config] level \(i): \(candidate) → \(exists ? "YES" : "NO")") + if exists { + return dir + "/Agent" + } + dir = (dir as NSString).deletingLastPathComponent + } + // 回退:当前工作目录 + let cwd = FileManager.default.currentDirectoryPath + "/Agent/models.json" + print("[Config] cwd: \(cwd) → \(FileManager.default.fileExists(atPath: cwd) ? "YES" : "NO")") + if FileManager.default.fileExists(atPath: cwd) { + return FileManager.default.currentDirectoryPath + "/Agent" + } + // 最后回退到开发默认路径 + return Bundle.main.bundlePath + "/../../../Agent" + } + + var agentModelsPath: String { agentDir + "/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 decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + guard let models = try? decoder.decode(AgentModelsFile.self, from: data) else { + return AgentModelsFile(tavilyApiKey: "", defaultModel: "", models: []) + } + return models + } + + func saveAgentModels(_ models: AgentModelsFile) { + let path = agentModelsPath + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + guard let data = try? encoder.encode(models) else { return } + try? data.write(to: URL(fileURLWithPath: path), options: .atomic) + } + + /// 有效的模型列表(过滤掉配置不完整的) + var validAgentModels: [AgentModelConfig] { + loadAgentModels().models.filter { m in + !m.url.isEmpty && !m.name.isEmpty && !m.apikey.isEmpty && !m.modes.isEmpty + } + } + func getLLMConfig(for mode: LLMMode) -> LLMConfig { switch mode { case .input: return inputLLM case .assist: return assistLLM + case .agent: return agentLLM } } - + func setLLMConfig(_ config: LLMConfig, for mode: LLMMode) { switch mode { case .input: inputLLM = config case .assist: assistLLM = config + case .agent: agentLLM = config } } } diff --git a/scripts/create_app_bundle.sh b/scripts/create_app_bundle.sh index 657e0ba..deb4c66 100755 --- a/scripts/create_app_bundle.sh +++ b/scripts/create_app_bundle.sh @@ -30,6 +30,13 @@ if [ -f "$PROJECT_DIR/Sources/Resources/AppIcon.icns" ]; then cp "$PROJECT_DIR/Sources/Resources/AppIcon.icns" "$RESOURCES_DIR/AppIcon.icns" fi +# 复制 Agent 目录(智能体配置和运行时) +if [ -d "$PROJECT_DIR/Agent" ]; then + mkdir -p "$RESOURCES_DIR/Agent" + cp -R "$PROJECT_DIR/Agent/"* "$RESOURCES_DIR/Agent/" + echo " ✅ Agent directory copied" +fi + # 更新 Info.plist 引用图标 /usr/libexec/PlistBuddy -c "Add :CFBundleIconFile string AppIcon" "$CONTENTS_DIR/Info.plist" 2>/dev/null || true