feat: v0.16.17 — 暗色模式 + diff工具 + 快捷键系统 + 审计日志 + 子代理分级权限 + Metrics仪表盘
This commit is contained in:
@@ -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} 个会话记录`);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user