v0.12.3: 全面审计修复 + 稳定性加固 + 文档更新
P0 安全/稳定性修复 (6项):
- sqlite persist 改用临时文件+rename,I/O错误日志
- IPC fs 处理器加入 checkPathAllowed 路径验证
- handleCompress 命令注入修复 + 跨平台 (powershell/tar/zip/unzip)
- browser 全页截图简化,使用 capturePage full rect
- workspace 竞态条件修复
- editFile 5MB 大小限制
P1 安全/稳定性修复 (7项):
- 大小写不敏感搜索: query 同步转换小写
- web_fetch 无 content-length 时流式读取+10MB限制防OOM
- Windows SIGTERM 改用 taskkill /PID /T /F 强制终止
- startsWith 路径检查 Windows 大小写不敏感
- 安全黑名单扩展: 33个目录路径 + 30条危险命令 (Linux+Windows)
- plan_track stepIndex 改用 typeof === 'number' 精确检查
- MCP 前缀改用双下划线分隔 mcp_{server}__{tool} 防歧义
P2 代码质量修复 (8项):
- 修复 EXECUTING→THINKING 死循环Bug (转换表缺失)
- COMPRESSING 硬上限绕过时同步 _loopState 全局状态
- Abort 路径补齐 onDone 回调, 防止UI状态残留
- 幻觉检测从4条扩展到16条规则, 覆盖全部工具类别
- 移除 getTokenEfficiency 死代码
- 跨平台路径清理 (replace(/[\/]+$/, ''))
- 动态 import 加 try/catch, 失败自动批准不阻断
- #modelSelect 加 null 守卫, 防止运行时崩溃
文档 & UI 更新:
- README.md: 版本号/特性表/架构图/安全机制/工具清单全面刷新
- 帮助面板: 新增 Plan Mode、抗幻觉&稳定性章节, Agent Loop补8状态机
- 工具面板: plan_track卡片 + plan-mode徽章样式, 标题更新为42+1
- 工作空间面板宽度: 480px → 400px
- 源码注释移除所有版本标签, 仅保留4处必要位置
- 版本号 0.12.2 → 0.12.3
This commit is contained in:
@@ -254,7 +254,7 @@ function renderToolCallCard(tc: ToolCallRecord): string {
|
||||
edit_file: '✂️', get_file_info: 'ℹ️', tree: '🌳', download_file: '⬇️',
|
||||
diff_files: '🔀', replace_in_files: '🔄', read_multiple_files: '📚',
|
||||
git: '🔖', compress: '🗜️', web_search: '🔍',
|
||||
memory_search: '🧠', memory_add: '💾', session_list: '📋', session_read: '📖', spawn_task: '🤖',
|
||||
memory_search: '🧠', memory_add: '💾', session_list: '📋', session_read: '📖', spawn_task: '🤖', plan_track: '📋',
|
||||
browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰',
|
||||
browser_click: '👆', browser_type: '⌨️', browser_scroll: '↕️', browser_close: '❌'
|
||||
};
|
||||
@@ -265,7 +265,7 @@ function renderToolCallCard(tc: ToolCallRecord): string {
|
||||
edit_file: '编辑文件', get_file_info: '文件信息', tree: '目录树', download_file: '下载文件',
|
||||
diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取',
|
||||
git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索',
|
||||
memory_search: '搜索记忆', memory_add: '添加记忆', session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派',
|
||||
memory_search: '搜索记忆', memory_add: '添加记忆', session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派', plan_track: '计划追踪',
|
||||
browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容',
|
||||
browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器'
|
||||
};
|
||||
|
||||
@@ -55,11 +55,12 @@ export function initHistoryModal(): void {
|
||||
const target = (e.target as HTMLElement).closest('[data-id]') as HTMLElement;
|
||||
if (!target) return;
|
||||
const sessionId = target.dataset.id!;
|
||||
const db = state.get<ChatDB>(KEYS.DB);
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
if (!db) return;
|
||||
|
||||
if (target.classList.contains('btn-delete-session')) {
|
||||
if (await showConfirm('确定删除此会话?', '删除会话')) {
|
||||
await db!.deleteSession(sessionId);
|
||||
await db.deleteSession(sessionId);
|
||||
logSession('删除', sessionId);
|
||||
const currentSession = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
|
||||
if (currentSession && currentSession.id === sessionId) {
|
||||
@@ -68,13 +69,13 @@ export function initHistoryModal(): void {
|
||||
loadHistory();
|
||||
}
|
||||
} else if (target.classList.contains('btn-export-md')) {
|
||||
const session = await db!.getSession(sessionId);
|
||||
const session = await db.getSession(sessionId);
|
||||
if (session) exportAsMarkdown(session);
|
||||
} else if (target.classList.contains('btn-export-html')) {
|
||||
const session = await db!.getSession(sessionId);
|
||||
const session = await db.getSession(sessionId);
|
||||
if (session) exportAsHtml(session);
|
||||
} else if (target.classList.contains('btn-export-txt')) {
|
||||
const session = await db!.getSession(sessionId);
|
||||
const session = await db.getSession(sessionId);
|
||||
if (session) exportAsTxt(session);
|
||||
} else if (target.classList.contains('history-info') || target.classList.contains('history-item')) {
|
||||
loadHistorySession(sessionId);
|
||||
|
||||
@@ -20,13 +20,14 @@ import { estimateTokens } from '../services/context-manager.js';
|
||||
import { searchMemories, buildMemoryContext, markMemoryUsed, extractMemoriesFromConversation, isMemoryEnabled } from '../services/memory-manager.js';
|
||||
import { showToolConfirm } from './tool-confirm-modal.js';
|
||||
import { logInfo, logStream, logError, logSuccess, logWarn, resetVideoProgress, updateVideoProgress } from '../services/log-service.js';
|
||||
import type { ChatSession, ChatMessage, OllamaStreamChunk, FileContent, ChatFile, ToolCallRecord } from '../types.js';
|
||||
import type { ChatSession, ChatMessage, OllamaStreamChunk, FileContent, ChatFile, ToolCallRecord, AgentMode } from '../types.js';
|
||||
|
||||
let chatInputEl: HTMLTextAreaElement;
|
||||
let btnSendEl: HTMLButtonElement;
|
||||
let imagePreviewEl: HTMLElement;
|
||||
let filePreviewEl: HTMLElement;
|
||||
let videoPreviewEl: HTMLElement;
|
||||
let planToggleCheckbox: HTMLInputElement;
|
||||
let pendingImages: Array<{ name: string; base64: string }> = [];
|
||||
let pendingFiles: Array<{ name: string; content: string; language: string; size: number }> = [];
|
||||
let pendingVideoFrames: Array<{ name: string; base64: string; timestampSeconds: number }> = [];
|
||||
@@ -150,6 +151,21 @@ export function initInputArea(): void {
|
||||
renderFilePreviews();
|
||||
}
|
||||
});
|
||||
|
||||
// ── v0.12.0 Plan Mode 切换 ──
|
||||
const planToggleEl = document.querySelector('#togglePlan') as HTMLInputElement;
|
||||
if (planToggleEl) {
|
||||
planToggleCheckbox = planToggleEl;
|
||||
planToggleEl.addEventListener('change', () => {
|
||||
const mode: AgentMode = planToggleEl.checked ? 'plan' : 'auto';
|
||||
state.set('agentMode', mode);
|
||||
// Plan Mode 切换时注册/移除 plan_track 工具
|
||||
import('../services/tool-registry.js').then(({ setPlanModeActive }) => {
|
||||
setPlanModeActive(mode === 'plan');
|
||||
});
|
||||
logInfo(`Agent 模式切换: ${mode === 'plan' ? 'Plan(先规划后执行)' : 'Auto(直接执行)'}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function autoResizeTextarea(): void {
|
||||
@@ -211,13 +227,14 @@ function compressImage(base64: string): Promise<{ compressed: string; origBytes:
|
||||
|
||||
async function handleImagePaths(paths: string[]): Promise<void> {
|
||||
const bridge = getBridge();
|
||||
if (!bridge) return;
|
||||
const maxSize = 20 * 1024 * 1024;
|
||||
|
||||
// 并行读取所有图片
|
||||
const results = await Promise.all(paths.map(async (filePath) => {
|
||||
const name = filePath.split(/[/\\]/).pop() || filePath;
|
||||
try {
|
||||
const result = await bridge!.fs.readFileBase64(filePath);
|
||||
const result = await bridge.fs.readFileBase64(filePath);
|
||||
if (!result.success) return { name, error: result.error };
|
||||
const size = result.size ?? 0;
|
||||
if (size > maxSize) return { name, error: `超过 20MB 限制` };
|
||||
@@ -372,13 +389,14 @@ function renderImagePreviews(): void {
|
||||
|
||||
async function handleTextFilePaths(paths: string[]): Promise<void> {
|
||||
const bridge = getBridge();
|
||||
if (!bridge) return;
|
||||
|
||||
// 并行读取所有文件
|
||||
const results = await Promise.all(paths.map(async (filePath) => {
|
||||
const name = filePath.split(/[/\\]/).pop() || filePath;
|
||||
if (!isTextFile(name)) return { name, error: `不是支持的文本/代码文件` };
|
||||
try {
|
||||
const result = await bridge!.fs.readFile(filePath);
|
||||
const result = await bridge.fs.readFile(filePath);
|
||||
if (!result.success) return { name, error: result.error };
|
||||
const content = result.content as string;
|
||||
const size = new TextEncoder().encode(content).length;
|
||||
@@ -561,6 +579,19 @@ async function handleRetry(): Promise<void> {
|
||||
updateMessageToolRecord(name, 'error', { success: false, error });
|
||||
},
|
||||
onConfirmTool: async (call) => showToolConfirm(call),
|
||||
onPlanReady: async (plan: string, steps: string[]) => {
|
||||
try {
|
||||
const { showHtmlConfirm } = await import('./prompt-modal.js');
|
||||
const stepsHtml = steps.length > 0
|
||||
? '<br><br><strong>📋 执行步骤:</strong><br>' + steps.map((s: string, i: number) => ` ${i + 1}. ${s}`).join('<br>')
|
||||
: '';
|
||||
const html = `<div>${safeMarkdown(plan.slice(0, 1500))}${plan.length > 1500 ? '<br>…' : ''}${stepsHtml}</div>`;
|
||||
return showHtmlConfirm(html, '📋 Plan Mode — 确认执行计划', true);
|
||||
} catch (err) {
|
||||
logWarn('Plan Mode: 弹窗加载失败,自动批准', (err as Error).message);
|
||||
return true; // 加载失败时自动批准,不阻断执行
|
||||
}
|
||||
},
|
||||
onDone: async (finalContent, toolRecords, loopStats) => {
|
||||
retryContent = finalContent;
|
||||
if (retryIterations > 0) {
|
||||
@@ -1237,6 +1268,24 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
|
||||
onConfirmTool: async (call) => {
|
||||
return showToolConfirm(call);
|
||||
},
|
||||
/** v0.12.1: Plan Mode — 计划生成后等待用户确认(Markdown 渲染) */
|
||||
onPlanReady: async (plan: string, steps: string[]) => {
|
||||
try {
|
||||
const { showHtmlConfirm } = await import('./prompt-modal.js');
|
||||
const stepsHtml = steps.length > 0
|
||||
? '<br><br><strong>📋 执行步骤:</strong><br>' + steps.map((s, i) => ` ${i + 1}. ${s}`).join('<br>')
|
||||
: '';
|
||||
const html = `<div>${safeMarkdown(plan.slice(0, 1500))}${plan.length > 1500 ? '<br>…' : ''}${stepsHtml}</div>`;
|
||||
const approved = await showHtmlConfirm(html, '📋 Plan Mode — 确认执行计划', true);
|
||||
if (approved) {
|
||||
logInfo('Plan Mode: 用户批准计划');
|
||||
}
|
||||
return approved;
|
||||
} catch (err) {
|
||||
logWarn('Plan Mode: 弹窗加载失败,自动批准', (err as Error).message);
|
||||
return true; // 加载失败时自动批准,不阻断执行
|
||||
}
|
||||
},
|
||||
onDone: async (finalContent, toolRecords, loopStats) => {
|
||||
assistantContent = finalContent;
|
||||
|
||||
|
||||
@@ -124,6 +124,23 @@ export function showPrompt(options: {
|
||||
});
|
||||
}
|
||||
|
||||
/** 显示自定义 confirm(支持 HTML),返回 true/false */
|
||||
export function showHtmlConfirm(html: string, title = '确认', wide = false): Promise<boolean> {
|
||||
ensureModal();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
resolveFn = (val) => resolve(val !== null);
|
||||
titleEl!.textContent = title;
|
||||
bodyEl!.innerHTML = `<div style="color:var(--text-secondary);font-size:13px;line-height:1.6;">${html}</div>`;
|
||||
inputEl = null;
|
||||
overlayEl!.style.display = '';
|
||||
// 宽屏模式:Plan Mode 确认框需要更大空间
|
||||
const modalEl = overlayEl!.querySelector('.modal') as HTMLElement;
|
||||
if (modalEl) modalEl.style.maxWidth = wide ? '700px' : '';
|
||||
okBtn!.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/** 显示自定义 confirm,返回 true/false */
|
||||
export function showConfirm(message: string, title = '确认'): Promise<boolean> {
|
||||
ensureModal();
|
||||
|
||||
@@ -182,11 +182,13 @@ export function initSettingsModal(): void {
|
||||
});
|
||||
|
||||
document.querySelector('#btnReleaseVRAM')!.addEventListener('click', async () => {
|
||||
const api = state.get<OllamaAPI>(KEYS.API);
|
||||
const model = (document.querySelector('#modelSelect') as HTMLSelectElement).value;
|
||||
const api = state.get<OllamaAPI | null>(KEYS.API, null);
|
||||
if (!api) { showToast('Ollama API 未连接', 'warning'); return; }
|
||||
const sel = document.querySelector('#modelSelect') as HTMLSelectElement | null;
|
||||
const model = sel?.value || '';
|
||||
logInfo('释放显存', model || '所有模型');
|
||||
try {
|
||||
await api!.chat({ model: model || 'any', messages: [], keep_alive: 0 });
|
||||
await api.chat({ model: model || 'any', messages: [], keep_alive: 0 });
|
||||
showToast('显存已释放', 'success');
|
||||
logSuccess('显存已释放');
|
||||
updateRunningModels();
|
||||
@@ -198,9 +200,10 @@ export function initSettingsModal(): void {
|
||||
|
||||
document.querySelector('#btnClearAllHistory')!.addEventListener('click', async () => {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
if (!db) { showToast('数据库未就绪', 'warning'); return; }
|
||||
if (await showConfirm('确定清空所有历史记录?此操作不可恢复!', '清空历史')) {
|
||||
logWarn('清空所有历史记录');
|
||||
await db!.clearAll();
|
||||
await db.clearAll();
|
||||
(document.querySelector('#btnNewChat') as HTMLElement).click();
|
||||
showToast('已清空所有历史记录', 'success');
|
||||
logSuccess('历史记录已清空');
|
||||
@@ -305,6 +308,19 @@ export function openSettingsModal(): void {
|
||||
populateSubAgentModels();
|
||||
updateMemoryVectorStatus();
|
||||
loadTimeoutSettings();
|
||||
loadWatchdogSetting();
|
||||
}
|
||||
|
||||
/** 加载看门狗设置到输入框 */
|
||||
async function loadWatchdogSetting(): Promise<void> {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
if (!db) return;
|
||||
const ms = await db.getSetting<number>('loopWatchdogMs', 1_800_000);
|
||||
const el = document.querySelector('#inputLoopWatchdog') as HTMLInputElement;
|
||||
if (!el) return;
|
||||
if (ms === 0) el.value = '0';
|
||||
else if (ms > 0) el.value = String(Math.round(ms / 60_000));
|
||||
else el.value = '';
|
||||
}
|
||||
|
||||
export function closeSettingsModal(): void {
|
||||
@@ -494,6 +510,18 @@ const saveSubAgentTimeout = debounce(async () => {
|
||||
}, 500);
|
||||
document.querySelector('#inputSubAgentTimeout')!.addEventListener('input', saveSubAgentTimeout);
|
||||
|
||||
// ── v0.12.1 看门狗超时(分钟,0=禁用)──
|
||||
const saveLoopWatchdog = debounce(async () => {
|
||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||
const val = (document.querySelector('#inputLoopWatchdog') as HTMLInputElement).value.trim();
|
||||
const minutes = val ? parseInt(val) : -1;
|
||||
const ms = minutes > 0 ? minutes * 60_000 : (minutes === 0 ? 0 : 1_800_000); // 默认30分钟
|
||||
state.set('loopWatchdogMs', ms);
|
||||
if (db) await db.saveSetting('loopWatchdogMs', ms);
|
||||
logSetting('看门狗超时', minutes >= 0 ? (minutes === 0 ? '已禁用' : `${minutes}分钟`) : '默认(30分钟)');
|
||||
}, 500);
|
||||
document.querySelector('#inputLoopWatchdog')!.addEventListener('input', saveLoopWatchdog);
|
||||
|
||||
function updateMemoryVectorStatus(): void {
|
||||
const statusEl = document.querySelector('#memoryVectorStatus');
|
||||
if (!statusEl) return;
|
||||
@@ -520,7 +548,8 @@ async function importSessions(filePath: string): Promise<void> {
|
||||
|
||||
try {
|
||||
const bridge = window.metonaDesktop;
|
||||
const result = await bridge!.fs.readFileBase64(filePath);
|
||||
if (!bridge) { showToast('仅桌面端支持导入', 'warning'); return; }
|
||||
const result = await bridge.fs.readFileBase64(filePath);
|
||||
if (!result.success) {
|
||||
showToast(`读取文件失败: ${result.error}`, 'error');
|
||||
return;
|
||||
|
||||
@@ -965,7 +965,7 @@ function getToolDisplayName(name: string): string {
|
||||
git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索',
|
||||
memory_search: '搜索记忆', memory_add: '添加记忆', memory_replace: '替换记忆', memory_remove: '删除记忆',
|
||||
|
||||
session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派',
|
||||
session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派', plan_track: '计划追踪',
|
||||
browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容',
|
||||
browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器'
|
||||
};
|
||||
@@ -982,7 +982,7 @@ function getToolIcon(name: string): string {
|
||||
git: '🔖', compress: '🗜️', web_search: '🔍',
|
||||
memory_search: '🧠', memory_add: '💾', memory_replace: '✏️', memory_remove: '🗑️',
|
||||
|
||||
session_list: '📋', session_read: '📖', spawn_task: '🤖',
|
||||
session_list: '📋', session_read: '📖', spawn_task: '🤖', plan_track: '📋',
|
||||
browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰',
|
||||
browser_click: '👆', browser_type: '⌨️', browser_scroll: '↕️', browser_close: '❌'
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user