feat: v0.16.16 — 稳定性增强 + 性能优化 + 体验补全

This commit is contained in:
2026-07-31 22:31:02 +08:00
parent afe93d7fed
commit 44094340a5
13 changed files with 661 additions and 66 deletions
+1 -1
View File
@@ -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()
});
+280 -2
View File
@@ -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,
'<span class="hl-json-key">&quot;$1&quot;</span>:'
);
// 高亮 string value: : "value" 或 [ "value"
result = result.replace(
/:\s*&quot;([^&]*(?:&amp;|&lt;|&gt;)?[^&]*)&quot;/g,
': <span class="hl-json-string">&quot;$1&quot;</span>'
);
result = result.replace(
/\[\s*&quot;([^&]*(?:&amp;|&lt;|&gt;)?[^&]*)&quot;/g,
'[ <span class="hl-json-string">&quot;$1&quot;</span>'
);
// 高亮数字
result = result.replace(
/:\s*(-?\d+\.?\d*)/g,
': <span class="hl-json-number">$1</span>'
);
// 高亮 boolean
result = result.replace(
/:\s*(true|false)/g,
': <span class="hl-json-boolean">$1</span>'
);
// 高亮 null
result = result.replace(
/:\s*(null)/g,
': <span class="hl-json-null">$1</span>'
);
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 = `<code>${highlighted}</code>`;
}
}
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<HTMLPreElement>) {
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<void> {
const bridge = window.metonaDesktop;
if (bridge) {
@@ -802,3 +908,175 @@ export async function exportAsTxt(session: ChatSession): Promise<void> {
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}`;
}
}
@@ -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);
}
+15 -1
View File
@@ -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.15</span>
<span class="app-version">v0.16.16</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"/>
@@ -46,6 +46,11 @@
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
<button class="icon-btn" id="btnSearch" title="对话内搜索 (Ctrl+F)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
</button>
<button class="icon-btn" id="btnMemory" title="Agent 记忆">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2a7 7 0 0 1 7 7c0 2.38-1.19 4.47-3 5.74V17a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1v-2.26C6.19 13.47 5 11.38 5 9a7 7 0 0 1 7-7z"/>
@@ -81,6 +86,15 @@
</div>
</header>
<!-- ═══════════════ 对话内搜索条 ═══════════════ -->
<div class="search-bar" id="searchBar" style="display:none;">
<input type="text" id="searchInput" placeholder="搜索对话内容..." autocomplete="off" />
<span class="search-count" id="searchCount"></span>
<button class="search-nav-btn" id="searchPrev" title="上一个"></button>
<button class="search-nav-btn" id="searchNext" title="下一个"></button>
<button class="search-close-btn" id="searchClose" title="关闭"></button>
</div>
<!-- ═══════════════ 模型选择栏 ═══════════════ -->
<div class="model-bar">
<div class="model-bar-icon">
+2 -1
View File
@@ -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<void> {
initHeader();
initModelBar();
initChatArea();
initSearchBar();
initInputArea();
initSettingsModal();
initHistoryModal();
+72 -20
View File
@@ -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<boolean>('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(`<<<REFERENCE_DATA_START>>>
${formatted}
<<<REFERENCE_DATA_END>>>`);
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<string>('_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) {
+4 -13
View File
@@ -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: 压缩上下文中工具引用解析 — 恢复被压缩的工具结果引用
+114
View File
@@ -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%): 无需压缩
+58 -22
View File
@@ -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<<<REFERENCE
let content = '';
let toolCalls: Array<{ name: string; arguments: Record<string, unknown> }> = [];
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 };
}
// 无工具调用 → 完成
+106
View File
@@ -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;
}