feat: v4.0.0 大版本升级 - SQLite3 + ReAct Agent
- 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 次
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user