feat: Agent 模式重大更新 — 稳定多轮对话、流式刘海、模型管理、交互优化

## Agent 核心功能
- **流式输出刘海弹窗**:实时展示模型思考内容,支持自动滚动
- **工具调用进度**:最多可见3条 + 可滚动查看全部历史 + 防抖并行滚动
- **打字/语音双输入**:Agent 模式可选手动打字或语音输入
- **快速/思考模式切换**:支持 thinking mode 开关
- **多轮对话连续性**:同一会话内持续对话,新建对话按钮 ⊕ 开启新会话
- **工作时间显示**:工作完成状态栏显示耗时,如「工作完成 · 00:42」

## Bug 修复
- **SIGTERM 优雅退出**:bridge.js 处理 SIGTERM,避免 code 15 误报异常
- **弹窗复用不重建**:输入内容继续对话时弹窗内部刷新,不再闪出闪进
- **关闭按钮立刻生效**:ignoreMouse=true 绕过 DynamicNotchKit 延迟
- **设置窗口 Agent UI 构建时机**:从延迟构建改为 init 中直接构建
- **模型编辑器**:修复 grid column nil 崩溃 + 改为独立 KeyEquivalentPanel 窗口支持 Cmd+A/C/V/X
- **双击 Control 三向轮换**:语音输入 ↔ AI助手 ↔ 智能体
- **Fn 长按冲突处理**:非 Agent 模式下自动关闭 Agent 刘海弹窗
- **Save 后刷新下拉**:保存配置后默认模型下拉框即时同步

## UI 交互优化
- **工具列表无灰色背景**:工具执行期间不再有底色变化
- **工作时隐藏新建对话按钮**:thinking/running 期间 ⊕ 自动隐藏
- **工具调用去 timeout 显示**:运行指令行不再显示 (timeout=30s)

## 文件拆分(单文件≤500行)
- AgentNotchView → AgentProgressTracker + AppKitTextField + AgentNotchView
- SettingsWindow → AgentModelEditor + KeyEquivalentSecureTextField + KeyEquivalentPanel + SettingsWindow

## 底层改动
- DynamicNotchKit: 新增 contentVersion + notifyContentChanged 机制
- NotchView: 动画绑定 contentVersion 支持弹窗内容变化弹性动画
This commit is contained in:
JOJO 2026-04-29 16:42:46 +08:00
parent c708ff32ac
commit 94b05c28b3
16 changed files with 2428 additions and 185 deletions

311
Agent/src/bridge.js Normal file
View File

@ -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);
});
// 优雅处理 SIGTERMSwift 端 stop() 会发 SIGTERM这里转为 code 0 退出
process.on('SIGTERM', () => {
process.stderr.write(`[bridge] received SIGTERM, exiting gracefully\n`);
process.exit(0);
});

View File

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

View File

@ -23,6 +23,9 @@ public class DynamicNotch<Content>: 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 {

View File

@ -57,7 +57,7 @@ struct NotchView<Content>: 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()
}

View File

@ -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<AgentNotchView>?
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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 {
// 0bundle Resources/AgentRelease
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
}
}
}

View File

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