v0.17.0: 安全加固 + 数据层性能重构 + 死代码清理 + 测试基建
CI / verify (push) Canceled after 0s

安全修复:
- 开启 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:
2026-08-24 07:39:34 +08:00
parent 6b5e42a26d
commit bae993c321
55 changed files with 2873 additions and 2592 deletions
+4 -2
View File
@@ -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);
+24 -19
View File
@@ -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[] = [];
+3 -35
View File
@@ -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 -2
View File
@@ -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);
}
+46 -3
View File
@@ -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 = '';
+16 -14
View File
@@ -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 配置已保存');
}
+27 -6
View File
@@ -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');
+20 -3
View File
@@ -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');
+1 -9
View File
@@ -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 {