feat: v0.16.17 — 暗色模式 + diff工具 + 快捷键系统 + 审计日志 + 子代理分级权限 + Metrics仪表盘

This commit is contained in:
2026-08-01 09:00:46 +08:00
parent 44094340a5
commit 794f94ab89
20 changed files with 1325 additions and 54 deletions
+233
View File
@@ -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<AbortController | null>(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<void> {
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<string, Keybind[]> {
const grouped: Record<string, Keybind[]> = {};
for (const kb of KEYBINDS) {
if (!grouped[kb.category]) grouped[kb.category] = [];
grouped[kb.category].push(kb);
}
return grouped;
}
@@ -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 = '<p class="text-muted" style="text-align:center;padding:40px;">暂无度量数据,开始对话后将有统计数据</p>';
} else {
const successRate = (agg.toolSuccessRate * 100).toFixed(1);
const avgIter = agg.avgIterationsPerTask.toFixed(1);
const tokenEff = agg.tokenEfficiency.toFixed(2);
overviewEl.innerHTML = `
<div class="md-card"><div class="md-card-value">${agg.totalSessions}</div><div class="md-card-label">会话总数</div></div>
<div class="md-card"><div class="md-card-value">${avgIter}</div><div class="md-card-label">平均迭代轮次</div></div>
<div class="md-card"><div class="md-card-value" style="color:var(--success)">${successRate}%</div><div class="md-card-label">工具成功率</div></div>
<div class="md-card"><div class="md-card-value" style="color:var(--caution)">${tokenEff}</div><div class="md-card-label">Token 效率</div></div>
`;
}
}
// ── 工具热力图 ──
const toolHeatmapEl = document.querySelector('#mdToolHeatmap');
if (toolHeatmapEl) {
if (history.length === 0) {
toolHeatmapEl.innerHTML = '<p class="text-muted" style="text-align:center;padding:20px;">暂无工具调用数据</p>';
} else {
const toolStats = new Map<string, { success: number; error: number; cancelled: number }>();
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 `
<div class="md-bar-row">
<span class="md-bar-label">${name}</span>
<div class="md-bar">
<div class="md-bar-success" style="width:${successPct}%"></div>
<div class="md-bar-error" style="width:${errorPct}%"></div>
<div class="md-bar-cancelled" style="width:${cancelPct}%"></div>
</div>
<span class="md-bar-count">${stat.success}/${total}</span>
</div>
`;
}).join('');
}
}
// ── Token 趋势 ──
const tokenTrendEl = document.querySelector('#mdTokenTrend');
if (tokenTrendEl) {
if (history.length === 0) {
tokenTrendEl.innerHTML = '<p class="text-muted" style="text-align:center;padding:20px;">暂无 Token 趋势数据</p>';
} 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 `
<div class="md-token-bar" title="会话 ${i + 1}: 输入 ${s.totalInputTokens} / 输出 ${s.totalOutputTokens}">
<div class="md-token-input" style="height:${inputPct}%"></div>
<div class="md-token-output" style="height:${outputPct}%"></div>
<span class="md-token-label">${i + 1}</span>
</div>
`;
}).join('');
}
}
// ── 改进建议 ──
const suggestionsEl = document.querySelector('#mdSuggestions');
if (suggestionsEl) {
if (suggestions.length === 0) {
suggestionsEl.innerHTML = '<p class="text-muted" style="text-align:center;padding:20px;">暂无改进建议</p>';
} else {
suggestionsEl.innerHTML = suggestions.map(s => `
<div class="md-suggestion">
<span class="md-suggestion-severity md-severity-${s.severity}">${s.severity}</span>
<span class="md-suggestion-msg">${s.message}</span>
</div>
`).join('');
}
}
logInfo('Metrics 仪表盘已刷新', `${history.length} 个会话记录`);
}
+29
View File
@@ -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<ChatDB | null>(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<void> {
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<void> {
const db = state.get<ChatDB | null>(KEYS.DB);