安全修复: - 开启 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 {
|
||||
|
||||
+82
-14
@@ -85,13 +85,14 @@ export class ChatDB {
|
||||
updated_at: session.updatedAt
|
||||
};
|
||||
await dbBridge().saveSession(row);
|
||||
// P1-P1 修复:只保存新增消息(不在 _savedMsgIds 中的),避免全量重写
|
||||
// 只保存新增消息(不在 _savedMsgIds 中的),并合并为一次批量 IPC + 单事务
|
||||
const newMsgs: Array<Record<string, unknown>> = [];
|
||||
for (let mi = 0; mi < session.messages.length; mi++) {
|
||||
const msg = session.messages[mi];
|
||||
const msgId = `${session.id}_${msg.timestamp}_${msg.role}_${mi}`;
|
||||
if (this._savedMsgIds.has(msgId)) continue; // 已保存,跳过
|
||||
this._savedMsgIds.add(msgId);
|
||||
const msgRow = {
|
||||
newMsgs.push({
|
||||
id: msgId,
|
||||
session_id: session.id,
|
||||
role: msg.role,
|
||||
@@ -105,8 +106,10 @@ export class ChatDB {
|
||||
prompt_eval_count: msg.prompt_eval_count || null,
|
||||
total_duration: msg.total_duration || null,
|
||||
created_at: msg.timestamp
|
||||
};
|
||||
await dbBridge().saveMessage(msgRow);
|
||||
});
|
||||
}
|
||||
if (newMsgs.length > 0) {
|
||||
await dbBridge().saveMessagesBatch(newMsgs as never[]);
|
||||
}
|
||||
return session.id;
|
||||
}
|
||||
@@ -154,17 +157,76 @@ export class ChatDB {
|
||||
|
||||
async getAllSessions(): Promise<ChatSession[]> {
|
||||
if (isDesktop()) {
|
||||
const rows = await dbBridge().getAllSessions();
|
||||
const sessions: ChatSession[] = [];
|
||||
for (const row of rows) {
|
||||
const session = await this.getSession(row.id);
|
||||
if (session) sessions.push(session);
|
||||
// 一次 IPC 取回全部会话+消息行,本地组装(取代 N+1 逐会话往返)
|
||||
const data = await dbBridge().getAllSessionsData();
|
||||
const bySession = new Map<string, any[]>();
|
||||
for (const r of data.messages) {
|
||||
if (!bySession.has(r.session_id)) bySession.set(r.session_id, []);
|
||||
bySession.get(r.session_id)!.push(r);
|
||||
}
|
||||
const sessions: ChatSession[] = [];
|
||||
for (const row of data.sessions) {
|
||||
const msgRows = bySession.get(row.id) || [];
|
||||
const messages = msgRows.map((r: any) => {
|
||||
let files, videos;
|
||||
try { const a = JSON.parse(r.attachments || '{}'); files = a.files; videos = a.videos; } catch { /* ignore */ }
|
||||
return {
|
||||
role: r.role,
|
||||
content: r.content || '',
|
||||
timestamp: r.created_at,
|
||||
think: r.thinking || undefined,
|
||||
images: r.images ? JSON.parse(r.images) : undefined,
|
||||
eval_count: r.eval_count || undefined,
|
||||
prompt_eval_count: r.prompt_eval_count || undefined,
|
||||
total_duration: r.total_duration || undefined,
|
||||
toolCalls: r.tool_calls ? JSON.parse(r.tool_calls) : undefined,
|
||||
...(files?.length && { files }),
|
||||
...(videos?.length && { _videos: videos })
|
||||
};
|
||||
});
|
||||
sessions.push({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
model: row.model,
|
||||
messages,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
});
|
||||
}
|
||||
// 与旧版行为一致:按更新时间倒序
|
||||
sessions.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
return sessions;
|
||||
}
|
||||
return this._idbGetAllSessions();
|
||||
}
|
||||
|
||||
/** 会话摘要列表(历史列表/搜索用,不再全量加载消息) */
|
||||
async listSessionSummaries(): Promise<Array<{ id: string; title: string; model: string; created_at: number; updated_at: number; message_count: number }>> {
|
||||
if (isDesktop()) {
|
||||
return dbBridge().getSessionSummaries();
|
||||
}
|
||||
// Web 端回退:从全量会话派生摘要
|
||||
const all = await this.getAllSessions();
|
||||
return all.map(s => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
model: s.model,
|
||||
created_at: s.createdAt,
|
||||
updated_at: s.updatedAt,
|
||||
message_count: s.messages.length,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 按标题或消息内容搜索会话(桌面端 SQL LIKE,Web 端本地过滤) */
|
||||
async searchSessions(query: string): Promise<Array<{ id: string; title: string; model: string; created_at: number; updated_at: number; message_count: number }>> {
|
||||
if (isDesktop()) {
|
||||
return dbBridge().searchSessions(query);
|
||||
}
|
||||
const all = await this.listSessionSummaries();
|
||||
const q = query.toLowerCase();
|
||||
return all.filter(s => s.title.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
async deleteSession(id: string): Promise<void> {
|
||||
if (isDesktop()) {
|
||||
await dbBridge().deleteSession(id);
|
||||
@@ -207,11 +269,6 @@ export class ChatDB {
|
||||
return this._idbImportSessions(sessions);
|
||||
}
|
||||
|
||||
async getSessionsByTimeRange(startTime: number, endTime: number): Promise<ChatSession[]> {
|
||||
const all = await this.getAllSessions();
|
||||
return all.filter(s => s.updatedAt >= startTime && s.updatedAt <= endTime);
|
||||
}
|
||||
|
||||
// ── Settings ──
|
||||
|
||||
async saveSetting(key: string, value: unknown): Promise<void> {
|
||||
@@ -222,6 +279,17 @@ export class ChatDB {
|
||||
return this._idbSaveSetting(key, value);
|
||||
}
|
||||
|
||||
/** 批量保存设置(单事务 + 单次刷盘;Web 端逐条写入) */
|
||||
async saveSettingsBatch(entries: Array<{ key: string; value: unknown }>): Promise<void> {
|
||||
if (isDesktop()) {
|
||||
await dbBridge().saveSettingsBatch(entries);
|
||||
return;
|
||||
}
|
||||
for (const { key, value } of entries) {
|
||||
await this._idbSaveSetting(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
async getSetting<T = unknown>(key: string, defaultValue: T | null = null): Promise<T> {
|
||||
if (isDesktop()) {
|
||||
return dbBridge().getSetting(key, defaultValue) as Promise<T>;
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<div class="header-left">
|
||||
<img class="logo" src="./assets/icons/llama.png" alt="logo" />
|
||||
<span class="app-title">Metona Ollama</span>
|
||||
<span class="app-version">v0.16.19</span>
|
||||
<span class="app-version">v0.17.0</span>
|
||||
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
|
||||
@@ -472,14 +472,14 @@
|
||||
<div class="modal-body">
|
||||
<div class="help-section"><h4>🚀 快速开始</h4><ol><li>确保 Ollama 已启动(默认 <code>http://127.0.0.1:11434</code>,可在设置中修改)</li><li>顶部模型栏选择一个模型,右侧会显示模型能力徽章(🧠 Think / 👁️ Vision / 🔧 Tools)</li><li>输入消息,按 <kbd>Enter</kbd> 发送,<kbd>Shift+Enter</kbd> 换行</li></ol></div>
|
||||
<div class="help-section"><h4>💬 聊天功能</h4><ul><li><strong>流式回复</strong> — 实时打字效果,随时点 ■ 停止</li><li><strong>Think 推理</strong> — 下方 Think 按钮切换,让模型展示深度思考过程(需模型支持,如 Qwen3)</li><li><strong>上下文长度手动控制</strong> — 设置面板下拉选择(128K / 256K / 512K / 1M),默认 128K。模型栏显示当前配置值,下拉框中每个模型旁显示其自身的上下文长度</li><li><strong>Qwen3 兼容</strong> — 自动保证 system 消息唯一且位于首位,避免 Qwen3 等模型 400 错误</li><li><strong>多模态</strong> — 上传图片或视频(≤10MB),模型需支持 Vision。图片自动压缩,视频 1fps 提取帧序列带时序标注</li><li><strong>文件分析</strong> — 支持 50+ 种文本/代码格式,单文件 ≤500KB,自动剥离注释并按上下文预算智能截断</li><li><strong>AI 回复顶部</strong> — 每条 AI 回复上方展示 📋 系统提示词折叠卡片,可点击查看实际发送给模型的完整上下文</li></ul></div>
|
||||
<div class="help-section"><h4>🔧 Tool Calling(始终开启)</h4><ul><li>所有消息均通过 <strong>Agent Loop 自主调用本地工具</strong>,像一个本地 Agent,无普通聊天模式</li><li><strong>32 个工具</strong>,分为 9 类:<ul><li><strong>文件系统</strong>(13 个):read_file / write_file / list_directory / search_files / create_directory / delete_file / move_file / copy_file / edit_file / tree / download_file / read_multiple_files / compress</li><li><strong>命令执行</strong>(1 个):run_command(实时流式输出,支持自动/需确认/禁用三种模式,可配置超时)</li><li><strong>联网搜索</strong>(2 个):web_search(支持 SearXNG 元搜索引擎 JSON API / 四引擎 HTML 解析,双模式可切换)/ web_fetch(反爬headers+UA自动切换+自动回退浏览器渲染)</li><li><strong>Git</strong>(1 个):git(17 个子操作,push/pull/clone 内置60-120s超时保护)</li><li><strong>浏览器控制</strong>(9 个):browser_open / browser_screenshot / browser_evaluate / browser_extract / browser_click / browser_type / browser_scroll / browser_wait / browser_close</li><li><strong>记忆 & 会话</strong>(5 个):memory(统一记忆管理,5 个 action)/ session_list / session_read / spawn_task / plan_track</li><li><strong>系统工具</strong>(1 个):calculator</li></ul></li><li>read_file 支持 binary模式读取/base64解码/字节分页/2000行默认+截断hint自动续读</li><li>write_file 支持 base64写二进制文件/mode(overwrite+append)合并append_file功能/10MB</li><li>edit_file 支持 use_regex 正则替换</li><li>search_files 支持正则表达式搜索(use_regex=true)</li><li>browser_screenshot 支持全页面截图+元素截图</li><li>browser_extract 支持CSS选择器提取特定区域</li><li>new browser_wait 等待元素出现或定时等待</li><li>所有写操作工具均支持三档开关:自动/需确认/禁用(浏览器工具始终自动执行)</li><li>危险命令(<code>rm -rf</code>、<code>mkfs</code>、反弹 shell 等)和系统路径(<code>/etc</code>、<code>~/.ssh</code> 等)被自动拦截</li><li>工具调用以<strong>可视化卡片</strong>展示状态(pending → running → success/error),在工作空间🔧工具页签中显示</li><li>默认最大 <strong>85 轮</strong>工具调用循环(设置面板可调),上下文使用率>80%时自动缩减到3轮</li><li>独立工具自动<strong>并行执行</strong>(11个只读工具加入并行白名单),有依赖关系的工具串行执行</li></ul></div>
|
||||
<div class="help-section"><h4>🔧 Tool Calling(始终开启)</h4><ul><li>所有消息均通过 <strong>Agent Loop 自主调用本地工具</strong>,像一个本地 Agent,无普通聊天模式</li><li><strong>33 个工具</strong>,分为 9 类:<ul><li><strong>文件系统</strong>(14 个):read_file / write_file / list_directory / search_files / create_directory / delete_file / move_file(跨盘自动回退 copy+delete)/ copy_file / edit_file / tree / download_file / read_multiple_files / compress / diff(unified diff 三种比对模式)</li><li><strong>命令执行</strong>(1 个):run_command(实时流式输出,支持自动/需确认/禁用三种模式,可配置超时)</li><li><strong>联网搜索</strong>(2 个):web_search(支持 SearXNG 元搜索引擎 JSON API / 四引擎 HTML 解析,双模式可切换)/ web_fetch(反爬headers+UA自动切换+自动回退浏览器渲染)</li><li><strong>Git</strong>(1 个):git(17 个子操作,push/pull/clone 内置60-120s超时保护;参数注入防护)</li><li><strong>浏览器控制</strong>(9 个):browser_open / browser_screenshot / browser_evaluate / browser_extract / browser_click / browser_type / browser_scroll / browser_wait / browser_close</li><li><strong>记忆 & 会话</strong>(5 个):memory(统一记忆管理,5 个 action)/ session_list / session_read / spawn_task / plan_track</li><li><strong>系统工具</strong>(1 个):calculator</li></ul></li><li>read_file 支持 binary模式读取/base64解码/字节分页/2000行默认+截断hint自动续读</li><li>write_file 支持 base64写二进制文件/mode(overwrite+append)合并append_file功能/10MB</li><li>edit_file 支持 use_regex 正则替换</li><li>search_files 支持正则表达式搜索(use_regex=true)</li><li>browser_screenshot 支持全页面截图+元素截图</li><li>browser_extract 支持CSS选择器提取特定区域</li><li>browser_wait 等待元素出现或定时等待</li><li>所有写操作工具均支持三档开关:自动/需确认/禁用(浏览器工具始终自动执行)</li><li>危险命令(<code>rm -rf</code>、<code>mkfs</code>、反弹 shell 等)和系统路径(<code>/etc</code>、<code>~/.ssh</code> 等)被自动拦截;<strong>SSRF 防护</strong>:web_fetch / download_file / browser_open 拦截内网与本机地址(localhost / 127.0.0.1 / 192.168.x 等)</li><li>工具调用以<strong>可视化卡片</strong>展示状态(pending → running → success/error),在工作空间🔧工具页签中显示</li><li>默认最大 <strong>85 轮</strong>工具调用循环(设置面板可调)</li><li>独立工具自动<strong>并行执行</strong>,有依赖关系的工具(如 write→read 路径依赖)自动串行执行</li></ul></div>
|
||||
<div class="help-section"><h4>🧠 Agent 记忆系统</h4><ul><li>记忆存储在工作空间 <strong>MEMORY.md</strong> 文件,受严格路径保护,仅 <code>memory</code> 工具可读写</li><li>新对话时自动检索相关记忆注入 AI 上下文,让 AI "记住"你</li><li>对话结束时 AI 自动提取有价值的用户信息保存(多层质量过滤,宁缺毋滥)</li><li><strong>memory 工具</strong>(5 个 action):search(关键词搜索)/ add(添加)/ replace(替换)/ remove(删除)/ read_all(读取全部)</li><li>点击顶部 🧠 按钮打开记忆面板:查看、添加、删除记忆条目</li><li><strong>记忆容量上限 500 条</strong>,超限时自动清理低价值条目(规则类型受保护)</li><li>写入前自动安全扫描(prompt injection / 敏感信息 / 不可见字符检测)</li><li>应用启动时自动校验 MEMORY.md 格式,格式错误自动备份重建</li></ul></div>
|
||||
<div class="help-section"><h4>📋 Plan Mode(计划模式)</h4><ul><li>点击输入框上方 📋 按钮开启 <strong>Plan Mode</strong>(开关式)</li><li>开启后,AI <strong>先生成执行计划</strong>(Markdown 渲染弹窗),用户批准后才开始执行</li><li>计划批准后自动初始化追踪器,每个步骤完成后调用 <code>plan_track</code> 工具标记进度</li><li>系统提示词自动注入当前进度状态,AI 始终知道还剩多少步未完成</li><li><strong>多步骤任务防遗忘</strong>:自动检测用户请求中的动作动词,对比已完成步骤,注入提醒</li><li>关闭 Plan Mode 后恢复正常 Agent Loop 模式</li><li><strong>仅 Plan 模式</strong> 下 plan_track 工具可见,避免污染普通模式的工具列表</li></ul></div>
|
||||
<div class="help-section"><h4>🛡️ 抗幻觉 & 稳定性</h4><ul><li><strong>5 层防御体系</strong>:系统提示词加固 → 任务感知 → 中途幻觉检测(中英双语规则覆盖全部工具类别)→ 进度锚点 → 完成闸门(6 项检查,幻觉/注入 → 阻断,质量/效率 → 咨询)</li><li><strong>完成闸门</strong>:对话结束前自动审查 AI 回复,检测工具幻觉和 prompt injection,阻断有问题的回复</li><li><strong>中英双语检测规则</strong>:覆盖中英文模型输出,防止 AI 声称执行了未调用的工具</li><li><strong>智能重试机制</strong>:永久错误(文件不存在/权限拒绝)立即返回,瞬态错误(网络/超时)指数退避最多重试 2 次</li><li><strong>看门狗超时</strong>:设置面板可配置全局超时(默认 30 分钟),AI 卡死或无限循环时自动中止</li><li><strong>流式总超时</strong>:可配置超时(默认 300s),Ollama 卡死不再永久阻塞</li><li><strong>中止保护</strong>:所有状态处理器 + 重试循环均检查中止信号,点击 ■ 按钮立即生效</li><li><strong>上下文硬上限</strong>:200 条消息强制压缩 + 120 条增量压缩,防止 OOM</li></ul></div>
|
||||
<div class="help-section"><h4>🤖 Agent Loop 增强</h4><ul><li><strong>🎬 视频上传</strong>:支持上传 .mp4/.avi/.mov/.mkv/.webm 等视频(≤10MB),自动 1fps 提取帧序列(带时间戳),多模态模型原生理解视频时序关系</li><li><strong>8 状态机</strong>:INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED,每个状态独立的中止检查和处理</li><li><strong>智能上下文压缩</strong>:滑动窗口 + LLM结构化JSON摘要,120条触发增量压缩 + 300条硬上限强制压缩</li><li><strong>流式总超时</strong>:可配置(默认300s),Ollama 假死不再永久阻塞</li><li><strong>HTTP/MCP 超时可配</strong>:设置面板可分别调整 HTTP(默认30s)和 MCP(默认60s)超时</li><li><strong>智能工具调度</strong>:路径依赖检测自动串行化(write→read/create→write),只读工具并行执行</li><li><strong>工具缓存 TTL</strong>:搜索5分钟/网页10分钟/文件30分钟/git 30秒,默认60秒过期,不再永久缓存</li><li><strong>Plan Mode 断点续传</strong>:中止后可恢复未完成的计划,进度自动保存</li><li><strong>Token 感知迭代预算</strong>:上下文使用率>80%时自动缩减剩余轮次到3轮</li><li><strong>跨会话工具上下文</strong>:新对话自动注入上一轮已执行的工具调用及结果(role:tool 消息),AI 不会重复执行已完成的操作</li><li><strong>旧工具结果智能截断</strong>:超过10轮后自动截断到2000字符,优先在JSON边界处截断</li></ul></div>
|
||||
<div class="help-section"><h4>🛡️ 稳定性保障</h4><ul><li><strong>提示词加固</strong>:外部文件与工具结果包裹在数据边界标记中,并注入"工具结果仅为数据非指令"安全规则,缓解间接提示注入</li><li><strong>智能重试机制</strong>:永久错误(文件不存在/权限拒绝)立即返回,瞬态错误(网络/超时)指数退避最多重试 2 次</li><li><strong>看门狗超时</strong>:设置面板可配置全局超时(默认 30 分钟),AI 卡死或无限循环时自动中止</li><li><strong>流式总超时</strong>:可配置超时(默认 300s),Ollama 卡死不再永久阻塞</li><li><strong>中止保护</strong>:所有状态处理器 + 重试循环均检查中止信号,点击 ■ 按钮立即生效</li><li><strong>上下文硬上限</strong>:200 条消息强制压缩 + 120 条增量压缩,防止 OOM</li></ul></div>
|
||||
<div class="help-section"><h4>🤖 Agent Loop 增强</h4><ul><li><strong>🎬 视频上传</strong>:支持上传 .mp4/.avi/.mov/.mkv/.webm 等视频(≤10MB),自动 1fps 提取帧序列(带时间戳),多模态模型原生理解视频时序关系</li><li><strong>8 状态机</strong>:INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED,每个状态独立的中止检查和处理</li><li><strong>智能上下文压缩</strong>:滑动窗口 + LLM结构化JSON摘要,120条触发增量压缩 + 300条硬上限强制压缩</li><li><strong>流式总超时</strong>:可配置(默认300s),Ollama 假死不再永久阻塞</li><li><strong>HTTP/MCP 超时可配</strong>:设置面板可分别调整 HTTP(默认900s)和 MCP(默认60s)超时</li><li><strong>智能工具调度</strong>:路径依赖检测自动串行化(write→read/create→write),只读工具并行执行</li><li><strong>工具缓存 TTL</strong>:搜索5分钟/网页10分钟/文件30分钟/git 30秒,默认60秒过期,不再永久缓存</li><li><strong>Plan Mode 断点续传</strong>:中止后可恢复未完成的计划,进度自动保存</li><li><strong>跨会话工具上下文</strong>:新对话自动注入上一轮已执行的工具调用及结果(role:tool 消息),AI 不会重复执行已完成的操作</li><li><strong>旧工具结果智能截断</strong>:超过10轮后自动截断到2000字符,优先在JSON边界处截断</li></ul></div>
|
||||
<div class="help-section"><h4>🔌 MCP(Model Context Protocol)</h4><ul><li>支持连接外部 MCP Server,动态扩展工具能力</li><li>设置面板可添加/启用/禁用/删除 MCP 服务器</li><li>MCP 工具以 <code>mcp_{server}__{tool}</code> 前缀注册,与内置工具统一调度</li><li>启动时自动连接已启用的 MCP 服务器</li></ul></div>
|
||||
<div class="help-section"><h4>🔍 SearXNG 元搜索引擎</h4><ul><li>点击顶部 🔍 按钮打开配置面板,可接入自部署的 SearXNG 实例</li><li><strong>JSON 模式</strong>:调用 SearXNG JSON API,聚合 70+ 引擎结果,结构化解析</li><li><strong>HTML 模式</strong>:获取原始搜索结果页面,交由 AI 自行分析提取信息</li><li>支持认证 Key(HTTP Header Authorization),保护私有实例</li><li>启用后替代内置四引擎方案;关闭即回退,无缝切换</li><li>所有参数(引擎、语言、安全搜索、时间范围等)均可独立配置</li></ul></div>
|
||||
<div class="help-section"><h4>📋 自定义文件(SOUL.md / AGENT.md / USER.md)</h4><ul><li>在工作空间目录创建以下文件即可自定义 AI 行为,修改后下一轮对话立即生效</li><li><strong>SOUL.md</strong> — AI 身份、性格、行为准则(<strong>永远不可被压缩</strong>,注入为最高优先级系统提示词)</li><li><strong>AGENT.md</strong> — 工具调用规则、链式调用模式、核心约束(仅从工作空间加载,有则注入,无则不注入)</li><li><strong>USER.md</strong> — 用户画像:技术栈、偏好、习惯等个人信息,AI 在对话中自动参考(仅工作空间,无内置默认)</li><li>可在 AI 回复顶部的 📋 系统提示词卡片中查看实际注入的完整上下文</li><li>SOUL.md 删除工作空间文件可恢复内置默认版本;AGENT.md / USER.md 无内置版,工作空间不存在则不注入</li></ul></div>
|
||||
<div class="help-section"><h4>📋 自定义文件(SOUL.md / AGENT.md / USER.md)</h4><ul><li>在工作空间目录创建以下文件即可自定义 AI 行为,修改后下一轮对话立即生效</li><li><strong>SOUL.md</strong> — AI 身份、性格、行为准则(<strong>永远不可被压缩</strong>,注入为最高优先级系统提示词)</li><li><strong>AGENT.md</strong> — 工具调用规则、行为约束(工作空间文件优先;不存在时使用应用内置默认版)</li><li><strong>USER.md</strong> — 用户画像:技术栈、偏好、习惯等个人信息,AI 在对话中自动参考(仅工作空间,无内置默认)</li><li><strong>安全保护</strong>:SOUL.md / AGENT.md / USER.md 对所有工具<strong>只读</strong>(防止 AI 被网页提示注入诱导改写自身人格文件),仅用户可手动编辑;MEMORY.md 仅 memory 工具可访问</li><li>可在 AI 回复顶部的 📋 系统提示词卡片中查看实际注入的完整上下文</li><li>删除工作空间中的 SOUL.md / AGENT.md 可恢复内置默认版本;USER.md 无内置版,工作空间不存在则不注入</li></ul></div>
|
||||
<div class="help-section"><h4>📊 Token 实时监控</h4><ul><li>点击顶部 📊 按钮打开 Token 监控仪表盘</li><li><strong>全局统计</strong> — 跨会话累计 Token 消耗,柱状图按会话展示趋势</li><li><strong>当前会话</strong> — 实时显示本轮对话的 Token 消耗,按轮次展示明细</li><li>每 2 秒自动刷新数据,支持输入/输出分色显示</li></ul></div>
|
||||
<div class="help-section"><h4>🖥️ 布局说明</h4><ul><li><strong>左侧面板</strong> — 执行日志,实时显示应用运行日志(连接、模型加载、工具调用等)</li><li><strong>中间区域</strong> — 聊天消息,顶部 Header + 模型栏,底部输入框</li><li><strong>右侧面板</strong> — 工作空间(常驻显示),包含 3 个页签:<ul><li><strong>💻 命令行 Tab</strong> — 终端界面,实时流式输出,支持长时间运行(无超时),单一终端进程</li><li><strong>🔧 工具 Tab</strong> — 展示本轮对话的工具调用卡片,含状态统计(总数 ✅ 成功 ❌ 失败),AI 执行命令时自动切到此页签</li><li><strong>📁 文件 Tab</strong> — 浏览工作空间目录,点击文件预览内容(带行号),支持上级目录导航</li></ul></li><li>工作空间目录可在设置中修改</li></ul></div>
|
||||
<div class="help-section"><h4>🕐 历史记录</h4><ul><li>所有会话自动保存到本地 SQLite</li><li>点击顶部 🕐 按钮查看、搜索、恢复历史会话</li><li>支持导出 JSON / <code>.metona</code> 加密备份</li></ul></div>
|
||||
@@ -492,7 +492,7 @@
|
||||
<div class="modal-overlay" id="toolsModal" style="display:none;">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>🔧 工具面板(33 个 + 1 Plan 专属)</h3>
|
||||
<h3>🔧 工具面板(32 个 + 1 Plan 专属)</h3>
|
||||
<button class="icon-btn" id="btnCloseTools">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
@@ -568,7 +568,7 @@
|
||||
</div>
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-header"><span class="tool-card-icon">📚</span><span class="tool-card-name">read_multiple_files</span><span class="tool-card-badge auto">自动</span></div>
|
||||
<div class="tool-card-desc">并行读取最多50个文件,每文件10KB</div>
|
||||
<div class="tool-card-desc">并行读取最多50个文件(默认不截断,可按文件限制字符数)</div>
|
||||
</div>
|
||||
<div class="tool-card">
|
||||
<div class="tool-card-header"><span class="tool-card-icon">🔖</span><span class="tool-card-name">git</span><span class="tool-card-badge auto">自动</span></div>
|
||||
|
||||
+14
-12
@@ -29,12 +29,12 @@ import { initToolConfirmModal } from './components/tool-confirm-modal.js';
|
||||
import { initWorkspacePanel, clearToolCardsExternal, clearTerminalExternal, switchToTab } from './components/workspace-panel.js';
|
||||
import { initLogPanel, addLog } from './services/log-service.js';
|
||||
import { logInfo, logSuccess, logError, logDebug, logInit, logWarn } from './services/log-service.js';
|
||||
import { initGlobalErrorHandler, validateConfig } from './services/infra-service.js';
|
||||
import { initGlobalErrorHandler } from './services/infra-service.js';
|
||||
import { initSearxngModal, closeSearxngModal, loadSearxngConfig } from './components/searxng-modal.js';
|
||||
import { initKeybindManager } from './components/keybind-manager.js';
|
||||
import { initMetricsDashboard } from './components/metrics-dashboard.js';
|
||||
import { initHarnessHooks } from './services/hooks.js';
|
||||
import { setAppVersion } from './services/agent-metrics.js';
|
||||
import { setAppVersion, loadMetricsHistory } from './services/agent-metrics.js';
|
||||
import type { ChatSession } from './types.js';
|
||||
|
||||
// ─── v4.0 数据迁移:IndexedDB → SQLite ───
|
||||
@@ -391,6 +391,9 @@ async function init(): Promise<void> {
|
||||
await db.init();
|
||||
state.set(KEYS.DB, db);
|
||||
|
||||
// 恢复历史度量数据(localStorage 持久化的 R84 度量历史)
|
||||
loadMetricsHistory();
|
||||
|
||||
// v4.0 数据迁移:检测 IndexedDB 是否有旧数据需要迁移到 SQLite
|
||||
await migrateIndexedDBToSQLite(db);
|
||||
|
||||
@@ -487,6 +490,14 @@ async function init(): Promise<void> {
|
||||
(document.querySelector('#inputSubAgentMaxLoops') as HTMLInputElement).value = String(subAgentMaxLoops);
|
||||
(document.querySelector('#inputSubAgentTimeout') as HTMLInputElement).value = subAgentTimeout >= 0 ? String(subAgentTimeout) : '';
|
||||
|
||||
// ── 子代理权限上限(AI 请求的权限只降不升)──
|
||||
{
|
||||
const subAgentMaxPermission = await db.getSetting<'readonly' | 'limited_write' | 'full_write'>('subAgentMaxPermission', 'readonly');
|
||||
state.set('subAgentMaxPermission', subAgentMaxPermission);
|
||||
const permSelect = document.querySelector('#selectSubAgentPermission') as HTMLSelectElement | null;
|
||||
if (permSelect) permSelect.value = subAgentMaxPermission;
|
||||
}
|
||||
|
||||
// ── 看门狗超时 ──
|
||||
let loopWatchdogMs = await db.getSetting<number>('loopWatchdogMs', 1_800_000);
|
||||
state.set('loopWatchdogMs', loopWatchdogMs);
|
||||
@@ -578,16 +589,7 @@ function bindGlobalEvents(): void {
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('error', (e) => {
|
||||
logError('未捕获错误', (e.error as Error)?.message || e.message);
|
||||
showToast(`发生错误: ${e.message}`, 'error', 5000);
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
logError('未处理 Promise 拒绝', (e.reason as Error)?.message || String(e.reason));
|
||||
const msg = e.reason?.message || String(e.reason);
|
||||
showToast(`操作失败: ${msg}`, 'error', 5000);
|
||||
});
|
||||
// ── 全局错误处理已由 infra-service.ts 的 initGlobalErrorHandler 统一注册 ──
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# METONA AGENT — 行为准则
|
||||
|
||||
> 本文件是 Agent 的行为准则(内置默认版)。将自定义 `AGENT.md` 放入工作空间目录可覆盖此文件。
|
||||
> 本文件对工具只读不可写,仅用户可手动编辑。
|
||||
|
||||
## 核心原则
|
||||
|
||||
1. **先理解,再行动**。动手前先读取必要的文件/目录,确认理解任务与现状,避免方向性返工。
|
||||
2. **结论先行**。汇报时先给结论与关键结果,再展开推理细节。
|
||||
3. **最小改动**。只做任务要求的事。不顺手重构、不扩大范围、不添加未要求的功能。
|
||||
4. **诚实汇报**。成功与失败如实陈述;不确定的内容明确标注"未验证",禁止编造路径、输出或数据。
|
||||
|
||||
## 工具使用
|
||||
|
||||
- 优先使用只读工具(`read_file` / `list_directory` / `search_files` / `tree`)建立认知,再执行写入类工具。
|
||||
- 修改文件优先 `edit_file` 精确替换;新建文件使用 `write_file`。
|
||||
- 每轮工具调用保持聚焦:一次解决一个子问题,避免同轮发起互相依赖的调用。
|
||||
- 工具返回错误时:先阅读错误信息与恢复建议,修正参数后重试;连续失败 2 次后换思路,不盲目重试。
|
||||
- 大结果已被截断时(结果中出现截断标记),按提示缩小范围重新获取,不要凭截断内容臆测。
|
||||
- `run_command` 需要用户确认(默认模式),长命令注意超时;不要交互式执行命令。
|
||||
|
||||
## 文件与路径
|
||||
|
||||
- 所有文件操作使用绝对路径;相对路径基于工作空间目录解析。
|
||||
- 系统目录、敏感目录(.ssh/.gnupg 等)被安全层禁止访问,不要尝试绕过。
|
||||
- `MEMORY.md` 只能通过 `memory` 工具访问;`SOUL.md` / `AGENT.md` / `USER.md` 可读不可写。
|
||||
- 删除操作谨慎:优先移动到临时目录而非直接删除,除非任务明确要求删除。
|
||||
|
||||
## 沟通风格
|
||||
|
||||
- 使用用户提问的语言回复。
|
||||
- 代码引用给出文件路径与行号;解释简洁,避免重复用户已知信息。
|
||||
- 完成任务后给出简明清单:改了什么、验证结果、遗留事项。
|
||||
|
||||
## 边界
|
||||
|
||||
- 不执行任何破坏性命令(格式化磁盘、递归删除系统目录、修改系统关键配置)。
|
||||
- 不访问内网/环回地址(安全层已拦截,收到拦截提示时停止尝试并说明)。
|
||||
- 遇到需要用户决策的分歧(多种可行方案、影响面大的改动),停下来询问,不自作主张。
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
initPlanTracker,
|
||||
getPlanTracker,
|
||||
clearPlanTracker,
|
||||
setSubAgentConfirmHandler,
|
||||
} from './tool-registry.js';
|
||||
import {
|
||||
compactOldToolResult,
|
||||
@@ -21,21 +22,21 @@ import {
|
||||
classifyError,
|
||||
calculateBackoff,
|
||||
validatePathSandbox,
|
||||
// R88: 工具结果元数据
|
||||
// 工具结果元数据
|
||||
addResultMetadata,
|
||||
// R97: 错误模式学习
|
||||
// 错误模式学习
|
||||
recordErrorPattern,
|
||||
// R109: 工具参数消毒
|
||||
sanitizeToolArgs,
|
||||
// R113: 命令安全检查
|
||||
// 命令安全检查
|
||||
checkCommandSafety,
|
||||
// R95: 按工具类型智能截断
|
||||
// 按工具类型智能截断
|
||||
smartTruncateByToolType,
|
||||
// R116: 错误恢复建议
|
||||
// 错误恢复建议
|
||||
getErrorRecoverySuggestions,
|
||||
formatErrorRecovery,
|
||||
} from './agent-safety.js';
|
||||
import { search, formatMemoryContext } from './memory-service.js';
|
||||
import { formatToolResultForModel, summarizeAuditResult } from './result-formatter.js';
|
||||
import { parseToolCallsFromText } from './tool-parsing.js';
|
||||
|
||||
import { showToast } from '../components/toast.js';
|
||||
import { logInfo, logWarn, logSuccess, logError, logToolStart, logToolResult, logAgentLoop, logModelResponse, logStreamProgress, resetStreamProgress } from './log-service.js';
|
||||
@@ -168,15 +169,7 @@ function getOSEnvironment() {
|
||||
};
|
||||
}
|
||||
|
||||
/** 始终可并行的只读/独立工具 */
|
||||
const ALWAYS_PARALLEL = new Set([
|
||||
'read_file', 'list_directory', 'search_files', 'tree',
|
||||
'web_search', 'browser_screenshot', 'browser_extract',
|
||||
'memory', 'session_list', 'session_read',
|
||||
'calculator', 'diff',
|
||||
]);
|
||||
|
||||
/** D4: 有副作用的工具 — 同轮次去重时不返回缓存,需实际执行 */
|
||||
/** 有副作用的工具 — 同轮次去重时不返回缓存,需实际执行 */
|
||||
const SIDE_EFFECT_TOOLS = new Set([
|
||||
'write_file', 'edit_file', 'create_directory', 'delete_file',
|
||||
'move_file', 'copy_file', 'download_file',
|
||||
@@ -489,158 +482,6 @@ function validateToolArgs(toolName: string, args: Record<string, unknown>): stri
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 工具名白名单:用于文本解析兜底时过滤非法工具名 */
|
||||
const VALID_TOOL_NAMES = new Set([
|
||||
'read_file', 'write_file', 'list_directory', 'search_files', 'create_directory',
|
||||
'delete_file', 'run_command', 'move_file', 'copy_file', 'web_fetch', 'web_search',
|
||||
'edit_file', 'tree', 'download_file',
|
||||
'read_multiple_files', 'git', 'compress',
|
||||
'memory', 'session_list', 'session_read',
|
||||
'calculator'
|
||||
]);
|
||||
|
||||
/**
|
||||
* 文本解析兜底:当模型没有通过 tool_calls 字段返回工具调用,
|
||||
* 而是在文本中写了工具调用时,从文本中提取。
|
||||
*
|
||||
* P2-3 增强:支持4种格式
|
||||
* 1. Action/Action Input 格式(原有)
|
||||
* 2. <tool_call> XML 标签格式
|
||||
* 3. ```json 代码块中含 "name" 字段
|
||||
* 4. 函数调用语法 func_name({...})
|
||||
*/
|
||||
function parseToolCallsFromText(content: string): ToolCall[] {
|
||||
const calls: ToolCall[] = [];
|
||||
|
||||
// 辅助函数:尝试解析 JSON 参数字符串,容错处理
|
||||
const tryParseArgs = (argsStr: string): Record<string, unknown> | null => {
|
||||
const TICK = String.fromCharCode(96);
|
||||
const tickJson = TICK + TICK + TICK + 'json';
|
||||
const tick3 = TICK + TICK + TICK;
|
||||
try {
|
||||
let cleaned = argsStr.split(tickJson).join('').split(tick3).join('').trim();
|
||||
return JSON.parse(cleaned);
|
||||
} catch {
|
||||
try {
|
||||
let fixed = argsStr
|
||||
.replace(/'/g, '"')
|
||||
.replace(/,\s*}/g, '}')
|
||||
.replace(/,\s*]/g, ']')
|
||||
.split(tickJson).join('')
|
||||
.split(tick3).join('')
|
||||
.trim();
|
||||
return JSON.parse(fixed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 辅助函数:验证工具名并添加到结果
|
||||
const tryAddCall = (toolName: string, argsStr: string): boolean => {
|
||||
toolName = toolName.trim();
|
||||
if (!VALID_TOOL_NAMES.has(toolName)) return false;
|
||||
const args = tryParseArgs(argsStr);
|
||||
if (!args) {
|
||||
logWarn("文本解析兜底: 工具 " + toolName + " 的参数 JSON 解析失败", argsStr.slice(0, 100));
|
||||
return false;
|
||||
}
|
||||
calls.push({ type: 'function', function: { name: toolName, arguments: args } });
|
||||
return true;
|
||||
};
|
||||
|
||||
// ── 格式1: Action/Action Input(原有格式)──
|
||||
const actionRegex = /\*{0,2}Action:?\*{0,2}\s*(\w+)\s+[\r\n\s]*\*{0,2}Action\s*Input:?\*{0,2}\s*(\{[\s\S]*?\})/gi;
|
||||
let match;
|
||||
while ((match = actionRegex.exec(content)) !== null) {
|
||||
tryAddCall(match[1], match[2]);
|
||||
}
|
||||
|
||||
// ── 格式2: <tool_call> XML 标签 ──
|
||||
// 匹配 <tool_call>{"name": "xxx", "arguments": {...}}</tool_call>
|
||||
const xmlRegex = /<tool_call>\s*([\s\S]*?)<\/tool_call>/gi;
|
||||
while ((match = xmlRegex.exec(content)) !== null) {
|
||||
const inner = match[1].trim().replace(/```json\s*/g, '').replace(/```/g, '').trim();
|
||||
try {
|
||||
const parsed = JSON.parse(inner);
|
||||
const toolName = parsed.name || parsed.function?.name || '';
|
||||
const toolArgs = parsed.arguments || parsed.function?.arguments || parsed.parameters || {};
|
||||
if (toolName && VALID_TOOL_NAMES.has(toolName)) {
|
||||
calls.push({ type: 'function', function: { name: toolName, arguments: toolArgs } });
|
||||
}
|
||||
} catch {
|
||||
// JSON 解析失败,尝试分别提取 name 和 arguments
|
||||
const nameMatch = inner.match(/"name"\s*:\s*"(\w+)"/i);
|
||||
if (nameMatch) {
|
||||
const argsMatch = inner.match(/"arguments"\s*:\s*(\{[\s\S]*\})/i);
|
||||
if (argsMatch) tryAddCall(nameMatch[1], argsMatch[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 格式3: ```json 代码块中含 "name" 字段 ──
|
||||
// 匹配 ```json\n{"name": "xxx", "arguments": {...}}\n```
|
||||
const codeBlockRegex = /```(?:json)?\s*(\{[\s\S]*?"name"\s*:\s*"\w+"[\s\S]*?\})\s*```/gi;
|
||||
while ((match = codeBlockRegex.exec(content)) !== null) {
|
||||
const jsonStr = match[1].trim();
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
const toolName = parsed.name || '';
|
||||
const toolArgs = parsed.arguments || parsed.parameters || {};
|
||||
if (toolName && VALID_TOOL_NAMES.has(toolName)) {
|
||||
calls.push({ type: 'function', function: { name: toolName, arguments: toolArgs } });
|
||||
}
|
||||
} catch {
|
||||
// 解析失败忽略,其他格式可能匹配
|
||||
}
|
||||
}
|
||||
|
||||
// ── 格式4: 函数调用语法 func_name({"key": "value"}) ──
|
||||
// R5: 修复嵌套大括号问题 — 使用平衡括号匹配替代 [^}]*
|
||||
// 旧正则 /\b(\w+)\s*\(\s*(\{[^}]*\})\s*\)/g 无法匹配嵌套 JSON 如 {"a": {"b": 1}}
|
||||
{
|
||||
const funcCallStart = /\b(\w+)\s*\(\s*\{/g;
|
||||
let fcMatch;
|
||||
while ((fcMatch = funcCallStart.exec(content)) !== null) {
|
||||
const toolName = fcMatch[1];
|
||||
const braceStart = fcMatch.index + fcMatch[0].length - 1; // 指向 '{'
|
||||
// 手动平衡匹配大括号
|
||||
let depth = 0;
|
||||
let endIdx = -1;
|
||||
let inString = false;
|
||||
let escapeNext = false;
|
||||
for (let i = braceStart; i < content.length; i++) {
|
||||
const ch = content[i];
|
||||
if (escapeNext) { escapeNext = false; continue; }
|
||||
if (ch === '\\') { escapeNext = true; continue; }
|
||||
if (ch === '"') { inString = !inString; continue; }
|
||||
if (inString) continue;
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) { endIdx = i; break; }
|
||||
}
|
||||
}
|
||||
if (endIdx > 0) {
|
||||
const jsonStr = content.slice(braceStart, endIdx + 1);
|
||||
// 检查后面是否有闭合括号
|
||||
const afterClose = content.slice(endIdx + 1).match(/^\s*\)/);
|
||||
if (afterClose) {
|
||||
tryAddCall(toolName, jsonStr);
|
||||
// 移动 regex 位置到匹配结束后
|
||||
funcCallStart.lastIndex = endIdx + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (calls.length > 0) {
|
||||
logInfo("文本解析兜底: 从回复中提取到 " + calls.length + " 个工具调用", calls.map(c => c.function.name).join(', '));
|
||||
}
|
||||
|
||||
return calls;
|
||||
}
|
||||
|
||||
/** R1: 工具缓存最大条目数,超出时按 LRU 策略淘汰最旧条目 */
|
||||
const MAX_TOOL_CACHE_SIZE = 100;
|
||||
const toolResultCache = new Map<string, { result: ToolResult; timestamp: number }>();
|
||||
@@ -723,309 +564,6 @@ function isDuplicateCall(call: ToolCall, allCalls: ToolCall[]): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 生成工具审计摘要 — 用于审计日志记录 */
|
||||
function summarizeAuditResult(toolName: string, result: ToolResult): string {
|
||||
try {
|
||||
switch (toolName) {
|
||||
case 'write_file':
|
||||
return `写入 ${result.path || ''} (${result.bytesWritten || 0}B${result.created ? ', 新建' : ''})`;
|
||||
case 'edit_file':
|
||||
return `编辑 ${result.path || ''} (${result.replaceCount || 0} 处替换)`;
|
||||
case 'delete_file':
|
||||
return result.batch ? `批量删除 ${result.successCount}/${result.totalPaths}` : `删除 ${result.path || ''}`;
|
||||
case 'create_directory':
|
||||
return `创建目录 ${result.path || ''}`;
|
||||
case 'move_file':
|
||||
return `移动 ${(result as any).source} → ${(result as any).destination}`;
|
||||
case 'copy_file':
|
||||
return `复制 ${(result as any).source} → ${(result as any).destination}`;
|
||||
case 'run_command':
|
||||
return `命令执行 ${result.exitCode === 0 ? '成功' : '失败'} (exit ${result.exitCode})`;
|
||||
case 'git':
|
||||
return `git ${result.action}`;
|
||||
case 'download_file':
|
||||
return `下载 ${(result as any).url} → ${(result as any).destination}`;
|
||||
case 'compress':
|
||||
return `${result.action} → ${(result as any).outputPath || (result as any).destination}`;
|
||||
default:
|
||||
return `${toolName} 完成`;
|
||||
}
|
||||
} catch {
|
||||
return `${toolName} 完成`;
|
||||
}
|
||||
}
|
||||
|
||||
/** 格式化工具结果的通用默认路径 */
|
||||
function formatDefaultToolResult(toolName: string, result: ToolResult): string {
|
||||
const clean: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(result)) {
|
||||
if (k === 'success' || k === 'formatted' || k === 'content_type' ||
|
||||
k === 'status' || k === 'length' || k === 'isDirectory') continue;
|
||||
clean[k] = v;
|
||||
}
|
||||
return JSON.stringify(clean);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化工具结果,生成模型友好的简洁表示
|
||||
*/
|
||||
export function formatToolResultForModel(toolName: string, result: ToolResult): string {
|
||||
if (!result.success) {
|
||||
return JSON.stringify({ success: false, error: result.error || '工具执行失败' });
|
||||
}
|
||||
|
||||
switch (toolName) {
|
||||
case 'web_search': {
|
||||
const raw = result.results as Array<{ title: string; url: string; snippet: string }> | undefined;
|
||||
if (!raw?.length) return JSON.stringify({ success: true, message: '未找到结果' });
|
||||
const top = raw.map((r, i) =>
|
||||
`[${i + 1}] ${r.title}\n URL: ${r.url}\n ${r.snippet}`
|
||||
).join('\n\n');
|
||||
const fetched = (result as any)._fetched as Array<{ url: string; title: string; content: string }> | undefined;
|
||||
const body = JSON.stringify({
|
||||
success: true, query: result.query, total: result.total, shown: raw.length, results: top,
|
||||
});
|
||||
if (fetched && fetched.length > 0) {
|
||||
return body + '\n\n' + fetched.map((f, i) =>
|
||||
`\n=== 📄 已抓取 ${i + 1}/${fetched.length}: ${f.title} ===\n${f.content}\n`
|
||||
).join('\n---\n');
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
case 'web_fetch': {
|
||||
let content = (result.content as string) || '';
|
||||
return JSON.stringify({ success: true, url: result.url, content });
|
||||
}
|
||||
|
||||
case 'read_file': {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
path: result.path,
|
||||
content: result.content,
|
||||
lines: result.lines,
|
||||
truncated: result.truncated,
|
||||
line_range: result.line_range
|
||||
});
|
||||
}
|
||||
|
||||
case 'read_multiple_files': {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
files: result.files,
|
||||
total: result.total
|
||||
});
|
||||
}
|
||||
|
||||
case 'list_directory': {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
path: result.path,
|
||||
entries: result.entries,
|
||||
total: result.total,
|
||||
truncated: result.truncated
|
||||
});
|
||||
}
|
||||
|
||||
case 'write_file': {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
path: result.path,
|
||||
bytesWritten: result.bytesWritten,
|
||||
created: result.created
|
||||
});
|
||||
}
|
||||
|
||||
case 'run_command': {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: result.exitCode,
|
||||
duration: result.duration
|
||||
});
|
||||
}
|
||||
|
||||
case 'git': {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
action: result.action,
|
||||
output: result.output,
|
||||
branch: result.branch,
|
||||
files: result.files,
|
||||
commits: result.commits
|
||||
});
|
||||
}
|
||||
|
||||
case 'search_files': {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
query: result.query,
|
||||
total_matches: result.total_matches,
|
||||
total_files: result.total_files,
|
||||
results: result.results
|
||||
});
|
||||
}
|
||||
|
||||
case 'memory': {
|
||||
// D1: 去重信号改为软提醒,不触发⛔强制终止
|
||||
if ((result as any).duplicate) {
|
||||
return JSON.stringify({ success: true, action: 'add', duplicate: true, message: `${(result as any).message || '相同内容已存在'}` });
|
||||
}
|
||||
// read_all / search 结果:包裹在 JSON 中以保持与其他工具一致的格式
|
||||
if ((result as any).action === 'read_all') {
|
||||
const entries = ((result as any).entries || []) as Array<{ id: string; type: string; content: string; importance: number; tags: string[] }>;
|
||||
if (entries.length === 0) return JSON.stringify({ success: true, action: 'read_all', message: '记忆为空,没有任何已保存的记忆条目。', total: 0 });
|
||||
const grouped: Record<string, typeof entries> = {};
|
||||
for (const e of entries) {
|
||||
const t = e.type || 'fact';
|
||||
(grouped[t] ||= []).push(e);
|
||||
}
|
||||
const lines: string[] = [`[记忆读取结果] 共 ${entries.length} 条记忆,按类型分组:`];
|
||||
const typeLabels: Record<string, string> = { rule: '规则(必须遵守)', preference: '偏好', fact: '事实' };
|
||||
for (const [t, items] of Object.entries(grouped)) {
|
||||
lines.push(`\n--- ${typeLabels[t] || t} ---`);
|
||||
for (const e of items) {
|
||||
lines.push(` • [${e.type}] ${e.content}(重要性:${e.importance}, 标签: ${(e.tags || []).join(', ') || '无'})`);
|
||||
}
|
||||
}
|
||||
return JSON.stringify({ success: true, action: 'read_all', formatted: lines.join('\n'), total: entries.length });
|
||||
}
|
||||
if ((result as any).action === 'search') {
|
||||
const results = ((result as any).results || []) as Array<{ id: string; type: string; content: string; importance: number; score: number }>;
|
||||
if (results.length === 0) return JSON.stringify({ success: true, action: 'search', message: '未找到匹配的记忆。', total: 0 });
|
||||
const lines = [`[记忆搜索结果] 共 ${results.length} 条:`];
|
||||
for (const r of results) {
|
||||
lines.push(` • [${r.type || 'fact'}] ${r.content}(重要性:${r.importance}, 匹配度:${(r.score || 0).toFixed(0)})`);
|
||||
}
|
||||
return JSON.stringify({ success: true, action: 'search', formatted: lines.join('\n'), total: results.length });
|
||||
}
|
||||
// remove_batch 结果:格式化每条匹配情况
|
||||
if ((result as any).action === 'remove_batch') {
|
||||
const items = ((result as any).results || []) as Array<{ old_text: string; matched: boolean; entry_id?: string; error?: string }>;
|
||||
const deleted = (result as any).deleted || 0;
|
||||
const failed = (result as any).failed || 0;
|
||||
const lines = [`[批量删除结果] 成功 ${deleted} 条${failed > 0 ? `, 失败 ${failed} 条` : ''}:`];
|
||||
for (const item of items) {
|
||||
if (item.matched) {
|
||||
lines.push(` ✅ "${item.old_text}" → 已删除 (${item.entry_id})`);
|
||||
} else {
|
||||
lines.push(` ❌ "${item.old_text}" → ${item.error || '失败'}`);
|
||||
}
|
||||
}
|
||||
return JSON.stringify({ success: (result as any).success, action: 'remove_batch', formatted: lines.join('\n'), deleted, failed });
|
||||
}
|
||||
// 其他 action(add/replace/remove)→ 保留完整 JSON,走 default 逻辑
|
||||
return formatDefaultToolResult(toolName, result);
|
||||
}
|
||||
|
||||
case 'delete_file': {
|
||||
// 批量删除
|
||||
if ((result as any).batch) {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
message: `批量删除完成:成功 ${result.successCount}/${result.totalPaths} 个路径`,
|
||||
batch: true,
|
||||
totalPaths: result.totalPaths,
|
||||
successCount: result.successCount,
|
||||
failCount: result.failCount,
|
||||
results: result.results,
|
||||
});
|
||||
}
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
message: `已删除${(result as any).type === 'directory' ? '目录' : '文件'}:${result.path}`,
|
||||
path: result.path,
|
||||
deleted: true,
|
||||
type: (result as any).type,
|
||||
deletedSize: result.deletedSize,
|
||||
...((result as any).filesDeleted !== undefined && { filesDeleted: (result as any).filesDeleted }),
|
||||
});
|
||||
}
|
||||
|
||||
case 'create_directory': {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
message: `目录已创建:${result.path}`,
|
||||
path: result.path,
|
||||
created: (result as any).created,
|
||||
});
|
||||
}
|
||||
|
||||
case 'move_file': {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
message: `已移动:${(result as any).source} → ${(result as any).destination}`,
|
||||
source: (result as any).source,
|
||||
destination: (result as any).destination,
|
||||
});
|
||||
}
|
||||
|
||||
case 'copy_file': {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
message: `已复制:${(result as any).source} → ${(result as any).destination}`,
|
||||
source: (result as any).source,
|
||||
destination: (result as any).destination,
|
||||
bytesCopied: (result as any).bytesCopied,
|
||||
});
|
||||
}
|
||||
|
||||
case 'download_file': {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
message: `已下载:${(result as any).url} → ${(result as any).destination}`,
|
||||
url: (result as any).url,
|
||||
destination: (result as any).destination,
|
||||
bytesDownloaded: (result as any).bytesDownloaded,
|
||||
});
|
||||
}
|
||||
|
||||
case 'compress': {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
message: `已压缩:${(result as any).outputPath}`,
|
||||
outputPath: (result as any).outputPath,
|
||||
originalSize: (result as any).originalSize,
|
||||
compressedSize: (result as any).compressedSize,
|
||||
filesProcessed: (result as any).filesProcessed,
|
||||
});
|
||||
}
|
||||
|
||||
case 'diff': {
|
||||
if ((result as any).identical) {
|
||||
return JSON.stringify({ success: true, identical: true, message: '文件内容完全相同,无差异' });
|
||||
}
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
mode: (result as any).mode,
|
||||
path1: (result as any).path1,
|
||||
path2: (result as any).path2,
|
||||
diff: (result as any).diff,
|
||||
additions: (result as any).additions,
|
||||
deletions: (result as any).deletions,
|
||||
hunk_count: (result as any).hunk_count,
|
||||
identical: false,
|
||||
});
|
||||
}
|
||||
|
||||
case 'tree': {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
message: `目录树:${result.path}(${(result as any).totalEntries} 项)`,
|
||||
path: result.path,
|
||||
entries: result.entries,
|
||||
totalEntries: (result as any).totalEntries,
|
||||
truncated: result.truncated,
|
||||
});
|
||||
}
|
||||
|
||||
default: {
|
||||
return formatDefaultToolResult(toolName, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface AgentCallbacks {
|
||||
onThinking: (text: string) => void;
|
||||
onContent: (text: string) => void;
|
||||
@@ -1209,6 +747,24 @@ function snapshotLoopContext(ctx: LoopContext): void {
|
||||
// Harness: 状态处理器
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** 读取应用内置资源(webSecurity 开启后 file:// 页面无法 fetch 相对路径,改走 IPC) */
|
||||
async function readBuiltinResource(name: string): Promise<string> {
|
||||
const bridge = window.metonaDesktop;
|
||||
if (bridge?.readAppResource) {
|
||||
try {
|
||||
const r = await bridge.readAppResource(name);
|
||||
if (r?.success && r.content) return r.content;
|
||||
} catch { /* ignore */ }
|
||||
return '';
|
||||
}
|
||||
// 非桌面(浏览器)模式回退相对路径 fetch
|
||||
try {
|
||||
const resp = await fetch('./' + name);
|
||||
if (resp.ok) return await resp.text();
|
||||
} catch { /* ignore */ }
|
||||
return '';
|
||||
}
|
||||
|
||||
/** P2-11: 加载自定义文件(SOUL.md / AGENT.md / USER.md),返回 system prompt 片段
|
||||
* S6: 所有外部文件内容包裹在数据边界标记中,防止间接提示词注入 */
|
||||
async function loadCustomFiles(workspaceDir: string, systemPromptParts: string[]): Promise<void> {
|
||||
@@ -1221,15 +777,12 @@ async function loadCustomFiles(workspaceDir: string, systemPromptParts: string[]
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
if (!soulMdContent) {
|
||||
try {
|
||||
const resp = await fetch('./SOUL.md');
|
||||
if (resp.ok) { soulMdContent = await resp.text(); logInfo('SOUL.md 已从内置加载', `${soulMdContent.length} 字符`); }
|
||||
} catch { /* ignore */ }
|
||||
soulMdContent = await readBuiltinResource('SOUL.md');
|
||||
if (soulMdContent) logInfo('SOUL.md 已从内置加载', `${soulMdContent.length} 字符`);
|
||||
}
|
||||
if (soulMdContent) systemPromptParts.unshift(`[SOUL.md]\n<<<REFERENCE_DATA_START>>>\n${sanitizeUntrustedInput(soulMdContent)}\n<<<REFERENCE_DATA_END>>>`);
|
||||
|
||||
// AGENT.md — 仅从工作空间加载,无内置 fallback,Token 预算截断
|
||||
// 有则注入,无则跳过(不注入任何 AGENT.md 内容)
|
||||
// AGENT.md — 工作空间优先,内置 fallback,Token 预算截断
|
||||
let agentMdContent = '';
|
||||
if (workspaceDir) {
|
||||
try {
|
||||
@@ -1237,6 +790,10 @@ async function loadCustomFiles(workspaceDir: string, systemPromptParts: string[]
|
||||
if (r?.success && r.content) { agentMdContent = r.content; logInfo('AGENT.md 已从工作空间加载', `${r.lines || 0} 行`); }
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
if (!agentMdContent) {
|
||||
agentMdContent = await readBuiltinResource('AGENT.md');
|
||||
if (agentMdContent) logInfo('AGENT.md 已从内置加载', `${agentMdContent.length} 字符`);
|
||||
}
|
||||
if (agentMdContent) systemPromptParts.push(`[AGENT.md]\n<<<REFERENCE_DATA_START>>>\n${sanitizeUntrustedInput(truncateByTokenBudget(agentMdContent, 2000))}\n<<<REFERENCE_DATA_END>>>`);
|
||||
|
||||
// USER.md — 仅工作空间,无内置 fallback
|
||||
@@ -1911,10 +1468,7 @@ async function handleExecuting(
|
||||
toolResultCache.delete(cacheKey);
|
||||
}
|
||||
|
||||
// R109: 工具参数消毒 — 防止通过工具参数注入恶意内容
|
||||
call.function.arguments = sanitizeToolArgs(call.function.name, call.function.arguments);
|
||||
|
||||
// R113: 命令安全检查 — 对 run_command 进行风险评估
|
||||
// 命令安全检查 — 对 run_command 进行风险评估
|
||||
if (call.function.name === 'run_command') {
|
||||
const cmdStr = String(call.function.arguments?.command || '');
|
||||
if (cmdStr) {
|
||||
@@ -2719,6 +2273,8 @@ export async function runAgentLoop(
|
||||
// Plan Mode 激活时注册 plan_track 工具
|
||||
const { setPlanModeActive } = await import('./tool-registry.js');
|
||||
setPlanModeActive(mode === 'plan');
|
||||
// 子代理确认管线:与主 Agent 共用同一确认回调(finally 中清理)
|
||||
setSubAgentConfirmHandler(callbacks.onConfirmTool ?? null);
|
||||
|
||||
// ── 状态机主循环 ──
|
||||
try {
|
||||
@@ -2729,8 +2285,8 @@ export async function runAgentLoop(
|
||||
// Phase 2-7: THINKING → PARSING → EXECUTING → OBSERVING → REFLECTING → (COMPRESSING) → loop
|
||||
while (ctx.state !== S.TERMINATED) {
|
||||
// ── 看门狗 — 全局超时熔断(可通过设置 loopWatchdogMs 配置,0=禁用)──
|
||||
// 默认 30 分钟,用户强调不要随意加超时限制
|
||||
const WATCHDOG_MS = state.get<number>('loopWatchdogMs', 3_600_000);
|
||||
// 默认 30 分钟(与设置面板默认值一致)
|
||||
const WATCHDOG_MS = state.get<number>('loopWatchdogMs', 1_800_000);
|
||||
if (WATCHDOG_MS > 0 && Date.now() - ctx.startTime > WATCHDOG_MS) {
|
||||
logWarn(`看门狗触发: Agent Loop 运行超过 ${WATCHDOG_MS / 60000} 分钟,强制终止`);
|
||||
callbacks.onDone(ctx.content || '(看门狗超时终止)', ctx.allToolRecords.length > 0 ? ctx.allToolRecords : undefined, makeStats(ctx));
|
||||
@@ -2829,7 +2385,9 @@ default:
|
||||
// P1-E2 修复:catch 块也需快照,确保终止状态写入 state._loopContext
|
||||
snapshotLoopContext(ctx);
|
||||
} finally {
|
||||
// ── P1-8: Plan Mode 断点续传 — 保存完整追踪器到 session,支持跨轮次恢复 ──
|
||||
// 清理子代理确认回调(防止泄漏到下一次循环外)
|
||||
setSubAgentConfirmHandler(null);
|
||||
// ── Plan Mode 断点续传 — 保存完整追踪器到 session,支持跨轮次恢复 ──
|
||||
if (ctx.mode === 'plan') {
|
||||
const tracker = getPlanTracker();
|
||||
if (tracker.active && tracker.steps.length > 0) {
|
||||
|
||||
@@ -288,41 +288,6 @@ export function generateImprovementSuggestions(): ImprovementSuggestion[] {
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将改进建议格式化为 AGENT.md 补充规则
|
||||
*/
|
||||
export function formatSuggestionsAsRules(suggestions: ImprovementSuggestion[]): string {
|
||||
if (suggestions.length === 0) return '';
|
||||
|
||||
let rules = '\n\n## 自动生成的改进规则\n';
|
||||
rules += '> 以下规则由 Agent Metrics 系统根据历史错误模式自动生成\n\n';
|
||||
|
||||
for (const s of suggestions) {
|
||||
rules += `### ${s.pattern}\n`;
|
||||
rules += `- **严重程度**: ${s.severity}\n`;
|
||||
rules += `- **出现频率**: ${s.frequency} 次\n`;
|
||||
rules += `- **建议**: ${s.suggestion}\n\n`;
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 格式化输出(供仪表盘使用)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export function formatMetricsReport(metrics: AgentMetrics): string {
|
||||
return [
|
||||
`Agent Metrics 报告 (${new Date(metrics.collectedAt).toLocaleString()})`,
|
||||
`${'─'.repeat(50)}`,
|
||||
`总会话数: ${metrics.totalSessions}`,
|
||||
`平均迭代/任务: ${metrics.avgIterationsPerTask}`,
|
||||
`工具成功率: ${formatPercent(metrics.toolSuccessRate)}`,
|
||||
`Token 效率: ${formatPercent(metrics.tokenEfficiency)}`,
|
||||
`高频错误: ${metrics.frequentErrors.length > 0 ? metrics.frequentErrors.map(e => `${e.pattern}(${e.count}次)`).join(', ') : '无'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** P3-14: 导出结构化指标(JSON 格式,兼容 Prometheus/OpenTelemetry 采集器) */
|
||||
export function exportMetricsJSON(): string {
|
||||
const metrics = aggregateMetrics();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* Agent Safety — Agent 安全防护与行为治理模块
|
||||
*
|
||||
* 从 agent-engine.ts 提取的 R51-R56 功能:
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
|
||||
import type { OllamaMessage } from '../types.js';
|
||||
import { logInfo, logWarn } from './log-service.js';
|
||||
import { logInfo } from './log-service.js';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R51: 工具结果离线存储
|
||||
@@ -52,48 +52,17 @@ export function compactOldToolResult(msg: OllamaMessage): OllamaMessage {
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R52: 状态震荡检测
|
||||
// 状态震荡 / 死循环检测(历史功能,检测逻辑已被移除;保留调用历史
|
||||
// 数组以维持快照/恢复 API 的兼容性)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const _toolCallHistory: string[] = [];
|
||||
const MAX_HISTORY_LEN = 8;
|
||||
|
||||
/** 检测工具调用序列是否存在震荡模式(A→B→A→B) */
|
||||
export function detectOscillation(): boolean {
|
||||
if (_toolCallHistory.length < 4) return false;
|
||||
const len = _toolCallHistory.length;
|
||||
const a = _toolCallHistory[len - 4];
|
||||
const b = _toolCallHistory[len - 3];
|
||||
const c = _toolCallHistory[len - 2];
|
||||
const d = _toolCallHistory[len - 1];
|
||||
return a === c && b === d && a !== b;
|
||||
}
|
||||
|
||||
/** 记录工具调用到历史序列 */
|
||||
/** 记录工具调用到历史序列(供快照/恢复) */
|
||||
export function recordToolCallHistory(toolName: string, args: Record<string, unknown>): void {
|
||||
const key = `${toolName}:${JSON.stringify(args, Object.keys(args).sort()).slice(0, 100)}`;
|
||||
_toolCallHistory.push(key);
|
||||
if (_toolCallHistory.length > MAX_HISTORY_LEN) _toolCallHistory.shift();
|
||||
}
|
||||
|
||||
/** 重置工具调用历史(新一轮对话开始时) */
|
||||
export function resetToolCallHistory(): void {
|
||||
_toolCallHistory.length = 0;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R54: 增强死循环检测
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** 检测连续 N 次完全相同的工具调用 */
|
||||
export function detectConsecutiveIdentical(minCount: number): { detected: boolean; toolName: string; count: number } {
|
||||
if (_toolCallHistory.length < minCount) return { detected: false, toolName: '', count: 0 };
|
||||
const recent = _toolCallHistory.slice(-minCount);
|
||||
const allSame = recent.every(k => k === recent[0]);
|
||||
if (allSame) {
|
||||
return { detected: true, toolName: recent[0].split(':')[0], count: minCount };
|
||||
}
|
||||
return { detected: false, toolName: '', count: 0 };
|
||||
if (_toolCallHistory.length > 8) _toolCallHistory.shift();
|
||||
}
|
||||
|
||||
// R56/R63 已删除:目标对齐验证 + 速率限制
|
||||
@@ -370,44 +339,13 @@ export function recordErrorPattern(toolName: string, errorMsg: string): string |
|
||||
// R104 已删除:工具结果去重
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R109: 工具参数消毒 — 防止通过工具参数注入恶意内容
|
||||
// R109 已移除:工具参数消毒(sanitizeToolArgs)
|
||||
// 该实现会污染 write_file 的 content 等数据型参数(把合法文本当作注入
|
||||
// 模式替换掉),安全收益不抵数据破坏风险;注入防御由以下机制承担:
|
||||
// - 主进程 checkPathAllowed / checkCommandAllowed / checkPublicHttpUrl
|
||||
// - 系统提示词的数据边界标记(REFERENCE_DATA / TOOL_RESULT 信封)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** R109: 消毒工具参数中的潜在注入内容 */
|
||||
export function sanitizeToolArgs(toolName: string, args: Record<string, unknown>): Record<string, unknown> {
|
||||
const sanitized = { ...args };
|
||||
|
||||
// 对字符串参数进行消毒
|
||||
const stringKeys = ['content', 'command', 'query', 'text', 'old_text', 'new_text', 'message'];
|
||||
for (const key of stringKeys) {
|
||||
if (typeof sanitized[key] === 'string') {
|
||||
sanitized[key] = sanitizeInjectionPatterns(sanitized[key] as string);
|
||||
}
|
||||
}
|
||||
|
||||
// R109: run_command 特殊处理 — 移除命令链中的注入尝试
|
||||
if (toolName === 'run_command' && typeof sanitized.command === 'string') {
|
||||
// 移除命令中的 prompt injection 尝试(如 `# 删除所有文件` 伪装为注释)
|
||||
sanitized.command = (sanitized.command as string)
|
||||
.replace(/#\s*(?:ignore|forget|override|disregard|忽略|忘记|覆盖)\s.*$/gim, '')
|
||||
.replace(/\|\s*(?:sh|bash|zsh|powershell|cmd)\b/gi, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/** R109: 清理潜在的 prompt injection 模式 */
|
||||
function sanitizeInjectionPatterns(text: string): string {
|
||||
if (!text || text.length < 20) return text;
|
||||
// 仅清理明显的注入模式,保留正常文本
|
||||
return text
|
||||
.replace(/ignore\s+(all\s+)?previous\s+(instructions?|prompts?)/gi, '[已过滤]')
|
||||
.replace(/forget\s+(all\s+)?(instructions?|prompts?|rules?)/gi, '[已过滤]')
|
||||
.replace(/disregard\s+(all|any|previous)\s+(instructions?|rules?)/gi, '[已过滤]')
|
||||
.replace(/忽略.{0,4}(之前|前面|以上|所有).{0,4}(指令|提示|规则|系统)/g, '[已过滤]');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R112: 诊断系统 — 收集 Agent 运行状态用于调试和优化
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -604,62 +542,8 @@ export function smartTruncateByToolType(
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R99: 工具结果引用解析 — 从归档存储检索完整工具结果
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* R99: 根据 refId 检索归档的完整工具结果
|
||||
* 当模型在上下文中看到 [工具结果已归档 ref=xxx] 标记时,
|
||||
* 可以通过此函数获取完整内容
|
||||
*/
|
||||
export function retrieveToolResult(refId: string): { toolName: string; fullContent: string; timestamp: number } | null {
|
||||
const entry = _toolResultStore.get(refId);
|
||||
if (!entry) return null;
|
||||
return { ...entry };
|
||||
}
|
||||
|
||||
/**
|
||||
* R99: 从文本中提取工具结果引用 ID
|
||||
* 匹配格式: [工具结果已归档 ref=toolref_xxx_yyy]
|
||||
*/
|
||||
export function extractToolResultRefs(text: string): string[] {
|
||||
const matches = text.match(/\[工具结果已归档\s+ref=(toolref_[\w_]+)\]/g);
|
||||
if (!matches) return [];
|
||||
return matches.map(m => {
|
||||
const idMatch = m.match(/ref=(toolref_[\w_]+)/);
|
||||
return idMatch ? idMatch[1] : '';
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* R99: 检查消息中是否引用了归档的工具结果,
|
||||
* 如果有则返回需要检索的引用信息
|
||||
*/
|
||||
export function checkArchivedReferences(messages: Array<{ content?: string }>): Array<{ refId: string; toolName: string; fullContent: string }> {
|
||||
const results: Array<{ refId: string; toolName: string; fullContent: string }> = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const msg of messages) {
|
||||
const content = msg.content || '';
|
||||
if (!content.includes('[工具结果已归档')) continue;
|
||||
const refIds = extractToolResultRefs(content);
|
||||
for (const refId of refIds) {
|
||||
if (seen.has(refId)) continue;
|
||||
seen.add(refId);
|
||||
const retrieved = retrieveToolResult(refId);
|
||||
if (retrieved) {
|
||||
results.push({
|
||||
refId,
|
||||
toolName: retrieved.toolName,
|
||||
fullContent: retrieved.fullContent,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
// R99 已移除:工具结果引用解析(retrieveToolResult / checkArchivedReferences)
|
||||
// 归档结果暂无工具可取回;如需查看完整结果,模型按归档提示重新调用原工具
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 统一重置(新会话开始时调用)
|
||||
@@ -841,7 +725,7 @@ export function formatErrorRecovery(suggestion: ErrorRecoverySuggestion): string
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R118: Agent 循环性能分析 — 识别 Agent Loop 瓶颈
|
||||
// R118: 循环计时数据(供安全状态快照/恢复使用;报告生成已移除)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export interface LoopTiming {
|
||||
@@ -852,333 +736,4 @@ export interface LoopTiming {
|
||||
}
|
||||
|
||||
const _loopTimings: LoopTiming[] = [];
|
||||
const MAX_TIMING_ENTRIES = 200;
|
||||
|
||||
/** R118: 记录阶段执行时间 */
|
||||
export function recordLoopTiming(loop: number, phase: string, durationMs: number): void {
|
||||
_loopTimings.push({ loop, phase, durationMs, timestamp: Date.now() });
|
||||
if (_loopTimings.length > MAX_TIMING_ENTRIES) {
|
||||
_loopTimings.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/** R118: 生成性能分析报告 */
|
||||
export function generatePerformanceReport(): {
|
||||
totalLoops: number;
|
||||
avgLoopTime: number;
|
||||
slowestPhase: string;
|
||||
phaseTimings: Record<string, { avg: number; max: number; count: number }>;
|
||||
bottlenecks: string[];
|
||||
} {
|
||||
if (_loopTimings.length === 0) {
|
||||
return {
|
||||
totalLoops: 0,
|
||||
avgLoopTime: 0,
|
||||
slowestPhase: '',
|
||||
phaseTimings: {},
|
||||
bottlenecks: ['无性能数据'],
|
||||
};
|
||||
}
|
||||
|
||||
// 按阶段汇总
|
||||
const phaseMap: Record<string, { total: number; max: number; count: number }> = {};
|
||||
const loopTotals: Record<number, number> = {};
|
||||
|
||||
for (const t of _loopTimings) {
|
||||
if (!phaseMap[t.phase]) {
|
||||
phaseMap[t.phase] = { total: 0, max: 0, count: 0 };
|
||||
}
|
||||
phaseMap[t.phase].total += t.durationMs;
|
||||
phaseMap[t.phase].max = Math.max(phaseMap[t.phase].max, t.durationMs);
|
||||
phaseMap[t.phase].count++;
|
||||
|
||||
loopTotals[t.loop] = (loopTotals[t.loop] || 0) + t.durationMs;
|
||||
}
|
||||
|
||||
// 计算平均值
|
||||
const phaseTimings: Record<string, { avg: number; max: number; count: number }> = {};
|
||||
for (const [phase, data] of Object.entries(phaseMap)) {
|
||||
phaseTimings[phase] = {
|
||||
avg: Math.round(data.total / data.count),
|
||||
max: data.max,
|
||||
count: data.count,
|
||||
};
|
||||
}
|
||||
|
||||
// 找到最慢的阶段
|
||||
let slowestPhase = '';
|
||||
let slowestAvg = 0;
|
||||
for (const [phase, data] of Object.entries(phaseTimings)) {
|
||||
if (data.avg > slowestAvg) {
|
||||
slowestAvg = data.avg;
|
||||
slowestPhase = phase;
|
||||
}
|
||||
}
|
||||
|
||||
// 计算平均每轮时间
|
||||
const loopTimes = Object.values(loopTotals);
|
||||
const avgLoopTime = loopTimes.length > 0
|
||||
? Math.round(loopTimes.reduce((s, t) => s + t, 0) / loopTimes.length)
|
||||
: 0;
|
||||
|
||||
// 识别瓶颈
|
||||
const bottlenecks: string[] = [];
|
||||
if (slowestAvg > 5000) {
|
||||
bottlenecks.push(`⚠️ ${slowestPhase} 阶段平均耗时 ${slowestAvg}ms,是主要瓶颈`);
|
||||
}
|
||||
if (avgLoopTime > 30000) {
|
||||
bottlenecks.push(`⚠️ 平均每轮 ${avgLoopTime}ms,整体速度较慢`);
|
||||
}
|
||||
// 检查是否有异常慢的单次执行
|
||||
for (const [phase, data] of Object.entries(phaseTimings)) {
|
||||
if (data.max > data.avg * 3) {
|
||||
bottlenecks.push(`⚠️ ${phase} 阶段最大耗时 ${data.max}ms 远超平均 ${data.avg}ms,可能存在异常`);
|
||||
}
|
||||
}
|
||||
if (bottlenecks.length === 0) {
|
||||
bottlenecks.push('✅ 未检测到明显性能瓶颈');
|
||||
}
|
||||
|
||||
return {
|
||||
totalLoops: loopTimes.length,
|
||||
avgLoopTime,
|
||||
slowestPhase,
|
||||
phaseTimings,
|
||||
bottlenecks,
|
||||
};
|
||||
}
|
||||
|
||||
/** R118: 格式化性能报告 */
|
||||
export function formatPerformanceReport(): string {
|
||||
const report = generatePerformanceReport();
|
||||
const lines = [
|
||||
`Agent Loop 性能分析 (${report.totalLoops} 轮)`,
|
||||
`${'─'.repeat(40)}`,
|
||||
`平均每轮耗时: ${report.avgLoopTime}ms`,
|
||||
`最慢阶段: ${report.slowestPhase}`,
|
||||
'',
|
||||
'阶段明细:',
|
||||
];
|
||||
for (const [phase, data] of Object.entries(report.phaseTimings)) {
|
||||
lines.push(` ${phase}: avg=${data.avg}ms max=${data.max}ms (${data.count}次)`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('瓶颈分析:');
|
||||
for (const b of report.bottlenecks) {
|
||||
lines.push(` ${b}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R114: 工具调用依赖图 — 分析工具间依赖关系优化并行执行
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
interface ToolDependency {
|
||||
tool: string;
|
||||
dependsOn: string[]; // 依赖的其他工具(必须先完成)
|
||||
produces: string[]; // 产出(文件路径等)
|
||||
consumes: string[]; // 消费(文件路径等)
|
||||
}
|
||||
|
||||
/** R114: 从工具调用序列推断依赖关系 */
|
||||
export function inferToolDependencies(
|
||||
toolCalls: Array<{ name: string; arguments: Record<string, unknown> }>
|
||||
): ToolDependency[] {
|
||||
const dependencies: ToolDependency[] = [];
|
||||
const fileProducers = new Map<string, string>(); // filePath → toolName
|
||||
|
||||
for (const call of toolCalls) {
|
||||
const dep: ToolDependency = {
|
||||
tool: call.name,
|
||||
dependsOn: [],
|
||||
produces: [],
|
||||
consumes: [],
|
||||
};
|
||||
|
||||
// write_file/create_directory 产生文件
|
||||
if (call.name === 'write_file' && call.arguments.path) {
|
||||
const path = String(call.arguments.path);
|
||||
dep.produces.push(path);
|
||||
fileProducers.set(path, call.name);
|
||||
}
|
||||
|
||||
// read_file/edit_file/delete_file 消费文件
|
||||
if (['read_file', 'edit_file', 'delete_file'].includes(call.name)) {
|
||||
// 支持 path 单个路径和 paths 数组
|
||||
const pathsToCheck: string[] = [];
|
||||
if (call.arguments.path) pathsToCheck.push(String(call.arguments.path));
|
||||
if (Array.isArray(call.arguments.paths)) pathsToCheck.push(...(call.arguments.paths as unknown[]).map(p => String(p)));
|
||||
for (const path of pathsToCheck) {
|
||||
dep.consumes.push(path);
|
||||
const producer = fileProducers.get(path);
|
||||
if (producer) {
|
||||
dep.dependsOn.push(producer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// run_command 可能消费前面产生的文件
|
||||
if (call.name === 'run_command' && call.arguments.command) {
|
||||
const cmd = String(call.arguments.command);
|
||||
for (const [filePath, producer] of fileProducers) {
|
||||
if (cmd.includes(filePath)) {
|
||||
dep.consumes.push(filePath);
|
||||
dep.dependsOn.push(producer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies.push(dep);
|
||||
}
|
||||
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
/** R114: 基于依赖关系对工具调用分组(可并行执行的分为一组) */
|
||||
export function groupToolsByDependency(
|
||||
toolCalls: Array<{ name: string; arguments: Record<string, unknown> }>
|
||||
): Array<Array<{ name: string; arguments: Record<string, unknown> }>> {
|
||||
const deps = inferToolDependencies(toolCalls);
|
||||
const groups: Array<Array<{ name: string; arguments: Record<string, unknown> }>> = [];
|
||||
const completed = new Set<string>();
|
||||
|
||||
let remaining = [...toolCalls.map((tc, i) => ({ ...tc, index: i }))];
|
||||
|
||||
while (remaining.length > 0) {
|
||||
const currentBatch: Array<{ name: string; arguments: Record<string, unknown> }> = [];
|
||||
const batchIndices = new Set<number>();
|
||||
|
||||
for (const tc of remaining) {
|
||||
const dep = deps[tc.index];
|
||||
// 检查所有依赖是否已完成
|
||||
const canRun = dep.dependsOn.every(d => completed.has(d));
|
||||
if (canRun) {
|
||||
currentBatch.push({ name: tc.name, arguments: tc.arguments });
|
||||
batchIndices.add(tc.index);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentBatch.length === 0) {
|
||||
// 没有可执行的(可能有循环依赖),强制执行剩余的
|
||||
groups.push(remaining.map(tc => ({ name: tc.name, arguments: tc.arguments })));
|
||||
break;
|
||||
}
|
||||
|
||||
groups.push(currentBatch);
|
||||
for (const idx of batchIndices) {
|
||||
completed.add(toolCalls[idx].name);
|
||||
}
|
||||
remaining = remaining.filter(tc => !batchIndices.has(tc.index));
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R117: 记忆搜索相关性调优 — 微调记忆搜索评分权重
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export interface MemorySearchConfig {
|
||||
idfWeight: number; // IDF 权重
|
||||
fuzzyWeight: number; // 模糊匹配权重
|
||||
phraseBonus: number; // 多词短语奖励
|
||||
recencyBonus: number; // 时近性奖励
|
||||
frequencyBonus: number; // 访问频率奖励
|
||||
}
|
||||
|
||||
const _memorySearchConfig: MemorySearchConfig = {
|
||||
idfWeight: 1.0,
|
||||
fuzzyWeight: 0.5,
|
||||
phraseBonus: 2.0,
|
||||
recencyBonus: 0.3,
|
||||
frequencyBonus: 0.2,
|
||||
};
|
||||
|
||||
/** R117: 获取当前记忆搜索配置 */
|
||||
export function getMemorySearchConfig(): MemorySearchConfig {
|
||||
return { ..._memorySearchConfig };
|
||||
}
|
||||
|
||||
/** R117: 更新记忆搜索配置 */
|
||||
export function updateMemorySearchConfig(updates: Partial<MemorySearchConfig>): void {
|
||||
Object.assign(_memorySearchConfig, updates);
|
||||
logInfo(`R117: 记忆搜索配置已更新`, JSON.stringify(_memorySearchConfig));
|
||||
}
|
||||
|
||||
/** R117: 根据搜索效果自动调优 */
|
||||
export function autoTuneMemorySearch(
|
||||
avgResultCount: number,
|
||||
avgRelevanceScore: number
|
||||
): { tuned: boolean; changes: string[] } {
|
||||
const changes: string[] = [];
|
||||
|
||||
// 如果结果太多但相关性低,增加 IDF 权重
|
||||
if (avgResultCount > 10 && avgRelevanceScore < 0.3) {
|
||||
_memorySearchConfig.idfWeight += 0.2;
|
||||
changes.push(`IDF 权重增加到 ${_memorySearchConfig.idfWeight.toFixed(1)}(提高区分度)`);
|
||||
}
|
||||
|
||||
// 如果结果太少,降低模糊匹配阈值
|
||||
if (avgResultCount < 2) {
|
||||
_memorySearchConfig.fuzzyWeight += 0.1;
|
||||
changes.push(`模糊匹配权重增加到 ${_memorySearchConfig.fuzzyWeight.toFixed(1)}(放宽匹配)`);
|
||||
}
|
||||
|
||||
// 如果相关性分数高但结果少,增加频率奖励
|
||||
if (avgRelevanceScore > 0.7 && avgResultCount < 5) {
|
||||
_memorySearchConfig.frequencyBonus += 0.1;
|
||||
changes.push(`频率奖励增加到 ${_memorySearchConfig.frequencyBonus.toFixed(1)}(优先高频条目)`);
|
||||
}
|
||||
|
||||
return { tuned: changes.length > 0, changes };
|
||||
}
|
||||
|
||||
// R119 已删除:工具优先级排序
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R124: 压缩上下文中工具引用解析 — 恢复被压缩的工具结果引用
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** R124: 在压缩后的上下文中解析工具引用 */
|
||||
export function resolveCompressedReferences(
|
||||
messages: Array<{ role: string; content: string }>
|
||||
): { resolved: number; unresolved: string[] } {
|
||||
let resolved = 0;
|
||||
const unresolved: string[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role !== 'tool') continue;
|
||||
const content = msg.content || '';
|
||||
|
||||
// 查找引用标记
|
||||
const refMatch = content.match(/\[工具结果已归档 ref=(\S+)/);
|
||||
if (refMatch) {
|
||||
const refId = refMatch[1];
|
||||
const stored = _toolResultStore.get(refId);
|
||||
if (stored) {
|
||||
resolved++;
|
||||
} else {
|
||||
unresolved.push(refId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { resolved, unresolved };
|
||||
}
|
||||
|
||||
/** R124: 恢复压缩引用为完整内容(仅对指定引用) */
|
||||
export function restoreCompressedReference(
|
||||
refId: string,
|
||||
maxLen?: number
|
||||
): string | null {
|
||||
const stored = _toolResultStore.get(refId);
|
||||
if (!stored) return null;
|
||||
|
||||
const content = stored.fullContent;
|
||||
if (maxLen && content.length > maxLen) {
|
||||
return content.slice(0, maxLen) + `\n...(已截断,完整内容 ${content.length} 字符)`;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
/**
|
||||
* Context Indexer — 渐进式披露模块
|
||||
* Harness Engineering: 三级上下文管理
|
||||
*
|
||||
* 索引层 (Index) — 始终保留:项目结构树 + 入口文件地图 + 技术栈摘要
|
||||
* 接口层 (Interface) — 按需加载:模块 API 声明 + 类型定义 + 配置文件
|
||||
* 实现层 (Implementation) — 修改时加载:具体源代码
|
||||
*
|
||||
* 设计理念:
|
||||
* - 用目录式索引告诉智能体"去哪找",而非"全记住"
|
||||
* - 上下文可从数万 Token 压至几千
|
||||
* - 通过 load_context 工具按需触发接口层和实现层的加载
|
||||
*/
|
||||
|
||||
import { logInfo, logDebug, logWarn } from './log-service.js';
|
||||
import { estimateTokens } from './context-manager.js';
|
||||
import type { ProjectIndex, ContextTier } from '../types.js';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 项目索引缓存
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** 项目索引缓存(5 分钟 TTL) */
|
||||
let cachedIndex: ProjectIndex | null = null;
|
||||
let cacheTimestamp = 0;
|
||||
const INDEX_CACHE_TTL = 5 * 60 * 1000; // 5 分钟
|
||||
|
||||
/** 最大索引 Token 预算 */
|
||||
const MAX_INDEX_TOKENS = 2000;
|
||||
|
||||
/**
|
||||
* 构建项目索引
|
||||
* 扫描工作空间目录结构,生成精简的结构摘要
|
||||
*/
|
||||
export async function buildProjectIndex(workspaceDir: string): Promise<ProjectIndex> {
|
||||
// 检查缓存
|
||||
if (cachedIndex && Date.now() - cacheTimestamp < INDEX_CACHE_TTL) {
|
||||
return cachedIndex;
|
||||
}
|
||||
|
||||
try {
|
||||
const bridge = window.metonaDesktop;
|
||||
if (!bridge?.isDesktop) {
|
||||
return createEmptyIndex();
|
||||
}
|
||||
|
||||
// 利用现有 tree 工具扫描目录结构(限制深度 3 层)
|
||||
const treeResult = await bridge.tool.execute('tree', {
|
||||
path: workspaceDir,
|
||||
max_depth: 3,
|
||||
include_hidden: false,
|
||||
});
|
||||
|
||||
let structure = '';
|
||||
if (treeResult.success && treeResult.tree) {
|
||||
structure = String(treeResult.tree);
|
||||
// Token 预算截断
|
||||
if (estimateTokens(structure) > MAX_INDEX_TOKENS) {
|
||||
const lines = structure.split('\n');
|
||||
structure = lines.slice(0, Math.min(lines.length, 60)).join('\n')
|
||||
+ '\n... (目录结构已截断,使用 list_directory 查看完整内容)';
|
||||
}
|
||||
} else {
|
||||
structure = '(无法读取工作空间目录结构)';
|
||||
}
|
||||
|
||||
// 识别入口文件
|
||||
const entryFiles: string[] = [];
|
||||
const commonEntries = [
|
||||
'package.json', 'tsconfig.json', 'vite.config.ts',
|
||||
'main.ts', 'index.ts', 'index.html', 'app.ts',
|
||||
'Cargo.toml', 'pyproject.toml', 'go.mod', 'CMakeLists.txt',
|
||||
'README.md', 'Makefile', 'docker-compose.yml',
|
||||
];
|
||||
for (const entry of commonEntries) {
|
||||
try {
|
||||
// 跨平台路径拼接(清理尾部斜杠,统一用 posix 风格,Node.js 可容错处理)
|
||||
const cleanDir = workspaceDir.replace(/[\\/]+$/, '');
|
||||
const filePath = cleanDir + '/' + entry;
|
||||
const checkResult = await bridge.workspace.readFile(filePath);
|
||||
if (checkResult?.success) {
|
||||
entryFiles.push(entry);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// 检测技术栈
|
||||
const techStack = detectTechStack(entryFiles);
|
||||
|
||||
const index: ProjectIndex = {
|
||||
structure,
|
||||
entryFiles,
|
||||
techStack,
|
||||
tokenCount: estimateTokens(structure),
|
||||
generatedAt: Date.now(),
|
||||
};
|
||||
|
||||
cachedIndex = index;
|
||||
cacheTimestamp = Date.now();
|
||||
logInfo('项目索引已构建', `${techStack.join(', ')}, ${entryFiles.length} 入口文件, ${index.tokenCount} tokens`);
|
||||
return index;
|
||||
} catch (err) {
|
||||
logWarn('项目索引构建失败', (err as Error).message);
|
||||
return createEmptyIndex();
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建空索引 */
|
||||
function createEmptyIndex(): ProjectIndex {
|
||||
return {
|
||||
structure: '(未检测到工作空间)',
|
||||
entryFiles: [],
|
||||
techStack: [],
|
||||
tokenCount: 0,
|
||||
generatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 根据入口文件检测技术栈 */
|
||||
function detectTechStack(entryFiles: string[]): string[] {
|
||||
const stack: string[] = [];
|
||||
const fileSet = new Set(entryFiles.map(f => f.toLowerCase()));
|
||||
|
||||
if (fileSet.has('package.json')) stack.push('Node.js');
|
||||
if (fileSet.has('tsconfig.json')) stack.push('TypeScript');
|
||||
if (fileSet.has('vite.config.ts')) stack.push('Vite');
|
||||
if (fileSet.has('cargo.toml')) stack.push('Rust');
|
||||
if (fileSet.has('pyproject.toml')) stack.push('Python');
|
||||
if (fileSet.has('go.mod')) stack.push('Go');
|
||||
if (fileSet.has('cmakelists.txt')) stack.push('C/C++');
|
||||
if (fileSet.has('docker-compose.yml')) stack.push('Docker');
|
||||
if (fileSet.has('makefile')) stack.push('Make');
|
||||
|
||||
return stack.length > 0 ? stack : ['未知'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成索引层系统提示词
|
||||
* 始终保留在上下文中,告诉 AI "去哪找"
|
||||
*/
|
||||
export function buildIndexContext(index: ProjectIndex): string {
|
||||
if (!index.structure || index.tokenCount === 0) return '';
|
||||
|
||||
let context = `【项目索引 — 始终可见】
|
||||
项目结构:
|
||||
${index.structure}
|
||||
|
||||
技术栈: ${index.techStack.join(', ') || '未检测'}
|
||||
入口文件: ${index.entryFiles.length > 0 ? index.entryFiles.join(', ') : '未检测'}
|
||||
|
||||
💡 使用 list_directory 查看目录详情,使用 read_file 读取具体文件。
|
||||
💡 使用 search_files 按内容搜索代码。
|
||||
`;
|
||||
|
||||
// Token 预算控制
|
||||
if (estimateTokens(context) > MAX_INDEX_TOKENS) {
|
||||
context = context.slice(0, Math.floor(context.length * 0.8)) + '\n... (索引已截断)';
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建接口层上下文(按需加载)
|
||||
* @param modulePattern 模块匹配模式,如 "src/services/"
|
||||
*/
|
||||
export async function buildInterfaceContext(modulePattern: string, workspaceDir: string): Promise<string> {
|
||||
try {
|
||||
const bridge = window.metonaDesktop;
|
||||
if (!bridge?.isDesktop) return '';
|
||||
|
||||
// 搜索模块相关的类型定义和配置文件
|
||||
const searchResult = await bridge.tool.execute('search_files', {
|
||||
path: workspaceDir,
|
||||
query: modulePattern,
|
||||
search_type: 'filename',
|
||||
max_results: 10,
|
||||
});
|
||||
|
||||
if (!searchResult.success || !(searchResult as any).results?.length) {
|
||||
return `(未找到与 "${modulePattern}" 相关的接口文件)`;
|
||||
}
|
||||
|
||||
const results = (searchResult as any).results as Array<{ path: string }>;
|
||||
const paths = results.map(r => r.path).slice(0, 8);
|
||||
|
||||
// 批量读取接口文件(限制每文件 2000 字符)
|
||||
const readResult = await bridge.tool.execute('read_multiple_files', {
|
||||
paths,
|
||||
max_chars_per_file: 2000,
|
||||
});
|
||||
|
||||
if (readResult.success) {
|
||||
const filesInfo = paths.map(p => ` 📄 ${p}`).join('\n');
|
||||
return `【接口层 — ${modulePattern}】
|
||||
相关文件:
|
||||
${filesInfo}
|
||||
|
||||
内容预览:
|
||||
${JSON.stringify((readResult as any).files)}`;
|
||||
}
|
||||
|
||||
return `【接口层 — ${modulePattern}】
|
||||
相关文件:${paths.join(', ')}`;
|
||||
} catch (err) {
|
||||
logWarn('接口层上下文加载失败', (err as Error).message);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载指定层级的上下文
|
||||
*/
|
||||
export async function loadContextByTier(
|
||||
tier: ContextTier,
|
||||
modulePattern: string,
|
||||
workspaceDir: string,
|
||||
): Promise<string> {
|
||||
switch (tier) {
|
||||
case 'index': {
|
||||
const index = await buildProjectIndex(workspaceDir);
|
||||
return buildIndexContext(index);
|
||||
}
|
||||
case 'interface':
|
||||
return buildInterfaceContext(modulePattern, workspaceDir);
|
||||
case 'implementation':
|
||||
// 实现层由 Agent 自行通过 read_file 加载
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -155,27 +155,17 @@ export function predictContextOverflow(numCtx: number): ContextPrediction {
|
||||
return { level, currentUsage, predictedUsage, turnsToOverflow, message };
|
||||
}
|
||||
|
||||
/** R18: 获取 token 使用趋势数据(供调试用) */
|
||||
export function getTokenUsageTrend(): TokenUsagePoint[] {
|
||||
return [..._tokenUsageTrend];
|
||||
}
|
||||
|
||||
// ── Token 校准系统 ──
|
||||
|
||||
/** 校准比例:actualTokens / estimatedTokens,基于 Ollama 返回的实际计数动态修正 */
|
||||
// ── Token 估算校准状态 ──
|
||||
let _calibrationModel = '';
|
||||
let _tokenCalibrationRatio = 1.0;
|
||||
let _calibrationSamples = 0;
|
||||
let _calibrationModel = ''; // C8: 记录校准时的模型名
|
||||
const MIN_CALIBRATION_SAMPLES = 3;
|
||||
const MIN_CALIBRATION_SAMPLES = 5;
|
||||
|
||||
/** 自动压缩触发阈值(占上下文窗口比例) */
|
||||
export const AUTO_COMPRESS_THRESHOLD = 0.5;
|
||||
|
||||
/**
|
||||
* 记录 Ollama 返回的实际 token 计数,用于校准估算器。
|
||||
* 在 agent-engine.ts 每轮流式完成后调用。
|
||||
* C8: 模型切换时自动重置校准比例,避免不同 tokenizer 导致估算失真
|
||||
* @param actualInputTokens Ollama 返回的 prompt_eval_count
|
||||
* @param actualOutputTokens Ollama 返回的 eval_count
|
||||
* @param estimatedTokens 本轮消息调用 estimateTokens 的合计值
|
||||
* @param modelName 当前使用的模型名
|
||||
*/
|
||||
export function recordActualTokens(actualInputTokens: number, actualOutputTokens: number, estimatedCount: number, modelName?: string): void {
|
||||
// C8: 模型切换时重置校准
|
||||
@@ -212,19 +202,7 @@ export function estimateTokens(text: string): number {
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** 获取当前校准比例(供调试用) */
|
||||
export function getTokenCalibration(): { ratio: number; samples: number } {
|
||||
return { ratio: _tokenCalibrationRatio, samples: _calibrationSamples };
|
||||
}
|
||||
|
||||
/** 自动压缩阈值:当消息 token 占 context window 比例超过此值时触发自动压缩
|
||||
* P2 #7 修复:从 0.3 提高到 0.5,避免过于频繁的压缩导致信息丢失
|
||||
*/
|
||||
export const AUTO_COMPRESS_THRESHOLD = 0.5;
|
||||
|
||||
/** R14: 自适应压缩阈值 — 根据模型上下文长度动态调整
|
||||
* P2 #7 修复:提高各档位阈值,减少不必要的压缩
|
||||
*/
|
||||
/** 自适应压缩阈值 — 根据模型上下文长度动态调整 */
|
||||
export function getAdaptiveCompressThreshold(numCtx: number): number {
|
||||
// 小上下文模型(<8K):更早触发压缩(55%),留余量
|
||||
// 中等上下文(8K-32K):标准阈值(50%)
|
||||
@@ -1580,235 +1558,6 @@ export function chooseCompressionStrategy(
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R115: 上下文水印 — 标记不可压缩的关键信息
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** 水印标记:带有此标记的消息在压缩时会被保留 */
|
||||
const WATERMARK_PREFIX = '[PRESERVE]';
|
||||
const _watermarkedIndices = new Set<number>();
|
||||
|
||||
/** R115: 标记消息为不可压缩 */
|
||||
export function watermarkMessage(index: number): void {
|
||||
_watermarkedIndices.add(index);
|
||||
}
|
||||
|
||||
/** R115: 检查消息是否被水印保护 */
|
||||
export function isWatermarked(index: number): boolean {
|
||||
return _watermarkedIndices.has(index);
|
||||
}
|
||||
|
||||
/** R115: 自动为关键消息添加水印 */
|
||||
export function autoWatermarkCritical(messages: OllamaMessage[]): number[] {
|
||||
const protectedIndices: number[] = [];
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
const content = msg.content || '';
|
||||
|
||||
// 系统消息始终保护
|
||||
if (msg.role === 'system') {
|
||||
watermarkMessage(i);
|
||||
protectedIndices.push(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 包含错误信息的用户消息保护
|
||||
if (msg.role === 'user' && (content.includes('错误') || content.includes('error') || content.includes('失败'))) {
|
||||
watermarkMessage(i);
|
||||
protectedIndices.push(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 最近 5 条消息保护
|
||||
if (i >= messages.length - 5) {
|
||||
watermarkMessage(i);
|
||||
protectedIndices.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
return protectedIndices;
|
||||
}
|
||||
|
||||
/** R115: 清除水印 */
|
||||
export function clearWatermarks(): void {
|
||||
_watermarkedIndices.clear();
|
||||
}
|
||||
|
||||
/** R115: 获取受保护的消息索引列表 */
|
||||
export function getWatermarkedIndices(): number[] {
|
||||
return Array.from(_watermarkedIndices).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R120: 上下文压缩跳过逻辑 — 不值得压缩时跳过
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** R120: 判断是否应该跳过压缩 */
|
||||
export function shouldSkipCompression(
|
||||
messages: OllamaMessage[],
|
||||
numCtx: number,
|
||||
recentCompressionRatio: number
|
||||
): { skip: boolean; reason: string } {
|
||||
const totalTokens = estimateTokens(messages.map(m => m.content || '').join(''));
|
||||
const usageRatio = numCtx > 0 ? totalTokens / numCtx : 0;
|
||||
|
||||
// 如果使用率很低,跳过
|
||||
if (usageRatio < 0.2) {
|
||||
return { skip: true, reason: `上下文使用率极低 (${(usageRatio * 100).toFixed(0)}%),无需压缩` };
|
||||
}
|
||||
|
||||
// 如果消息数太少,跳过
|
||||
if (messages.length < 10) {
|
||||
return { skip: true, reason: `消息数过少 (${messages.length} 条),无需压缩` };
|
||||
}
|
||||
|
||||
// 如果最近压缩收益很低(压缩比 < 10%),跳过
|
||||
if (recentCompressionRatio > 0.9) {
|
||||
return { skip: true, reason: `最近压缩收益低 (压缩比 ${(recentCompressionRatio * 100).toFixed(0)}%),跳过` };
|
||||
}
|
||||
|
||||
// 如果大部分消息已经被归档/压缩过,跳过
|
||||
const archivedCount = messages.filter(m =>
|
||||
m.content?.includes('[工具结果已归档]') || m.content?.includes('[PRESERVE]')
|
||||
).length;
|
||||
if (archivedCount / messages.length > 0.6) {
|
||||
return { skip: true, reason: `大部分消息已归档 (${(archivedCount / messages.length * 100).toFixed(0)}%),跳过` };
|
||||
}
|
||||
|
||||
return { skip: false, reason: '' };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R121: 滑动窗口自适应大小 — 根据上下文压力动态调整窗口大小
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** R121: 根据上下文压力获取自适应滑动窗口大小 */
|
||||
export function getAdaptiveWindowSize(
|
||||
totalMessages: number,
|
||||
pressureLevel: string,
|
||||
numCtx: number
|
||||
): { keepRecent: number; keepSystem: number; reason: string } {
|
||||
const baseWindow = Math.min(totalMessages, 40);
|
||||
|
||||
switch (pressureLevel) {
|
||||
case 'critical':
|
||||
return {
|
||||
keepRecent: Math.min(baseWindow, 15),
|
||||
keepSystem: 2,
|
||||
reason: '关键压力:保留最近 15 条 + 系统 2 条',
|
||||
};
|
||||
case 'high':
|
||||
return {
|
||||
keepRecent: Math.min(baseWindow, 25),
|
||||
keepSystem: 3,
|
||||
reason: '高压力:保留最近 25 条 + 系统 3 条',
|
||||
};
|
||||
case 'medium':
|
||||
return {
|
||||
keepRecent: Math.min(baseWindow, 35),
|
||||
keepSystem: 5,
|
||||
reason: '中等压力:保留最近 35 条 + 系统 5 条',
|
||||
};
|
||||
case 'low':
|
||||
default:
|
||||
return {
|
||||
keepRecent: Math.min(baseWindow, 50),
|
||||
keepSystem: 5,
|
||||
reason: '低压力:保留最近 50 条 + 系统 5 条',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R122: Token 趋势分析 — 深度分析 token 使用趋势用于预测性压缩
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export interface TrendAnalysis {
|
||||
trend: 'increasing' | 'decreasing' | 'stable';
|
||||
avgGrowthRate: number; // 每轮平均 token 增长量
|
||||
projectedOverflow: number; // 预计几轮后溢出(-1=不会)
|
||||
recommendedAction: string;
|
||||
confidence: number; // 0-1
|
||||
}
|
||||
|
||||
/** R122: 分析 token 使用趋势 */
|
||||
export function analyzeTokenTrend(numCtx: number): TrendAnalysis {
|
||||
if (_tokenUsageTrend.length < 3) {
|
||||
return {
|
||||
trend: 'stable',
|
||||
avgGrowthRate: 0,
|
||||
projectedOverflow: -1,
|
||||
recommendedAction: '数据不足,暂不推荐操作',
|
||||
confidence: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const points = _tokenUsageTrend;
|
||||
const n = points.length;
|
||||
|
||||
// 计算平均增长率
|
||||
let totalGrowth = 0;
|
||||
let growthCount = 0;
|
||||
for (let i = 1; i < n; i++) {
|
||||
const growth = points[i].tokens - points[i - 1].tokens;
|
||||
totalGrowth += growth;
|
||||
growthCount++;
|
||||
}
|
||||
const avgGrowthRate = growthCount > 0 ? totalGrowth / growthCount : 0;
|
||||
|
||||
// 线性回归确定趋势
|
||||
const xs = points.map(p => p.turn);
|
||||
const ys = points.map(p => p.tokens);
|
||||
const xMean = xs.reduce((s, x) => s + x, 0) / n;
|
||||
const yMean = ys.reduce((s, y) => s + y, 0) / n;
|
||||
let num = 0, den = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
num += (xs[i] - xMean) * (ys[i] - yMean);
|
||||
den += (xs[i] - xMean) ** 2;
|
||||
}
|
||||
const slope = den !== 0 ? num / den : 0;
|
||||
|
||||
// 判断趋势
|
||||
let trend: TrendAnalysis['trend'];
|
||||
if (slope > 100) trend = 'increasing';
|
||||
else if (slope < -50) trend = 'decreasing';
|
||||
else trend = 'stable';
|
||||
|
||||
// 预测溢出
|
||||
let projectedOverflow = -1;
|
||||
if (slope > 0) {
|
||||
const currentTokens = points[n - 1].tokens;
|
||||
const remaining = numCtx - currentTokens;
|
||||
projectedOverflow = Math.ceil(remaining / slope);
|
||||
if (projectedOverflow < 0) projectedOverflow = 0;
|
||||
}
|
||||
|
||||
// 推荐操作
|
||||
let recommendedAction = '';
|
||||
if (trend === 'increasing' && projectedOverflow >= 0 && projectedOverflow <= 5) {
|
||||
recommendedAction = `⚠️ 预计 ${projectedOverflow} 轮后上下文溢出,建议立即压缩`;
|
||||
} else if (trend === 'increasing' && projectedOverflow > 5 && projectedOverflow <= 10) {
|
||||
recommendedAction = `建议在接下来 2-3 轮内进行压缩(${projectedOverflow} 轮后溢出)`;
|
||||
} else if (trend === 'stable') {
|
||||
recommendedAction = 'Token 使用趋势稳定,无需额外操作';
|
||||
} else if (trend === 'decreasing') {
|
||||
recommendedAction = 'Token 使用量在下降,压缩策略生效';
|
||||
}
|
||||
|
||||
// 置信度:基于数据点数量和趋势一致性
|
||||
let confidence = Math.min(1, n / 10);
|
||||
if (trend === 'stable') confidence *= 0.7;
|
||||
|
||||
return {
|
||||
trend,
|
||||
avgGrowthRate: Math.round(avgGrowthRate),
|
||||
projectedOverflow,
|
||||
recommendedAction,
|
||||
confidence,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R123: 会话摘要持久化 — 跨会话引用
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -1862,7 +1611,7 @@ export function generateSessionSummary(
|
||||
const toolsUsed = [...new Set(toolRecords.map(t => t.name))];
|
||||
const assistantMessages = messages.filter(m => m.role === 'assistant');
|
||||
const lastAssistant = assistantMessages[assistantMessages.length - 1];
|
||||
|
||||
|
||||
return {
|
||||
id: `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
createdAt: Date.now(),
|
||||
@@ -1886,7 +1635,7 @@ export function formatSessionSummariesForContext(summaries: SessionSummary[]): s
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R125: Agent 状态检查点 — 保存和恢复 Agent 状态
|
||||
// R125: Agent 状态检查点 — 保存 Agent 运行状态(恢复 API 见后续迭代)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export interface AgentCheckpoint {
|
||||
@@ -1919,138 +1668,18 @@ export function createCheckpoint(
|
||||
toolRecordsCount,
|
||||
goal,
|
||||
};
|
||||
|
||||
|
||||
_checkpoints.push(checkpoint);
|
||||
if (_checkpoints.length > MAX_CHECKPOINTS) {
|
||||
_checkpoints.shift();
|
||||
}
|
||||
|
||||
|
||||
logInfo(`R125: 检查点已创建 (loop=${loopCount}, state=${agentState})`);
|
||||
return checkpoint;
|
||||
}
|
||||
|
||||
/** R125: 获取最近的检查点 */
|
||||
export function getLatestCheckpoint(): AgentCheckpoint | null {
|
||||
return _checkpoints.length > 0 ? _checkpoints[_checkpoints.length - 1] : null;
|
||||
}
|
||||
|
||||
/** R125: 恢复到指定检查点 */
|
||||
export function restoreCheckpoint(id: string): AgentCheckpoint | null {
|
||||
const cp = _checkpoints.find(c => c.id === id);
|
||||
if (!cp) {
|
||||
logWarn(`R125: 检查点 ${id} 不存在`);
|
||||
return null;
|
||||
}
|
||||
logInfo(`R125: 恢复到检查点 ${id} (loop=${cp.loopCount})`);
|
||||
return cp;
|
||||
}
|
||||
|
||||
/** R125: 获取所有检查点 */
|
||||
export function getAllCheckpoints(): AgentCheckpoint[] {
|
||||
return [..._checkpoints];
|
||||
}
|
||||
|
||||
/** R125: 清除所有检查点 */
|
||||
export function clearCheckpoints(): void {
|
||||
_checkpoints.length = 0;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// R126: 上下文预算分配 — 按消息类型分配上下文 token 预算
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
export interface ContextBudgetAllocation {
|
||||
system: number; // 系统消息预算
|
||||
user: number; // 用户消息预算
|
||||
assistant: number; // 助手消息预算
|
||||
tool: number; // 工具结果预算
|
||||
memory: number; // 记忆注入预算
|
||||
total: number; // 总预算
|
||||
}
|
||||
|
||||
/** R126: 默认预算分配比例 */
|
||||
const DEFAULT_BUDGET_RATIOS = {
|
||||
system: 0.05, // 5%
|
||||
user: 0.15, // 15%
|
||||
assistant: 0.25, // 25%
|
||||
tool: 0.45, // 45%
|
||||
memory: 0.10, // 10%
|
||||
};
|
||||
|
||||
/** R126: 根据消息分布动态调整预算分配 */
|
||||
export function allocateContextBudget(
|
||||
messages: OllamaMessage[],
|
||||
numCtx: number
|
||||
): ContextBudgetAllocation {
|
||||
const total = numCtx;
|
||||
|
||||
// 统计各类型消息当前占比
|
||||
const counts = { system: 0, user: 0, assistant: 0, tool: 0 };
|
||||
let memorySize = 0;
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role in counts) {
|
||||
counts[msg.role as keyof typeof counts]++;
|
||||
}
|
||||
if (msg.content?.includes('[记忆注入]')) {
|
||||
memorySize += estimateTokens(msg.content);
|
||||
}
|
||||
}
|
||||
|
||||
const totalMsgs = messages.length || 1;
|
||||
|
||||
// 动态调整:如果工具结果占比过高,增加工具预算
|
||||
const toolRatio = counts.tool / totalMsgs;
|
||||
const ratios = { ...DEFAULT_BUDGET_RATIOS };
|
||||
|
||||
if (toolRatio > 0.5) {
|
||||
// 工具结果过多,从助手预算中转移一部分给工具
|
||||
const shift = Math.min(0.1, (toolRatio - 0.5) * 0.3);
|
||||
ratios.assistant -= shift;
|
||||
ratios.tool += shift;
|
||||
}
|
||||
|
||||
// 如果记忆注入很大,增加记忆预算
|
||||
if (memorySize > numCtx * 0.1) {
|
||||
const shift = Math.min(0.05, (memorySize / numCtx - 0.1) * 0.2);
|
||||
ratios.tool -= shift;
|
||||
ratios.memory += shift;
|
||||
}
|
||||
|
||||
return {
|
||||
system: Math.floor(total * ratios.system),
|
||||
user: Math.floor(total * ratios.user),
|
||||
assistant: Math.floor(total * ratios.assistant),
|
||||
tool: Math.floor(total * ratios.tool),
|
||||
memory: Math.floor(total * ratios.memory),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/** R126: 检查消息是否超出预算 */
|
||||
export function checkBudgetOverflow(
|
||||
messages: OllamaMessage[],
|
||||
budget: ContextBudgetAllocation
|
||||
): { role: string; current: number; budget: number; overflow: number }[] {
|
||||
const tokensByRole: Record<string, number> = {};
|
||||
for (const msg of messages) {
|
||||
tokensByRole[msg.role] = (tokensByRole[msg.role] || 0) + estimateTokens(msg.content || '');
|
||||
}
|
||||
|
||||
const overflows: { role: string; current: number; budget: number; overflow: number }[] = [];
|
||||
const budgetMap: Record<string, number> = {
|
||||
system: budget.system,
|
||||
user: budget.user,
|
||||
assistant: budget.assistant,
|
||||
tool: budget.tool,
|
||||
};
|
||||
|
||||
for (const [role, current] of Object.entries(tokensByRole)) {
|
||||
const bud = budgetMap[role] || Infinity;
|
||||
if (current > bud) {
|
||||
overflows.push({ role, current, budget: bud, overflow: current - bud });
|
||||
}
|
||||
}
|
||||
|
||||
return overflows;
|
||||
}
|
||||
|
||||
@@ -110,13 +110,7 @@ function contentFingerprint(content: string): string {
|
||||
return hash.toString(16);
|
||||
}
|
||||
|
||||
/** 暴露写入记录供 agent-engine 清理 */
|
||||
export function clearWrittenFiles(): void {
|
||||
_writtenFileFingerprints.clear();
|
||||
}
|
||||
export function hasWrittenFile(path: string): boolean {
|
||||
return _writtenFileFingerprints.has(path);
|
||||
}
|
||||
/** 暴露写入记录供 agent-engine 记录成功路径 */
|
||||
export function addWrittenFile(path: string, content?: string): void {
|
||||
_writtenFileFingerprints.set(path, content ? contentFingerprint(content) : '');
|
||||
}
|
||||
|
||||
@@ -1,303 +1,30 @@
|
||||
/**
|
||||
* Infrastructure Service - 基础设施服务 (R41-R50)
|
||||
* 内存泄漏防护、全局错误处理、性能监控、配置验证、健康检查
|
||||
* Infrastructure Service - 基础设施服务
|
||||
* 全局错误边界:统一捕获未处理异常(渲染进程唯一定义,避免重复弹窗)
|
||||
*/
|
||||
|
||||
import { logInfo, logWarn, logError } from './log-service.js';
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// R41: 内存泄漏防护 — 事件监听器管理
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/** R41: 已注册的事件监听器追踪表 */
|
||||
const _trackedListeners = new Map<string, { target: EventTarget; type: string; listener: EventListenerOrEventListenerObject; options?: boolean | AddEventListenerOptions }>();
|
||||
|
||||
let _listenerIdCounter = 0;
|
||||
import { logError, logInfo } from './log-service.js';
|
||||
import { showToast } from '../components/toast.js';
|
||||
|
||||
/**
|
||||
* R41: 注册并追踪事件监听器,便于统一清理
|
||||
* @returns 监听器 ID,可用于单独移除
|
||||
* 初始化全局错误处理(应用启动时调用一次)
|
||||
* - 未捕获 JS 错误 / Promise rejection → 日志 + Toast
|
||||
* - 阻止默认行为(避免原生错误对话框)
|
||||
*/
|
||||
export function trackEventListener(
|
||||
target: EventTarget,
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): string {
|
||||
const id = `listener_${++_listenerIdCounter}`;
|
||||
_trackedListeners.set(id, { target, type, listener, options });
|
||||
target.addEventListener(type, listener, options);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** R41: 移除单个事件监听器 */
|
||||
export function removeTrackedListener(id: string): void {
|
||||
const entry = _trackedListeners.get(id);
|
||||
if (entry) {
|
||||
entry.target.removeEventListener(entry.type, entry.listener, entry.options);
|
||||
_trackedListeners.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** R41: 清理所有已追踪的事件监听器(用于页面卸载或会话切换时) */
|
||||
export function cleanupAllListeners(): void {
|
||||
let count = 0;
|
||||
for (const [id, entry] of _trackedListeners) {
|
||||
try {
|
||||
entry.target.removeEventListener(entry.type, entry.listener, entry.options);
|
||||
count++;
|
||||
} catch { /* ignore */ }
|
||||
_trackedListeners.delete(id);
|
||||
}
|
||||
if (count > 0) {
|
||||
logInfo(`R41: 已清理 ${count} 个事件监听器`);
|
||||
}
|
||||
}
|
||||
|
||||
/** R41: 获取当前追踪的监听器数量(供调试用) */
|
||||
export function getTrackedListenerCount(): number {
|
||||
return _trackedListeners.size;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// R42: 全局错误边界 — 捕获未处理的异常
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/** R42: 初始化全局错误处理 */
|
||||
export function initGlobalErrorHandler(): void {
|
||||
// 捕获未处理的 JS 错误
|
||||
window.addEventListener('error', (e) => {
|
||||
logError('R42: 未捕获错误', `${e.message} @ ${e.filename}:${e.lineno}:${e.colno}`);
|
||||
// 阻止默认的错误处理(避免弹出丑陋的错误对话框)
|
||||
logError('未捕获错误', `${e.message} @ ${e.filename}:${e.lineno}:${e.colno}`);
|
||||
showToast(`发生错误: ${e.message}`, 'error', 5000);
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
// 捕获未处理的 Promise rejection
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
const reason = e.reason;
|
||||
const msg = reason instanceof Error ? reason.message : String(reason);
|
||||
logError('R42: 未处理的 Promise Rejection', msg);
|
||||
logError('未处理的 Promise Rejection', msg);
|
||||
showToast(`操作失败: ${msg}`, 'error', 5000);
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
logInfo('R42: 全局错误处理器已初始化');
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// R43: 性能监控 — 关键操作耗时追踪
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
export interface PerfMetric {
|
||||
name: string;
|
||||
duration: number;
|
||||
timestamp: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const _perfMetrics: PerfMetric[] = [];
|
||||
const MAX_PERF_METRICS = 200;
|
||||
const _perfTimers = new Map<string, number>();
|
||||
|
||||
/** R43: 开始性能计时 */
|
||||
export function perfStart(name: string): void {
|
||||
_perfTimers.set(name, performance.now());
|
||||
}
|
||||
|
||||
/** R43: 结束性能计时并记录 */
|
||||
export function perfEnd(name: string, metadata?: Record<string, unknown>): number {
|
||||
const startTime = _perfTimers.get(name);
|
||||
if (startTime === undefined) {
|
||||
logWarn(`R43: perfEnd 未找到对应的 perfStart: ${name}`);
|
||||
return 0;
|
||||
}
|
||||
const duration = performance.now() - startTime;
|
||||
_perfTimers.delete(name);
|
||||
|
||||
_perfMetrics.push({ name, duration, timestamp: Date.now(), metadata });
|
||||
|
||||
// 超过上限时移除最早的
|
||||
if (_perfMetrics.length > MAX_PERF_METRICS) {
|
||||
_perfMetrics.shift();
|
||||
}
|
||||
|
||||
// 慢操作警告(超过 1 秒)
|
||||
if (duration > 1000) {
|
||||
logWarn(`R43: 慢操作: ${name} 耗时 ${duration.toFixed(0)}ms`);
|
||||
}
|
||||
|
||||
return duration;
|
||||
}
|
||||
|
||||
/** R43: 获取性能指标 */
|
||||
export function getPerfMetrics(): PerfMetric[] {
|
||||
return [..._perfMetrics];
|
||||
}
|
||||
|
||||
/** R43: 获取平均性能指标 */
|
||||
export function getAvgPerfMetric(name: string): number {
|
||||
const metrics = _perfMetrics.filter(m => m.name === name);
|
||||
if (metrics.length === 0) return 0;
|
||||
return metrics.reduce((sum, m) => sum + m.duration, 0) / metrics.length;
|
||||
}
|
||||
|
||||
/** R43: 清空性能指标 */
|
||||
export function clearPerfMetrics(): void {
|
||||
_perfMetrics.length = 0;
|
||||
_perfTimers.clear();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// R48: 配置验证 — 启动时验证关键配置
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
export interface ConfigValidationResult {
|
||||
valid: boolean;
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/** R48: 验证应用配置 */
|
||||
export function validateConfig(config: Record<string, unknown>): ConfigValidationResult {
|
||||
const warnings: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
// 验证 numCtx
|
||||
const numCtx = config.numCtx as number;
|
||||
if (numCtx !== undefined) {
|
||||
if (numCtx < 2048) {
|
||||
warnings.push(`numCtx=${numCtx} 过小,可能导致上下文截断。建议至少 4096。`);
|
||||
}
|
||||
if (numCtx > 131072) {
|
||||
warnings.push(`numCtx=${numCtx} 过大,可能导致内存不足。建议不超过 131072。`);
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 temperature
|
||||
const temperature = config.temperature as number;
|
||||
if (temperature !== undefined) {
|
||||
if (temperature < 0 || temperature > 2) {
|
||||
errors.push(`temperature=${temperature} 超出有效范围 [0, 2]`);
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 maxTurns
|
||||
const maxTurns = config.maxTurns as number;
|
||||
if (maxTurns !== undefined) {
|
||||
if (maxTurns < 1) {
|
||||
errors.push(`maxTurns=${maxTurns} 不能小于 1`);
|
||||
}
|
||||
if (maxTurns > 50) {
|
||||
warnings.push(`maxTurns=${maxTurns} 过大,可能导致长时间运行。建议不超过 20。`);
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 streamTimeout
|
||||
const streamTimeout = config.streamTimeout as number;
|
||||
if (streamTimeout !== undefined) {
|
||||
if (streamTimeout < 10000) {
|
||||
warnings.push(`streamTimeout=${streamTimeout} 过短,可能导致大模型生成被中断。建议至少 30000ms。`);
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 subAgentTimeout
|
||||
const subAgentTimeout = config.subAgentTimeout as number;
|
||||
if (subAgentTimeout !== undefined && subAgentTimeout < 5000) {
|
||||
warnings.push(`subAgentTimeout=${subAgentTimeout} 过短,子代理可能无法完成任务。`);
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
warnings,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// R50: 健康检查 — 系统健康监控
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
export interface HealthCheckResult {
|
||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||
checks: Array<{ name: string; status: 'pass' | 'fail' | 'warn'; message: string }>;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** R50: 执行系统健康检查 */
|
||||
export async function runHealthCheck(): Promise<HealthCheckResult> {
|
||||
const checks: Array<{ name: string; status: 'pass' | 'fail' | 'warn'; message: string }> = [];
|
||||
|
||||
// 检查 1: 桌面 API 可用性
|
||||
const bridge = window.metonaDesktop;
|
||||
if (bridge?.isDesktop) {
|
||||
checks.push({ name: '桌面 API', status: 'pass', message: '桌面 API 可用' });
|
||||
} else {
|
||||
checks.push({ name: '桌面 API', status: 'fail', message: '桌面 API 不可用(Web 模式)' });
|
||||
}
|
||||
|
||||
// 检查 2: 数据库可用性
|
||||
if (bridge?.db) {
|
||||
try {
|
||||
const sessions = await bridge.db.getAllSessions();
|
||||
checks.push({ name: '数据库', status: 'pass', message: `数据库正常(${sessions.length} 个会话)` });
|
||||
} catch (err) {
|
||||
checks.push({ name: '数据库', status: 'fail', message: `数据库访问失败: ${(err as Error).message}` });
|
||||
}
|
||||
} else {
|
||||
checks.push({ name: '数据库', status: 'warn', message: '数据库 API 不可用' });
|
||||
}
|
||||
|
||||
// 检查 3: 内存使用
|
||||
const memInfo = (performance as any).memory;
|
||||
if (memInfo) {
|
||||
const usedMB = (memInfo.usedJSHeapSize / 1024 / 1024).toFixed(0);
|
||||
const limitMB = (memInfo.jsHeapSizeLimit / 1024 / 1024).toFixed(0);
|
||||
const usageRatio = memInfo.usedJSHeapSize / memInfo.jsHeapSizeLimit;
|
||||
if (usageRatio > 0.8) {
|
||||
checks.push({ name: '内存', status: 'warn', message: `内存使用较高: ${usedMB}/${limitMB}MB (${(usageRatio * 100).toFixed(0)}%)` });
|
||||
} else {
|
||||
checks.push({ name: '内存', status: 'pass', message: `内存使用正常: ${usedMB}/${limitMB}MB` });
|
||||
}
|
||||
} else {
|
||||
checks.push({ name: '内存', status: 'pass', message: '内存监控不可用(非 Chromium)' });
|
||||
}
|
||||
|
||||
// 检查 4: 工作空间可用性
|
||||
if (bridge?.workspace) {
|
||||
try {
|
||||
const result = await bridge.workspace.getDir();
|
||||
if (result.dir) {
|
||||
checks.push({ name: '工作空间', status: 'pass', message: `工作空间: ${result.dir}` });
|
||||
} else {
|
||||
checks.push({ name: '工作空间', status: 'warn', message: '工作空间未设置' });
|
||||
}
|
||||
} catch {
|
||||
checks.push({ name: '工作空间', status: 'warn', message: '工作空间访问失败' });
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 5: 事件监听器数量(内存泄漏检测)
|
||||
const listenerCount = _trackedListeners.size;
|
||||
if (listenerCount > 100) {
|
||||
checks.push({ name: '事件监听器', status: 'warn', message: `追踪的事件监听器较多: ${listenerCount} 个,可能存在内存泄漏` });
|
||||
} else {
|
||||
checks.push({ name: '事件监听器', status: 'pass', message: `事件监听器数量正常: ${listenerCount} 个` });
|
||||
}
|
||||
|
||||
// 确定整体状态
|
||||
const hasFail = checks.some(c => c.status === 'fail');
|
||||
const hasWarn = checks.some(c => c.status === 'warn');
|
||||
const status: 'healthy' | 'degraded' | 'unhealthy' = hasFail ? 'unhealthy' : hasWarn ? 'degraded' : 'healthy';
|
||||
|
||||
return { status, checks, timestamp: Date.now() };
|
||||
}
|
||||
|
||||
/** R50: 格式化健康检查结果为可读字符串 */
|
||||
export function formatHealthCheck(result: HealthCheckResult): string {
|
||||
const statusIcon = result.status === 'healthy' ? '✅' : result.status === 'degraded' ? '⚠️' : '❌';
|
||||
const lines = [`${statusIcon} 系统健康检查 — ${result.status.toUpperCase()}`, ''];
|
||||
for (const check of result.checks) {
|
||||
const icon = check.status === 'pass' ? '✅' : check.status === 'warn' ? '⚠️' : '❌';
|
||||
lines.push(`${icon} ${check.name}: ${check.message}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
logInfo('全局错误处理器已初始化');
|
||||
}
|
||||
|
||||
@@ -65,8 +65,11 @@ const FILE_HEADER = `# METONA MEMORY
|
||||
|
||||
`;
|
||||
|
||||
/** 条目元数据正则: ## [类型] | id: [ID] | importance: [1-10] | tags: [tag1, tag2] */
|
||||
const ENTRY_HEADER_RE = /^##\s+(fact|preference|rule)\s*\|\s*id:\s*(mem_\d{8}_\d{3})\s*\|\s*importance:\s*(\d{1,2})\s*\|\s*tags:\s*(.+)$/i;
|
||||
/**
|
||||
* 条目元数据正则: ## [类型] | id: [ID] | importance: [1-10] | tags: [tag1, tag2] [| hits: N | last: TS]
|
||||
* hits/last 为可选的访问统计后缀(R106),旧格式无此后缀也可解析。
|
||||
*/
|
||||
const ENTRY_HEADER_RE = /^##\s+(fact|preference|rule)\s*\|\s*id:\s*(mem_\d{8}_\d{3})\s*\|\s*importance:\s*(\d{1,2})\s*\|\s*tags:\s+(.+?)(?:\s*\|\s*hits:\s*(\d+)\s*\|\s*last:\s*(\d+))?\s*$/i;
|
||||
|
||||
const VALID_TYPES: MemoryType[] = ['fact', 'preference', 'rule'];
|
||||
|
||||
@@ -181,8 +184,13 @@ export function parseMemoryMd(content: string): MemoryEntry[] {
|
||||
const importance = parseInt(headerMatch[3], 10);
|
||||
const tagsStr = headerMatch[4];
|
||||
const tags = tagsStr.split(',').map(t => t.trim()).filter(t => t.length > 0);
|
||||
const hits = headerMatch[5] ? parseInt(headerMatch[5], 10) : 0;
|
||||
const last = headerMatch[6] ? parseInt(headerMatch[6], 10) : undefined;
|
||||
|
||||
currentEntry = { id, type, content: '', importance: Math.min(10, Math.max(1, importance)), tags };
|
||||
currentEntry = {
|
||||
id, type, content: '', importance: Math.min(10, Math.max(1, importance)), tags,
|
||||
accessCount: hits, lastAccessed: last,
|
||||
};
|
||||
contentLines = [];
|
||||
} else if (currentEntry) {
|
||||
contentLines.push(line);
|
||||
@@ -260,7 +268,8 @@ export function validateMemoryMd(content: string): { valid: boolean; error?: str
|
||||
}
|
||||
|
||||
/**
|
||||
* 将条目数组序列化为 MEMORY.md 内容
|
||||
* 将条目数组序列化为 MEMORY.md 内容。
|
||||
* 访问统计(hits/last)随条目一并持久化,使 R106 的 TTL 访问保护真正生效。
|
||||
*/
|
||||
export function serializeMemoryMd(entries: MemoryEntry[]): string {
|
||||
let content = FILE_HEADER;
|
||||
@@ -268,7 +277,10 @@ export function serializeMemoryMd(entries: MemoryEntry[]): string {
|
||||
for (const entry of entries) {
|
||||
if (!entry.content.trim()) continue;
|
||||
const tagsStr = entry.tags.join(', ');
|
||||
content += `## ${entry.type} | id: ${entry.id} | importance: ${entry.importance} | tags: ${tagsStr}\n`;
|
||||
const statsSuffix = (entry.accessCount && entry.accessCount > 0)
|
||||
? ` | hits: ${entry.accessCount}${entry.lastAccessed ? ` | last: ${entry.lastAccessed}` : ''}`
|
||||
: '';
|
||||
content += `## ${entry.type} | id: ${entry.id} | importance: ${entry.importance} | tags: ${tagsStr}${statsSuffix}\n`;
|
||||
content += entry.content.trim() + '\n\n';
|
||||
}
|
||||
|
||||
@@ -500,6 +512,43 @@ function withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return result;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 条目缓存(模块级单例)
|
||||
// 1. 避免每次搜索/CRUD 都重新读文件并解析
|
||||
// 2. 支持访问统计(hits/last)的延迟持久化(60s 防抖写回)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
let _entriesCache: MemoryEntry[] | null = null;
|
||||
let _hitsFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const HITS_FLUSH_INTERVAL = 60_000;
|
||||
|
||||
/** 使条目缓存失效(memory:init 重建文件 / 工作空间切换后调用) */
|
||||
export function invalidateMemoryCache(): void {
|
||||
_entriesCache = null;
|
||||
}
|
||||
|
||||
/** 访问统计防抖写回:searchMemory 更新内存计数后,延迟 60s 持久化 */
|
||||
function scheduleHitsFlush(): void {
|
||||
if (_hitsFlushTimer) return;
|
||||
_hitsFlushTimer = setTimeout(async () => {
|
||||
_hitsFlushTimer = null;
|
||||
try {
|
||||
if (_entriesCache && _entriesCache.length > 0) {
|
||||
await writeMemoryFile(serializeMemoryMd(_entriesCache));
|
||||
}
|
||||
} catch {
|
||||
// 写回失败不影响主流程,下次访问会再次调度
|
||||
}
|
||||
}, HITS_FLUSH_INTERVAL);
|
||||
}
|
||||
|
||||
/** 写入条目(同步更新缓存) */
|
||||
async function persistEntries(entries: MemoryEntry[]): Promise<void> {
|
||||
_entriesCache = entries;
|
||||
const fileContent = entries.length > 0 ? serializeMemoryMd(entries) : '';
|
||||
await writeMemoryFile(fileContent);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CRUD 操作(全部通过专用 IPC 通道读写 MEMORY.md)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -520,20 +569,26 @@ function generateMemoryId(existingIds?: Set<string>): string {
|
||||
return `mem_${dateStr}_${String(now.getTime() % 1000).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
/** 加载全部条目 */
|
||||
/** 加载全部条目(带缓存:读文件+解析一次,后续访问走内存) */
|
||||
export async function loadAllEntries(): Promise<MemoryEntry[]> {
|
||||
if (_entriesCache) return _entriesCache;
|
||||
const content = await readMemoryFile();
|
||||
if (!content.trim()) return [];
|
||||
return parseMemoryMd(content);
|
||||
_entriesCache = content.trim() ? parseMemoryMd(content) : [];
|
||||
return _entriesCache;
|
||||
}
|
||||
|
||||
/** 搜索记忆(读取 MEMORY.md → 解析 → 搜索) */
|
||||
/** 搜索记忆(读取缓存 → 搜索 → 更新访问计数并调度延迟写回) */
|
||||
export async function search(query: string, limit = 0): Promise<MemorySearchResult[]> {
|
||||
try {
|
||||
// R57: 触发 TTL 衰减检查(带节流,不会每次搜索都执行)
|
||||
// 触发 TTL 衰减检查(带节流,不会每次搜索都执行)
|
||||
maybeRunTTLDecay().catch(() => {}); // 非阻塞,失败不影响搜索
|
||||
const entries = await loadAllEntries();
|
||||
return searchMemory(entries, query, limit);
|
||||
const results = searchMemory(entries, query, limit);
|
||||
if (results.length > 0) {
|
||||
// 访问计数已在缓存条目上更新,延迟持久化
|
||||
scheduleHitsFlush();
|
||||
}
|
||||
return results;
|
||||
} catch (err) {
|
||||
logWarn('记忆搜索失败', (err as Error).message);
|
||||
return [];
|
||||
@@ -611,7 +666,7 @@ export async function addEntry(
|
||||
throw new Error(`序列化后校验失败: ${validation.error}`);
|
||||
}
|
||||
|
||||
await writeMemoryFile(fileContent);
|
||||
await persistEntries(entries);
|
||||
logMemory(`新增: ${type}`, content.slice(0, 60));
|
||||
return entry;
|
||||
});
|
||||
@@ -659,7 +714,7 @@ export async function replaceEntry(oldText: string, newContent: string): Promise
|
||||
return { success: false, message: `序列化后校验失败: ${validation.error}` };
|
||||
}
|
||||
|
||||
await writeMemoryFile(fileContent);
|
||||
await persistEntries(entries);
|
||||
logMemory('替换记忆', `${target.id}: ${oldText.slice(0, 30)} → ${newContent.slice(0, 30)}`);
|
||||
return { success: true, message: `已替换记忆: ${target.id}` };
|
||||
});
|
||||
@@ -687,14 +742,7 @@ export async function removeEntry(oldText: string): Promise<{ success: boolean;
|
||||
}
|
||||
|
||||
const newEntries = entries.filter(e => e.id !== matches[0].id);
|
||||
const fileContent = newEntries.length > 0 ? serializeMemoryMd(newEntries) : '';
|
||||
|
||||
if (fileContent) {
|
||||
await writeMemoryFile(fileContent);
|
||||
} else {
|
||||
// 清空文件(写入空内容让主进程删除或留空)
|
||||
await writeMemoryFile('');
|
||||
}
|
||||
await persistEntries(newEntries);
|
||||
|
||||
logMemory('删除记忆', `${matches[0].id}: ${oldText.slice(0, 50)}`);
|
||||
return { success: true, message: `已删除记忆: ${matches[0].id}` };
|
||||
@@ -756,8 +804,8 @@ export async function removeEntries(oldTexts: string[]): Promise<{
|
||||
}
|
||||
|
||||
const newEntries = entries.filter(e => !idsToDelete.has(e.id));
|
||||
const fileContent = newEntries.length > 0 ? serializeMemoryMd(newEntries) : '';
|
||||
await writeMemoryFile(fileContent);
|
||||
|
||||
await persistEntries(newEntries);
|
||||
|
||||
logMemory('批量删除', `删除 ${deleted} 条, 失败 ${failed} 条`);
|
||||
return {
|
||||
@@ -770,10 +818,26 @@ export async function removeEntries(oldTexts: string[]): Promise<{
|
||||
});
|
||||
}
|
||||
|
||||
/** 按 ID 精确删除记忆(UI 面板使用,避免子串误匹配) */
|
||||
export async function removeEntryById(id: string): Promise<{ success: boolean; message: string }> {
|
||||
return withWriteLock(async () => {
|
||||
const entries = await loadAllEntries();
|
||||
const target = entries.find(e => e.id === id);
|
||||
if (!target) {
|
||||
return { success: false, message: `记忆 ${id} 不存在` };
|
||||
}
|
||||
const newEntries = entries.filter(e => e.id !== id);
|
||||
await persistEntries(newEntries);
|
||||
logMemory('删除记忆', `${id}: ${target.content.slice(0, 50)}`);
|
||||
return { success: true, message: `已删除记忆: ${id}` };
|
||||
});
|
||||
}
|
||||
|
||||
/** 清空所有记忆
|
||||
* M8: 使用写入锁串行化 */
|
||||
export async function clearAll(): Promise<void> {
|
||||
return withWriteLock(async () => {
|
||||
_entriesCache = [];
|
||||
await writeMemoryFile('');
|
||||
logMemory('清空', '所有记忆已删除');
|
||||
});
|
||||
@@ -1023,8 +1087,7 @@ async function maybeRunTTLDecay(): Promise<void> {
|
||||
|
||||
const { decayed, removed, changed } = applyTTLDecay(entries);
|
||||
if (changed && removed > 0) {
|
||||
const fileContent = decayed.length > 0 ? serializeMemoryMd(decayed) : '';
|
||||
await writeMemoryFile(fileContent);
|
||||
await persistEntries(decayed);
|
||||
logMemory('TTL 衰减', `已持久化: 移除 ${removed} 条,剩余 ${decayed.length} 条`);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -1249,6 +1312,8 @@ export async function initMemoryFile(): Promise<{ action: string; existed: boole
|
||||
return { action: 'failed', existed: false, valid: false };
|
||||
}
|
||||
|
||||
// 主进程可能重建了文件(格式错误时备份+重建),使条目缓存失效
|
||||
invalidateMemoryCache();
|
||||
// 日志已由主进程打印,渲染进程补充简要汇总
|
||||
switch (result.action) {
|
||||
case 'created':
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Result Formatter — 工具结果 → 模型友好格式
|
||||
* 从 agent-engine.ts 拆分的纯格式化模块(无状态,便于测试与复用)。
|
||||
*/
|
||||
|
||||
import type { ToolResult } from '../types.js';
|
||||
|
||||
/** 生成工具审计摘要 — 用于审计日志记录 */
|
||||
function summarizeAuditResult(toolName: string, result: ToolResult): string {
|
||||
try {
|
||||
switch (toolName) {
|
||||
case 'write_file':
|
||||
return `写入 ${result.path || ''} (${result.bytesWritten || 0}B${result.created ? ', 新建' : ''})`;
|
||||
case 'edit_file':
|
||||
return `编辑 ${result.path || ''} (${result.replaceCount || 0} 处替换)`;
|
||||
case 'delete_file':
|
||||
return result.batch ? `批量删除 ${result.successCount}/${result.totalPaths}` : `删除 ${result.path || ''}`;
|
||||
case 'create_directory':
|
||||
return `创建目录 ${result.path || ''}`;
|
||||
case 'move_file':
|
||||
return `移动 ${(result as Record<string, unknown>).source} → ${(result as Record<string, unknown>).destination}`;
|
||||
case 'copy_file':
|
||||
return `复制 ${(result as Record<string, unknown>).source} → ${(result as Record<string, unknown>).destination}`;
|
||||
case 'run_command':
|
||||
return `命令执行 ${result.exitCode === 0 ? '成功' : '失败'} (exit ${result.exitCode})`;
|
||||
case 'git':
|
||||
return `git ${result.action}`;
|
||||
case 'download_file':
|
||||
return `下载 ${(result as Record<string, unknown>).url} → ${(result as Record<string, unknown>).destination}`;
|
||||
case 'compress':
|
||||
return `${result.action} → ${(result as Record<string, unknown>).outputPath || (result as Record<string, unknown>).destination}`;
|
||||
default:
|
||||
return `${toolName} 完成`;
|
||||
}
|
||||
} catch {
|
||||
return `${toolName} 完成`;
|
||||
}
|
||||
}
|
||||
|
||||
export { summarizeAuditResult };
|
||||
|
||||
/** 格式化工具结果的通用默认路径 */
|
||||
function formatDefaultToolResult(toolName: string, result: ToolResult): string {
|
||||
const clean: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(result)) {
|
||||
if (k === 'success' || k === 'formatted' || k === 'content_type' ||
|
||||
k === 'status' || k === 'length' || k === 'isDirectory') continue;
|
||||
clean[k] = v;
|
||||
}
|
||||
return JSON.stringify(clean);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化工具结果,生成模型友好的简洁表示
|
||||
*/
|
||||
export function formatToolResultForModel(toolName: string, result: ToolResult): string {
|
||||
if (!result.success) {
|
||||
return JSON.stringify({ success: false, error: result.error || '工具执行失败' });
|
||||
}
|
||||
|
||||
switch (toolName) {
|
||||
case 'web_search': {
|
||||
const raw = result.results as Array<{ title: string; url: string; snippet: string }> | undefined;
|
||||
if (!raw?.length) return JSON.stringify({ success: true, message: '未找到结果' });
|
||||
const top = raw.map((r, i) =>
|
||||
`[${i + 1}] ${r.title}\n URL: ${r.url}\n ${r.snippet}`
|
||||
).join('\n\n');
|
||||
const fetched = (result as Record<string, unknown>)._fetched as Array<{ url: string; title: string; content: string }> | undefined;
|
||||
const body = JSON.stringify({
|
||||
success: true, query: result.query, total: result.total, shown: raw.length, results: top,
|
||||
});
|
||||
if (fetched && fetched.length > 0) {
|
||||
return body + '\n\n' + fetched.map((f, i) =>
|
||||
`\n=== 📄 已抓取 ${i + 1}/${fetched.length}: ${f.title} ===\n${f.content}\n`
|
||||
).join('\n---\n');
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
case 'web_fetch': {
|
||||
const content = (result.content as string) || '';
|
||||
return JSON.stringify({ success: true, url: result.url, content });
|
||||
}
|
||||
|
||||
case 'read_file': {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
path: result.path,
|
||||
content: result.content,
|
||||
lines: result.lines,
|
||||
truncated: result.truncated,
|
||||
line_range: result.line_range
|
||||
});
|
||||
}
|
||||
|
||||
case 'read_multiple_files': {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
files: result.files,
|
||||
total: result.total
|
||||
});
|
||||
}
|
||||
|
||||
case 'list_directory': {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
path: result.path,
|
||||
entries: result.entries,
|
||||
total: result.total,
|
||||
truncated: result.truncated
|
||||
});
|
||||
}
|
||||
|
||||
case 'write_file': {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
path: result.path,
|
||||
bytesWritten: result.bytesWritten,
|
||||
created: result.created
|
||||
});
|
||||
}
|
||||
|
||||
case 'run_command': {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: result.exitCode,
|
||||
duration: result.duration
|
||||
});
|
||||
}
|
||||
|
||||
case 'git': {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
action: result.action,
|
||||
output: result.output,
|
||||
branch: result.branch,
|
||||
files: result.files,
|
||||
commits: result.commits
|
||||
});
|
||||
}
|
||||
|
||||
case 'search_files': {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
query: result.query,
|
||||
total_matches: result.total_matches,
|
||||
total_files: result.total_files,
|
||||
results: result.results
|
||||
});
|
||||
}
|
||||
|
||||
case 'memory': {
|
||||
// 去重信号改为软提醒,不触发强制终止
|
||||
if ((result as Record<string, unknown>).duplicate) {
|
||||
return JSON.stringify({ success: true, action: 'add', duplicate: true, message: `${(result as Record<string, unknown>).message || '相同内容已存在'}` });
|
||||
}
|
||||
// read_all / search 结果:包裹在 JSON 中以保持与其他工具一致的格式
|
||||
if ((result as Record<string, unknown>).action === 'read_all') {
|
||||
const entries = ((result as Record<string, unknown>).entries || []) as Array<{ id: string; type: string; content: string; importance: number; tags: string[] }>;
|
||||
if (entries.length === 0) return JSON.stringify({ success: true, action: 'read_all', message: '记忆为空,没有任何已保存的记忆条目。', total: 0 });
|
||||
const grouped: Record<string, typeof entries> = {};
|
||||
for (const e of entries) {
|
||||
const t = e.type || 'fact';
|
||||
(grouped[t] ||= []).push(e);
|
||||
}
|
||||
const lines: string[] = [`[记忆读取结果] 共 ${entries.length} 条记忆,按类型分组:`];
|
||||
const typeLabels: Record<string, string> = { rule: '规则(必须遵守)', preference: '偏好', fact: '事实' };
|
||||
for (const [t, items] of Object.entries(grouped)) {
|
||||
lines.push(`\n--- ${typeLabels[t] || t} ---`);
|
||||
for (const e of items) {
|
||||
lines.push(` • [${e.type}] ${e.content}(重要性:${e.importance}, 标签: ${(e.tags || []).join(', ') || '无'})`);
|
||||
}
|
||||
}
|
||||
return JSON.stringify({ success: true, action: 'read_all', formatted: lines.join('\n'), total: entries.length });
|
||||
}
|
||||
if ((result as Record<string, unknown>).action === 'search') {
|
||||
const results = ((result as Record<string, unknown>).results || []) as Array<{ id: string; type: string; content: string; importance: number; score: number }>;
|
||||
if (results.length === 0) return JSON.stringify({ success: true, action: 'search', message: '未找到匹配的记忆。', total: 0 });
|
||||
const lines = [`[记忆搜索结果] 共 ${results.length} 条:`];
|
||||
for (const r of results) {
|
||||
lines.push(` • [${r.type || 'fact'}] ${r.content}(重要性:${r.importance}, 匹配度:${(r.score || 0).toFixed(0)})`);
|
||||
}
|
||||
return JSON.stringify({ success: true, action: 'search', formatted: lines.join('\n'), total: results.length });
|
||||
}
|
||||
// remove_batch 结果:格式化每条匹配情况
|
||||
if ((result as Record<string, unknown>).action === 'remove_batch') {
|
||||
const items = ((result as Record<string, unknown>).results || []) as Array<{ old_text: string; matched: boolean; entry_id?: string; error?: string }>;
|
||||
const deleted = (result as Record<string, unknown>).deleted as number || 0;
|
||||
const failed = (result as Record<string, unknown>).failed as number || 0;
|
||||
const lines = [`[批量删除结果] 成功 ${deleted} 条${failed > 0 ? `, 失败 ${failed} 条` : ''}:`];
|
||||
for (const item of items) {
|
||||
if (item.matched) {
|
||||
lines.push(` ✅ "${item.old_text}" → 已删除 (${item.entry_id})`);
|
||||
} else {
|
||||
lines.push(` ❌ "${item.old_text}" → ${item.error || '失败'}`);
|
||||
}
|
||||
}
|
||||
return JSON.stringify({ success: (result as Record<string, unknown>).success, action: 'remove_batch', formatted: lines.join('\n'), deleted, failed });
|
||||
}
|
||||
// 其他 action(add/replace/remove)→ 保留完整 JSON,走 default 逻辑
|
||||
return formatDefaultToolResult(toolName, result);
|
||||
}
|
||||
|
||||
case 'delete_file': {
|
||||
// 批量删除
|
||||
if ((result as Record<string, unknown>).batch) {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
message: `批量删除完成:成功 ${result.successCount}/${result.totalPaths} 个路径`,
|
||||
batch: true,
|
||||
totalPaths: result.totalPaths,
|
||||
successCount: result.successCount,
|
||||
failCount: result.failCount,
|
||||
results: result.results,
|
||||
});
|
||||
}
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
message: `已删除${(result as Record<string, unknown>).type === 'directory' ? '目录' : '文件'}:${result.path}`,
|
||||
path: result.path,
|
||||
deleted: true,
|
||||
type: (result as Record<string, unknown>).type,
|
||||
deletedSize: result.deletedSize,
|
||||
...((result as Record<string, unknown>).filesDeleted !== undefined && { filesDeleted: (result as Record<string, unknown>).filesDeleted }),
|
||||
});
|
||||
}
|
||||
|
||||
case 'create_directory': {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
message: `目录已创建:${result.path}`,
|
||||
path: result.path,
|
||||
created: (result as Record<string, unknown>).created,
|
||||
});
|
||||
}
|
||||
|
||||
case 'move_file': {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
message: `已移动:${(result as Record<string, unknown>).source} → ${(result as Record<string, unknown>).destination}`,
|
||||
source: (result as Record<string, unknown>).source,
|
||||
destination: (result as Record<string, unknown>).destination,
|
||||
});
|
||||
}
|
||||
|
||||
case 'copy_file': {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
message: `已复制:${(result as Record<string, unknown>).source} → ${(result as Record<string, unknown>).destination}`,
|
||||
source: (result as Record<string, unknown>).source,
|
||||
destination: (result as Record<string, unknown>).destination,
|
||||
bytesCopied: (result as Record<string, unknown>).bytesCopied,
|
||||
});
|
||||
}
|
||||
|
||||
case 'download_file': {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
message: `已下载:${(result as Record<string, unknown>).url} → ${(result as Record<string, unknown>).destination}`,
|
||||
url: (result as Record<string, unknown>).url,
|
||||
destination: (result as Record<string, unknown>).destination,
|
||||
bytesDownloaded: (result as Record<string, unknown>).bytesDownloaded,
|
||||
});
|
||||
}
|
||||
|
||||
case 'compress': {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
message: `已压缩:${(result as Record<string, unknown>).outputPath}`,
|
||||
outputPath: (result as Record<string, unknown>).outputPath,
|
||||
originalSize: (result as Record<string, unknown>).originalSize,
|
||||
compressedSize: (result as Record<string, unknown>).compressedSize,
|
||||
filesProcessed: (result as Record<string, unknown>).filesProcessed,
|
||||
});
|
||||
}
|
||||
|
||||
case 'diff': {
|
||||
if ((result as Record<string, unknown>).identical) {
|
||||
return JSON.stringify({ success: true, identical: true, message: '文件内容完全相同,无差异' });
|
||||
}
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
mode: (result as Record<string, unknown>).mode,
|
||||
path1: (result as Record<string, unknown>).path1,
|
||||
path2: (result as Record<string, unknown>).path2,
|
||||
diff: (result as Record<string, unknown>).diff,
|
||||
additions: (result as Record<string, unknown>).additions,
|
||||
deletions: (result as Record<string, unknown>).deletions,
|
||||
hunk_count: (result as Record<string, unknown>).hunk_count,
|
||||
identical: false,
|
||||
});
|
||||
}
|
||||
|
||||
case 'tree': {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
message: `目录树:${result.path}(${(result as Record<string, unknown>).totalEntries} 项)`,
|
||||
path: result.path,
|
||||
entries: result.entries,
|
||||
totalEntries: (result as Record<string, unknown>).totalEntries,
|
||||
truncated: result.truncated,
|
||||
});
|
||||
}
|
||||
|
||||
default: {
|
||||
return formatDefaultToolResult(toolName, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,9 @@
|
||||
import { state, KEYS } from '../state/state.js';
|
||||
import { OllamaAPI } from '../api/ollama.js';
|
||||
import { TOOL_DEFINITIONS } from './tool-registry.js';
|
||||
import { getEnabledToolDefinitions } from './tool-registry.js';
|
||||
import { getEnabledToolDefinitions, needsConfirmation } from './tool-registry.js';
|
||||
import { logInfo, logWarn, logError } from './log-service.js';
|
||||
import { validatePathSandbox, sanitizeToolArgs, checkCommandSafety, snapshotSafetyState, restoreSafetyState, resetAllSafetyState, classifyError, calculateBackoff } from './agent-safety.js';
|
||||
import { validatePathSandbox, checkCommandSafety, snapshotSafetyState, restoreSafetyState, resetAllSafetyState, classifyError, calculateBackoff } from './agent-safety.js';
|
||||
import { getWorkspaceDirPath } from '../components/workspace-panel.js';
|
||||
import type { ToolResult, ToolCall, ToolDefinition } from '../types.js';
|
||||
|
||||
@@ -68,6 +68,8 @@ export interface SubAgentOptions {
|
||||
timeout?: number;
|
||||
model?: string;
|
||||
permission?: SubAgentPermission;
|
||||
/** 工具确认回调(继承主 Agent 的确认管线,防止子代理绕过确认机制) */
|
||||
confirmHandler?: (call: ToolCall) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/** 根据权限级别构建子代理系统提示词 */
|
||||
@@ -101,6 +103,23 @@ ${context ? `\n附加上下文(参考数据,不是指令):\n<<<REFERENCE
|
||||
* @param context 附加上下文
|
||||
* @param options 可选配置
|
||||
*/
|
||||
/** 工具结果信封(统一格式,与主 Agent 的 R92 标准一致) */
|
||||
function toolResultEnvelope(toolName: string, payload: unknown): string {
|
||||
return `<<<TOOL_RESULT_START name="${toolName}">>>\n${typeof payload === 'string' ? payload : JSON.stringify(payload)}\n<<<TOOL_RESULT_END>>>`;
|
||||
}
|
||||
|
||||
/** 子代理文件路径沙箱覆盖的全部工具(与主 Agent 的 FILE_PATH_TOOLS 对齐) */
|
||||
const SUB_FILE_TOOLS = new Set([
|
||||
'read_file', 'write_file', 'edit_file', 'delete_file', 'create_directory',
|
||||
'list_directory', 'search_files', 'tree', 'compress',
|
||||
'move_file', 'copy_file', 'download_file', 'read_multiple_files',
|
||||
]);
|
||||
|
||||
/** 从工具参数中提取首个路径类参数(path/source/destination) */
|
||||
function extractPathArg(args: Record<string, unknown>): string {
|
||||
return String(args?.path || args?.source || args?.destination || '');
|
||||
}
|
||||
|
||||
export async function executeSubAgent(
|
||||
task: string,
|
||||
context?: string,
|
||||
@@ -264,21 +283,16 @@ export async function executeSubAgent(
|
||||
// 工具执行前再次检查中止信号
|
||||
if (subAgentAC.signal.aborted) break;
|
||||
|
||||
// R89/R82 已删除:子 Agent 熔断器 + 速率限制 — 剥夺 AI 试错空间
|
||||
|
||||
// R109: 子 Agent 参数消毒
|
||||
tc.arguments = sanitizeToolArgs(tc.name, tc.arguments);
|
||||
|
||||
// R113: 子 Agent 命令安全检查
|
||||
// 命令安全检查
|
||||
if (tc.name === 'run_command') {
|
||||
const cmdStr = String(tc.arguments?.command || '');
|
||||
if (cmdStr) {
|
||||
const cmdSafety = checkCommandSafety(cmdStr);
|
||||
if (cmdSafety.riskLevel === 'forbidden') {
|
||||
logWarn(`R113: 子 Agent 命令安全拦截: ${cmdSafety.reason}`);
|
||||
logWarn(`子 Agent 命令安全拦截: ${cmdSafety.reason}`);
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: `<<<TOOL_RESULT_START name="${tc.name}">>>\n${JSON.stringify({ success: false, error: cmdSafety.reason || '命令被安全规则拦截' })}\n<<<TOOL_RESULT_END>>>`,
|
||||
content: toolResultEnvelope(tc.name, { success: false, error: cmdSafety.reason || '命令被安全规则拦截' }),
|
||||
tool_name: tc.name
|
||||
});
|
||||
continue;
|
||||
@@ -286,40 +300,55 @@ export async function executeSubAgent(
|
||||
}
|
||||
}
|
||||
|
||||
// R81: 子 Agent 路径沙箱 — 确保文件操作不超出工作空间
|
||||
const SUB_FILE_TOOLS = new Set(['read_file', 'list_directory', 'search_files', 'web_fetch']);
|
||||
// 路径沙箱:确保文件操作不超出工作空间
|
||||
if (SUB_FILE_TOOLS.has(tc.name)) {
|
||||
const wsDir = getWorkspaceDirPath();
|
||||
if (wsDir) {
|
||||
const pathArg = String(tc.arguments?.path || '');
|
||||
if (pathArg) {
|
||||
const sandbox = validatePathSandbox(pathArg, wsDir);
|
||||
if (!sandbox.valid) {
|
||||
logWarn(`R81: 子 Agent 路径沙箱拦截: ${tc.name}(${pathArg}) — ${sandbox.reason}`);
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: `<<<TOOL_RESULT_START name="${tc.name}">>>\n${JSON.stringify({ success: false, error: sandbox.reason || '路径不在工作空间范围内' })}\n<<<TOOL_RESULT_END>>>`,
|
||||
tool_name: tc.name
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const pathArg = extractPathArg(tc.arguments);
|
||||
if (wsDir && pathArg) {
|
||||
const sandbox = validatePathSandbox(pathArg, wsDir);
|
||||
if (!sandbox.valid) {
|
||||
logWarn(`子 Agent 路径沙箱拦截: ${tc.name}(${pathArg}) — ${sandbox.reason}`);
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: toolResultEnvelope(tc.name, { success: false, error: sandbox.reason || '路径不在工作空间范围内' }),
|
||||
tool_name: tc.name
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 确认管线:子代理的写类工具与主 Agent 共用确认机制,
|
||||
// 防止借道子代理绕过用户确认(无确认回调时默认拒绝)
|
||||
if (needsConfirmation(tc.name)) {
|
||||
const callObj: ToolCall = { type: 'function', function: { name: tc.name, arguments: tc.arguments } };
|
||||
const confirmed = options.confirmHandler ? await options.confirmHandler(callObj) : false;
|
||||
if (!confirmed) {
|
||||
logWarn(`子 Agent 工具被用户取消: ${tc.name}`);
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: toolResultEnvelope(tc.name, { success: false, error: '用户取消了操作' }),
|
||||
tool_name: tc.name
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// 确认期间用户可能中止了整个 Agent
|
||||
if (subAgentAC.signal.aborted) break;
|
||||
}
|
||||
|
||||
try {
|
||||
const { executeTool } = await import('./tool-registry.js');
|
||||
const result = await executeTool(tc.name, tc.arguments);
|
||||
const resultStr = formatResult(tc.name, result);
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: `<<<TOOL_RESULT_START name="${tc.name}">>>\n${resultStr}\n<<<TOOL_RESULT_END>>>`,
|
||||
content: toolResultEnvelope(tc.name, resultStr),
|
||||
tool_name: tc.name
|
||||
});
|
||||
} catch (err) {
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: `<<<TOOL_RESULT_START name="${tc.name}">>>\n${JSON.stringify({ success: false, error: (err as Error).message })}\n<<<TOOL_RESULT_END>>>`,
|
||||
content: toolResultEnvelope(tc.name, { success: false, error: (err as Error).message }),
|
||||
tool_name: tc.name
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Tool Parsing — 模型文本输出的工具调用解析(兜底)
|
||||
* 从 agent-engine.ts 拆分的纯解析模块。
|
||||
*
|
||||
* 覆盖场景:模型未通过原生 tool_calls 字段返回,而是在文本中书写工具调用。
|
||||
* 支持四种格式:Action/Action Input、<tool_call> XML、```json 代码块、函数调用语法。
|
||||
*/
|
||||
|
||||
import { logInfo, logWarn } from './log-service.js';
|
||||
import { TOOL_DEFINITIONS } from './tool-registry.js';
|
||||
import type { ToolCall } from '../types.js';
|
||||
|
||||
/** 工具名白名单:从注册表派生(含 MCP 工具),不再手工维护 */
|
||||
const VALID_TOOL_NAMES: Set<string> = new Set(TOOL_DEFINITIONS.map(d => d.function.name));
|
||||
|
||||
function isValidToolName(name: string): boolean {
|
||||
return VALID_TOOL_NAMES.has(name) || name.startsWith('mcp_');
|
||||
}
|
||||
|
||||
export function parseToolCallsFromText(content: string): ToolCall[] {
|
||||
const calls: ToolCall[] = [];
|
||||
|
||||
// 辅助函数:尝试解析 JSON 参数字符串,容错处理
|
||||
const tryParseArgs = (argsStr: string): Record<string, unknown> | null => {
|
||||
const TICK = String.fromCharCode(96);
|
||||
const tickJson = TICK + TICK + TICK + 'json';
|
||||
const tick3 = TICK + TICK + TICK;
|
||||
try {
|
||||
let cleaned = argsStr.split(tickJson).join('').split(tick3).join('').trim();
|
||||
return JSON.parse(cleaned);
|
||||
} catch {
|
||||
try {
|
||||
let fixed = argsStr
|
||||
.replace(/'/g, '"')
|
||||
.replace(/,\s*}/g, '}')
|
||||
.replace(/,\s*]/g, ']')
|
||||
.split(tickJson).join('')
|
||||
.split(tick3).join('')
|
||||
.trim();
|
||||
return JSON.parse(fixed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 辅助函数:验证工具名并添加到结果
|
||||
const tryAddCall = (toolName: string, argsStr: string): boolean => {
|
||||
toolName = toolName.trim();
|
||||
if (!isValidToolName(toolName)) return false;
|
||||
const args = tryParseArgs(argsStr);
|
||||
if (!args) {
|
||||
logWarn('文本解析兜底: 工具 ' + toolName + ' 的参数 JSON 解析失败', argsStr.slice(0, 100));
|
||||
return false;
|
||||
}
|
||||
calls.push({ type: 'function', function: { name: toolName, arguments: args } });
|
||||
return true;
|
||||
};
|
||||
|
||||
// ── 格式1: Action/Action Input ──
|
||||
const actionRegex = /\*{0,2}Action:?\*{0,2}\s*(\w+)\s+[\r\n\s]*\*{0,2}Action\s*Input:?\*{0,2}\s*(\{[\s\S]*?\})/gi;
|
||||
let match;
|
||||
while ((match = actionRegex.exec(content)) !== null) {
|
||||
tryAddCall(match[1], match[2]);
|
||||
}
|
||||
|
||||
// ── 格式2: <tool_call> XML 标签 ──
|
||||
const xmlRegex = /<tool_call>\s*([\s\S]*?)<\/tool_call>/gi;
|
||||
while ((match = xmlRegex.exec(content)) !== null) {
|
||||
const inner = match[1].trim().replace(/```json\s*/g, '').replace(/```/g, '').trim();
|
||||
try {
|
||||
const parsed = JSON.parse(inner);
|
||||
const toolName = parsed.name || parsed.function?.name || '';
|
||||
const toolArgs = parsed.arguments || parsed.function?.arguments || parsed.parameters || {};
|
||||
if (toolName && isValidToolName(toolName)) {
|
||||
calls.push({ type: 'function', function: { name: toolName, arguments: toolArgs } });
|
||||
}
|
||||
} catch {
|
||||
// JSON 解析失败,尝试分别提取 name 和 arguments
|
||||
const nameMatch = inner.match(/"name"\s*:\s*"(\w+)"/i);
|
||||
if (nameMatch) {
|
||||
const argsMatch = inner.match(/"arguments"\s*:\s*(\{[\s\S]*\})/i);
|
||||
if (argsMatch) tryAddCall(nameMatch[1], argsMatch[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 格式3: ```json 代码块中含 "name" 字段 ──
|
||||
const codeBlockRegex = /```(?:json)?\s*(\{[\s\S]*?"name"\s*:\s*"\w+"[\s\S]*?\})\s*```/gi;
|
||||
while ((match = codeBlockRegex.exec(content)) !== null) {
|
||||
const jsonStr = match[1].trim();
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
const toolName = parsed.name || '';
|
||||
const toolArgs = parsed.arguments || parsed.parameters || {};
|
||||
if (toolName && isValidToolName(toolName)) {
|
||||
calls.push({ type: 'function', function: { name: toolName, arguments: toolArgs } });
|
||||
}
|
||||
} catch {
|
||||
// 解析失败忽略,其他格式可能匹配
|
||||
}
|
||||
}
|
||||
|
||||
// ── 格式4: 函数调用语法 func_name({"key": "value"}) ──
|
||||
// 使用平衡括号匹配替代 [^}]*,支持嵌套 JSON 如 {"a": {"b": 1}}
|
||||
{
|
||||
const funcCallStart = /\b(\w+)\s*\(\s*\{/g;
|
||||
let fcMatch;
|
||||
while ((fcMatch = funcCallStart.exec(content)) !== null) {
|
||||
const toolName = fcMatch[1];
|
||||
const braceStart = fcMatch.index + fcMatch[0].length - 1; // 指向 '{'
|
||||
// 手动平衡匹配大括号
|
||||
let depth = 0;
|
||||
let endIdx = -1;
|
||||
let inString = false;
|
||||
let escapeNext = false;
|
||||
for (let i = braceStart; i < content.length; i++) {
|
||||
const ch = content[i];
|
||||
if (escapeNext) { escapeNext = false; continue; }
|
||||
if (ch === '\\') { escapeNext = true; continue; }
|
||||
if (ch === '"') { inString = !inString; continue; }
|
||||
if (inString) continue;
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) { endIdx = i; break; }
|
||||
}
|
||||
}
|
||||
if (endIdx > 0) {
|
||||
const jsonStr = content.slice(braceStart, endIdx + 1);
|
||||
// 检查后面是否有闭合括号
|
||||
const afterClose = content.slice(endIdx + 1).match(/^\s*\)/);
|
||||
if (afterClose) {
|
||||
tryAddCall(toolName, jsonStr);
|
||||
// 移动 regex 位置到匹配结束后
|
||||
funcCallStart.lastIndex = endIdx + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (calls.length > 0) {
|
||||
logInfo('文本解析兜底: 从回复中提取到 ' + calls.length + ' 个工具调用', calls.map(c => c.function.name).join(', '));
|
||||
}
|
||||
|
||||
return calls;
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
* 管理所有可用工具的定义,负责执行调度
|
||||
*/
|
||||
|
||||
import type { ToolDefinition, ToolResult } from '../types.js';
|
||||
import type { ToolDefinition, ToolResult, ToolCall } from '../types.js';
|
||||
import type { SubAgentPermission } from './sub-agent.js';
|
||||
import { state, KEYS } from '../state/state.js';
|
||||
import { logToolStart, logToolResult, logError, logInfo, logWarn } from './log-service.js';
|
||||
import { getMCPToolDefinitions } from './mcp-client.js';
|
||||
@@ -577,7 +578,8 @@ permission: { type: 'string', enum: ['readonly', 'limited_write', 'full_write'],
|
||||
|
||||
// 支持三档开关的工具列表(auto/confirm/disabled)
|
||||
// 浏览器工具不需要确认,永远自动执行
|
||||
const MODE_TOOLS = [
|
||||
// 导出供 tools-modal.ts 等 UI 复用,保持单一事实来源
|
||||
export const MODE_TOOLS = [
|
||||
'run_command',
|
||||
'write_file', 'create_directory', 'delete_file',
|
||||
'edit_file', 'move_file', 'copy_file',
|
||||
@@ -586,6 +588,17 @@ const MODE_TOOLS = [
|
||||
|
||||
export type ToolMode = 'auto' | 'confirm' | 'disabled';
|
||||
|
||||
/**
|
||||
* 子代理工具确认回调(由 agent-engine 在主循环生命周期内设置)。
|
||||
* 子代理与主 Agent 共用同一确认管线:全局模式为 confirm 时,
|
||||
* 子代理的写类工具同样需要用户确认,防止借道子代理绕过确认机制。
|
||||
*/
|
||||
let _subAgentConfirmHandler: ((call: ToolCall) => Promise<boolean>) | null = null;
|
||||
|
||||
export function setSubAgentConfirmHandler(handler: ((call: ToolCall) => Promise<boolean>) | null): void {
|
||||
_subAgentConfirmHandler = handler;
|
||||
}
|
||||
|
||||
// 工具模式缓存:key=工具名, value=模式
|
||||
const _toolModes = new Map<string, ToolMode>();
|
||||
|
||||
@@ -627,7 +640,7 @@ let enabledTools: Set<string> = new Set([
|
||||
'run_command',
|
||||
'move_file', 'copy_file', 'web_fetch', 'web_search', 'edit_file',
|
||||
'tree', 'download_file',
|
||||
'read_multiple_files', 'git', 'compress',
|
||||
'read_multiple_files', 'git', 'compress', 'diff',
|
||||
'memory',
|
||||
'session_list', 'session_read', 'spawn_task',
|
||||
'browser_open', 'browser_screenshot', 'browser_evaluate', 'browser_extract',
|
||||
@@ -1053,14 +1066,18 @@ export function truncateToolResult(result: ToolResult, toolName: string): ToolRe
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 兜底:如果经过上述截断后仍然超限,对整个 JSON 暴力截断
|
||||
// 3. 兜底:如果经过上述截断后仍然超限,直接返回结构化预览对象。
|
||||
// (旧实现拼接非法 JSON 后 JSON.parse 必然抛出异常)
|
||||
let finalStr = JSON.stringify(truncated);
|
||||
if (finalStr.length > MAX_TOOL_RESULT_CHARS) {
|
||||
const head = finalStr.slice(0, MAX_TOOL_RESULT_CHARS - 500);
|
||||
const tail = finalStr.slice(-300);
|
||||
const omitted = finalStr.length - (MAX_TOOL_RESULT_CHARS - 200);
|
||||
logWarn(`R22: 工具 ${toolName} 结果过大,暴力截断 (${jsonStr.length} → ~${MAX_TOOL_RESULT_CHARS} 字符)`);
|
||||
return JSON.parse(head + `"... [已暴力截断 ${omitted} 字符] ..."}` + tail) as ToolResult;
|
||||
const omitted = finalStr.length - MAX_TOOL_RESULT_CHARS;
|
||||
logWarn(`R22: 工具 ${toolName} 结果过大,暴力截断 (${finalStr.length} → ${MAX_TOOL_RESULT_CHARS} 字符)`);
|
||||
return {
|
||||
success: truncated.success,
|
||||
_truncated: true,
|
||||
preview: finalStr.slice(0, MAX_TOOL_RESULT_CHARS),
|
||||
_omitted_chars: omitted,
|
||||
} as ToolResult;
|
||||
}
|
||||
|
||||
logWarn(`R22: 工具 ${toolName} 结果已截断 (${jsonStr.length} → ~${finalStr.length} 字符)`);
|
||||
@@ -1388,21 +1405,22 @@ const results = await search(query, limit);
|
||||
if (toolName === 'session_list') {
|
||||
const bridge = window.metonaDesktop;
|
||||
if (!bridge?.db) return { success: false, error: '桌面 API 不可用' };
|
||||
const limit = (args.limit as number) || 0; // 0 = 不限制
|
||||
const limit = (args.limit as number) || 0;
|
||||
const search = (args.search as string) || '';
|
||||
const sessions = await bridge.db.getAllSessions();
|
||||
let filtered = sessions.map((s: any) => ({
|
||||
// 使用会话摘要(单条 SQL),避免为列表工具全量加载所有消息
|
||||
const summaries = await bridge.db.getSessionSummaries();
|
||||
let filtered = summaries.map((s: { id: string; title: string; model: string; message_count: number; created_at: number; updated_at: number }) => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
model: s.model,
|
||||
messageCount: 0, // 从 SQLite 获取的原始行不含 messages
|
||||
messageCount: s.message_count,
|
||||
createdAt: s.created_at,
|
||||
updatedAt: s.updated_at
|
||||
}));
|
||||
if (search) {
|
||||
filtered = filtered.filter((s: any) => s.title.toLowerCase().includes(search.toLowerCase()));
|
||||
filtered = filtered.filter(s => s.title.toLowerCase().includes(search.toLowerCase()));
|
||||
}
|
||||
filtered.sort((a: any, b: any) => b.updatedAt - a.updatedAt);
|
||||
filtered.sort((a: { updatedAt: number }, b: { updatedAt: number }) => b.updatedAt - a.updatedAt);
|
||||
if (limit > 0) filtered = filtered.slice(0, limit);
|
||||
logToolResult('session_list', true, `${filtered.length} 个会话`);
|
||||
return { success: true, sessions: filtered, total: filtered.length };
|
||||
@@ -1437,10 +1455,17 @@ const results = await search(query, limit);
|
||||
// 设置面板存的模型名直接使用(面板加载时已验证过列表)
|
||||
model = configuredModel;
|
||||
}
|
||||
if (!task) return { success: false, error: '缺少 task 参数' };
|
||||
const permission = (args.permission as 'readonly' | 'limited_write' | 'full_write' | undefined) ?? 'readonly';
|
||||
logInfo(`子代理委派: ${task.slice(0, 80)}${model ? ` (模型: ${model})` : ' (跟随当前模型)'} (权限: ${permission})`);
|
||||
const result = await executeSubAgent(task, context, { model, permission });
|
||||
if (!task) return { success: false, error: '缺少 task 参数' };
|
||||
// 权限上限:AI 请求的权限只降不升,封顶于用户设置的 subAgentMaxPermission。
|
||||
// 防止提示注入让 AI 自授 full_write 绕过权限分级。
|
||||
const PERMISSION_RANK: Record<SubAgentPermission, number> = { readonly: 0, limited_write: 1, full_write: 2 };
|
||||
const requested = (args.permission as SubAgentPermission | undefined) ?? 'readonly';
|
||||
const maxAllowed = state.get<SubAgentPermission>('subAgentMaxPermission', 'readonly');
|
||||
const permission: SubAgentPermission = (PERMISSION_RANK[requested] ?? 0) <= (PERMISSION_RANK[maxAllowed] ?? 0)
|
||||
? requested
|
||||
: maxAllowed;
|
||||
logInfo(`子代理委派: ${task.slice(0, 80)}${model ? ` (模型: ${model})` : ' (跟随当前模型)'} (权限: ${permission}, 上限: ${maxAllowed})`);
|
||||
const result = await executeSubAgent(task, context, { model, permission, confirmHandler: _subAgentConfirmHandler ?? undefined });
|
||||
logToolResult('spawn_task', result.success, result.success ? `完成, ${(result as any).loops} 轮` : result.error);
|
||||
return result;
|
||||
}
|
||||
|
||||
Vendored
+22
-12
@@ -157,6 +157,10 @@ export interface WorkspaceDirResult {
|
||||
export interface MetonaDesktopAPI {
|
||||
isDesktop: boolean;
|
||||
info: () => Promise<AppInfo>;
|
||||
/** 读取应用内置资源(SOUL.md / AGENT.md),basename 防路径穿越 */
|
||||
readAppResource: (name: string) => Promise<{ success: boolean; content?: string; error?: string }>;
|
||||
/** 更新 Ollama 服务地址的 CORS 允许清单 */
|
||||
setOllamaOrigin: (url: string) => Promise<{ success: boolean }>;
|
||||
sys: {
|
||||
homeDir: string;
|
||||
tmpDir: string;
|
||||
@@ -468,18 +472,6 @@ export interface AgentMetrics {
|
||||
collectedAt: number;
|
||||
}
|
||||
|
||||
/** 渐进式披露:上下文层级 */
|
||||
export type ContextTier = 'index' | 'interface' | 'implementation';
|
||||
|
||||
/** 项目索引摘要 */
|
||||
export interface ProjectIndex {
|
||||
structure: string;
|
||||
entryFiles: string[];
|
||||
techStack: string[];
|
||||
tokenCount: number;
|
||||
generatedAt: number;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// ReAct Trace 类型 (v4.0)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
@@ -528,16 +520,34 @@ export interface MessageRow {
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
/** 会话摘要行(单条 SQL 聚合,列表/搜索不再全量加载消息) */
|
||||
export interface SessionSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
model: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
message_count: number;
|
||||
}
|
||||
|
||||
export interface DBAPI {
|
||||
saveSession: (session: SessionRow) => Promise<{ success: boolean; id?: string; error?: string }>;
|
||||
getSession: (id: string) => Promise<SessionRow | null>;
|
||||
getAllSessions: () => Promise<SessionRow[]>;
|
||||
/** 会话摘要列表(单条 SQL,历史列表/搜索不再全量加载消息) */
|
||||
getSessionSummaries: () => Promise<SessionSummary[]>;
|
||||
/** 按标题或消息内容搜索会话 */
|
||||
searchSessions: (query: string) => Promise<SessionSummary[]>;
|
||||
/** 全量会话+消息行(导出用,一次 IPC 取代 N+1 往返) */
|
||||
getAllSessionsData: () => Promise<{ sessions: SessionRow[]; messages: MessageRow[] }>;
|
||||
deleteSession: (id: string) => Promise<{ success: boolean; error?: string }>;
|
||||
clearAllSessions: () => Promise<{ success: boolean; error?: string }>;
|
||||
saveMessage: (msg: MessageRow) => Promise<{ success: boolean; id?: string; error?: string }>;
|
||||
saveMessagesBatch: (msgs: MessageRow[]) => Promise<{ success: boolean; count?: number; error?: string }>;
|
||||
getMessages: (sessionId: string) => Promise<MessageRow[]>;
|
||||
saveSetting: (key: string, value: unknown) => Promise<{ success: boolean; error?: string }>;
|
||||
getSetting: <T = unknown>(key: string, defaultValue?: T) => Promise<T>;
|
||||
saveSettingsBatch: (entries: Array<{ key: string; value: unknown }>) => Promise<{ success: boolean; error?: string }>;
|
||||
saveTrace: (trace: unknown) => Promise<{ success: boolean; id?: string; error?: string }>;
|
||||
saveTracesBatch: (traces: unknown[]) => Promise<{ success: boolean; count?: number; error?: string }>;
|
||||
getTraces: (sessionId: string) => Promise<TraceEntry[]>;
|
||||
|
||||
Reference in New Issue
Block a user