diff --git a/src/renderer/components/input-area.ts b/src/renderer/components/input-area.ts index 1de4521..9f0026a 100644 --- a/src/renderer/components/input-area.ts +++ b/src/renderer/components/input-area.ts @@ -16,6 +16,7 @@ import { addToolCard, updateToolCard, clearToolCardsExternal, clearTerminalExter import { ChatDB } from '../db/chat-db.js'; import { OllamaAPI } from '../api/ollama.js'; import { runAgentLoop } from '../services/agent-engine.js'; +import { estimateTokens } from '../services/context-manager.js'; import { searchMemories, buildMemoryContext, markMemoryUsed, extractMemoriesFromConversation, isMemoryEnabled } from '../services/memory-manager.js'; import { showToolConfirm } from './tool-confirm-modal.js'; import { logInfo, logStream, logError, logSuccess, logWarn } from '../services/log-service.js'; @@ -448,7 +449,7 @@ async function handleCompress(): Promise { logInfo('上下文压缩: 开始'); - // 取中间的消息做摘要(保留首尾各 2 条) + // 取中间的消息做摘要(保留首尾各 2 条,跳过已压缩的) const messages = currentSession.messages; const keepStart = 2; const keepEnd = 2; @@ -456,10 +457,16 @@ async function handleCompress(): Promise { const tail = messages.slice(-keepEnd); const middle = messages.slice(keepStart, messages.length - keepEnd); - const conversationText = middle.map(m => { + // 过滤掉已压缩的消息,避免重复压缩 + const uncompressedMiddle = middle.filter(m => !m.compressed); + if (uncompressedMiddle.length === 0) { + showToast('中间消息已全部压缩,无需再次压缩', 'info'); + return; + } + + const conversationText = uncompressedMiddle.map(m => { const role = m.role === 'user' ? '用户' : 'AI'; let content = m.content || ''; - // 截断过长内容 if (content.length > 500) content = content.slice(0, 500) + '...'; if (m.toolCalls?.length) content += ` [工具调用: ${m.toolCalls.map(t => t.name).join(', ')}]`; return `${role}: ${content}`; @@ -473,7 +480,7 @@ async function handleCompress(): Promise { model, messages: [{ role: 'user', - content: `请将以下对话摘要为一段简洁的上下文总结。保留关键信息:讨论的主题、得出的结论、用户的偏好、未完成的任务。用中文输出,不超过 500 字。\n\n对话记录:\n${conversationText}` + content: `请将以下对话摘要为一段简洁的上下文总结。保留关键信息:讨论的主题、得出的结论、用户的偏好、未完成的任务、关键工具调用结果。用中文输出,不超过 500 字。\n\n对话记录:\n${conversationText}` }], stream: true, think: false, @@ -489,14 +496,17 @@ async function handleCompress(): Promise { return; } - // 构建压缩后的消息列表 + // 构建压缩后的摘要消息(标记 compressed) const summaryMsg: ChatMessage = { role: 'system', - content: `📋 以下是对之前对话的摘要(已压缩 ${middle.length} 条消息):\n\n${summary}`, - timestamp: Date.now() + content: `📋 以下是对之前对话的摘要(已压缩 ${uncompressedMiddle.length} 条消息):\n\n${summary}`, + timestamp: Date.now(), + compressed: true }; - currentSession.messages = [...head, summaryMsg, ...tail]; + // 保留已压缩的中间消息 + 新摘要 + const alreadyCompressed = middle.filter(m => m.compressed); + currentSession.messages = [...head, ...alreadyCompressed, summaryMsg, ...tail]; const db = state.get(KEYS.DB); if (db) await db.saveSession(currentSession); @@ -504,8 +514,8 @@ async function handleCompress(): Promise { clearMessagesDOM(); renderMessages(); - logInfo(`上下文压缩完成: ${middle.length} 条 → 1 条摘要`); - showToast(`已压缩 ${middle.length} 条消息为摘要`, 'success'); + logInfo(`上下文压缩完成: ${uncompressedMiddle.length} 条 → 1 条摘要`); + showToast(`已压缩 ${uncompressedMiddle.length} 条消息为摘要`, 'success'); } catch (err) { logError('上下文压缩失败', (err as Error).message); showToast(`压缩失败: ${(err as Error).message}`, 'error'); diff --git a/src/renderer/services/agent-engine.ts b/src/renderer/services/agent-engine.ts index 8f3446c..cfaa817 100644 --- a/src/renderer/services/agent-engine.ts +++ b/src/renderer/services/agent-engine.ts @@ -17,7 +17,7 @@ import { showToast } from '../components/toast.js'; import { logInfo, logWarn, logError, logToolStart, logToolResult, logAgentLoop, logModelResponse } from './log-service.js'; import { getWorkspaceDirPath } from '../components/workspace-panel.js'; import { generateId } from '../utils/utils.js'; -import { buildContext, estimateTokens } from './context-manager.js'; +import { buildContext, estimateTokens, shouldAutoCompress, compressWithLLM, AUTO_COMPRESS_THRESHOLD } from './context-manager.js'; import type { OllamaMessage, OllamaStreamChunk, @@ -497,6 +497,22 @@ export async function runAgentLoop( messages.length = 0; messages.push(...contextResult); + // 自动压缩:当上下文 token 超过 context window 的 50% 时,调用 LLM 摘要压缩 + if (shouldAutoCompress(messages, numCtx)) { + logInfo(`自动上下文压缩触发: tokens≈${estimateTokens(messages.map(m => m.content || '').join(''))} > ${Math.floor(numCtx * AUTO_COMPRESS_THRESHOLD)} (50% of ${numCtx})`); + try { + const compressed = await compressWithLLM(messages, api, model, { abortController }); + if (compressed.length < messages.length || estimateTokens(compressed.map(m => m.content || '').join('')) < estimateTokens(messages.map(m => m.content || '').join(''))) { + messages.length = 0; + messages.push(...compressed); + logSuccess(`自动上下文压缩完成: 剩余 ${messages.length} 条消息`); + } + } catch (err) { + if ((err as Error).name === 'AbortError') throw err; + logWarn('自动上下文压缩失败,继续使用当前上下文', (err as Error).message); + } + } + logInfo(`ReAct Agent Loop 启动: ${model}`, `工具: ${useTools ? '开启' : '关闭'}, 记忆: ${isMemoryEnabled() ? '开启' : '关闭'}, tokens≈${estimateTokens(messages.map(m => m.content || '').join(''))}`); // 迭代预算:从 state 读取,默认 85 diff --git a/src/renderer/services/context-manager.ts b/src/renderer/services/context-manager.ts index 126bfa6..6369f94 100644 --- a/src/renderer/services/context-manager.ts +++ b/src/renderer/services/context-manager.ts @@ -1,10 +1,11 @@ /** - * Context Manager - 智能上下文管理 (v4.0) - * 三层策略:滑动窗口 + 消息摘要压缩 + 记忆注入 + * Context Manager - 智能上下文管理 (v5.0) + * 三层策略:滑动窗口 + LLM 摘要压缩 + 记忆注入 + * 支持自动压缩与手动 /compress 触发 */ -import type { OllamaMessage } from '../types.js'; -import { logInfo, logWarn } from './log-service.js'; +import type { OllamaMessage, OllamaStreamChunk } from '../types.js'; +import { logInfo, logWarn, logSuccess, logError } from './log-service.js'; /** 粗略估算 token 数 */ export function estimateTokens(text: string): number { @@ -19,6 +20,13 @@ export function estimateTokens(text: string): number { return Math.ceil(chineseChars / 1.5 + otherChars / 4); } +/** 自动压缩阈值:当消息 token 占 context window 比例超过此值时触发自动压缩 */ +export const AUTO_COMPRESS_THRESHOLD = 0.5; + +/** 压缩后保留首尾消息数 */ +const COMPRESS_KEEP_HEAD = 2; +const COMPRESS_KEEP_TAIL = 2; + export interface ContextBuildOptions { /** 滑动窗口大小(最近 N 条消息完整保留) */ windowSize?: number; @@ -41,10 +49,10 @@ const DEFAULT_OPTIONS: Required = { }; /** - * 构建发送给模型的 messages + * 构建发送给模型的 messages(同步,滑动窗口) * 三层策略: * a. 滑动窗口:最近 N 条消息完整保留 - * b. 更早的消息:每 N 条压缩为一段摘要 + * b. 更早的消息:每 N 条压缩为一段摘要(快速文本截取) * c. 系统 prompt 注入记忆上下文 */ export function buildContext( @@ -64,9 +72,9 @@ export function buildContext( } // 提取已有的 system 消息 - const existingSystem = allMessages.find(m => m.role === 'system'); - if (existingSystem) { - systemContent += existingSystem.content; + const existingSystem = allMessages.filter(m => m.role === 'system'); + for (const sys of existingSystem) { + systemContent += sys.content + '\n'; } if (systemContent.trim()) { @@ -77,7 +85,6 @@ export function buildContext( const nonSystemMessages = allMessages.filter(m => m.role !== 'system'); if (nonSystemMessages.length <= opts.windowSize) { - // 消息不多,全部保留 result.push(...nonSystemMessages); return result; } @@ -85,11 +92,21 @@ export function buildContext( // 滑动窗口:最近 N 条 const recentMessages = nonSystemMessages.slice(-opts.windowSize); - // 更早的消息:压缩为摘要 + // 更早的消息:已压缩标记的保留原样,未压缩的做快速摘要 const olderMessages = nonSystemMessages.slice(0, -opts.windowSize); - const summaries = summarizeOlderMessages(olderMessages, opts.summaryBatchSize); + const compressedMsgs = olderMessages.filter(m => m.compressed); + const uncompressedMsgs = olderMessages.filter(m => !m.compressed); - result.push(...summaries, ...recentMessages); + // 已压缩的消息直接保留 + result.push(...compressedMsgs); + + // 未压缩的消息做快速摘要 + if (uncompressedMsgs.length > 0) { + const summaries = summarizeOlderMessages(uncompressedMsgs, opts.summaryBatchSize); + result.push(...summaries); + } + + result.push(...recentMessages); // Token 估算和裁剪 const trimmed = trimByTokenLimit(result, opts.maxTokens); @@ -101,7 +118,132 @@ export function buildContext( } /** - * 将较早的消息每 batchSize 条压缩为一段摘要 + * 判断是否需要自动压缩 + * 当总 token 数超过 context window 的 AUTO_COMPRESS_THRESHOLD 比例时返回 true + */ +export function shouldAutoCompress(messages: OllamaMessage[], numCtx: number): boolean { + const totalTokens = estimateTokens(messages.map(m => m.content || '').join('')); + const threshold = numCtx * AUTO_COMPRESS_THRESHOLD; + return totalTokens > threshold; +} + +/** + * LLM 摘要压缩:调用模型对中间消息生成摘要 + * 保留首尾各 keepHead/keepTail 条消息,中间用 LLM 摘要替换 + * + * @returns 压缩后的消息列表(包含 compressed 标记的摘要消息) + */ +export async function compressWithLLM( + messages: OllamaMessage[], + api: { chatStream: (params: Record, onChunk: (chunk: OllamaStreamChunk) => void, ac?: AbortController) => Promise }, + model: string, + options: { + keepHead?: number; + keepTail?: number; + maxSummaryTokens?: number; + abortController?: AbortController; + } = {} +): Promise { + const keepHead = options.keepHead ?? COMPRESS_KEEP_HEAD; + const keepTail = options.keepTail ?? COMPRESS_KEEP_TAIL; + const maxSummaryTokens = options.maxSummaryTokens ?? 500; + + // 分离 system 和非 system 消息 + const systemMsgs = messages.filter(m => m.role === 'system'); + const nonSystemMsgs = messages.filter(m => m.role !== 'system'); + + if (nonSystemMsgs.length <= keepHead + keepTail + 2) { + logInfo('上下文压缩: 消息太少,跳过压缩'); + return messages; + } + + const head = nonSystemMsgs.slice(0, keepHead); + const tail = nonSystemMsgs.slice(-keepTail); + const middle = nonSystemMsgs.slice(keepHead, nonSystemMsgs.length - keepTail); + + // 过滤掉已经压缩过的消息(避免重复压缩) + const uncompressedMiddle = middle.filter(m => !m.compressed); + if (uncompressedMiddle.length === 0) { + logInfo('上下文压缩: 中间消息已全部压缩,跳过'); + return messages; + } + + // 构建对话文本 + const conversationText = uncompressedMiddle.map(m => { + const role = m.role === 'user' ? '用户' : 'AI'; + let content = m.content || ''; + if (content.length > 500) content = content.slice(0, 500) + '...'; + if (m.tool_calls?.length) { + const toolNames = m.tool_calls.map(t => t.function.name).join(', '); + content += ` [工具调用: ${toolNames}]`; + } + return `${role}: ${content}`; + }).join('\n'); + + logInfo(`上下文压缩: 开始 LLM 摘要,${uncompressedMiddle.length} 条消息待压缩`); + + let summary = ''; + try { + await api.chatStream( + { + model, + messages: [{ + role: 'user', + content: `请将以下对话摘要为一段简洁的上下文总结。保留关键信息:讨论的主题、得出的结论、用户的偏好、未完成的任务、关键工具调用结果。用中文输出,不超过 ${maxSummaryTokens} 字。\n\n对话记录:\n${conversationText}` + }], + stream: true, + think: false, + options: { num_ctx: 8192, temperature: 0.3 } + }, + (chunk: OllamaStreamChunk) => { + if (chunk.message?.content) { + summary += chunk.message.content; + } + }, + options.abortController + ); + } catch (err) { + if ((err as Error).name === 'AbortError') { + logWarn('上下文压缩: LLM 调用被中止'); + return messages; + } + logError('上下文压缩: LLM 调用失败', (err as Error).message); + return messages; + } + + if (!summary.trim()) { + logWarn('上下文压缩: 模型未返回摘要内容'); + return messages; + } + + // 构建压缩后的摘要消息 + const summaryMsg: OllamaMessage = { + role: 'system', + content: `📋 以下是对之前对话的摘要(已压缩 ${uncompressedMiddle.length} 条消息):\n\n${summary}`, + compressed: true + }; + + // 保留已压缩的中间消息 + 新摘要 + const alreadyCompressed = middle.filter(m => m.compressed); + + const result: OllamaMessage[] = [ + ...systemMsgs, + ...head, + ...alreadyCompressed, + summaryMsg, + ...tail + ]; + + const beforeTokens = estimateTokens(messages.map(m => m.content || '').join('')); + const afterTokens = estimateTokens(result.map(m => m.content || '').join('')); + + logSuccess(`上下文压缩完成: ${messages.length} 条 → ${result.length} 条, tokens: ${beforeTokens} → ${afterTokens}`); + + return result; +} + +/** + * 将较早的消息每 batchSize 条压缩为一段摘要(快速文本截取,不调用 LLM) */ function summarizeOlderMessages(messages: OllamaMessage[], batchSize: number): OllamaMessage[] { const summaries: OllamaMessage[] = []; @@ -111,7 +253,8 @@ function summarizeOlderMessages(messages: OllamaMessage[], batchSize: number): O const summary = createQuickSummary(batch); summaries.push({ role: 'system', - content: `【更早的对话摘要(第 ${Math.floor(i / batchSize) + 1} 部分)】\n${summary}` + content: `【更早的对话摘要(第 ${Math.floor(i / batchSize) + 1} 部分)】\n${summary}`, + compressed: true }); } @@ -127,12 +270,10 @@ function createQuickSummary(messages: OllamaMessage[]): string { for (const msg of messages) { const role = msg.role === 'user' ? '用户' : 'AI'; const content = msg.content || ''; - // 取前 100 字符作为摘要 const preview = content.length > 100 ? content.slice(0, 100) + '...' : content; if (preview.trim()) { parts.push(`${role}: ${preview}`); } - // 如果有工具调用,记录工具名 if (msg.tool_calls?.length) { const toolNames = msg.tool_calls.map(t => t.function.name).join(', '); parts.push(` [工具: ${toolNames}]`); @@ -149,7 +290,6 @@ function trimByTokenLimit(messages: OllamaMessage[], maxTokens: number): OllamaM let totalTokens = 0; const result: OllamaMessage[] = []; - // 从后往前添加(优先保留最近的消息) for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; const msgTokens = estimateTokens(msg.content || '') + @@ -164,7 +304,7 @@ function trimByTokenLimit(messages: OllamaMessage[], maxTokens: number): OllamaM } if (totalTokens + msgTokens > maxTokens && result.length > 2) { - break; // 已到限制,保留至少 system + 1 条消息 + break; } totalTokens += msgTokens; diff --git a/src/renderer/services/skill-manager.ts b/src/renderer/services/skill-manager.ts index 6e71e3f..76e53f1 100644 --- a/src/renderer/services/skill-manager.ts +++ b/src/renderer/services/skill-manager.ts @@ -13,6 +13,8 @@ export interface Skill { id: string; name: string; description: string; + /** Level 0 简短摘要(~100 tokens),用于索引展示 */ + summary: string; trigger_keywords: string | null; tool_chain: string; // JSON: ToolChainStep[] success_count: number; @@ -83,10 +85,13 @@ export async function extractSkillsFromToolRecords( const name = generateSkillName(chain, userMessage); const description = generateSkillDescription(chain, sessionTitle); + const summary = generateSkillSummary(chain, userMessage); + const skill: Skill = { id: `skill_${generateId()}`, name, description, + summary, trigger_keywords: keywords, tool_chain: JSON.stringify(chain), success_count: 1, @@ -157,28 +162,21 @@ export async function matchSkills(userMessage: string, limit = 3): Promise { - let chain: ToolChainStep[] = []; - try { chain = JSON.parse(skill.tool_chain); } catch { return ''; } - - const steps = chain.map((s, i) => - ` ${i + 1}. ${s.name}(${s.description})${s.args_hint ? ` — 参数: ${s.args_hint}` : ''}` - ).join('\n'); - const total = skill.success_count + skill.fail_count; const rate = total > 0 ? Math.round(skill.success_count / total * 100) : 100; - - return `【技能】${skill.name}\n${skill.description}\n成功执行 ${skill.success_count} 次(成功率 ${rate}%)\n工具链:\n${steps}`; + return `• ${skill.name} — ${skill.description}(成功率 ${rate}%)`; }).filter(Boolean); if (parts.length === 0) return ''; - return `【可用技能提示 — 以下技能曾成功完成过类似任务,可作为参考】\n\n${parts.join('\n\n')}\n\n提示:如果当前任务与某个技能匹配,可以参考其工具链步骤,但根据实际情况调整参数。`; + return `【可用技能 — 以下技能曾成功完成过类似任务】\n${parts.join('\n')}\n\n提示:使用 skill_view 工具查看某个技能的完整工具链和参数提示。`; } // ── 辅助函数 ── @@ -269,12 +267,20 @@ function generateSkillDescription(chain: ToolChainStep[], sessionTitle: string): return `从会话「${sessionTitle}」中提取。步骤:${steps}`; } +/** 生成技能简短摘要(Level 0,~100 tokens) */ +function generateSkillSummary(chain: ToolChainStep[], userMessage: string): string { + const toolNames = chain.map(s => s.name).join(' → '); + const msgPreview = userMessage.slice(0, 30).replace(/[\r\n]/g, ' '); + return `工具链: ${toolNames}。场景: ${msgPreview}`; +} + // ── 渐进式技能加载 ── -/** 列出所有技能(渐进式 Level 0:只返回名称、描述、成功率) */ +/** 列出所有技能(渐进式 Level 0:只返回名称、描述摘要、成功率) */ export async function listSkills(): Promise> { @@ -292,6 +298,7 @@ export async function listSkills(): Promise