diff --git a/README.md b/README.md index 31a31db..f7ebb27 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

- version + version electron typescript license @@ -245,7 +245,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -产出:`release/Metona Ollama Setup v0.16.16.exe` +产出:`release/Metona Ollama Setup v0.16.17.exe` ## 🛠️ 常用命令 @@ -485,7 +485,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -Output: `release/Metona Ollama Setup v0.16.16.exe` +Output: `release/Metona Ollama Setup v0.16.17.exe` ## 🛠️ Common Commands diff --git a/package-lock.json b/package-lock.json index 970abef..089b8d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ollama-desktop", - "version": "0.16.16", + "version": "0.16.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ollama-desktop", - "version": "0.16.16", + "version": "0.16.17", "license": "MIT", "dependencies": { "ffmpeg-static": "^5.2.0", diff --git a/package.json b/package.json index 381d136..42554c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ollama-desktop", - "version": "0.16.16", + "version": "0.16.17", "description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端", "main": "dist/main/main.js", "author": "thzxx", diff --git a/src/main/db/sqlite.ts b/src/main/db/sqlite.ts index e8b79c9..171324b 100644 --- a/src/main/db/sqlite.ts +++ b/src/main/db/sqlite.ts @@ -193,6 +193,20 @@ export async function initDatabase(): Promise { ); CREATE INDEX IF NOT EXISTS idx_traces_session ON traces(session_id, created_at); + -- 工具审计日志表(写类工具调用持久化,支持事后审计) + CREATE TABLE IF NOT EXISTS tool_audit ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + tool_name TEXT NOT NULL, + args_json TEXT, + result_status TEXT, + result_summary TEXT, + duration_ms INTEGER, + created_at INTEGER NOT NULL, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_tool_audit_session ON tool_audit(session_id, created_at); + `); // 兼容迁移:为已有 messages 表补充 attachments 列(文件/视频等附件 JSON) @@ -492,3 +506,36 @@ export function importSessions(data: ExportData): { imported: number; skipped: n return { imported, skipped }; } +// ─── 工具审计日志 ─── + +export interface ToolAuditRow { + id: string; + session_id: string; + tool_name: string; + args_json: string | null; + result_status: string | null; + result_summary: string | null; + duration_ms: number | null; + created_at: number; +} + +export function saveToolAudit(audit: Omit & { id?: string }): string { + const d = getDb(); + const id = audit.id || `audit_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + runExec(d, + `INSERT INTO tool_audit (id, session_id, tool_name, args_json, result_status, result_summary, duration_ms, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [id, audit.session_id, audit.tool_name, audit.args_json, audit.result_status, audit.result_summary, audit.duration_ms, audit.created_at] + ); + persist(); + return id; +} + +export function getToolAuditsBySession(sessionId: string): ToolAuditRow[] { + return queryAll(getDb(), 'SELECT * FROM tool_audit WHERE session_id = ? ORDER BY created_at ASC', [sessionId]) as unknown as ToolAuditRow[]; +} + +export function getAllToolAudits(limit: number = 200): ToolAuditRow[] { + return queryAll(getDb(), 'SELECT * FROM tool_audit ORDER BY created_at DESC LIMIT ?', [limit]) as unknown as ToolAuditRow[]; +} + diff --git a/src/main/ipc.ts b/src/main/ipc.ts index fa21a29..88aee02 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -14,7 +14,8 @@ import { saveSetting, getSetting, saveSettingsBatch, saveTrace, saveTracesBatch, getTracesBySession, exportAllSessions, importSessions, - getAllSessionsTokenStats + getAllSessionsTokenStats, + saveToolAudit, getToolAuditsBySession, getAllToolAudits } from './db/sqlite.js'; import type { ExportData } from './db/sqlite.js'; @@ -41,7 +42,8 @@ import { handleReadMultipleFiles, handleGit, handleCompress, - handleCalculator + handleCalculator, + handleDiff } from './tool-handlers.js'; import { browserOpen, browserScreenshot, browserEvaluate, browserExtract, browserClick, browserType, browserScroll, browserClose, browserWait } from './browser.js'; import { startServer, stopServer, stopAllServers, callTool, getAllTools, getServerStatuses, refreshTools, setMCPTimeout } from './mcp-manager.js'; @@ -77,6 +79,7 @@ function summarizeResult(toolName: string, result: Record): str case 'read_multiple_files': return `${result.total} 个文件`; case 'git': return `${result.action} ✓`; case 'compress': return `${result.action} → ${result.archive || result.destination}`; +case 'diff': return result.identical ? '无差异' : `+${result.additions} -${result.deletions} (${result.hunk_count} hunks)`; case 'calculator': return `${result.expression} = ${result.result}`; default: return '完成'; } @@ -201,6 +204,7 @@ export async function setupIPC(): Promise { case 'read_multiple_files':result = await handleReadMultipleFiles(args as { paths: string[]; max_chars_per_file?: number }); break; case 'git': result = await handleGit(args as { action: string; path?: string; files?: string[]; message?: string; branch?: string; tag_name?: string; stash_sub?: string; remote?: string; remote_url?: string; count?: number; all?: boolean; staged?: boolean; new_branch?: boolean; delete_branch?: boolean; force?: boolean; url?: string }); break; case 'compress': result = await handleCompress(args as { action: string; path: string; destination?: string; format?: string }); break; +case 'diff': result = await handleDiff(args as { mode: 'file_vs_file' | 'file_vs_content' | 'file_vs_git_head'; path1?: string; path2?: string; content?: string; context_lines?: number }); break; case 'calculator': result = handleCalculator(args as { expression: string }); break; // v5.1 Browser 控制(增强版) case 'browser_open': result = await browserOpen(args.url as string, args.wait_selector as string | undefined); break; @@ -408,6 +412,20 @@ export async function setupIPC(): Promise { catch (err) { sendLog('error', '获取全局 Token 统计失败', (err as Error).message); return null; } }); + // ── 工具审计日志 ── + ipcMain.handle('db:saveToolAudit', (_: unknown, audit: unknown) => { + try { return { success: true, id: saveToolAudit(audit as any) }; } + catch (err) { return { success: false, error: (err as Error).message }; } + }); + ipcMain.handle('db:getToolAudits', (_: unknown, sessionId: string) => { + try { return { success: true, audits: getToolAuditsBySession(sessionId) }; } + catch (err) { return { success: false, error: (err as Error).message }; } + }); + ipcMain.handle('db:getAllToolAudits', (_: unknown, limit?: number) => { + try { return { success: true, audits: getAllToolAudits(limit || 200) }; } + catch (err) { return { success: false, error: (err as Error).message }; } + }); + // ── Memory 文件访问(专用通道,绕过 checkPathAllowed,仅限 MEMORY.md)── ipcMain.handle('memory:read', async () => { const wsDir = getWorkspaceDir(); diff --git a/src/main/menu.ts b/src/main/menu.ts index d6b2603..8f43a78 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -101,7 +101,7 @@ export function createMenu(): void { dialog.showMessageBox(mainWindow!, { type: 'info', title: '关于 Metona Ollama', - message: 'Metona Ollama Desktop v0.16.16', + message: 'Metona Ollama Desktop v0.16.17', detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama', icon: getIconPath() }); diff --git a/src/main/preload.ts b/src/main/preload.ts index 915ceba..c5cbc02 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -72,6 +72,9 @@ contextBridge.exposeInMainWorld('metonaDesktop', { importSessions: (data: unknown) => ipcRenderer.invoke('db:importSessions', data), getAllTokenStats: () => ipcRenderer.invoke('db:getAllTokenStats'), + saveToolAudit: (audit: unknown) => ipcRenderer.invoke('db:saveToolAudit', audit), + getToolAudits: (sessionId: string) => ipcRenderer.invoke('db:getToolAudits', sessionId), + getAllToolAudits: (limit?: number) => ipcRenderer.invoke('db:getAllToolAudits', limit), }, workspace: { getDir: () => ipcRenderer.invoke('workspace:getDir'), diff --git a/src/main/tool-handlers-fs.ts b/src/main/tool-handlers-fs.ts index 83b2340..0c58f4b 100644 --- a/src/main/tool-handlers-fs.ts +++ b/src/main/tool-handlers-fs.ts @@ -712,3 +712,269 @@ export async function handleCopyFile(params: { source: string; destination: stri return { success: false, error: (err as Error).message }; } } + +/** + * diff 工具:比较文件差异,返回 unified diff 格式 + * 支持三种模式:file_vs_file / file_vs_content / file_vs_git_head + */ +export async function handleDiff(params: { + mode: 'file_vs_file' | 'file_vs_content' | 'file_vs_git_head'; + path1?: string; + path2?: string; + content?: string; + context_lines?: number; +}): Promise { + try { + const mode = params.mode || 'file_vs_file'; + const contextLines = params.context_lines ?? 3; + const MAX_LINES = 5000; + + // 获取左侧内容(文件1) + let leftLabel: string; + let leftContent: string; + + if (mode === 'file_vs_file' || mode === 'file_vs_content' || mode === 'file_vs_git_head') { + if (!params.path1) return { success: false, error: 'path1 参数必填' }; + const filePath1 = resolvePath(params.path1); + const check1 = checkPathAllowed(filePath1, 'read'); + if (!check1.ok) return { success: false, error: check1.reason }; + + const stat1 = await fs.stat(filePath1); + if (stat1.isDirectory()) return { success: false, error: `${filePath1} 是目录,不是文件` }; + if (stat1.size > 5 * 1024 * 1024) return { success: false, error: `文件过大 (${(stat1.size / 1024 / 1024).toFixed(1)}MB),最大支持 5MB` }; + + if (mode === 'file_vs_git_head') { + // git HEAD 版本 + leftLabel = `a/${path.basename(filePath1)} (HEAD)`; + try { + const { execFile } = await import('child_process'); + const gitResult = await new Promise((resolve, reject) => { + execFile('git', ['show', `HEAD:${params.path1}`], { + cwd: path.dirname(filePath1), + maxBuffer: 5 * 1024 * 1024, + encoding: 'utf-8', + }, (err, stdout) => { + if (err) reject(err); + else resolve(stdout); + }); + }); + leftContent = gitResult; + } catch { + return { success: false, error: '无法获取 git HEAD 版本(确保文件在 git 仓库中且有提交历史)' }; + } + } else { + leftLabel = `a/${path.basename(filePath1)}`; + leftContent = await fs.readFile(filePath1, 'utf-8'); + } + } else { + return { success: false, error: `不支持的 mode: ${mode}` }; + } + + // 获取右侧内容 + let rightLabel: string; + let rightContent: string; + + if (mode === 'file_vs_file') { + if (!params.path2) return { success: false, error: 'file_vs_file 模式下 path2 参数必填' }; + const filePath2 = resolvePath(params.path2); + const check2 = checkPathAllowed(filePath2, 'read'); + if (!check2.ok) return { success: false, error: check2.reason }; + + const stat2 = await fs.stat(filePath2); + if (stat2.isDirectory()) return { success: false, error: `${filePath2} 是目录,不是文件` }; + if (stat2.size > 5 * 1024 * 1024) return { success: false, error: `文件过大,最大支持 5MB` }; + + rightLabel = `b/${path.basename(filePath2)}`; + rightContent = await fs.readFile(filePath2, 'utf-8'); + } else if (mode === 'file_vs_content') { + rightLabel = `b/${path.basename(params.path1 || '')} (new)`; + rightContent = params.content ?? ''; + } else { + // file_vs_git_head: 右侧是当前工作区文件 + rightLabel = `b/${path.basename(params.path1 || '')}`; + rightContent = await fs.readFile(resolvePath(params.path1!), 'utf-8'); + } + + // 按行分割 + const leftLines = leftContent.split('\n'); + const rightLines = rightContent.split('\n'); + + // 限制行数 + if (leftLines.length > MAX_LINES || rightLines.length > MAX_LINES) { + return { success: false, error: `文件行数过多(左侧 ${leftLines.length} / 右侧 ${rightLines.length}),最大支持 ${MAX_LINES} 行` }; + } + + // ── LCS 差异算法 ── + const diff = computeUnifiedDiff(leftLines, rightLines, contextLines); + + const added = diff.additions; + const removed = diff.deletions; + const unchanged = leftLines.length + rightLines.length - added - removed; + + sendLog('info', `🔍 diff (${mode})`, `${params.path1 || ''} ${mode === 'file_vs_file' ? '↔ ' + (params.path2 || '') : mode === 'file_vs_content' ? '↔ content' : '↔ HEAD'} → +${added} -${removed}`); + + if (diff.hunks.length === 0) { + return { + success: true, + mode, + path1: params.path1, + path2: params.path2, + diff: '', + additions: 0, + deletions: 0, + unchanged, + identical: true, + message: '文件内容完全相同,无差异', + }; + } + + // 生成 unified diff 头部 + hunks + const header = `--- ${leftLabel}\n+++ ${rightLabel}\n`; + const diffText = header + diff.hunks.join('\n'); + + return { + success: true, + mode, + path1: params.path1, + path2: params.path2, + diff: diffText, + additions: added, + deletions: removed, + unchanged, + identical: false, + hunk_count: diff.hunks.length, + total_lines: leftLines.length + rightLines.length, + }; + } catch (err) { + sendLog('error', `🔍 diff 失败`, (err as Error).message); + return { success: false, error: (err as Error).message }; + } +} + +/** + * 计算 unified diff(行级 LCS 算法) + */ +interface DiffResult { + hunks: string[]; + additions: number; + deletions: number; +} + +function computeUnifiedDiff(oldLines: string[], newLines: string[], contextSize: number): DiffResult { + const n = oldLines.length; + const m = newLines.length; + + // 构建 LCS 表(使用 Uint32Array 节省内存) + // dp[(n+1) * (m+1)],索引 [i][j] = i * (m+1) + j + const dp = new Uint32Array((n + 1) * (m + 1)); + for (let i = 1; i <= n; i++) { + for (let j = 1; j <= m; j++) { + if (oldLines[i - 1] === newLines[j - 1]) { + dp[i * (m + 1) + j] = dp[(i - 1) * (m + 1) + (j - 1)] + 1; + } else { + dp[i * (m + 1) + j] = Math.max( + dp[(i - 1) * (m + 1) + j], + dp[i * (m + 1) + (j - 1)] + ); + } + } + } + + // 回溯生成操作序列 + type Op = 'equal' | 'delete' | 'insert'; + const ops: Array<{ op: Op; line: string; oldIdx?: number; newIdx?: number }> = []; + let i = n, j = m; + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) { + ops.push({ op: 'equal', line: oldLines[i - 1], oldIdx: i - 1, newIdx: j - 1 }); + i--; j--; + } else if (j > 0 && (i === 0 || dp[i * (m + 1) + (j - 1)] >= dp[(i - 1) * (m + 1) + j])) { + ops.push({ op: 'insert', line: newLines[j - 1], newIdx: j - 1 }); + j--; + } else { + ops.push({ op: 'delete', line: oldLines[i - 1], oldIdx: i - 1 }); + i--; + } + } + ops.reverse(); + + // 分组为 hunks(带上下文行) + const hunks: string[] = []; + let additions = 0; + let deletions = 0; + + // 找到所有变更点 + const changeIndices: number[] = []; + for (let k = 0; k < ops.length; k++) { + if (ops[k].op !== 'equal') changeIndices.push(k); + } + + if (changeIndices.length === 0) { + return { hunks, additions: 0, deletions: 0 }; + } + + // 按上下文分组合并为 hunks + let hunkStart = Math.max(0, changeIndices[0] - contextSize); + let hunkEnd = Math.min(ops.length - 1, changeIndices[0] + contextSize); + let oldStart = ops[hunkStart].oldIdx ?? 0; + let newStart = ops[hunkStart].newIdx ?? 0; + let hunkLines: string[] = []; + + for (let k = hunkStart; k <= hunkEnd && k < ops.length; k++) { + const op = ops[k]; + if (op.op === 'equal') hunkLines.push(' ' + op.line); + else if (op.op === 'delete') { hunkLines.push('-' + op.line); deletions++; } + else { hunkLines.push('+' + op.line); additions++; } + } + + // 扩展 hunk:如果下一个变更点在当前 hunk 的上下文范围内 + for (let idx = 1; idx < changeIndices.length; idx++) { + const nextChange = changeIndices[idx]; + if (nextChange - hunkEnd <= contextSize * 2) { + // 合并到当前 hunk + while (hunkEnd < nextChange) { + hunkEnd++; + if (hunkEnd >= ops.length) break; + const op = ops[hunkEnd]; + if (op.op === 'equal') hunkLines.push(' ' + op.line); + else if (op.op === 'delete') { hunkLines.push('-' + op.line); deletions++; } + else { hunkLines.push('+' + op.line); additions++; } + } + // 添加后续上下文 + for (let c = 1; c <= contextSize && hunkEnd + c < ops.length; c++) { + hunkEnd++; + const op = ops[hunkEnd]; + if (op.op === 'equal') hunkLines.push(' ' + op.line); + else if (op.op === 'delete') { hunkLines.push('-' + op.line); deletions++; } + else { hunkLines.push('+' + op.line); additions++; } + } + } else { + // 完成当前 hunk + const oldCount = hunkLines.filter(l => l.startsWith(' ') || l.startsWith('-')).length; + const newCount = hunkLines.filter(l => l.startsWith(' ') || l.startsWith('+')).length; + hunks.push(`@@ -${oldStart + 1},${oldCount} +${newStart + 1},${newCount} @@\n${hunkLines.join('\n')}`); + + // 开始新 hunk + hunkStart = Math.max(0, nextChange - contextSize); + hunkEnd = Math.min(ops.length - 1, nextChange + contextSize); + oldStart = ops[hunkStart].oldIdx ?? 0; + newStart = ops[hunkStart].newIdx ?? 0; + hunkLines = []; + for (let k = hunkStart; k <= hunkEnd && k < ops.length; k++) { + const op = ops[k]; + if (op.op === 'equal') hunkLines.push(' ' + op.line); + else if (op.op === 'delete') { hunkLines.push('-' + op.line); deletions++; } + else { hunkLines.push('+' + op.line); additions++; } + } + } + } + + // 最后一个 hunk + if (hunkLines.length > 0) { + const oldCount = hunkLines.filter(l => l.startsWith(' ') || l.startsWith('-')).length; + const newCount = hunkLines.filter(l => l.startsWith(' ') || l.startsWith('+')).length; + hunks.push(`@@ -${oldStart + 1},${oldCount} +${newStart + 1},${newCount} @@\n${hunkLines.join('\n')}`); + } + + return { hunks, additions, deletions }; +} diff --git a/src/main/tool-handlers.ts b/src/main/tool-handlers.ts index 3881ace..5e0727e 100644 --- a/src/main/tool-handlers.ts +++ b/src/main/tool-handlers.ts @@ -6,7 +6,7 @@ export { sendLog, resolvePath, type ToolResult } from './tool-handlers-shared.js'; -// 文件系统操作(11 个) +// 文件系统操作(12 个) export { handleReadFile, handleWriteFile, @@ -19,6 +19,7 @@ export { handleEditFile, handleTree, handleReadMultipleFiles, + handleDiff, } from './tool-handlers-fs.js'; // 系统与网络操作(7 个) diff --git a/src/renderer/components/keybind-manager.ts b/src/renderer/components/keybind-manager.ts new file mode 100644 index 0000000..d78c323 --- /dev/null +++ b/src/renderer/components/keybind-manager.ts @@ -0,0 +1,233 @@ +/** + * KeybindManager — 全局快捷键管理 + * 集中注册所有快捷键,避免分散在各组件中 + */ + +import { state, KEYS } from '../state/state.js'; +import { logInfo, logDebug } from '../services/log-service.js'; +import { showToast } from './toast.js'; +import { showConfirm } from './prompt-modal.js'; + +/** 快捷键定义 */ +export interface Keybind { + keys: string; // 显示用的按键组合(如 "Ctrl+Enter") + description: string; // 功能描述 + category: 'chat' | 'navigation' | 'agent' | 'system'; +} + +/** 所有快捷键定义(用于设置面板/帮助页面展示) */ +export const KEYBINDS: Keybind[] = [ + { keys: 'Ctrl+N', description: '新建会话', category: 'chat' }, + { keys: 'Ctrl+Enter', description: '发送消息', category: 'chat' }, + { keys: 'Ctrl+K', description: '聚焦输入框', category: 'chat' }, + { keys: 'Ctrl+L', description: '清空当前对话', category: 'chat' }, + { keys: 'Ctrl+F', description: '对话内搜索', category: 'chat' }, + { keys: 'Ctrl+P', description: '切换 Plan Mode', category: 'agent' }, + { keys: 'Ctrl+Shift+Backspace', description: '中止 Agent', category: 'agent' }, + { keys: 'Ctrl+M', description: '打开记忆面板', category: 'navigation' }, + { keys: 'Ctrl+H', description: '打开历史记录', category: 'navigation' }, + { keys: 'Ctrl+,', description: '打开设置', category: 'navigation' }, + { keys: 'Ctrl+Shift+L', description: '切换日志面板', category: 'system' }, + { keys: 'Esc', description: '关闭弹窗', category: 'system' }, +]; + +/** 检查是否有模态框打开 */ +function isModalOpen(): boolean { + const modals = ['#settingsModal', '#historyModal', '#helpModal', '#toolsModal', + '#tokenDashboardModal', '#toolConfirmModal', '#searxngModal', '#memoryModal']; + for (const sel of modals) { + const el = document.querySelector(sel) as HTMLElement | null; + if (el && el.style.display !== 'none') return true; + } + return false; +} + +/** 关闭所有打开的模态框 */ +function closeAllModals(): void { + const closeIds: Array<{ closeId: string }> = [ + { closeId: 'btnCloseSettings' }, + { closeId: 'btnCloseHistory' }, + { closeId: 'btnCloseHelp' }, + { closeId: 'btnCloseTools' }, + { closeId: 'btnCloseTokenDashboard' }, + { closeId: 'btnCloseSearxng' }, + ]; + for (const { closeId } of closeIds) { + const btn = document.getElementById(closeId); + if (btn) btn.click(); + } + // 关闭记忆面板 + const memModal = document.getElementById('memoryModal'); + if (memModal && memModal.style.display !== 'none') { + memModal.style.display = 'none'; + } + // 关闭工具确认 + const toolConfirm = document.getElementById('toolConfirmModal'); + if (toolConfirm && toolConfirm.style.display !== 'none') { + const cancelBtn = document.getElementById('toolCancelBtn'); + if (cancelBtn) cancelBtn.click(); + } +} + +/** 中止当前 Agent */ +function abortAgent(): void { + const ac = state.get(KEYS.ABORT_CONTROLLER); + if (ac) { + ac.abort(); + logInfo('快捷键中止 Agent'); + } else { + showToast('当前没有正在运行的 Agent', 'info'); + } +} + +/** 切换 Plan Mode */ +function togglePlanMode(): void { + const toggle = document.getElementById('togglePlan') as HTMLInputElement | null; + if (toggle) { + toggle.checked = !toggle.checked; + toggle.dispatchEvent(new Event('change', { bubbles: true })); + showToast(toggle.checked ? 'Plan Mode 已开启' : 'Plan Mode 已关闭', 'info'); + } +} + +/** 切换日志面板 */ +function toggleLogPanel(): void { + const logPanel = document.getElementById('logPanel'); + if (logPanel) { + const isVisible = logPanel.style.display !== 'none'; + logPanel.style.display = isVisible ? 'none' : ''; + logDebug(`日志面板 ${isVisible ? '已隐藏' : '已显示'}`); + } +} + +/** 清空当前对话 */ +async function clearChat(): Promise { + if (isModalOpen()) return; + if (await showConfirm('确定清空当前对话?此操作不可恢复!', '清空对话')) { + document.getElementById('btnNewChat')?.click(); + showToast('对话已清空', 'success'); + } +} + +/** 初始化快捷键系统 */ +export function initKeybindManager(): void { + document.addEventListener('keydown', (e: KeyboardEvent) => { + // ── Esc:关闭弹窗(最高优先级)── + if (e.key === 'Escape') { + if (isModalOpen()) { + closeAllModals(); + e.preventDefault(); + return; + } + // 关闭搜索栏 + const searchBar = document.getElementById('searchBar'); + if (searchBar && searchBar.style.display !== 'none') { + document.getElementById('searchClose')?.click(); + e.preventDefault(); + return; + } + } + + // 以下快捷键需要 Ctrl 修饰键 + if (!e.ctrlKey && !e.metaKey) return; + + const key = e.key.toLowerCase(); + + // ── Ctrl+Shift 组合键 ── + if (e.shiftKey) { + // Ctrl+Shift+Backspace — 中止 Agent + if (e.code === 'Backspace') { + e.preventDefault(); + abortAgent(); + return; + } + // Ctrl+Shift+L — 切换日志面板 + if (key === 'l') { + e.preventDefault(); + toggleLogPanel(); + return; + } + return; + } + + // ── 模态框打开时,只允许 Esc 和 Ctrl+, ── + if (isModalOpen()) { + if (key === ',') { + e.preventDefault(); + // 如果设置面板已打开,关闭它;否则打开 + const settings = document.getElementById('settingsModal'); + if (settings && settings.style.display !== 'none') { + document.getElementById('btnCloseSettings')?.click(); + } else { + document.getElementById('btnSettings')?.click(); + } + return; + } + return; // 其他快捷键在模态框打开时不响应 + } + + // ── 普通快捷键 ── + switch (key) { + case 'n': + e.preventDefault(); + document.getElementById('btnNewChat')?.click(); + break; + + case 'enter': { + e.preventDefault(); + const btnSend = document.getElementById('btnSend') as HTMLButtonElement | null; + if (btnSend && !btnSend.classList.contains('disabled')) { + btnSend.click(); + } + break; + } + + case 'k': + e.preventDefault(); + document.getElementById('chatInput')?.focus(); + break; + + case 'f': + e.preventDefault(); + document.getElementById('btnSearch')?.click(); + break; + + case 'p': + e.preventDefault(); + togglePlanMode(); + break; + + case 'm': + e.preventDefault(); + document.getElementById('btnMemory')?.click(); + break; + + case 'h': + e.preventDefault(); + document.getElementById('btnHistory')?.click(); + break; + + case ',': + e.preventDefault(); + document.getElementById('btnSettings')?.click(); + break; + + case 'l': + e.preventDefault(); + clearChat(); + break; + } + }); + + logInfo('快捷键系统已初始化', `${KEYBINDS.length} 个快捷键已注册`); +} + +/** 获取快捷键列表(按分类分组) */ +export function getKeybindsByCategory(): Record { + const grouped: Record = {}; + for (const kb of KEYBINDS) { + if (!grouped[kb.category]) grouped[kb.category] = []; + grouped[kb.category].push(kb); + } + return grouped; +} diff --git a/src/renderer/components/metrics-dashboard.ts b/src/renderer/components/metrics-dashboard.ts new file mode 100644 index 0000000..b0e3e1c --- /dev/null +++ b/src/renderer/components/metrics-dashboard.ts @@ -0,0 +1,139 @@ +/** + * MetricsDashboard — Agent Metrics 可视化仪表盘 + * 展示效率概览、工具热力图、Token 趋势 + */ + +import { getMetricsHistory, aggregateMetrics, generateImprovementSuggestions } from '../services/agent-metrics.js'; +import { logInfo } from '../services/log-service.js'; + +let metricsModalEl: HTMLElement | null = null; + +export function initMetricsDashboard(): void { + metricsModalEl = document.querySelector('#metricsDashboardModal'); + + document.querySelector('#btnMetrics')?.addEventListener('click', openMetricsDashboard); + document.querySelector('#btnCloseMetrics')?.addEventListener('click', closeMetricsDashboard); + + if (metricsModalEl) { + metricsModalEl.addEventListener('click', (e) => { + if (e.target === metricsModalEl) closeMetricsDashboard(); + }); + } +} + +function openMetricsDashboard(): void { + if (!metricsModalEl) return; + metricsModalEl.style.display = ''; + renderMetricsDashboard(); +} + +function closeMetricsDashboard(): void { + if (metricsModalEl) metricsModalEl.style.display = 'none'; +} + +function renderMetricsDashboard(): void { + const history = getMetricsHistory(); + const agg = aggregateMetrics(); + const suggestions = generateImprovementSuggestions(); + + // ── 效率概览 ── + const overviewEl = document.querySelector('#mdOverview'); + if (overviewEl) { + if (agg.totalSessions === 0) { + overviewEl.innerHTML = '

暂无度量数据,开始对话后将有统计数据

'; + } else { + const successRate = (agg.toolSuccessRate * 100).toFixed(1); + const avgIter = agg.avgIterationsPerTask.toFixed(1); + const tokenEff = agg.tokenEfficiency.toFixed(2); + overviewEl.innerHTML = ` +
${agg.totalSessions}
会话总数
+
${avgIter}
平均迭代轮次
+
${successRate}%
工具成功率
+
${tokenEff}
Token 效率
+ `; + } + } + + // ── 工具热力图 ── + const toolHeatmapEl = document.querySelector('#mdToolHeatmap'); + if (toolHeatmapEl) { + if (history.length === 0) { + toolHeatmapEl.innerHTML = '

暂无工具调用数据

'; + } else { + const toolStats = new Map(); + for (const session of history) { + for (const tc of session.toolCalls) { + if (!toolStats.has(tc.name)) toolStats.set(tc.name, { success: 0, error: 0, cancelled: 0 }); + const stat = toolStats.get(tc.name)!; + if (tc.status === 'success') stat.success++; + else if (tc.status === 'error') stat.error++; + else stat.cancelled++; + } + } + const sortedTools = [...toolStats.entries()].sort((a, b) => { + const aTotal = a[1].success + a[1].error + a[1].cancelled; + const bTotal = b[1].success + b[1].error + b[1].cancelled; + return bTotal - aTotal; + }); + const maxTotal = sortedTools.length > 0 + ? Math.max(...sortedTools.map(([_, s]) => s.success + s.error + s.cancelled)) + : 1; + toolHeatmapEl.innerHTML = sortedTools.slice(0, 20).map(([name, stat]) => { + const total = stat.success + stat.error + stat.cancelled; + const successPct = (stat.success / maxTotal * 100).toFixed(1); + const errorPct = (stat.error / maxTotal * 100).toFixed(1); + const cancelPct = (stat.cancelled / maxTotal * 100).toFixed(1); + return ` +
+ ${name} +
+
+
+
+
+ ${stat.success}/${total} +
+ `; + }).join(''); + } + } + + // ── Token 趋势 ── + const tokenTrendEl = document.querySelector('#mdTokenTrend'); + if (tokenTrendEl) { + if (history.length === 0) { + tokenTrendEl.innerHTML = '

暂无 Token 趋势数据

'; + } else { + const recent = history.slice(-20); + const maxTokens = Math.max(...recent.map(s => s.totalInputTokens + s.totalOutputTokens), 1); + tokenTrendEl.innerHTML = recent.map((s, i) => { + const inputPct = (s.totalInputTokens / maxTokens * 100).toFixed(1); + const outputPct = (s.totalOutputTokens / maxTokens * 100).toFixed(1); + return ` +
+
+
+ ${i + 1} +
+ `; + }).join(''); + } + } + + // ── 改进建议 ── + const suggestionsEl = document.querySelector('#mdSuggestions'); + if (suggestionsEl) { + if (suggestions.length === 0) { + suggestionsEl.innerHTML = '

暂无改进建议

'; + } else { + suggestionsEl.innerHTML = suggestions.map(s => ` +
+ ${s.severity} + ${s.message} +
+ `).join(''); + } + } + + logInfo('Metrics 仪表盘已刷新', `${history.length} 个会话记录`); +} diff --git a/src/renderer/components/settings-modal.ts b/src/renderer/components/settings-modal.ts index 2a23ced..2d6ee21 100644 --- a/src/renderer/components/settings-modal.ts +++ b/src/renderer/components/settings-modal.ts @@ -25,6 +25,27 @@ export function initSettingsModal(): void { if (e.target === settingsModalEl) closeSettingsModal(); }); + // ── 主题选择 ── + document.querySelector('#selectTheme')?.addEventListener('change', async () => { + const mode = (document.querySelector('#selectTheme') as HTMLSelectElement).value as 'light' | 'dark' | 'auto'; + // 直接内联实现主题切换,避免动态导入 main.ts(入口模块不可安全导出函数) + localStorage.setItem('metona-theme', mode); + let effective: 'light' | 'dark'; + if (mode === 'auto') { + effective = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } else { + effective = mode; + } + document.documentElement.setAttribute('data-theme', effective); + const metaThemeColor = document.querySelector('meta[name="theme-color"]'); + if (metaThemeColor) { + metaThemeColor.setAttribute('content', effective === 'dark' ? '#1A1B26' : '#FAF7F2'); + } + const db = state.get(KEYS.DB); + if (db) await db.saveSetting('themeMode', mode); + logInfo(`主题已切换: ${mode === 'auto' ? '跟随系统' : mode === 'dark' ? '暗色' : '亮色'}`); + }); + const saveServerUrl = debounce(async () => { const url = (document.querySelector('#inputServerUrl') as HTMLInputElement).value.trim(); if (!url) return; @@ -339,6 +360,7 @@ export function openSettingsModal(): void { updateRunningModels(); loadTimeoutSettings(); loadWatchdogSetting(); + loadThemeSetting(); // 刷新工作空间目录显示 const bridge = window.metonaDesktop; if (bridge?.isDesktop) { @@ -367,6 +389,13 @@ export function closeSettingsModal(): void { settingsModalEl.style.display = 'none'; } +/** 加载主题设置到下拉框 */ +async function loadThemeSetting(): Promise { + const saved = (localStorage.getItem('metona-theme') || 'auto') as 'light' | 'dark' | 'auto'; + const select = document.querySelector('#selectTheme') as HTMLSelectElement | null; + if (select) select.value = saved; +} + /** 加载已保存的超时设置到输入框:-1=默认(显示空), 0=禁用, 正数=自定义 */ async function loadTimeoutSettings(): Promise { const db = state.get(KEYS.DB); diff --git a/src/renderer/index.html b/src/renderer/index.html index 2875afe..4a6cd9e 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -28,7 +28,7 @@
Metona Ollama - v0.16.16 + v0.16.17 +
@@ -287,6 +292,17 @@ @@ -476,7 +492,7 @@