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:
parent
c708ff32ac
commit
94b05c28b3
311
Agent/src/bridge.js
Normal file
311
Agent/src/bridge.js
Normal 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);
|
||||
});
|
||||
|
||||
// 优雅处理 SIGTERM:Swift 端 stop() 会发 SIGTERM,这里转为 code 0 退出
|
||||
process.on('SIGTERM', () => {
|
||||
process.stderr.write(`[bridge] received SIGTERM, exiting gracefully\n`);
|
||||
process.exit(0);
|
||||
});
|
||||
223
Agent/src/ui/tool_display.js
Normal file
223
Agent/src/ui/tool_display.js
Normal 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 };
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
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() }
|
||||
}
|
||||
|
||||
// Fn 键:长按录音
|
||||
keyMonitor.onFnDown = { [weak self] in
|
||||
self?.startRecording()
|
||||
}
|
||||
keyMonitor.onFnUp = { [weak self] in
|
||||
self?.stopRecording()
|
||||
}
|
||||
// Control 键:双击切换模式
|
||||
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,113 +208,41 @@ 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()
|
||||
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)
|
||||
}
|
||||
finalTranscription = ""
|
||||
return
|
||||
}
|
||||
|
||||
// 根据 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() {
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
241
Sources/Core/AgentBridge.swift
Normal file
241
Sources/Core/AgentBridge.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
193
Sources/UI/AgentModelEditor.swift
Normal file
193
Sources/UI/AgentModelEditor.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
298
Sources/UI/AgentNotchView.swift
Normal file
298
Sources/UI/AgentNotchView.swift
Normal 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 = ""
|
||||
}
|
||||
}
|
||||
105
Sources/UI/AgentProgressTracker.swift
Normal file
105
Sources/UI/AgentProgressTracker.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
84
Sources/UI/AppKitTextField.swift
Normal file
84
Sources/UI/AppKitTextField.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
26
Sources/UI/KeyEquivalentPanel.swift
Normal file
26
Sources/UI/KeyEquivalentPanel.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
19
Sources/UI/KeyEquivalentSecureTextField.swift
Normal file
19
Sources/UI/KeyEquivalentSecureTextField.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,10 +107,7 @@ final class SettingsWindow: NSWindow {
|
||||
return super.performKeyEquivalent(with: event)
|
||||
}
|
||||
|
||||
private func buildUI() {
|
||||
guard let content = contentView else { return }
|
||||
|
||||
func makeLabel(_ text: String) -> NSTextField {
|
||||
private func makeLabel(_ text: String) -> NSTextField {
|
||||
let label = NSTextField(labelWithString: text)
|
||||
label.font = .systemFont(ofSize: 11, weight: .semibold)
|
||||
label.textColor = .secondaryLabelColor
|
||||
@ -89,6 +116,14 @@ final class SettingsWindow: NSWindow {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
@ -198,10 +246,121 @@ final class Config {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
@ -209,6 +368,7 @@ final class Config {
|
||||
switch mode {
|
||||
case .input: inputLLM = config
|
||||
case .assist: assistLLM = config
|
||||
case .agent: agentLLM = config
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user