安全修复: - 开启 webSecurity(CORS 改为 webRequest 允许清单精确放行 Ollama 地址) - 新增 net-guard SSRF 防护:web_fetch/download_file/browser_open 拦截环回/内网/链路本地地址(DNS 解析后校验) - browser_open 协议白名单(仅 http/https,阻止 file:// 绕过路径安全层) - git 参数注入防护(用户可控参数禁止 - 开头;git add 强制 -- 分隔) - 身份文件保护:SOUL.md/AGENT.md/USER.md 工具只读(防提示注入持久化劫持) - 系统目录硬红线 + 工作空间/白名单不可豁免系统目录 - spawn_task 权限只降不升(封顶于用户设置 subAgentMaxPermission) - 子代理写类工具接入主 Agent 确认管线 + 完整路径沙箱 - toast 改 textContent、HTML 导出 escapeHtml(XSS 修复) - Agent 浏览器改用 memory: 内存分区(退出清空 cookie/storage) 数据层重构: - sql.js 写入改防抖批量落盘(300ms 合并快照 + temp 原子替换 + 退出刷盘) - Schema 迁移改 PRAGMA user_version 顺序迁移数组 - 消息/设置/轨迹批量写(单事务);SearXNG 配置 13 次写合并为 1 次 - 会话摘要查询(getSessionSummaries/searchSessions 单条 SQL)消除 N+1 - 导出改 getAllSessionsData 一次 IPC 取回全部行 Bug 修复: - edit_file 替换符污染($&/$1 被特殊解释导致文件写坏) - truncateToolResult 暴力截断拼接非法 JSON 必然崩溃 - diff 算法 100MB dp 数组 → 前缀/后缀裁剪 + LCS 限额 + 回退 - move_file 跨盘 rename 失败回退 copy+delete - Ctrl+K 快捷键冲突(双注册);全局错误处理器双注册 - ffmpeg stderr 无限累积 + 帧进度 O(n²) 正则 - 搜索可达性预检只取响应头(Range: bytes=0-0) - 备份导出逐字节 base64 拼接(O(n²))改 FileReader - MCP clientInfo 版本硬编码 5.0.0 改真实版本;tools/list 支持 nextCursor 分页 - 看门狗默认值统一为 30 分钟;download_file 超时跟随用户配置 架构改进: - 主进程工具分发注册表 tool-dispatch.ts(消除 switch 硬编码) - agent-engine 拆分 result-formatter.ts / tool-parsing.ts(纯函数) - 文本兜底解析白名单改从注册表派生(补齐 browser_*/diff/spawn_task/mcp_*) - diff 工具默认启用;MODE_TOOLS 单一事实来源(tools-modal 复用) - 记忆系统:条目缓存 + 访问统计(hits/last)持久化 + removeById 按 ID 删除 - 度量历史启动恢复 + Metrics 仪表盘接入 JSON/Prometheus 导出 - 子代理模型下拉框打开设置时刷新(此前从未填充) 死代码清理(约 1400 行): - 删除 context-indexer 整模块、agent-safety 震荡检测/性能报告/依赖图/记忆调优/归档取回 - 删除 context-manager 水印/跳过压缩/自适应窗口/趋势分析/预算分配等未接线函数 - 删除 sanitizeToolArgs(污染 write_file 内容,防注入职责移交主进程安全层) - infra-service 裁剪为全局错误处理器唯一定义 文档对齐: - 新增内置 AGENT.md(工作空间同名文件可覆盖) - README/帮助面板/DEVELOPMENT 移除失实描述(WAL/内部URL拦截/5层防御/并行白名单/Hook 数量) - 工具数量口径统一 33;安全机制表新增 SSRF/身份保护/子代理权限等 9 项 工程化: - Vitest + 34 个单元测试(myers-diff/calculator/net-guard/MEMORY.md 格式) - Gitea Actions CI(typecheck + test + build) - package.json 新增 typecheck/test 脚本
This commit is contained in:
@@ -910,9 +910,11 @@ export async function exportAsHtml(session: ChatSession): Promise<void> {
|
||||
.assistant{background:#fff;padding:12px;border-radius:12px;margin:8px 0;border:1px solid rgba(0,0,0,0.06);box-shadow:0 1px 4px rgba(45,32,22,0.04);}
|
||||
pre{background:#2D2016;color:#F5F0E8;padding:14px;border-radius:12px;overflow-x:auto;}
|
||||
code{background:#F5F0E8;color:#E8734A;padding:2px 6px;border-radius:4px;}</style></head><body>
|
||||
<h1>${escapeHtml(session.title)}</h1><p>${formatTime(session.createdAt)} · ${session.model}</p><hr>`;
|
||||
<h1>${escapeHtml(session.title)}</h1><p>${formatTime(session.createdAt)} · ${escapeHtml(session.model)}</p><hr>`;
|
||||
session.messages.forEach(m => {
|
||||
html += `<div class="${m.role}"><strong>${m.role === 'user' ? '👤 用户' : '🤖 AI'}</strong><br>${(m.content || '').replace(/\n/g, '<br>')}</div>`;
|
||||
// escapeHtml 防止 AI 回复中的 HTML/脚本在导出文件中被执行
|
||||
const safeContent = escapeHtml(m.content || '').replace(/\n/g, '<br>');
|
||||
html += `<div class="${m.role}"><strong>${m.role === 'user' ? '👤 用户' : '🤖 AI'}</strong><br>${safeContent}</div>`;
|
||||
});
|
||||
html += '</body></html>';
|
||||
await nativeSaveFile(`${session.title}.html`, html);
|
||||
|
||||
@@ -94,42 +94,47 @@ export function initHistoryModal(): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** 历史列表条目(摘要形态,不加载消息正文) */
|
||||
interface HistorySummary {
|
||||
id: string;
|
||||
title: string;
|
||||
model: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
message_count: number;
|
||||
}
|
||||
|
||||
async function loadHistory(): Promise<void> {
|
||||
const db = state.get<ChatDB>(KEYS.DB);
|
||||
if (!db) return;
|
||||
|
||||
let allSessions = await db.getAllSessions();
|
||||
// 摘要查询:搜索走 SQL LIKE(标题+消息内容),否则单条聚合 SQL。
|
||||
// 不再把所有会话的全部消息拉进渲染进程。
|
||||
let summaries: HistorySummary[] = historySearchQuery
|
||||
? await db.searchSessions(historySearchQuery)
|
||||
: await db.listSessionSummaries();
|
||||
|
||||
if (historySearchQuery) {
|
||||
const q = historySearchQuery.toLowerCase();
|
||||
allSessions = allSessions.filter(s => {
|
||||
if (s.title?.toLowerCase().includes(q)) return true;
|
||||
if (s.model?.toLowerCase().includes(q)) return true;
|
||||
return s.messages.some(m => m.content?.toLowerCase().includes(q));
|
||||
});
|
||||
}
|
||||
summaries = [...summaries].sort((a, b) => b.updated_at - a.updated_at);
|
||||
|
||||
allSessions.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
|
||||
if (allSessions.length === 0) {
|
||||
historyListEl.innerHTML = `<div class="empty-history"><p class="text-muted">暂无历史记录</p></div>`;
|
||||
if (summaries.length === 0) {
|
||||
historyListEl.innerHTML = `<div class="empty-history"><p class="text-muted">${historySearchQuery ? '未找到匹配的会话' : '暂无历史记录'}</p></div>`;
|
||||
historyPaginationEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(allSessions.length / HISTORY_PAGE_SIZE);
|
||||
const totalPages = Math.ceil(summaries.length / HISTORY_PAGE_SIZE);
|
||||
if (historyPage > totalPages) historyPage = totalPages;
|
||||
|
||||
const start = (historyPage - 1) * HISTORY_PAGE_SIZE;
|
||||
const pageSessions = allSessions.slice(start, start + HISTORY_PAGE_SIZE);
|
||||
const pageSessions = summaries.slice(start, start + HISTORY_PAGE_SIZE);
|
||||
|
||||
historyListEl.innerHTML = pageSessions.map(s => `
|
||||
<div class="history-item" data-id="${s.id}">
|
||||
<div class="history-info" data-id="${s.id}">
|
||||
<div class="history-title">${escapeHtml(s.title)}</div>
|
||||
<div class="history-meta">
|
||||
<span>${formatTime(s.updatedAt)}</span>
|
||||
<span>${s.messages.length} 条消息</span>
|
||||
<span>${formatTime(s.updated_at)}</span>
|
||||
<span>${s.message_count} 条消息</span>
|
||||
<span>${s.model || '无模型'}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,11 +148,11 @@ async function loadHistory(): Promise<void> {
|
||||
`).join('');
|
||||
|
||||
if (totalPages <= 1) {
|
||||
historyPaginationEl.innerHTML = `<span class="page-info">共 ${allSessions.length} 条</span>`;
|
||||
historyPaginationEl.innerHTML = `<span class="page-info">共 ${summaries.length} 条</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `<span class="page-info">共 ${allSessions.length} 条</span><div class="page-buttons">`;
|
||||
let html = `<span class="page-info">共 ${summaries.length} 条</span><div class="page-buttons">`;
|
||||
html += `<button class="page-btn${historyPage <= 1 ? ' disabled' : ''}" data-page="${historyPage - 1}">‹</button>`;
|
||||
const range = 2;
|
||||
const pages: number[] = [];
|
||||
|
||||
@@ -15,10 +15,10 @@ import { showToast } from './toast.js';
|
||||
import { addToolCard, startToolCard, updateToolCard, clearToolCardsExternal, clearTerminalExternal, getWorkspaceDirPath, hasActiveCards, showWorkingHint, clearWorkingHint } from './workspace-panel.js';
|
||||
import { ChatDB } from '../db/chat-db.js';
|
||||
import { OllamaAPI } from '../api/ollama.js';
|
||||
import { runAgentLoop, formatToolResultForModel } from '../services/agent-engine.js';
|
||||
import { runAgentLoop } from '../services/agent-engine.js';
|
||||
import { formatToolResultForModel } from '../services/result-formatter.js';
|
||||
import { estimateTokens } from '../services/context-manager.js';
|
||||
import { showToolConfirm } from './tool-confirm-modal.js';
|
||||
import { showConfirm } from './prompt-modal.js';
|
||||
import { logInfo, logStream, logError, logSuccess, logWarn, resetVideoProgress, updateVideoProgress } from '../services/log-service.js';
|
||||
import type { ChatSession, ChatMessage, OllamaStreamChunk, OllamaMessage, FileContent, ChatFile, ToolCallRecord, AgentMode } from '../types.js';
|
||||
|
||||
@@ -91,25 +91,13 @@ export function initInputArea(): void {
|
||||
}
|
||||
});
|
||||
|
||||
// R39: 全局键盘快捷键
|
||||
// R39: 全局键盘快捷键(Ctrl+K 聚焦输入框由 keybind-manager.ts 统一管理,此处不再重复注册)
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Ctrl+K: 清空聊天(需要确认)
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
showConfirm('确定要清空当前对话吗?', '清空对话').then(ok => {
|
||||
if (ok) clearMessages();
|
||||
});
|
||||
}
|
||||
// Escape: 停止生成(当正在流式输出时)
|
||||
if (e.key === 'Escape' && state.get<boolean>(KEYS.IS_STREAMING)) {
|
||||
e.preventDefault();
|
||||
stopGeneration();
|
||||
}
|
||||
// Ctrl+/: 聚焦输入框
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
|
||||
e.preventDefault();
|
||||
chatInputEl.focus();
|
||||
}
|
||||
// Ctrl+Shift+C: 复制最后一条 AI 消息
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'C') {
|
||||
e.preventDefault();
|
||||
@@ -963,26 +951,6 @@ function buildFileContentParts(fileContents: Array<{ name: string; language: str
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
function buildApiMessages(messages: ChatMessage[]): Array<{ role: string; content: string; images?: string[] }> {
|
||||
return messages.map(m => {
|
||||
let content = m.content || '';
|
||||
if (m._fileContents && m._fileContents.length > 0) {
|
||||
const fileParts = buildFileContentParts(m._fileContents);
|
||||
if (content) {
|
||||
content += '\n\n---\n' + fileParts.join('\n\n---\n');
|
||||
} else {
|
||||
const count = m._fileContents.length;
|
||||
content = `请分析以下 ${count > 1 ? count + ' 个' : ''}文件:\n\n${fileParts.join('\n\n---\n')}`;
|
||||
}
|
||||
}
|
||||
return {
|
||||
role: m.role,
|
||||
content,
|
||||
...(m.images && { images: m.images })
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 从会话消息构建 Ollama 格式的历史消息列表。
|
||||
* 注入 assistant + user(含 _apiContent)+ tool_calls + role:'tool' 结果。
|
||||
|
||||
@@ -20,8 +20,10 @@ export const KEYBINDS: Keybind[] = [
|
||||
{ keys: 'Ctrl+N', description: '新建会话', category: 'chat' },
|
||||
{ keys: 'Ctrl+Enter', description: '发送消息', category: 'chat' },
|
||||
{ keys: 'Ctrl+K', description: '聚焦输入框', category: 'chat' },
|
||||
{ keys: 'Ctrl+/', description: '聚焦输入框(备用)', category: 'chat' },
|
||||
{ keys: 'Ctrl+L', description: '清空当前对话', category: 'chat' },
|
||||
{ keys: 'Ctrl+F', description: '对话内搜索', category: 'chat' },
|
||||
{ keys: 'Ctrl+Shift+C', description: '复制最后一条 AI 回复', category: 'chat' },
|
||||
{ keys: 'Ctrl+P', description: '切换 Plan Mode', category: 'agent' },
|
||||
{ keys: 'Ctrl+Shift+Backspace', description: '中止 Agent', category: 'agent' },
|
||||
{ keys: 'Ctrl+M', description: '打开记忆面板', category: 'navigation' },
|
||||
@@ -187,6 +189,11 @@ export function initKeybindManager(): void {
|
||||
document.getElementById('chatInput')?.focus();
|
||||
break;
|
||||
|
||||
case '/':
|
||||
e.preventDefault();
|
||||
document.getElementById('chatInput')?.focus();
|
||||
break;
|
||||
|
||||
case 'f':
|
||||
e.preventDefault();
|
||||
document.getElementById('btnSearch')?.click();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 基于工作空间 MEMORY.md 文件
|
||||
*/
|
||||
|
||||
import { loadAllEntries, addEntry, removeEntry, DuplicateEntryError, type MemoryEntry, type MemoryType } from '../services/memory-service.js';
|
||||
import { loadAllEntries, addEntry, removeEntryById, DuplicateEntryError, type MemoryEntry, type MemoryType } from '../services/memory-service.js';
|
||||
import { showToast } from './toast.js';
|
||||
import { showPrompt, showConfirm } from './prompt-modal.js';
|
||||
import { escapeHtml, formatTime } from '../utils/utils.js';
|
||||
@@ -54,7 +54,8 @@ export function initMemoryModal(): void {
|
||||
const entries = await loadAllEntries();
|
||||
const entry = entries.find(e => e.id === id);
|
||||
if (entry && await showConfirm(`确定删除这条记忆?\n\n${entry.content.slice(0, 100)}`, '删除记忆')) {
|
||||
await removeEntry(entry.content.slice(0, 50));
|
||||
// 按 ID 精确删除(取代旧的子串匹配删除,避免误删)
|
||||
await removeEntryById(id);
|
||||
renderList();
|
||||
showToast('记忆已删除', 'info', 1500);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* MetricsDashboard — Agent Metrics 可视化仪表盘
|
||||
* 展示效率概览、工具热力图、Token 趋势
|
||||
* 展示效率概览、工具热力图、Token 趋势;支持 JSON / Prometheus 格式导出
|
||||
*/
|
||||
|
||||
import { getMetricsHistory, aggregateMetrics, generateImprovementSuggestions } from '../services/agent-metrics.js';
|
||||
import { logInfo } from '../services/log-service.js';
|
||||
import { getMetricsHistory, aggregateMetrics, generateImprovementSuggestions, exportMetricsJSON, exportMetricsPrometheus } from '../services/agent-metrics.js';
|
||||
import { logInfo, logError, logSuccess } from '../services/log-service.js';
|
||||
import { showToast } from './toast.js';
|
||||
|
||||
let metricsModalEl: HTMLElement | null = null;
|
||||
|
||||
@@ -14,6 +15,10 @@ export function initMetricsDashboard(): void {
|
||||
document.querySelector('#btnMetrics')?.addEventListener('click', openMetricsDashboard);
|
||||
document.querySelector('#btnCloseMetrics')?.addEventListener('click', closeMetricsDashboard);
|
||||
|
||||
// 指标导出(JSON / Prometheus 文本)
|
||||
document.querySelector('#btnExportMetricsJson')?.addEventListener('click', () => exportMetrics('json'));
|
||||
document.querySelector('#btnExportMetricsPrometheus')?.addEventListener('click', () => exportMetrics('prometheus'));
|
||||
|
||||
if (metricsModalEl) {
|
||||
metricsModalEl.addEventListener('click', (e) => {
|
||||
if (e.target === metricsModalEl) closeMetricsDashboard();
|
||||
@@ -21,6 +26,44 @@ export function initMetricsDashboard(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** 导出指标为 JSON 或 Prometheus 文本格式 */
|
||||
async function exportMetrics(format: 'json' | 'prometheus'): Promise<void> {
|
||||
try {
|
||||
const bridge = window.metonaDesktop;
|
||||
const content = format === 'json' ? exportMetricsJSON() : exportMetricsPrometheus();
|
||||
const ext = format === 'json' ? 'json' : 'prom';
|
||||
const ts = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-');
|
||||
if (bridge) {
|
||||
const filePath = await bridge.dialog.saveFile({
|
||||
defaultPath: `metona-metrics-${ts}.${ext}`,
|
||||
filters: [
|
||||
format === 'json'
|
||||
? { name: 'JSON', extensions: ['json'] }
|
||||
: { name: 'Prometheus 文本', extensions: ['prom', 'txt'] },
|
||||
]
|
||||
});
|
||||
if (!filePath) return;
|
||||
const result = await bridge.fs.writeFile(filePath, content, 'utf-8');
|
||||
if (!result.success) {
|
||||
showToast(`导出失败: ${result.error}`, 'error');
|
||||
return;
|
||||
}
|
||||
logSuccess(`指标已导出 (${format})`, filePath);
|
||||
showToast('指标已导出', 'success');
|
||||
} else {
|
||||
// 浏览器回退:直接下载
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `metona-metrics-${ts}.${ext}`;
|
||||
a.click();
|
||||
}
|
||||
} catch (err) {
|
||||
logError('指标导出失败', (err as Error).message);
|
||||
showToast(`导出失败: ${(err as Error).message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function openMetricsDashboard(): void {
|
||||
if (!metricsModalEl) return;
|
||||
metricsModalEl.style.display = '';
|
||||
|
||||
@@ -29,21 +29,23 @@ let initialized = false;
|
||||
/** SearXNG 运行时配置缓存(主进程搜索时通过 IPC 读取,渲染进程通过此变量快速访问) */
|
||||
export let searxngConfig = { ...DEFAULTS };
|
||||
|
||||
/** 写入配置到 SQLite + 更新运行时缓存 */
|
||||
/** 写入配置到 SQLite + 更新运行时缓存(单事务批量写,避免 13 次全库写盘) */
|
||||
async function saveConfig(db: ChatDB): Promise<void> {
|
||||
await db.saveSetting('searxng_enabled', searxngConfig.enabled);
|
||||
await db.saveSetting('searxng_url', searxngConfig.url);
|
||||
await db.saveSetting('searxng_engines', searxngConfig.engines);
|
||||
await db.saveSetting('searxng_language', searxngConfig.language);
|
||||
await db.saveSetting('searxng_safesearch', searxngConfig.safesearch);
|
||||
await db.saveSetting('searxng_time_range', searxngConfig.time_range);
|
||||
await db.saveSetting('searxng_max_results', searxngConfig.max_results);
|
||||
await db.saveSetting('searxng_auth_key', searxngConfig.auth_key);
|
||||
await db.saveSetting('searxng_auth_type', searxngConfig.auth_type);
|
||||
await db.saveSetting('searxng_format', searxngConfig.format);
|
||||
// 通用搜索设置(非 SearXNG 专属,主进程搜索时读取)
|
||||
await db.saveSetting('fetch_count', searxngConfig.fetch_count);
|
||||
await db.saveSetting('fetch_mode', searxngConfig.fetch_mode);
|
||||
await db.saveSettingsBatch([
|
||||
{ key: 'searxng_enabled', value: searxngConfig.enabled },
|
||||
{ key: 'searxng_url', value: searxngConfig.url },
|
||||
{ key: 'searxng_engines', value: searxngConfig.engines },
|
||||
{ key: 'searxng_language', value: searxngConfig.language },
|
||||
{ key: 'searxng_safesearch', value: searxngConfig.safesearch },
|
||||
{ key: 'searxng_time_range', value: searxngConfig.time_range },
|
||||
{ key: 'searxng_max_results', value: searxngConfig.max_results },
|
||||
{ key: 'searxng_auth_key', value: searxngConfig.auth_key },
|
||||
{ key: 'searxng_auth_type', value: searxngConfig.auth_type },
|
||||
{ key: 'searxng_format', value: searxngConfig.format },
|
||||
// 通用搜索设置(非 SearXNG 专属,主进程搜索时读取)
|
||||
{ key: 'fetch_count', value: searxngConfig.fetch_count },
|
||||
{ key: 'fetch_mode', value: searxngConfig.fetch_mode },
|
||||
]);
|
||||
logSuccess('SearXNG 配置已保存');
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,11 @@ export function initSettingsModal(): void {
|
||||
const api = new OllamaAPI(url);
|
||||
state.set(KEYS.API, api);
|
||||
if (db) await db.saveSetting('serverUrl', url);
|
||||
// 通知主进程更新 CORS 允许清单(webSecurity 开启后仅放行该地址)
|
||||
const bridge = window.metonaDesktop;
|
||||
if (bridge?.setOllamaOrigin) {
|
||||
try { await bridge.setOllamaOrigin(url); } catch { /* ignore */ }
|
||||
}
|
||||
updateConnectionInfo();
|
||||
checkConnection();
|
||||
loadModels();
|
||||
@@ -307,6 +312,19 @@ export function initSettingsModal(): void {
|
||||
});
|
||||
}
|
||||
|
||||
// ── 子代理权限上限(AI 请求的权限只降不升)──
|
||||
const selectSubAgentPermission = document.querySelector('#selectSubAgentPermission') as HTMLSelectElement | null;
|
||||
if (selectSubAgentPermission) {
|
||||
selectSubAgentPermission.addEventListener('change', async () => {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
const val = selectSubAgentPermission.value as 'readonly' | 'limited_write' | 'full_write';
|
||||
state.set('subAgentMaxPermission', val);
|
||||
if (db) await db.saveSetting('subAgentMaxPermission', val);
|
||||
const permNames: Record<string, string> = { readonly: '只读', limited_write: '有限写入', full_write: '完整写入' };
|
||||
logSetting('子代理权限上限', permNames[val] || val);
|
||||
});
|
||||
}
|
||||
|
||||
// ── 子代理相关设置(原顶层事件监听器移入此处,确保 DOM 已就绪)──
|
||||
// 子代理模型设置保存
|
||||
document.querySelector('#selectSubAgentModel')?.addEventListener('change', async () => {
|
||||
@@ -361,6 +379,8 @@ export function openSettingsModal(): void {
|
||||
loadTimeoutSettings();
|
||||
loadWatchdogSetting();
|
||||
loadThemeSetting();
|
||||
// 刷新子代理模型下拉列表(此前从未被调用,导致下拉框永远只有默认项)
|
||||
populateSubAgentModels().catch(() => {});
|
||||
// 刷新工作空间目录显示
|
||||
const bridge = window.metonaDesktop;
|
||||
if (bridge?.isDesktop) {
|
||||
@@ -443,12 +463,13 @@ async function exportAllSessions(): Promise<void> {
|
||||
filters: [{ name: 'Metona 备份', extensions: ['metona'] }]
|
||||
});
|
||||
if (!filePath) return;
|
||||
// blob → ArrayBuffer → base64 字符串,通过 base64 编码写入二进制文件
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
||||
const b64 = btoa(binary);
|
||||
// blob → base64:使用 FileReader(取代逐字节字符串拼接的 O(n²) 实现)
|
||||
const b64 = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result).split(',')[1] || '');
|
||||
reader.onerror = () => reject(new Error('读取备份数据失败'));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
const result = await bridge.fs.writeFile(filePath, b64, 'base64');
|
||||
if (!result.success) {
|
||||
showToast(`导出失败: ${result.error}`, 'error');
|
||||
|
||||
@@ -1,21 +1,38 @@
|
||||
/**
|
||||
* Toast - 通知组件
|
||||
* 使用 textContent 渲染消息文本(防 XSS:文件名等外部输入可能包含 HTML)
|
||||
*/
|
||||
|
||||
let toastContainer: HTMLElement | null = null;
|
||||
|
||||
function getContainer(): HTMLElement | null {
|
||||
if (!toastContainer) {
|
||||
toastContainer = document.querySelector('#toastContainer');
|
||||
}
|
||||
return toastContainer;
|
||||
}
|
||||
|
||||
export function initToast(): void {
|
||||
toastContainer = document.querySelector('#toastContainer');
|
||||
}
|
||||
|
||||
export function showToast(text: string, type: 'info' | 'success' | 'warning' | 'error' = 'info', duration = 3000): void {
|
||||
if (!toastContainer) return;
|
||||
const container = getContainer();
|
||||
if (!container) return;
|
||||
|
||||
const iconMap: Record<string, string> = { success: '✓', error: '✗', warning: '⚠', info: 'ℹ' };
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.innerHTML = `<span class="toast-icon">${iconMap[type] || 'ℹ'}</span><span>${text}</span>`;
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'toast-icon';
|
||||
icon.textContent = iconMap[type] || 'ℹ';
|
||||
|
||||
const msg = document.createElement('span');
|
||||
msg.textContent = text; // textContent 防止外部输入注入 HTML
|
||||
|
||||
toast.append(icon, msg);
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.add('removing');
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { state, KEYS } from '../state/state.js';
|
||||
import { setToolMode, type ToolMode } from '../services/tool-registry.js';
|
||||
import { setToolMode, MODE_TOOLS as MANAGED_TOOLS, type ToolMode } from '../services/tool-registry.js';
|
||||
import { showToast } from './toast.js';
|
||||
import { logInfo } from '../services/log-service.js';
|
||||
import type { ChatDB } from '../db/chat-db.js';
|
||||
@@ -17,14 +17,6 @@ const MODE_NAMES: Record<string, string> = {
|
||||
confirm: '需确认',
|
||||
};
|
||||
|
||||
// 需要全局模式管理的工具列表(与 tool-registry.ts MODE_TOOLS 一致)
|
||||
const MANAGED_TOOLS = [
|
||||
'run_command',
|
||||
'write_file', 'create_directory', 'delete_file',
|
||||
'edit_file', 'move_file', 'copy_file',
|
||||
'download_file', 'compress',
|
||||
];
|
||||
|
||||
function updateAllBadges(mode: string): void {
|
||||
const badgeText = mode === 'auto' ? '自动' : '需确认';
|
||||
const badgeCls = mode === 'auto' ? 'auto' : 'confirm';
|
||||
|
||||
@@ -96,9 +96,6 @@ let toolCards: ToolCallRecord[] = [];
|
||||
/** 当前正在运行的 AI 命令(用于用户手动终止时通知 AI) */
|
||||
let currentAiCommand: string | null = null;
|
||||
|
||||
/** 终止通知回调(由外部设置) */
|
||||
let onToolTerminated: ((command: string) => void) | null = null;
|
||||
|
||||
|
||||
function genId(): string {
|
||||
return `ws_${Date.now()}_${++_counter}`;
|
||||
@@ -435,11 +432,6 @@ function killCurrentProcess(): void {
|
||||
renderTerminal();
|
||||
updateStopBtnState();
|
||||
updateHint();
|
||||
|
||||
// 通知 AI 命令被用户终止
|
||||
if (cmd && onToolTerminated) {
|
||||
onToolTerminated(cmd);
|
||||
}
|
||||
}
|
||||
|
||||
function clearTerminal(): void {
|
||||
|
||||
Reference in New Issue
Block a user