diff --git a/README.md b/README.md index 9ae5ffa..31a31db 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

- version + version electron typescript license @@ -245,7 +245,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -产出:`release/Metona Ollama Setup v0.16.15.exe` +产出:`release/Metona Ollama Setup v0.16.16.exe` ## 🛠️ 常用命令 @@ -485,7 +485,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -Output: `release/Metona Ollama Setup v0.16.15.exe` +Output: `release/Metona Ollama Setup v0.16.16.exe` ## 🛠️ Common Commands diff --git a/package-lock.json b/package-lock.json index 1e2e71a..970abef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ollama-desktop", - "version": "0.16.15", + "version": "0.16.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ollama-desktop", - "version": "0.16.15", + "version": "0.16.16", "license": "MIT", "dependencies": { "ffmpeg-static": "^5.2.0", diff --git a/package.json b/package.json index f8debae..381d136 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ollama-desktop", - "version": "0.16.15", + "version": "0.16.16", "description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端", "main": "dist/main/main.js", "author": "thzxx", diff --git a/src/main/menu.ts b/src/main/menu.ts index 83bbd1a..d6b2603 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -101,7 +101,7 @@ export function createMenu(): void { dialog.showMessageBox(mainWindow!, { type: 'info', title: '关于 Metona Ollama', - message: 'Metona Ollama Desktop v0.16.15', + message: 'Metona Ollama Desktop v0.16.16', detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama', icon: getIconPath() }); diff --git a/src/renderer/components/chat-area.ts b/src/renderer/components/chat-area.ts index 805ac50..e45ec49 100644 --- a/src/renderer/components/chat-area.ts +++ b/src/renderer/components/chat-area.ts @@ -662,14 +662,76 @@ export function enableAutoScroll(): void { if (scrollBtnEl) scrollBtnEl.style.display = 'none'; } -// ── R38: 代码块复制按钮 ── +// ── R38: 代码块复制按钮 + 语法高亮 ── -/** R38: 为代码块添加复制按钮 */ +/** 轻量级 JSON 语法高亮 — 检测 JSON 并高亮关键字、字符串、数字 */ +export function highlightJsonContent(text: string): string { + const escaped = escapeHtml(text); + + // 尝试 JSON 解析并格式化 + try { + const parsed = JSON.parse(text); + const formatted = JSON.stringify(parsed, null, 2); + return formatJsonSyntax(escapeHtml(formatted)); + } catch { + // 非 JSON,返回转义后的原文 + return escaped; + } +} + +/** JSON 语法着色 — key/string/number/boolean/null 分别着色 */ +function formatJsonSyntax(escaped: string): string { + // 高亮 JSON key: "key": + let result = escaped.replace( + /"([^&]*?)"\s*:/g, + '"$1":' + ); + // 高亮 string value: : "value" 或 [ "value" + result = result.replace( + /:\s*"([^&]*(?:&|<|>)?[^&]*)"/g, + ': "$1"' + ); + result = result.replace( + /\[\s*"([^&]*(?:&|<|>)?[^&]*)"/g, + '[ "$1"' + ); + // 高亮数字 + result = result.replace( + /:\s*(-?\d+\.?\d*)/g, + ': $1' + ); + // 高亮 boolean + result = result.replace( + /:\s*(true|false)/g, + ': $1' + ); + // 高亮 null + result = result.replace( + /:\s*(null)/g, + ': $1' + ); + return result; +} + +/** 为代码块添加复制按钮 + 语法高亮 */ function addCodeBlockCopyButtons(scope?: ParentNode): void { const root = scope || messagesContainerEl; const codeBlocks = root.querySelectorAll('pre:not([data-copy-added])'); for (const pre of codeBlocks) { pre.setAttribute('data-copy-added', 'true'); + + // 语法高亮:检测 JSON 并格式化 + const codeEl = pre.querySelector('code'); + const rawText = codeEl ? codeEl.textContent : pre.textContent; + if (rawText && rawText.trim().startsWith('{')) { + const highlighted = highlightJsonContent(rawText); + if (codeEl) { + codeEl.innerHTML = highlighted; + } else { + pre.innerHTML = `${highlighted}`; + } + } + const btn = document.createElement('button'); btn.className = 'code-copy-btn'; btn.textContent = '📋 复制'; @@ -741,6 +803,50 @@ function addMessageActions(): void { // ── 导出功能 ── +/** 为工作空间面板的工具结果添加语法高亮和复制按钮 */ +export function addToolResultHighlighting(scope: ParentNode): void { + const resultBlocks = scope.querySelectorAll('pre.tool-result-content:not([data-highlighted])'); + for (const pre of resultBlocks as NodeListOf) { + pre.setAttribute('data-highlighted', 'true'); + + // 确保 pre 是相对定位 + if (getComputedStyle(pre).position === 'static') { + pre.style.position = 'relative'; + } + + // 语法高亮:检测 JSON 并格式化 + const rawText = pre.textContent || ''; + if (rawText.trim().startsWith('{') || rawText.trim().startsWith('[')) { + const highlighted = highlightJsonContent(rawText); + if (highlighted !== escapeHtml(rawText)) { + pre.innerHTML = highlighted; + } + } + + // 复制按钮 + const btn = document.createElement('button'); + btn.className = 'code-copy-btn'; + btn.textContent = '📋 复制'; + btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:4px;cursor:pointer;opacity:0;transition:opacity 0.2s;z-index:10;'; + pre.addEventListener('mouseenter', () => { btn.style.opacity = '1'; }); + pre.addEventListener('mouseleave', () => { btn.style.opacity = '0'; }); + btn.addEventListener('click', (e) => { + e.preventDefault(); + const text = pre.textContent || ''; + if (text) { + navigator.clipboard.writeText(text).then(() => { + btn.textContent = '✅ 已复制'; + setTimeout(() => { btn.textContent = '📋 复制'; }, 2000); + }).catch(() => { + btn.textContent = '❌ 失败'; + setTimeout(() => { btn.textContent = '📋 复制'; }, 2000); + }); + } + }); + pre.appendChild(btn); + } +} + async function nativeSaveFile(defaultName: string, content: string): Promise { const bridge = window.metonaDesktop; if (bridge) { @@ -802,3 +908,175 @@ export async function exportAsTxt(session: ChatSession): Promise { await nativeSaveFile(`${session.title}.txt`, txt); logSession('导出', `${session.title}.txt`); } + +// ── 对话内搜索 ── + +let searchInputEl: HTMLInputElement; +let searchBarEl: HTMLElement; +let searchCountEl: HTMLElement; +let searchMatches: HTMLElement[] = []; +let searchCurrentIndex = -1; + +/** 初始化对话内搜索功能 */ +export function initSearchBar(): void { + searchBarEl = document.querySelector('#searchBar')!; + searchInputEl = document.querySelector('#searchInput') as HTMLInputElement; + searchCountEl = document.querySelector('#searchCount')!; + + document.querySelector('#btnSearch')?.addEventListener('click', toggleSearchBar); + + document.querySelector('#searchClose')?.addEventListener('click', closeSearchBar); + document.querySelector('#searchPrev')?.addEventListener('click', () => navigateMatch(-1)); + document.querySelector('#searchNext')?.addEventListener('click', () => navigateMatch(1)); + + searchInputEl.addEventListener('input', performSearch); + + // Ctrl+F 快捷键 + document.addEventListener('keydown', (e) => { + if (e.ctrlKey && e.key === 'f') { + e.preventDefault(); + toggleSearchBar(); + } + if (e.key === 'Escape' && searchBarEl.style.display !== 'none') { + closeSearchBar(); + } + if (searchBarEl.style.display !== 'none') { + if (e.key === 'Enter') { + e.preventDefault(); + navigateMatch(e.shiftKey ? -1 : 1); + } + } + }); +} + +function toggleSearchBar(): void { + if (searchBarEl.style.display === 'none') { + searchBarEl.style.display = ''; + searchInputEl.focus(); + searchInputEl.select(); + } else { + closeSearchBar(); + } +} + +function closeSearchBar(): void { + searchBarEl.style.display = 'none'; + clearHighlights(); + searchInputEl.value = ''; + searchCountEl.textContent = ''; + searchMatches = []; + searchCurrentIndex = -1; +} + +/** 执行搜索 */ +function performSearch(): void { + clearHighlights(); + const query = searchInputEl.value.trim(); + + if (!query) { + searchCountEl.textContent = ''; + searchMatches = []; + searchCurrentIndex = -1; + return; + } + + const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`(${escapedQuery})`, 'gi'); + + // 在所有消息内容元素中搜索 + const contentEls = messagesContainerEl.querySelectorAll('.msg-content, .msg-thinking, .tool-result-content'); + searchMatches = []; + + contentEls.forEach(el => { + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, { + acceptNode: (node) => { + // 跳过 script/style 标签内的文本 + const parent = node.parentElement; + if (!parent) return NodeFilter.FILTER_REJECT; + if (parent.tagName === 'SCRIPT' || parent.tagName === 'STYLE') return NodeFilter.FILTER_REJECT; + return node.textContent && regex.test(node.textContent) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; + } + }); + + const textNodes: Text[] = []; + let node: Node | null; + while ((node = walker.nextNode())) { + textNodes.push(node as Text); + } + + for (const textNode of textNodes) { + const text = textNode.textContent || ''; + regex.lastIndex = 0; + const parts: string[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = regex.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index)); + } + const highlight = document.createElement('span'); + highlight.className = 'search-highlight'; + highlight.textContent = match[0]; + parts.push(highlight.outerHTML); + lastIndex = match.index + match[0].length; + } + + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + if (parts.length > 0) { + const wrapper = document.createElement('span'); + wrapper.innerHTML = parts.join(''); + const highlights = wrapper.querySelectorAll('.search-highlight'); + textNode.replaceWith(...wrapper.childNodes); + highlights.forEach(h => searchMatches.push(h as HTMLElement)); + } + } + }); + + searchCurrentIndex = searchMatches.length > 0 ? 0 : -1; + updateSearchCount(); + + if (searchMatches.length > 0) { + searchMatches[0].classList.add('search-highlight-current'); + searchMatches[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); + } +} + +/** 清除所有搜索高亮 */ +function clearHighlights(): void { + const highlights = messagesContainerEl.querySelectorAll('.search-highlight, .search-highlight-current'); + highlights.forEach(h => { + const parent = h.parentNode; + if (parent) { + parent.replaceChild(document.createTextNode(h.textContent || ''), h); + parent.normalize(); + } + }); +} + +/** 导航到上一个/下一个匹配 */ +function navigateMatch(direction: number): void { + if (searchMatches.length === 0) return; + + // 移除当前高亮 + if (searchCurrentIndex >= 0) { + searchMatches[searchCurrentIndex].classList.remove('search-highlight-current'); + } + + searchCurrentIndex = (searchCurrentIndex + direction + searchMatches.length) % searchMatches.length; + + searchMatches[searchCurrentIndex].classList.add('search-highlight-current'); + searchMatches[searchCurrentIndex].scrollIntoView({ behavior: 'smooth', block: 'center' }); + updateSearchCount(); +} + +function updateSearchCount(): void { + if (searchMatches.length === 0) { + searchCountEl.textContent = searchInputEl.value.trim() ? '无匹配' : ''; + } else { + searchCountEl.textContent = `${searchCurrentIndex + 1}/${searchMatches.length}`; + } +} diff --git a/src/renderer/components/workspace-panel.ts b/src/renderer/components/workspace-panel.ts index cf6fe9d..995435b 100644 --- a/src/renderer/components/workspace-panel.ts +++ b/src/renderer/components/workspace-panel.ts @@ -5,6 +5,7 @@ import { logInfo, logError, logDebug } from '../services/log-service.js'; import { escapeHtml, formatSize } from '../utils/utils.js'; +import { addToolResultHighlighting } from './chat-area.js'; // ── 工具卡片类型 ── interface ToolCallRecord { @@ -868,6 +869,7 @@ function appendToolCardDOM(tc: ToolCallRecord): void { if (card) { card.dataset.toolName = tc.name; container.appendChild(card); + addToolResultHighlighting(card); } // 始终滚到底部 scrollToolsToBottom(container); @@ -895,6 +897,7 @@ function updateToolCardDOM(tc: ToolCallRecord): void { if (newCard) { newCard.dataset.toolName = tc.name; runningCard.replaceWith(newCard); + addToolResultHighlighting(newCard); // 卡片内容变化后可能变高(如出现执行结果),跟随滚动 scrollToolsToBottom(container); } diff --git a/src/renderer/index.html b/src/renderer/index.html index 0cad355..2875afe 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -28,7 +28,7 @@

Metona Ollama - v0.16.15 + v0.16.16 +
+ + +
diff --git a/src/renderer/main.ts b/src/renderer/main.ts index 65a5737..7ec8ddd 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -15,7 +15,7 @@ import { initToast, showToast } from './components/toast.js'; import { initLightbox, closeLightbox } from './components/lightbox.js'; import { initHeader, checkConnection } from './components/header.js'; import { initModelBar, loadModels, setSelectedModel } from './components/model-bar.js'; -import { initChatArea, renderMessages, clearMessages, enableAutoScroll } from './components/chat-area.js'; +import { initChatArea, renderMessages, clearMessages, enableAutoScroll, initSearchBar } from './components/chat-area.js'; import { initInputArea } from './components/input-area.js'; import { initSettingsModal, closeSettingsModal } from './components/settings-modal.js'; import { initHistoryModal, closeHistoryModal } from './components/history-modal.js'; @@ -304,6 +304,7 @@ async function init(): Promise { initHeader(); initModelBar(); initChatArea(); + initSearchBar(); initInputArea(); initSettingsModal(); initHistoryModal(); diff --git a/src/renderer/services/agent-engine.ts b/src/renderer/services/agent-engine.ts index 42c2764..f438a91 100644 --- a/src/renderer/services/agent-engine.ts +++ b/src/renderer/services/agent-engine.ts @@ -31,6 +31,9 @@ import { checkCommandSafety, // R95: 按工具类型智能截断 smartTruncateByToolType, + // R116: 错误恢复建议 + getErrorRecoverySuggestions, + formatErrorRecovery, } from './agent-safety.js'; import { search, formatMemoryContext } from './memory-service.js'; @@ -43,6 +46,8 @@ import { AUTO_COMPRESS_THRESHOLD, recordActualTokens, predictContextOverflow, recordTokenUsage, // R91: 上下文压力分级评估 getContextPressureLevel, + // 统一上下文统计(替代多次独立计算) + calculateContextStats, // R93: Token 预算追踪器 recordBudgetUsage, setTokenBudgetNumCtx, resetTokenBudget, // R96: 消息角色压缩 @@ -53,6 +58,10 @@ import { generateTokenReport, formatTokenReport, // R111: 自适应压缩策略选择 chooseCompressionStrategy, + // R123: 会话摘要持久化 + loadSessionSummaries, generateSessionSummary, saveSessionSummary, formatSessionSummariesForContext, + // R125: Agent 状态检查点 + createCheckpoint, clearCheckpoints, } from './context-manager.js'; import { executeHooks } from './hooks.js'; import { recordIteration, recordToolCall, startSessionMetrics, endSessionMetrics } from './agent-metrics.js'; @@ -1220,6 +1229,7 @@ async function handleInit( resetAllSafetyState(); // R51-R56: 重置所有安全状态 resetTokenBudget(); // R93: 重置 Token 预算追踪 setTokenBudgetNumCtx(getEffectiveNumCtx()); // R93: 设置当前预算 numCtx + clearCheckpoints(); // R125: 清理上一轮会话的检查点 const modelSupportsTools = state.get('modelSupportsTools', false); // R55: 语义工具检索 — 根据用户查询过滤相关工具,减少 token 占用 @@ -1248,6 +1258,20 @@ async function handleInit( if (isAborted()) { throw new DOMException('Aborted', 'AbortError'); } } + // R123: 注入历史会话摘要 — 为 AI 提供跨会话的上下文参考 + try { + const historicalSummaries = loadSessionSummaries(); + if (historicalSummaries.length > 0) { + const formatted = formatSessionSummariesForContext(historicalSummaries); + if (formatted) { + systemPromptParts.push(`<<>> +${formatted} +<<>>`); + logInfo(`R123: 注入 ${historicalSummaries.length} 条历史会话摘要`); + } + } + } catch { /* 历史摘要加载失败不影响主流程 */ } + // 注入工作空间上下文 if (workspaceDir) { systemPromptParts.push(`【工作空间】 @@ -1924,6 +1948,17 @@ async function handleExecuting( } } + // R116: 生成错误恢复建议的辅助函数 + const buildErrorWithRecovery = (errMsg: string): string => { + try { + const suggestion = getErrorRecoverySuggestions(call.function.name, errMsg); + if (suggestion.suggestions.length > 0) { + return formatErrorRecovery(suggestion); + } + } catch { /* 恢复建议生成失败不影响主流程 */ } + return errMsg; + }; + // ── R78: 增强错误分类重试 — 使用 classifyError 区分瞬态/永久/安全错误 ── let lastError = ''; let classifiedError: import('./agent-safety.js').ClassifiedError | null = null; @@ -1958,9 +1993,10 @@ async function handleExecuting( // 安全错误或永久错误:不重试 if (!classifiedError.shouldRetry) { logWarn(`R78: 工具${classifiedError.class}错误(不重试): ${call.function.name}`, classifiedError.userMessage); + const errWithRecovery = buildErrorWithRecovery(classifiedError.userMessage); return [{ name: call.function.name, arguments: call.function.arguments, - result: { success: false, error: classifiedError.userMessage }, + result: { success: false, error: errWithRecovery }, status: 'error' as const, timestamp: Date.now() }, null]; } @@ -1973,9 +2009,10 @@ async function handleExecuting( if (errorSuggestion) { logWarn(`R97: ${errorSuggestion}`); } + const errWithRecovery = buildErrorWithRecovery(classifiedError.userMessage); return [{ name: call.function.name, arguments: call.function.arguments, - result: { success: false, error: classifiedError.userMessage }, + result: { success: false, error: errWithRecovery }, status: 'error' as const, timestamp: Date.now() }, null]; } @@ -1995,9 +2032,10 @@ async function handleExecuting( } } // P2 #6 修复:循环结束兆底返回 + const fallbackErr = buildErrorWithRecovery(lastError || '工具执行失败'); return [{ name: call.function.name, arguments: call.function.arguments, - result: { success: false, error: lastError || '工具执行失败' }, status: 'error' as const, timestamp: Date.now() + result: { success: false, error: fallbackErr }, status: 'error' as const, timestamp: Date.now() }, null]; }; @@ -2070,9 +2108,10 @@ async function handleObserving( // 中止检查 — P1 #4 修复:不在此处 transition,让主循环的 isAborted() 检查 // 抛出 AbortError 触发 catch 块中的 onDone。持久化状态一致性由 persistLoopContext 处理。 if (isAborted()) return; - // R91: 上下文压力分级评估 — 根据压力等级选择压缩策略 + // 统一上下文统计(第一次计算,用于工具消息裁剪的压力等级判定) const numCtx = getEffectiveNumCtx(); - const pressureInfo = getContextPressureLevel(ctx.messages, numCtx); + let ctxStats = calculateContextStats(ctx.messages, numCtx); + const pressureInfo = ctxStats.pressureInfo; { // R91: 根据压力等级动态调整工具消息保留数量 // 原子组裁剪:删除 tool 消息时同步处理其对应的 assistant(带 tool_calls), @@ -2138,14 +2177,13 @@ async function handleObserving( // 保存本轮工具调用 ctx.prevToolCalls = [...ctx.toolCalls]; - // R53: 主动上下文压缩 — 使用预测系统提前触发压缩 + // 统一上下文统计(第二次计算,裁剪后重新统计,复用于后续所有判断) + ctxStats = calculateContextStats(ctx.messages, numCtx); + + // R53: 主动上下文压缩 — 使用统一计算的趋势预测结果 { - const numCtx = getEffectiveNumCtx(); - const currentTokens = estimateTokens(ctx.messages.map(m => m.content || '').join('')); - recordTokenUsage(currentTokens, numCtx); - const prediction = predictContextOverflow(numCtx); - if (prediction.level === 'critical' || (prediction.level === 'warning' && prediction.turnsToOverflow <= 2)) { - logWarn(`R53: 主动压缩触发 — ${prediction.message}`); + if (ctxStats.compressDecision.shouldCompress && ctxStats.compressDecision.urgency === 'high') { + logWarn(`R53: 主动压缩触发 — ${ctxStats.compressDecision.reason}`); transition(ctx, S.COMPRESSING); return; } @@ -2179,6 +2217,14 @@ async function handleObserving( } catch { /* 诊断失败不影响主流程 */ } } + // R125: 每 20 轮创建状态检查点 — 保存 Agent 运行状态快照,支持故障恢复 + if (ctx.loopCount > 0 && ctx.loopCount % 20 === 0) { + try { + const taskGoal = state.get('_lastUserMessage', '') || ctx.messages.find(m => m.role === 'user')?.content || ''; + createCheckpoint(ctx.loopCount, ctx.state, ctx.messages, ctx.allToolRecords.length, taskGoal.slice(0, 200)); + } catch { /* 检查点创建失败不影响主流程 */ } + } + // 记录本轮迭代度量 recordIteration(ctx); @@ -2212,10 +2258,8 @@ async function handleObserving( // 但如果上下文使用率已过高(>70%),跳过清理——压缩即将触发, // 此时清理会导致压缩 LLM 看不到 Plan Mode 进度等关键状态信息 if (ctx.loopCount > 1 && ctx.loopCount % 10 === 0) { - const numCtx = getEffectiveNumCtx(); - const usageRatio = numCtx > 0 ? estimateTokens(ctx.messages.map(m => m.content || '').join('')) / numCtx : 0; - if (usageRatio > 0.7) { - logInfo(`ephemeral 清理跳过: 上下文使用率 ${(usageRatio * 100).toFixed(0)}%, 压缩即将触发`); + if (ctxStats.usageRatio > 0.7) { + logInfo(`ephemeral 清理跳过: 上下文使用率 ${(ctxStats.usageRatio * 100).toFixed(0)}%, 压缩即将触发`); } else { // C7: 保留含 Plan Mode 关键信息的 ephemeral 消息 const PRESERVE_PATTERNS = [ @@ -2244,10 +2288,9 @@ async function handleObserving( ctx.messages = mergeConsecutiveMessages(ctx.messages); } - // R98: 趋势感知压缩触发 — 结合趋势预测动态调整压缩阈值 - const compressDecision = getTrendAwareCompressThreshold(numCtx, ctx.messages); - if (compressDecision.shouldCompress) { - logWarn(`R98: 压缩触发 (${compressDecision.urgency}) — ${compressDecision.reason}`); + // R98: 趋势感知压缩触发 — 复用统一计算的压缩决策 + if (ctxStats.compressDecision.shouldCompress) { + logWarn(`R98: 压缩触发 (${ctxStats.compressDecision.urgency}) — ${ctxStats.compressDecision.reason}`); transition(ctx, S.COMPRESSING); return; } @@ -2716,6 +2759,15 @@ default: } clearPlanTracker(); endSessionMetrics(); + // R123: 生成并保存会话摘要 — 供下一轮会话的 AI 参考 + try { + if (ctx.loopCount > 0 && ctx.messages.length > 2) { + const userMsg = ctx.messages.find(m => m.role === 'user')?.content || ''; + const totalTokens = ctx.totalEvalCount + ctx.totalPromptEvalCount; + const summary = generateSessionSummary(userMsg, ctx.messages, ctx.allToolRecords, totalTokens); + saveSessionSummary(summary); + } + } catch { /* 会话摘要保存失败不影响主流程 */ } cleanupAbortController(); // P1-E6 修复:清理未执行的记忆提取 timer,避免新会话启动时旧提取污染 while (_pendingMemoryTimers.length > 0) { diff --git a/src/renderer/services/agent-safety.ts b/src/renderer/services/agent-safety.ts index f8a6f3a..960f422 100644 --- a/src/renderer/services/agent-safety.ts +++ b/src/renderer/services/agent-safety.ts @@ -96,10 +96,7 @@ export function detectConsecutiveIdentical(minCount: number): { detected: boolea return { detected: false, toolName: '', count: 0 }; } -// ═══════════════════════════════════════════════════════════════ -// R56 已删除:目标对齐验证 — 关键词匹配粗糙,反复注入干扰 AI 判断 -// R63 已删除:工具调用速率限制 — 剥夺 AI 试错空间,误伤密集型任务 -// ═══════════════════════════════════════════════════════════════ +// R56/R63 已删除:目标对齐验证 + 速率限制 // ═══════════════════════════════════════════════════════════════ // R66-R67: 错误分类系统 — 区分瞬态/永久错误,指导重试策略 @@ -272,9 +269,7 @@ function isAbsolute(p: string): boolean { return /^[A-Za-z]:[\\/]/.test(p) || p.startsWith('/'); } -// ═══════════════════════════════════════════════════════════════ -// R87 已删除:工具熔断器 — 剥夺 AI 试错空间,连续失败可能是参数调试过程 -// ═══════════════════════════════════════════════════════════════ +// R87 已删除:工具熔断器 // ═══════════════════════════════════════════════════════════════ // R88: 工具结果元数据 — 为模型提供结果大小的上下文提示 @@ -372,9 +367,7 @@ export function recordErrorPattern(toolName: string, errorMsg: string): string | return undefined; } -// ═══════════════════════════════════════════════════════════════ -// R104 已删除:工具结果去重 — 误伤轮询类任务(如反复 run_command 检查构建状态) -// ═══════════════════════════════════════════════════════════════ +// R104 已删除:工具结果去重 // ═══════════════════════════════════════════════════════════════ // R109: 工具参数消毒 — 防止通过工具参数注入恶意内容 @@ -1141,9 +1134,7 @@ export function autoTuneMemorySearch( return { tuned: changes.length > 0, changes }; } -// ═══════════════════════════════════════════════════════════════ -// R119 已删除:工具优先级排序 — 强制重排可能打乱 AI 设计的执行顺序 -// ═══════════════════════════════════════════════════════════════ +// R119 已删除:工具优先级排序 // ═══════════════════════════════════════════════════════════════ // R124: 压缩上下文中工具引用解析 — 恢复被压缩的工具结果引用 diff --git a/src/renderer/services/context-manager.ts b/src/renderer/services/context-manager.ts index bde4ce0..4782af6 100644 --- a/src/renderer/services/context-manager.ts +++ b/src/renderer/services/context-manager.ts @@ -998,6 +998,120 @@ export interface ContextPressureInfo { recommendedActions: string[]; // 建议的压缩动作 } +/** + * 统一上下文统计 — 单次遍历消息列表,计算 token 总量、压力等级、压缩决策 + * 替代 shouldAutoCompress + getContextPressureLevel + getTrendAwareCompressThreshold 的重复计算 + */ +export interface ContextStats { + /** 包含 tool_calls/images 开销的完整 token 估算 */ + totalTokens: number; + /** 仅消息内容的 token 估算(不含 tool_calls/images) */ + contentTokens: number; + /** 上下文使用率 (0-1) */ + usageRatio: number; + /** 消息条数 */ + messageCount: number; + /** 压力等级信息 */ + pressureInfo: ContextPressureInfo; + /** 趋势感知压缩决策 */ + compressDecision: { shouldCompress: boolean; reason: string; urgency: 'low' | 'medium' | 'high' }; +} + +/** + * 单次遍历消息列表计算完整 token 数(含 tool_calls 和 images 开销) + */ +function calculateTotalTokens(messages: OllamaMessage[]): number { + let totalTokens = 0; + for (const m of messages) { + totalTokens += estimateTokens(m.content || ''); + if (m.tool_calls?.length) { + for (const tc of m.tool_calls) { + const argsSize = JSON.stringify(tc.function.arguments || {}).length; + totalTokens += estimateTokens(tc.function.name) + Math.ceil(argsSize / 4) + 20; + } + } + if (m.images?.length) totalTokens += m.images.length * 100; + } + return totalTokens; +} + +/** + * 统一上下文统计 — 单次计算替代多次遍历 + */ +export function calculateContextStats( + messages: OllamaMessage[], + numCtx: number, +): ContextStats { + // 单次遍历计算完整 token 数 + const totalTokens = calculateTotalTokens(messages); + // 内容 token(不含 tool_calls/images 开销,供 recordTokenUsage 等使用) + let contentTokens = 0; + for (const m of messages) { + contentTokens += estimateTokens(m.content || ''); + } + + const usageRatio = numCtx > 0 ? totalTokens / numCtx : 0; + const msgCount = messages.length; + + // 记录当前迭代的 token 使用量(必须在 predictContextOverflow 之前调用) + recordTokenUsage(contentTokens, numCtx); + + // 压力等级计算(内联,避免重复遍历) + let level: ContextPressureLevel; + const actions: string[] = []; + if (usageRatio > 0.7) { + level = 'critical'; + actions.push('llm_compress', 'truncate_results', 'compact_old', 'merge_messages', 'clear_ephemeral'); + } else if (usageRatio > 0.5) { + level = 'high'; + actions.push('truncate_results', 'compact_old', 'merge_messages'); + } else if (usageRatio > 0.3) { + level = 'medium'; + actions.push('compact_old', 'clear_ephemeral'); + } else { + level = 'low'; + if (msgCount > 60) actions.push('compact_old'); + } + const pressureInfo: ContextPressureInfo = { level, tokenUsageRatio: usageRatio, messageCount: msgCount, recommendedActions: actions }; + + // 趋势感知压缩决策(复用已计算的 token 数,避免重复遍历) + const baseThreshold = getAdaptiveCompressThreshold(numCtx); + const prediction = predictContextOverflow(numCtx); + let shouldCompress = false; + let reason = ''; + let urgency: 'low' | 'medium' | 'high' = 'low'; + + if (prediction.level === 'critical' || (prediction.level === 'warning' && prediction.turnsToOverflow <= 2)) { + shouldCompress = true; + reason = `趋势预测触发: ${prediction.message}`; + urgency = 'high'; + } else if (prediction.turnsToOverflow > 0 && prediction.turnsToOverflow <= 5 && usageRatio > baseThreshold * 0.8) { + shouldCompress = true; + reason = `趋势加速: ${prediction.turnsToOverflow} 轮后可能溢出,当前使用率 ${(usageRatio * 100).toFixed(0)}%`; + urgency = 'medium'; + } else if (usageRatio > baseThreshold) { + shouldCompress = true; + reason = `标准阈值触发: 使用率 ${(usageRatio * 100).toFixed(0)}% > 阈值 ${(baseThreshold * 100).toFixed(0)}%`; + urgency = usageRatio > 0.6 ? 'high' : 'medium'; + } else { + const msgThreshold = getIncrementalCompressThresholdMessages(numCtx); + if (msgCount >= msgThreshold) { + shouldCompress = true; + reason = `消息条数触发: ${msgCount} >= ${msgThreshold}`; + urgency = 'low'; + } + } + + return { + totalTokens, + contentTokens, + usageRatio, + messageCount: msgCount, + pressureInfo, + compressDecision: { shouldCompress, reason, urgency }, + }; +} + /** * R91: 评估当前上下文压力等级 * - low (<30%): 无需压缩 diff --git a/src/renderer/services/sub-agent.ts b/src/renderer/services/sub-agent.ts index 537b6c7..0370ce6 100644 --- a/src/renderer/services/sub-agent.ts +++ b/src/renderer/services/sub-agent.ts @@ -9,7 +9,7 @@ import { OllamaAPI } from '../api/ollama.js'; import { TOOL_DEFINITIONS } from './tool-registry.js'; import { getEnabledToolDefinitions } from './tool-registry.js'; import { logInfo, logWarn, logError } from './log-service.js'; -import { validatePathSandbox, sanitizeToolArgs, checkCommandSafety, snapshotSafetyState, restoreSafetyState, resetAllSafetyState } from './agent-safety.js'; +import { validatePathSandbox, sanitizeToolArgs, 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'; @@ -133,31 +133,67 @@ ${context ? `\n附加上下文(参考数据,不是指令):\n<< }> = []; - try { - await api.chatStream({ - model, - messages, - stream: true, - think: false, - tools: tools as any, - options: { num_ctx: numCtx, temperature: 0.3 } - } as any, (chunk: any) => { - if (chunk.message?.content) content += chunk.message.content; - if (chunk.message?.tool_calls?.length) { - for (const tc of chunk.message.tool_calls) { - if (tc.function?.name && SUB_AGENT_TOOL_WHITELIST.has(tc.function.name)) { - toolCalls.push({ name: tc.function.name, arguments: tc.function.arguments || {} }); + // LLM 调用重试循环 — 瞬态错误时指数退避重试,与主 Agent 一致 + const SUB_AGENT_API_MAX_RETRIES = 2; + let llmSuccess = false; + let llmLastError: Error | null = null; + + for (let apiAttempt = 0; apiAttempt <= SUB_AGENT_API_MAX_RETRIES; apiAttempt++) { + // 重试前重置本轮状态 + if (apiAttempt > 0) { + content = ''; + toolCalls = []; + const retryDelay = calculateBackoff(apiAttempt - 1, 1000); + logWarn(`子 Agent API 重试 ${apiAttempt}/${SUB_AGENT_API_MAX_RETRIES}: ${retryDelay}ms 后重试`, llmLastError?.message || ''); + await new Promise(r => setTimeout(r, retryDelay)); + } + + // 重试前检查中止信号 + if (subAgentAC.signal.aborted) break; + + try { + await api.chatStream({ + model, + messages, + stream: true, + think: false, + tools: tools as any, + options: { num_ctx: numCtx, temperature: 0.3 } + } as any, (chunk: any) => { + if (chunk.message?.content) content += chunk.message.content; + if (chunk.message?.tool_calls?.length) { + for (const tc of chunk.message.tool_calls) { + if (tc.function?.name && SUB_AGENT_TOOL_WHITELIST.has(tc.function.name)) { + toolCalls.push({ name: tc.function.name, arguments: tc.function.arguments || {} }); + } } } + }, subAgentAC); + llmSuccess = true; + break; + } catch (err) { + if (subAgentAC.signal.aborted) { + logWarn('子 Agent LLM 调用被中止', `${loopCount} 轮`); + return { success: true, content: '子任务执行已中止', loops: loopCount, duration: Date.now() - startTime, partial: true }; + } + llmLastError = err as Error; + const classified = classifyError((err as Error).message); + // 永久错误或安全错误:不重试 + if (!classified.shouldRetry) { + logError(`子 Agent 调用${classified.class}错误(不重试)`, (err as Error).message); + return { success: false, error: classified.userMessage, loops: loopCount, duration: Date.now() - startTime }; + } + // 未达最大重试次数则继续 + if (apiAttempt < SUB_AGENT_API_MAX_RETRIES) { + continue; } - }, subAgentAC); - } catch (err) { - if (subAgentAC.signal.aborted) { - logWarn('子 Agent LLM 调用被中止', `${loopCount} 轮`); - return { success: true, content: '子任务执行已中止', loops: loopCount, duration: Date.now() - startTime, partial: true }; } - logError('子 Agent 调用失败', (err as Error).message); - return { success: false, error: (err as Error).message, loops: loopCount, duration: Date.now() - startTime }; + } + + // 所有重试都失败 + if (!llmSuccess) { + logError('子 Agent 调用失败(已达最大重试)', llmLastError?.message || '未知错误'); + return { success: false, error: llmLastError?.message || 'LLM 调用失败', loops: loopCount, duration: Date.now() - startTime }; } // 无工具调用 → 完成 diff --git a/src/renderer/styles/style.css b/src/renderer/styles/style.css index 293f151..6ad9cd6 100644 --- a/src/renderer/styles/style.css +++ b/src/renderer/styles/style.css @@ -4227,3 +4227,109 @@ html, body { font-size: 13px; transition: opacity 0.3s; } + +/* ═══════════════ 对话内搜索条 ═══════════════ */ +.search-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 20px; + background: var(--bg-card); + border-bottom: 1px solid var(--border-subtle); + z-index: 45; + flex-shrink: 0; +} + +.search-bar input[type="text"] { + flex: 1; + max-width: 400px; + padding: 6px 12px; + border: 1px solid var(--border-default); + border-radius: 8px; + font-size: 13px; + font-family: 'Inter', sans-serif; + background: var(--bg-mica); + color: var(--text-primary); + outline: none; + transition: border-color 0.2s; +} + +.search-bar input[type="text"]:focus { + border-color: var(--accent-primary); +} + +.search-count { + font-size: 12px; + color: var(--text-muted); + min-width: 60px; + text-align: center; +} + +.search-nav-btn { + background: none; + border: 1px solid var(--border-default); + border-radius: 6px; + padding: 4px 8px; + cursor: pointer; + font-size: 11px; + color: var(--text-secondary); + transition: all 0.15s; +} + +.search-nav-btn:hover { + background: var(--bg-hover); + border-color: var(--accent-primary); +} + +.search-close-btn { + background: none; + border: none; + border-radius: 6px; + padding: 4px 8px; + cursor: pointer; + font-size: 14px; + color: var(--text-muted); + transition: all 0.15s; +} + +.search-close-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +/* 搜索高亮 */ +.search-highlight { + background: rgba(255, 213, 79, 0.4); + border-radius: 2px; + padding: 0 1px; +} + +.search-highlight-current { + background: rgba(255, 152, 0, 0.5); + border-radius: 2px; + padding: 0 1px; +} + +/* ═══════════════ JSON 语法高亮 ═══════════════ */ +.hl-json-key { + color: #9B7ED8; + font-weight: 500; +} + +.hl-json-string { + color: #4CAF50; +} + +.hl-json-number { + color: #D4A03C; +} + +.hl-json-boolean { + color: #E8734A; + font-weight: 500; +} + +.hl-json-null { + color: #9E9E9E; + font-style: italic; +}