- P0: 存储层 IndexedDB → SQLite3 (better-sqlite3),7张表 + FTS5全文搜索 - P1: Agent Engine 升级为 ReAct 模式(思考→行动→观察→反思) - P2: 新增 4 个工具(memory_search, memory_add, session_list, session_read) - P3: 上下文管理器(滑动窗口 + 摘要压缩 + 记忆注入) - P4: UI 改版(ReAct 执行面板样式 + 思考过程卡片) - P5: IndexedDB → SQLite 数据迁移支持 - MAX_LOOPS 10→15, MAX_LOOP_TIME 5min→10min, 错误自动重试 2 次
176 lines
5.0 KiB
TypeScript
176 lines
5.0 KiB
TypeScript
/**
|
|
* Context Manager - 智能上下文管理 (v4.0)
|
|
* 三层策略:滑动窗口 + 消息摘要压缩 + 记忆注入
|
|
*/
|
|
|
|
import type { OllamaMessage } from '../types.js';
|
|
import { logInfo, logWarn } from './log-service.js';
|
|
|
|
/** 粗略估算 token 数 */
|
|
export function estimateTokens(text: string): number {
|
|
if (!text) return 0;
|
|
// 中文按 1.5 字/token,英文按 4 字符/token
|
|
let chineseChars = 0;
|
|
let otherChars = 0;
|
|
for (const ch of text) {
|
|
if (/[\u4e00-\u9fff]/.test(ch)) chineseChars++;
|
|
else otherChars++;
|
|
}
|
|
return Math.ceil(chineseChars / 1.5 + otherChars / 4);
|
|
}
|
|
|
|
export interface ContextBuildOptions {
|
|
/** 滑动窗口大小(最近 N 条消息完整保留) */
|
|
windowSize?: number;
|
|
/** 每 N 条更早的消息压缩为一段摘要 */
|
|
summaryBatchSize?: number;
|
|
/** 最大 token 数限制 */
|
|
maxTokens?: number;
|
|
/** 系统 prompt 注入的记忆上下文 */
|
|
memoryContext?: string;
|
|
/** 工作空间上下文 */
|
|
workspaceContext?: string;
|
|
}
|
|
|
|
const DEFAULT_OPTIONS: Required<ContextBuildOptions> = {
|
|
windowSize: 20,
|
|
summaryBatchSize: 20,
|
|
maxTokens: 24576,
|
|
memoryContext: '',
|
|
workspaceContext: ''
|
|
};
|
|
|
|
/**
|
|
* 构建发送给模型的 messages
|
|
* 三层策略:
|
|
* a. 滑动窗口:最近 N 条消息完整保留
|
|
* b. 更早的消息:每 N 条压缩为一段摘要
|
|
* c. 系统 prompt 注入记忆上下文
|
|
*/
|
|
export function buildContext(
|
|
allMessages: OllamaMessage[],
|
|
options: ContextBuildOptions = {}
|
|
): OllamaMessage[] {
|
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
const result: OllamaMessage[] = [];
|
|
|
|
// 系统 prompt
|
|
let systemContent = '';
|
|
if (opts.memoryContext) {
|
|
systemContent += opts.memoryContext + '\n\n';
|
|
}
|
|
if (opts.workspaceContext) {
|
|
systemContent += opts.workspaceContext + '\n\n';
|
|
}
|
|
|
|
// 提取已有的 system 消息
|
|
const existingSystem = allMessages.find(m => m.role === 'system');
|
|
if (existingSystem) {
|
|
systemContent += existingSystem.content;
|
|
}
|
|
|
|
if (systemContent.trim()) {
|
|
result.push({ role: 'system', content: systemContent.trim() });
|
|
}
|
|
|
|
// 非 system 消息
|
|
const nonSystemMessages = allMessages.filter(m => m.role !== 'system');
|
|
|
|
if (nonSystemMessages.length <= opts.windowSize) {
|
|
// 消息不多,全部保留
|
|
result.push(...nonSystemMessages);
|
|
return result;
|
|
}
|
|
|
|
// 滑动窗口:最近 N 条
|
|
const recentMessages = nonSystemMessages.slice(-opts.windowSize);
|
|
|
|
// 更早的消息:压缩为摘要
|
|
const olderMessages = nonSystemMessages.slice(0, -opts.windowSize);
|
|
const summaries = summarizeOlderMessages(olderMessages, opts.summaryBatchSize);
|
|
|
|
result.push(...summaries, ...recentMessages);
|
|
|
|
// Token 估算和裁剪
|
|
const trimmed = trimByTokenLimit(result, opts.maxTokens);
|
|
|
|
logInfo(`上下文构建: ${nonSystemMessages.length} 条消息 → ${trimmed.length} 条 (窗口: ${opts.windowSize})`,
|
|
`估算 tokens: ${estimateTokens(trimmed.map(m => m.content).join(''))}`);
|
|
|
|
return trimmed;
|
|
}
|
|
|
|
/**
|
|
* 将较早的消息每 batchSize 条压缩为一段摘要
|
|
*/
|
|
function summarizeOlderMessages(messages: OllamaMessage[], batchSize: number): OllamaMessage[] {
|
|
const summaries: OllamaMessage[] = [];
|
|
|
|
for (let i = 0; i < messages.length; i += batchSize) {
|
|
const batch = messages.slice(i, i + batchSize);
|
|
const summary = createQuickSummary(batch);
|
|
summaries.push({
|
|
role: 'system',
|
|
content: `【更早的对话摘要(第 ${Math.floor(i / batchSize) + 1} 部分)】\n${summary}`
|
|
});
|
|
}
|
|
|
|
return summaries;
|
|
}
|
|
|
|
/**
|
|
* 快速摘要(不调用模型,直接提取关键信息)
|
|
*/
|
|
function createQuickSummary(messages: OllamaMessage[]): string {
|
|
const parts: 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}]`);
|
|
}
|
|
}
|
|
|
|
return parts.join('\n');
|
|
}
|
|
|
|
/**
|
|
* 根据 token 限制裁剪消息
|
|
*/
|
|
function trimByTokenLimit(messages: OllamaMessage[], maxTokens: number): OllamaMessage[] {
|
|
let totalTokens = 0;
|
|
const result: OllamaMessage[] = [];
|
|
|
|
// 从后往前添加(优先保留最近的消息)
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
const msg = messages[i];
|
|
const msgTokens = estimateTokens(msg.content || '') +
|
|
(msg.images ? msg.images.length * 100 : 0) +
|
|
(msg.tool_calls ? msg.tool_calls.length * 50 : 0);
|
|
|
|
// system 消息始终保留
|
|
if (msg.role === 'system') {
|
|
totalTokens += msgTokens;
|
|
result.unshift(msg);
|
|
continue;
|
|
}
|
|
|
|
if (totalTokens + msgTokens > maxTokens && result.length > 2) {
|
|
break; // 已到限制,保留至少 system + 1 条消息
|
|
}
|
|
|
|
totalTokens += msgTokens;
|
|
result.unshift(msg);
|
|
}
|
|
|
|
return result;
|
|
}
|