# 上下文压缩 (Context Compaction) 实施计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 为 NetaClaw Agent 引擎添加四阶段有损上下文压缩能力,支持手动 `/compact` 和自动阈值双触发,前端实时显示 token 用量进度条与压缩状态。 **Architecture:** 压缩逻辑位于 `gateway/server.ts` 的 `handleChat` 流程中(`runAgent()` 之前),通过 `CompactionService` 执行四阶段有损压缩(裁工具结果 → 边界检测 → LLM 摘要 → 装配清理孤儿)。辅助模型通过 `AuxiliaryLLMClient` Service 调用,渠道配置支持 Agent 级覆盖和系统默认。前端通过 WebSocket 接收压缩事件,底部进度条实时显示 token 占比。 **Tech Stack:** Midway.js 3.20 + TypeORM + Socket.IO (后端), Vue 3.5 + Element Plus (前端), MySQL 8+ **设计文档:** `docs/superpowers/specs/2026-04-17-context-compaction-design.md` --- ## 文件结构 ### 新建文件 | 文件 | 职责 | |---|---| | `backend/src/modules/netaclaw/runtime/compaction.ts` | CompactionService — 四阶段压缩算法核心 | | `backend/src/modules/netaclaw/runtime/auxiliary_llm.ts` | AuxiliaryLLMClient — 辅助模型调用 Service | | `backend/src/modules/netaclaw/runtime/token_utils.ts` | Token 估算工具函数 | ### 修改文件 | 文件 | 修改内容 | |---|---| | `backend/src/modules/netaclaw/entity/message.ts` | 新增 3 字段 + 2 索引 | | `backend/src/modules/netaclaw/entity/agent.ts` | 新增 4 字段 | | `backend/src/modules/netaclaw/entity/model_channel.ts` | 新增 1 字段 | | `backend/src/modules/netaclaw/plugins/plugin_entry.ts` | 新增 `LLMMessageWithId` 接口 | | `backend/src/modules/netaclaw/gateway/protocol.ts` | 新增压缩相关 WS 事件类型 | | `backend/src/modules/netaclaw/gateway/session.ts` | `loadHistory` 增加 view 参数 + `applyCompaction` 方法 | | `backend/src/modules/netaclaw/gateway/server.ts` | 集成自动/手动压缩触发 + token 估算系数修正 | | `frontend/src/modules/agent/components/token-stats.vue` | 重构为进度条式显示 | | `frontend/src/modules/agent/store/chat.ts` | 新增 compactionState + 压缩事件处理 | | `frontend/src/modules/agent/hooks/websocket.ts` | 注册压缩事件回调 | | `frontend/src/modules/agent/views/agent-edit.vue` | "模型配置" Tab 新增压缩配置子区域 | | `frontend/src/modules/agent/views/model-channel.vue` | 渠道编辑表单新增 isAuxiliary 勾选 | --- ## PR 1 — 后端核心(DB + 压缩算法 + 辅助模型) ### Task 1: Entity 字段扩展(message + agent + model_channel) **Files:** - Modify: `packages/backend/src/modules/netaclaw/entity/message.ts` - Modify: `packages/backend/src/modules/netaclaw/entity/agent.ts` - Modify: `packages/backend/src/modules/netaclaw/entity/model_channel.ts` - [ ] **Step 1: 修改 message.ts — 新增 3 字段 + 2 索引** 在 `metadata` 字段后追加: ```typescript @Column({ comment: '被压缩的时刻', type: 'datetime', nullable: true }) @Index() compactedAt: Date; @Column({ comment: '关联的摘要消息ID', nullable: true }) compactedIntoId: number; @Column({ comment: '是否为压缩摘要消息', default: false }) @Index() isCompactionSummary: boolean; ``` - [ ] **Step 2: 修改 agent.ts — 新增 4 字段** 在 `isCrewMaster` 字段后追加: ```typescript @Column({ comment: '辅助模型渠道ID', nullable: true }) auxiliaryModelChannelId: number; @Column({ comment: '辅助模型ID', nullable: true }) auxiliaryModelId: string; @Column({ comment: '自动压缩阈值百分比 0=禁用', default: 70 }) compactionThreshold: number; @Column({ comment: '压缩保留最近N条消息', default: 4 }) compactionKeepRecent: number; ``` - [ ] **Step 3: 修改 model_channel.ts — 新增 1 字段** 在 `status` 字段前追加: ```typescript @Column({ comment: '是否可作为辅助模型渠道', default: false }) isAuxiliary: boolean; ``` - [ ] **Step 4: 验证 — 启动后端确认 synchronize 自动建列** ```bash cd packages/backend && pnpm dev ``` **Commit:** ```bash git add packages/backend/src/modules/netaclaw/entity/ git commit -m "feat(netaclaw): 扩展 Entity 字段支持上下文压缩" ``` --- ### Task 2: token_utils.ts — Token 估算工具函数 **Files:** - Create: `packages/backend/src/modules/netaclaw/runtime/token_utils.ts` - [ ] **Step 1: 创建 token_utils.ts** ```typescript import { LLMMessage } from '../plugins/plugin_entry.js'; /** 每个 token 约 4 个字符 */ const CHARS_PER_TOKEN = 4; /** 每条消息的固定开销 token */ const MSG_OVERHEAD = 4; /** 估算单条消息的 token 数 */ export function estimateMessageTokens(msg: LLMMessage): number { let chars = (msg.content ?? '').length; if (msg.toolCalls) { for (const tc of msg.toolCalls) { // ToolCall.arguments 已经是 JSON string chars += (tc.arguments ?? '').length; chars += (tc.name ?? '').length; chars += (tc.id ?? '').length; } } if (msg.toolCallId) { chars += msg.toolCallId.length; } return Math.ceil(chars / CHARS_PER_TOKEN) + MSG_OVERHEAD; } /** 估算整个历史的 token 数 */ export function estimateHistoryTokens(messages: LLMMessage[]): number { return messages.reduce((sum, m) => sum + estimateMessageTokens(m), 0); } /** 计算上下文占用百分比 */ export function contextPercent(usedTokens: number, maxTokens: number): number { if (maxTokens <= 0) return 0; return Math.min(100, Math.round((usedTokens / maxTokens) * 100)); } ``` **Commit:** ```bash git add packages/backend/src/modules/netaclaw/runtime/token_utils.ts git commit -m "feat(netaclaw): 新增 token_utils.ts — 消息级 token 估算工具" ``` --- ### Task 3: plugin_entry.ts — 新增 LLMMessageWithId 接口 **Files:** - Modify: `packages/backend/src/modules/netaclaw/plugins/plugin_entry.ts` - [ ] **Step 1: 在 LLMMessage 接口后追加 LLMMessageWithId** ```typescript /** 带数据库 ID 的消息,用于压缩流程定位 */ export interface LLMMessageWithId extends LLMMessage { /** 数据库自增 ID (来自 NetaClawMessageEntity.id) */ id: number; } ``` **Commit:** ```bash git add packages/backend/src/modules/netaclaw/plugins/plugin_entry.ts git commit -m "feat(netaclaw): 新增 LLMMessageWithId 接口供压缩流程使用" ``` --- ### Task 4: AuxiliaryLLMClient — 辅助模型调用 Service **Files:** - Create: `packages/backend/src/modules/netaclaw/runtime/auxiliary_llm.ts` - [ ] **Step 1: 创建 auxiliary_llm.ts** 参考 `model_channel.ts` 的 `testConnection` 方法写法,通过 `channelService.info(id)` 获取渠道,用 `supplierToProvider` 映射获取 provider name,再调用 `getProvider(providerName).chat()`。 **重要:** 对外暴露 `summarize(params)` 方法(与设计文档一致),返回 `{ summary, usage }`。由 AuxiliaryLLMClient 内部构造首次/迭代压缩 Prompt 并完成调用。 ```typescript import { Provide, Scope, ScopeEnum, Inject, Logger } from '@midwayjs/core'; import { ILogger } from '@midwayjs/logger'; import { InjectEntityModel } from '@midwayjs/typeorm'; import { Repository } from 'typeorm'; import { NetaClawModelChannelService } from '../service/model_channel.js'; import { DictTypeEntity } from '../../dict/entity/type.js'; import { DictInfoEntity } from '../../dict/entity/info.js'; import { LLMMessage } from '../plugins/plugin_entry.js'; import { getProvider, initDefaultProviders } from './model_selection.js'; /** 辅助模型默认配置键名(存储在 dict 表中) */ const DICT_KEY = 'netaclaw'; const DICT_AUX_CHANNEL = 'auxiliary_channel_id'; const DICT_AUX_MODEL = 'auxiliary_model_id'; /** 摘要调用入参 */ export interface SummarizeParams { /** Agent 级辅助渠道ID,不传则使用字典表系统默认 */ agentAuxChannelId?: number; /** 已序列化的被压缩历史文本 */ serialized: string; /** 上一次摘要(存在则走迭代更新模板,否则走首次压缩模板) */ previousSummary?: string; /** 本次摘要的 token 预算(作为 maxTokens 传入 LLM) */ summaryBudget: number; } /** 摘要调用结果 */ export interface SummarizeResult { /** 生成的摘要文本 */ summary: string; /** 本次调用的 token 使用情况 */ usage: { inputTokens: number; outputTokens: number }; } @Provide() @Scope(ScopeEnum.Singleton) export class AuxiliaryLLMClient { @Logger() logger: ILogger; @Inject() channelService: NetaClawModelChannelService; @InjectEntityModel(DictTypeEntity) dictTypeRepo: Repository; @InjectEntityModel(DictInfoEntity) dictInfoRepo: Repository; /** 供应商 → LLM Provider 映射(与 model_channel.ts 保持一致) */ private readonly supplierToProvider: Record = { openai: 'openai', anthropic: 'anthropic', deepseek: 'deepseek', zhipu: 'openai', tongyi: 'openai', minimax: 'openai', volcengine: 'openai', ollama: 'openai', azure: 'openai', }; /** * 从字典表获取系统默认辅助模型配置 * 查询方式:先查 dict_type 获取 netaclaw 的 id,再用 typeId + name 查 dict_info */ private async getDictValue(name: string): Promise { const dictType = await this.dictTypeRepo.findOneBy({ key: DICT_KEY }); if (!dictType) return null; const info = await this.dictInfoRepo.findOneBy({ typeId: dictType.id, name }); return info?.value ?? null; } /** * 解析辅助模型渠道 ID * 优先级:Agent 级配置 > 字典表系统默认 */ private async resolveChannelId(agentAuxChannelId?: number): Promise { if (agentAuxChannelId && agentAuxChannelId > 0) return agentAuxChannelId; const chVal = await this.getDictValue(DICT_AUX_CHANNEL); if (!chVal) throw new Error('未配置辅助模型渠道,请在 Agent 或字典表中配置'); return parseInt(chVal, 10); } /** * 调用辅助模型生成摘要(与设计文档契约一致) * 返回 { summary, usage } */ async summarize(params: SummarizeParams): Promise { const channelId = await this.resolveChannelId(params.agentAuxChannelId); const channel = await this.channelService.info(channelId); if (!channel) throw new Error(`辅助模型渠道 ${channelId} 不可用`); if (channel.status !== 1) throw new Error(`辅助模型渠道 ${channel.name} 已禁用`); const models = Array.isArray(channel.models) ? channel.models : []; const modelName = typeof models[0] === 'string' ? (models[0] as string) : (models[0] as any)?.name; if (!modelName) throw new Error(`辅助模型渠道 ${channelId} 未配置模型`); const providerName = this.supplierToProvider[channel.supplier] || 'openai'; initDefaultProviders(); const provider = getProvider(providerName); const prompt = params.previousSummary ? this.buildIterativePrompt(params.previousSummary, params.serialized, params.summaryBudget) : this.buildFirstPrompt(params.serialized, params.summaryBudget); const response = await provider.chat( [{ role: 'user', content: prompt }], [], { provider: providerName, model: modelName, apiKey: channel.apiKey, baseUrl: channel.baseUrl, temperature: 0.3, maxTokens: params.summaryBudget, thinkLevel: 'off', } as any, ); const summary = (response.content || '').trim(); if (summary.length < 200) { throw new Error('摘要内容过短,压缩失败'); } return { summary, usage: { inputTokens: response.usage?.inputTokens ?? 0, outputTokens: response.usage?.outputTokens ?? 0, }, }; } /** 首次压缩 Prompt 模板(见设计文档第 3 节) */ private buildFirstPrompt(serialized: string, budget: number): string { return `你是一个对话摘要助手。请将下面的多轮对话压缩为一段结构化摘要(不超过 ${budget} tokens),保留: 1. 用户的核心诉求和关键问题 2. 助手已完成的工作、关键结论 3. 调用过的工具及其核心输出 4. 尚未解决的问题 输出格式(Markdown): ## 对话摘要(由上下文压缩生成) ### 用户目标 ... ### 已完成工作 - ... ### 关键发现 - ... ### 待办事项 - ... --- 以下是待压缩的对话: ${serialized}`; } /** 迭代更新 Prompt 模板(见设计文档第 3 节) */ private buildIterativePrompt(previousSummary: string, serialized: string, budget: number): string { return `你是一个对话摘要助手。下面是同一会话上一次生成的摘要,以及本次新增的对话内容。请在保留上一次摘要关键信息的基础上,融合本次新内容,输出一份更新后的结构化摘要(不超过 ${budget} tokens)。 要求: 1. 保留用户目标不要丢失 2. 合并"已完成工作",去重但不漏关键里程碑 3. 更新"关键发现"和"待办事项" 4. 如果新增内容让某些旧待办变成已完成,要移动到"已完成工作" --- 【上一次摘要】 ${previousSummary} --- 【本次新增的对话】 ${serialized} --- 请输出融合后的摘要(Markdown,结构与上次摘要保持一致):`; } } ``` **Commit:** ```bash git add packages/backend/src/modules/netaclaw/runtime/auxiliary_llm.ts git commit -m "feat(netaclaw): 新增 AuxiliaryLLMClient — 辅助模型调用 Service" ``` --- ### Task 5: CompactionService — 四阶段压缩算法核心 **Files:** - Create: `packages/backend/src/modules/netaclaw/runtime/compaction.ts` - [ ] **Step 1: 创建 compaction.ts** 四阶段压缩算法: 1. **Stage 1 裁工具结果**:将老工具结果消息的 content 替换为 `[truncated]` 摘要标记 2. **Stage 2 边界检测**:找到保留最近 N 条 + 正在进行的工具调用对 3. **Stage 3 LLM 摘要**:用辅助模型生成摘要 4. **Stage 4 装配清理**:构造新历史 [保留消息 + 摘要消息 + 最近消息],标记旧消息 compactedAt ```typescript import { Provide, Scope, ScopeEnum, Inject, Logger } from '@midwayjs/core'; import { ILogger } from '@midwayjs/logger'; import { InjectEntityModel } from '@midwayjs/typeorm'; import { Repository, In, IsNull } from 'typeorm'; import { NetaClawMessageEntity } from '../entity/message.js'; import { LLMMessage, LLMMessageWithId } from '../plugins/plugin_entry.js'; import { AuxiliaryLLMClient } from './auxiliary_llm.js'; import { estimateMessageTokens, estimateHistoryTokens } from './token_utils.js'; /** 压缩触发原因 */ export type CompactionReason = 'manual' | 'auto'; /** 压缩结果 */ export interface CompactionResult { success: boolean; /** 被压缩的消息数 */ compactedCount: number; /** 保留在历史末尾的消息数 */ keepRecentCount: number; /** 生成的摘要消息完整内容 */ summaryContent: string; /** 摘要消息在 DB 中的 id */ summaryMessageId: number; /** 压缩前 token 估算 */ tokensBefore: number; /** 压缩后 token 估算 */ tokensAfter: number; /** 失败原因 */ errorMessage?: string; } /** 工具结果裁剪占位符 */ const TOOL_RESULT_PLACEHOLDER = '[...truncated by compaction...]'; /** 尾部 token 预算占比(阈值 token 的百分比) */ const TAIL_TOKEN_BUDGET_RATIO = 0.3; @Provide() @Scope(ScopeEnum.Singleton) export class CompactionService { @Logger() logger: ILogger; @Inject() auxiliaryLLM: AuxiliaryLLMClient; @InjectEntityModel(NetaClawMessageEntity) messageRepo: Repository; /** * 执行压缩 * @param sessionId 会话ID * @param history 带 id 的完整历史(来自 session.loadHistoryWithId()) * @param thresholdTokens 压缩阈值对应的 token 数(maxTokens * threshold%) * @param auxChannelId Agent 级辅助渠道ID(可选) * @param reason 触发原因 */ async compact( sessionId: string, history: LLMMessageWithId[], thresholdTokens: number, auxChannelId: number | undefined, reason: CompactionReason, ): Promise { const tokensBefore = estimateHistoryTokens(history); // 查询上一次摘要(用于迭代压缩) const previousSummary = await this.getLatestSummary(sessionId); // Stage 1: 裁剪工具结果(尾部 tailBudget 内的消息不裁剪) const pruned = this.phase1PruneToolResults(history, TAIL_TOKEN_BUDGET_RATIO, thresholdTokens); // Stage 2: 边界检测(用 token 预算而非固定条数) const { compressEnd } = this.phase2FindBoundaries(pruned, TAIL_TOKEN_BUDGET_RATIO, thresholdTokens); const toCompact = history.slice(0, compressEnd); const toKeep = history.slice(compressEnd); if (toCompact.length === 0) { return { success: false, compactedCount: 0, keepRecentCount: toKeep.length, summaryContent: '', summaryMessageId: 0, tokensBefore, tokensAfter: tokensBefore, errorMessage: '没有可压缩的历史消息', }; } // 序列化被压缩部分 const serialized = this.serializeMessages(pruned.slice(0, compressEnd)); // Stage 3: LLM 摘要 const summaryBudget = Math.max(1024, Math.floor(thresholdTokens * 0.15)); let summaryContent: string; let usage: { inputTokens: number; outputTokens: number }; try { const result = await this.auxiliaryLLM.summarize({ agentAuxChannelId: auxChannelId, serialized, previousSummary: previousSummary ?? undefined, summaryBudget, }); summaryContent = result.summary; usage = result.usage; } catch (e: any) { this.logger.error('[Compaction] 摘要生成失败: %s', e.message); return { success: false, compactedCount: toCompact.length, keepRecentCount: toKeep.length, summaryContent: '', summaryMessageId: 0, tokensBefore, tokensAfter: tokensBefore, errorMessage: `摘要生成失败: ${e.message}`, }; } // Stage 4: 装配 — 创建摘要消息,标记旧消息 const summaryMsg = await this.messageRepo.save({ sessionId, role: 'user', content: summaryContent, isCompactionSummary: true, metadata: { reason, compactedIds: toCompact.map(m => m.id), usage, hasPreviousSummary: !!previousSummary, } as any, }); const compactedAt = new Date(); const ids = toCompact.map(m => m.id); if (ids.length > 0) { await this.messageRepo.update( { id: In(ids) }, { compactedAt, compactedIntoId: summaryMsg.id }, ); } // 计算压缩后 token const summaryLLMMsg: LLMMessage = { role: 'user', content: summaryContent }; const tokensAfter = estimateMessageTokens(summaryLLMMsg) + estimateHistoryTokens(toKeep); return { success: true, compactedCount: toCompact.length, keepRecentCount: toKeep.length, summaryContent, summaryMessageId: summaryMsg.id, tokensBefore, tokensAfter, }; } /** * 查询该 session 最近的压缩摘要内容(用于迭代压缩) */ async getLatestSummary(sessionId: string): Promise { const msg = await this.messageRepo.findOne({ where: { sessionId, isCompactionSummary: true, compactedAt: IsNull() }, order: { createTime: 'DESC' }, }); return msg?.content ?? null; } /** * Stage 1: 裁剪工具结果 * 从末尾反向累计 token,超过 tailBudget 后,前面的 tool 结果消息内容超过 200 字符的替换为占位符 */ private phase1PruneToolResults( messages: LLMMessageWithId[], tailTokenBudgetRatio: number, thresholdTokens: number, ): LLMMessageWithId[] { const tailBudget = Math.floor(thresholdTokens * tailTokenBudgetRatio); let tailTokens = 0; let cutIndex = messages.length; for (let i = messages.length - 1; i >= 0; i--) { tailTokens += estimateMessageTokens(messages[i]); if (tailTokens >= tailBudget) { cutIndex = i; break; } } return messages.map((m, i) => { if (i < cutIndex && m.role === 'tool' && (m.content?.length ?? 0) > 200) { return { ...m, content: TOOL_RESULT_PLACEHOLDER }; } return m; }); } /** * Stage 2: 边界检测 * 从末尾反向累积 token 到 tailBudget 耗尽,确定 compressEnd 分割点 * 同时保证不拆分工具调用对 */ private phase2FindBoundaries( messages: LLMMessageWithId[], tailTokenBudgetRatio: number, thresholdTokens: number, ): { compressEnd: number } { const tailBudget = Math.floor(thresholdTokens * tailTokenBudgetRatio); let tailTokens = 0; let boundary = messages.length; // 从末尾反向累积到 tailBudget for (let i = messages.length - 1; i >= 0; i--) { tailTokens += estimateMessageTokens(messages[i]); if (tailTokens >= tailBudget) { boundary = i; break; } } if (boundary <= 0) return { compressEnd: 0 }; // 向前扩展:不拆分工具调用对 while (boundary > 0) { const current = messages[boundary]; const prev = messages[boundary - 1]; // 如果当前是 tool 消息,前一个必须是带 toolCalls 的 assistant;回退 if (current.role === 'tool') { boundary -= 1; continue; } // 如果前一个 assistant 有未闭合的 toolCalls,回退 if (prev.role === 'assistant' && prev.toolCalls && prev.toolCalls.length > 0) { const expectedIds = new Set(prev.toolCalls.map(tc => tc.id)); for (let i = boundary; i < messages.length && expectedIds.size > 0; i++) { if (messages[i].role === 'tool' && messages[i].toolCallId) { expectedIds.delete(messages[i].toolCallId!); } } if (expectedIds.size > 0) { boundary -= 1; continue; } } break; } return { compressEnd: boundary }; } /** 将消息列表序列化为文本(供 LLM 摘要使用) */ private serializeMessages(messages: (LLMMessage | LLMMessageWithId)[]): string { return messages.map(m => { const prefix = `[${m.role}]`; const toolCallInfo = m.toolCalls ? ` (调用工具: ${m.toolCalls.map(t => t.name).join(',')})` : ''; return `${prefix}${toolCallInfo} ${m.content}`; }).join('\n\n'); } } ``` - [ ] **Step 2: 验证编译** ```bash cd packages/backend && npx tsc --noEmit ``` **Commit:** ```bash git add packages/backend/src/modules/netaclaw/runtime/compaction.ts git commit -m "feat(netaclaw): 新增 CompactionService — 四阶段有损上下文压缩算法" ``` --- ## PR 2 — 后端集成(Session + Protocol + Gateway) ### Task 6: SessionService — loadHistory 增加 view 参数 + applyCompaction **Files:** - Modify: `packages/backend/src/modules/netaclaw/gateway/session.ts` - [ ] **Step 1: 修改 loadHistory 签名,新增 view 参数** 原方法签名 `loadHistory(sessionId: string): Promise` 改为支持视图选择: ```typescript import { LLMMessage, LLMMessageWithId } from '../plugins/plugin_entry.js'; /** * 加载会话历史 * @param sessionId 会话ID * @param options.view 'compacted'(默认,供 LLM 使用)= 排除被压缩的消息 * 'full'(前端归档视图) = 返回全部 * 'withId' 返回带 id 的消息(供压缩服务使用) */ async loadHistory( sessionId: string, options: { view?: 'compacted' | 'full' } = {}, ): Promise { const view = options.view ?? 'compacted'; const where: any = { sessionId }; if (view === 'compacted') { // 排除被压缩掉的消息(compactedAt IS NULL),保留摘要消息 where.compactedAt = null; } const messages = await this.messageRepo.find({ where, order: { createTime: 'ASC' }, }); return messages.map(m => ({ role: m.role as LLMMessage['role'], content: m.content, toolCalls: m.toolCalls as any, toolCallId: m.toolCallId, })); } /** * 加载带数据库 ID 的历史(供 CompactionService 使用) */ async loadHistoryWithId(sessionId: string): Promise { const messages = await this.messageRepo.find({ where: { sessionId, compactedAt: null as any }, order: { createTime: 'ASC' }, }); return messages.map(m => ({ id: m.id, role: m.role as LLMMessage['role'], content: m.content, toolCalls: m.toolCalls as any, toolCallId: m.toolCallId, })); } ``` - [ ] **Step 2: 新增 applyCompaction 方法(可选,若 CompactionService 已直接写库可跳过)** CompactionService 已经直接操作 messageRepo 写入摘要并更新压缩标记。Session 层可以不暴露 applyCompaction,保持职责单一。如果需要一个查询方法: ```typescript /** 查询指定摘要消息关联的原始消息 */ async getCompactedMessagesBySummary(summaryId: number) { return this.messageRepo.find({ where: { compactedIntoId: summaryId }, order: { createTime: 'ASC' }, }); } /** * 查询该 session 最近的压缩摘要内容(供迭代压缩使用) * 注:CompactionService.getLatestSummary 会直接调用此方法,或在 CompactionService 内自行查询 messageRepo。 * 二者择一实现即可;本实施计划在 CompactionService 内部实现了同等查询,若集中管理建议放在 SessionService。 */ async getLatestSummary(sessionId: string): Promise { const msg = await this.messageRepo.findOne({ where: { sessionId, isCompactionSummary: true, compactedAt: IsNull() as any }, order: { createTime: 'DESC' }, }); return msg?.content ?? null; } ``` **Commit:** ```bash git add packages/backend/src/modules/netaclaw/gateway/session.ts git commit -m "feat(netaclaw): SessionService 增加 view 参数支持压缩/完整历史切换" ``` --- ### Task 7: Protocol — 新增压缩相关 WS 事件类型 **Files:** - Modify: `packages/backend/src/modules/netaclaw/gateway/protocol.ts` - [ ] **Step 1: 新增客户端 compact 消息类型** 在 `ClientClarifyResponseMessage` 后追加: ```typescript export interface ClientCompactMessage { type: 'compact'; sessionId: string; /** 可选的用户指令文本(用于未来扩展,当前未使用) */ instructions?: string; } ``` 并更新联合类型: ```typescript export type ClientMessage = | ClientChatMessage | ClientPingMessage | ClientSetThinkingLevelMessage | ClientClarifyResponseMessage | ClientCompactMessage; ``` - [ ] **Step 2: 扩展 ServerTokenUpdateEvent — 仅在 data.context 中追加字段** 修改现有 `ServerTokenUpdateEvent`(不重新定义结构): ```typescript export interface ServerTokenUpdateEvent { type: 'token_update'; sessionId: string; data: { current: { inputTokens: number; outputTokens: number; totalTokens: number; apiCalls: number }; context: { usedTokens: number; maxTokens: number; percent: number; /** 新增:压缩阈值百分比(来自 Agent 配置),前端用于决定是否显示警告色 */ thresholdPercent?: number; /** 新增:是否为本地估算值(true=未走完整模型返回,仅字符估算) */ estimated?: boolean; }; }; } ``` - [ ] **Step 3: 新增压缩相关服务端事件** ```typescript export interface ServerCompactionStartEvent { type: 'compaction_start'; sessionId: string; data: { /** 触发原因 */ reason: 'manual' | 'auto'; /** 即将压缩的消息数 */ messageCount: number; }; } export interface ServerCompactionDoneEvent { type: 'compaction_done'; sessionId: string; data: { /** 压缩成功 */ success: boolean; /** 实际被压缩的消息数 */ compactedCount: number; /** 保留的最近消息数 */ keepRecentCount: number; /** 摘要消息在 DB 中的 id(前端用于跳转或加载全文) */ summaryMessageId: number; /** 摘要完整内容(前端存入 store 供 drawer 读取) */ summaryContent: string; /** 压缩前后 token 对比 */ tokensBefore: number; tokensAfter: number; /** 失败时的原因 */ errorMessage?: string; }; } export interface ServerCompactionErrorEvent { type: 'compaction_error'; sessionId: string; data: { message: string; /** 是否允许重试 */ retryable: boolean; }; } ``` - [ ] **Step 4: 更新 ServerEvent 联合类型** ```typescript export type ServerEvent = | ServerTokenEvent | ServerThinkingEvent | ServerToolCallEvent | ServerToolResultEvent | ServerSkillStartEvent | ServerSkillEndEvent | ServerProgressEvent | ServerTokenUpdateEvent | ServerThinkingDeltaEvent | ServerThinkingDoneEvent | ServerTodoUpdateEvent | ServerClarifyRequestEvent | ServerCompactionStartEvent | ServerCompactionDoneEvent | ServerCompactionErrorEvent | ServerDoneEvent | ServerErrorEvent | ServerPongEvent; ``` **Commit:** ```bash git add packages/backend/src/modules/netaclaw/gateway/protocol.ts git commit -m "feat(netaclaw): Protocol 新增压缩相关 WS 事件类型(ClientCompact + compaction_start/done/error)" ``` --- ### Task 8: Gateway — 集成自动/手动压缩 + token 估算修正 **Files:** - Modify: `packages/backend/src/modules/netaclaw/gateway/server.ts` - [ ] **Step 1: 注入 CompactionService + AuxiliaryLLMClient(间接)** 在 `NetaClawGateway` 顶部追加: ```typescript import { CompactionService } from '../runtime/compaction.js'; import { estimateHistoryTokens } from '../runtime/token_utils.js'; // 注意:getModelMaxTokens 已在原 server.ts 顶部导入(line 11),本次无需重复导入 // 在类内: @Inject() compactionService: CompactionService; ``` - [ ] **Step 2: 在 sessionState 中新增 compactionInFlight 锁** ```typescript /** 会话级状态:思考级别 + TodoStore + 压缩进行中标记 */ private sessionState = new Map; }>(); ``` - [ ] **Step 3: 封装 parseSlashCommand** ```typescript /** 解析前端的斜杠命令,供未来扩展(/compact、/clear、/help 等) */ private parseSlashCommand(content: string): { command: string; args: string } | null { const trimmed = content.trim(); if (!trimmed.startsWith('/')) return null; const spaceIdx = trimmed.indexOf(' '); if (spaceIdx === -1) return { command: trimmed.slice(1).toLowerCase(), args: '' }; return { command: trimmed.slice(1, spaceIdx).toLowerCase(), args: trimmed.slice(spaceIdx + 1), }; } ``` - [ ] **Step 4: onMessage 新增 compact 分支** 在现有 switch 结构里追加: ```typescript if (msg.type === 'compact') { await this.handleCompact(msg.sessionId, 'manual'); return; } ``` - [ ] **Step 5: 实现 handleCompact** ```typescript private async handleCompact(sid: string, reason: 'manual' | 'auto', agentInfo?: any) { const state = this.sessionState.get(sid) ?? {}; if (state.compactionInFlight) { this.logger.warn('[NetaClaw] 压缩已在进行中,忽略重复触发: %s', sid); return; } state.compactionInFlight = true; this.sessionState.set(sid, state); try { // 加载带 ID 的历史 const historyWithId = await this.sessionService.loadHistoryWithId(sid); // 解析 Agent 压缩配置 const keepRecent = agentInfo?.compactionKeepRecent ?? 4; const auxChannelId = agentInfo?.auxiliaryModelChannelId; // 注:新版 CompactionService 使用 Agent 渠道配置中的 modelName,不再单独接收 auxModelId // 模型名由 AuxiliaryLLMClient.summarize 内部从 channel.models[0] 解析 // 计算压缩阈值对应的 token 数(用于 Stage 1/2 的 tailBudget 计算) const agentConfigForCompact = agentInfo?.modelConfig ?? {}; const maxTokensForCompact = getModelMaxTokens(agentConfigForCompact.model || 'gpt-4o'); const thresholdPercent = agentInfo?.compactionThreshold ?? 70; const thresholdTokens = Math.floor(maxTokensForCompact * (thresholdPercent / 100)); this.send({ type: 'compaction_start', sessionId: sid, data: { reason, messageCount: Math.max(0, historyWithId.length - keepRecent) }, }); const result = await this.compactionService.compact( sid, historyWithId, thresholdTokens, auxChannelId, reason, ); if (!result.success) { this.send({ type: 'compaction_error', sessionId: sid, data: { message: result.errorMessage ?? '压缩失败', retryable: true }, }); return; } this.send({ type: 'compaction_done', sessionId: sid, data: { success: true, compactedCount: result.compactedCount, keepRecentCount: result.keepRecentCount, summaryMessageId: result.summaryMessageId, summaryContent: result.summaryContent, tokensBefore: result.tokensBefore, tokensAfter: result.tokensAfter, }, }); } catch (e: any) { this.logger.error('[NetaClaw] 压缩失败: %s', e.stack || e.message); this.send({ type: 'compaction_error', sessionId: sid, data: { message: e.message ?? '压缩流程异常', retryable: true }, }); } finally { const st = this.sessionState.get(sid) ?? {}; st.compactionInFlight = false; this.sessionState.set(sid, st); // 清空排队 const queued = st.pendingChat; st.pendingChat = []; this.sessionState.set(sid, st); if (queued && queued.length > 0) { for (const q of queued) { await this.handleChat(sid, q.content, q.agentName, q.agentId); } } } } ``` - [ ] **Step 6: handleChat 修改 — 加载压缩视图 + 压缩排队 + token 修正** 定位到 `const history = await this.sessionService.loadHistory(sid);`(原 line 115),改为: ```typescript // 压缩进行中 → 排队等待 const stateForLock = this.sessionState.get(sid); if (stateForLock?.compactionInFlight) { this.logger.info('[NetaClaw] 压缩进行中,用户消息排队: sid=%s', sid); stateForLock.pendingChat = stateForLock.pendingChat ?? []; stateForLock.pendingChat.push({ content, agentName, agentId }); this.sessionState.set(sid, stateForLock); return; } // 加载历史(compacted 视图,排除已压缩消息) let history = await this.sessionService.loadHistory(sid, { view: 'compacted' }); ``` > 注意:这里是 `let` 而非 `const`,因为后面可能会重新加载(压缩后)。 - [ ] **Step 7: handleChat 在 runAgent 之前插入自动压缩检测** 在 `const result = await runAgent({...});` 之前插入: ```typescript // ---- 自动压缩检测 ---- if (agentId) { const agentInfoForCompact = await this.agentService.info(agentId); const threshold = agentInfoForCompact?.compactionThreshold ?? 80; if (threshold > 0 && agentConfig) { const maxTokens = getModelMaxTokens(agentConfig.model); const usedTokens = estimateHistoryTokens(history); const percent = maxTokens > 0 ? (usedTokens / maxTokens) * 100 : 0; // 边界场景:单条消息已超过阈值(如系统提示词极长) if (percent >= threshold) { this.logger.info('[NetaClaw] 触发自动压缩: sid=%s, percent=%s, threshold=%s', sid, percent.toFixed(1), threshold); await this.handleCompact(sid, 'auto', agentInfoForCompact); // 压缩后重新加载历史 history = await this.sessionService.loadHistory(sid, { view: 'compacted' }); } } } ``` - [ ] **Step 8: 修正 token 估算系数 /3 → /4** 定位到 `history.reduce((sum, m) => sum + ((m.content as string)?.length || 0) / 3, 0)`,替换为: ```typescript const historyTokens = estimateHistoryTokens(history); ``` 并在 `token_update` 事件的 `context` 里追加 thresholdPercent 与 estimated 字段: ```typescript let thresholdPercent: number | undefined; if (agentId) { const info = await this.agentService.info(agentId); thresholdPercent = info?.compactionThreshold; } this.send({ type: 'token_update', sessionId: sid, data: { current: { inputTokens: inp, outputTokens: out, totalTokens: inp + out, apiCalls: (result.usage as any).apiCalls || 1, }, context: { usedTokens, maxTokens, percent: Math.min(100, Math.round((usedTokens / maxTokens) * 100)), thresholdPercent, estimated: false, }, }, }); ``` - [ ] **Step 9: 验证 — 手动触发 /compact、自动触发、排队** 启动后端,前端发送 `{ type: 'compact', sessionId }`,预期收到 compaction_start → compaction_done。 **Commit:** ```bash git add packages/backend/src/modules/netaclaw/gateway/server.ts git commit -m "feat(netaclaw): Gateway 集成自动/手动压缩触发 + token 估算修正(/3→/4)" ``` --- ## PR 3 — 前端 UI(进度条 + /compact + 压缩气泡 + 历史视图 + 配置页) ### Task 9: 前端类型定义 — 同步后端 Protocol **Files:** - Modify: `packages/frontend/src/modules/agent/types/protocol.ts`(若不存在则新建) - [ ] **Step 1: 定义前端使用的 Protocol 类型(与后端 protocol.ts 保持一致)** ```typescript // packages/frontend/src/modules/agent/types/protocol.ts export interface ClientChatMessage { type: 'chat'; sessionId: string; content: string; agentName?: string; agentId?: number; } export interface ClientPingMessage { type: 'ping'; } export interface ClientSetThinkingLevelMessage { type: 'set_thinking_level'; sessionId: string; level: string; } export interface ClientClarifyResponseMessage { type: 'clarify_response'; sessionId: string; requestId: string; answer: string; } /** 新增:手动触发上下文压缩 */ export interface ClientCompactMessage { type: 'compact'; sessionId: string; instructions?: string; } export type ClientMessage = | ClientChatMessage | ClientPingMessage | ClientSetThinkingLevelMessage | ClientClarifyResponseMessage | ClientCompactMessage; // 服务端 token_update 事件(含压缩阈值) export interface ServerTokenUpdateEvent { type: 'token_update'; sessionId: string; data: { current: { inputTokens: number; outputTokens: number; totalTokens: number; apiCalls: number }; context: { usedTokens: number; maxTokens: number; percent: number; thresholdPercent?: number; estimated?: boolean; }; }; } // 压缩事件 export interface ServerCompactionStartEvent { type: 'compaction_start'; sessionId: string; data: { reason: 'manual' | 'auto'; messageCount: number }; } export interface ServerCompactionDoneEvent { type: 'compaction_done'; sessionId: string; data: { success: boolean; compactedCount: number; keepRecentCount: number; summaryMessageId: number; summaryContent: string; tokensBefore: number; tokensAfter: number; errorMessage?: string; }; } export interface ServerCompactionErrorEvent { type: 'compaction_error'; sessionId: string; data: { message: string; retryable: boolean }; } /** 压缩状态(store 中维护) */ export interface CompactionState { /** 正在压缩中 */ inFlight: boolean; /** 最近一次压缩结果(包含摘要完整文本) */ lastResult?: ServerCompactionDoneEvent['data']; /** 最近一次压缩错误 */ lastError?: string; } ``` **Commit:** ```bash git add packages/frontend/src/modules/agent/types/protocol.ts git commit -m "feat(agent-fe): 新增前端 Protocol 类型定义同步后端压缩相关事件" ``` --- ### Task 10: token-stats.vue — 重构为进度条式显示 **Files:** - Modify: `packages/frontend/src/modules/agent/components/token-stats.vue` - [ ] **Step 1: 重写组件为进度条样式** ```vue ``` **Commit:** ```bash git add packages/frontend/src/modules/agent/components/token-stats.vue git commit -m "feat(agent-fe): token-stats 重构为进度条,支持阈值颜色和估算标识" ``` --- ### Task 11: chat.ts (store) — 新增 compactionState + 事件处理 **Files:** - Modify: `packages/frontend/src/modules/agent/store/chat.ts` - [ ] **Step 1: 在 state 中新增字段** ```typescript import { CompactionState } from '../types/protocol'; // state const compactionState = ref({ inFlight: false }); // 历史视图切换(用户在抽屉中切换显示) const historyView = ref<'compacted' | 'full'>('compacted'); ``` - [ ] **Step 2: 新增事件处理 actions** ```typescript function onCompactionStart(data: { reason: 'manual' | 'auto'; messageCount: number }) { compactionState.value = { ...compactionState.value, inFlight: true, lastError: undefined, }; // 插入一条 UI 气泡消息(role=system / pending) messages.value.push({ id: `compaction-start-${Date.now()}`, role: 'compaction', status: 'running', reason: data.reason, messageCount: data.messageCount, createdAt: Date.now(), } as any); } function onCompactionDone(data: any) { compactionState.value = { inFlight: false, lastResult: data, }; // 更新最后一条 compaction 气泡为 done,附带完整 summaryContent const last = [...messages.value].reverse().find(m => (m as any).role === 'compaction' && (m as any).status === 'running'); if (last) { (last as any).status = 'done'; (last as any).compactedCount = data.compactedCount; (last as any).keepRecentCount = data.keepRecentCount; (last as any).tokensBefore = data.tokensBefore; (last as any).tokensAfter = data.tokensAfter; (last as any).summaryMessageId = data.summaryMessageId; (last as any).summaryContent = data.summaryContent; } } function onCompactionError(data: { message: string; retryable: boolean }) { compactionState.value = { inFlight: false, lastError: data.message, }; const last = [...messages.value].reverse().find(m => (m as any).role === 'compaction' && (m as any).status === 'running'); if (last) { (last as any).status = 'error'; (last as any).errorMessage = data.message; } } /** 用户点击"手动压缩"按钮 */ function triggerCompact() { if (compactionState.value.inFlight) return; ws.send({ type: 'compact', sessionId: currentSessionId.value }); } /** 切换历史视图(仅影响前端显示,不影响 WS 请求) */ function setHistoryView(view: 'compacted' | 'full') { historyView.value = view; } ``` - [ ] **Step 3: export 新字段与方法** ```typescript return { // ... 原有 compactionState, historyView, onCompactionStart, onCompactionDone, onCompactionError, triggerCompact, setHistoryView, }; ``` **Commit:** ```bash git add packages/frontend/src/modules/agent/store/chat.ts git commit -m "feat(agent-fe): chat store 新增 compactionState + 压缩事件处理" ``` --- ### Task 12: websocket.ts — 注册压缩事件回调 + /compact 命令解析 **Files:** - Create: `packages/frontend/src/modules/agent/utils/slash_command.ts` - Modify: `packages/frontend/src/modules/agent/hooks/websocket.ts` - [ ] **Step 1: 新建 slash_command.ts — 前端斜杠命令解析工具** ```typescript // packages/frontend/src/modules/agent/utils/slash_command.ts export interface SlashCommand { name: string; args: string[]; } /** 解析用户输入的斜杠命令,非斜杠命令返回 null */ export function parseSlashCommand(raw: string): SlashCommand | null { const trimmed = raw.trim(); if (!trimmed.startsWith('/')) return null; const parts = trimmed.slice(1).split(/\s+/); return { name: parts[0].toLowerCase(), args: parts.slice(1) }; } ``` - [ ] **Step 2: 消息路由新增 compaction_* 分支** 在 WebSocket 的 onMessage 处理器中追加: ```typescript switch (event.type) { // ... 原有 case 'compaction_start': chatStore.onCompactionStart(event.data); break; case 'compaction_done': chatStore.onCompactionDone(event.data); break; case 'compaction_error': chatStore.onCompactionError(event.data); break; } ``` - [ ] **Step 3: 发送聊天前解析 /compact 斜杠命令** 在 sendChat 函数中使用 `parseSlashCommand` 封装: ```typescript import { parseSlashCommand } from '../utils/slash_command'; function sendChat(content: string, ...) { const cmd = parseSlashCommand(content); if (cmd?.name === 'compact') { chatStore.triggerCompact(); return; } // 原有逻辑 ws.send({ type: 'chat', sessionId, content, agentName, agentId }); } ``` **Commit:** ```bash git add packages/frontend/src/modules/agent/utils/slash_command.ts \ packages/frontend/src/modules/agent/hooks/websocket.ts git commit -m "feat(agent-fe): 新增 parseSlashCommand 工具 + WebSocket 注册压缩事件 + 支持 /compact 斜杠命令" ``` --- ### Task 13: 压缩事件气泡 — 消息流中的 compaction 消息渲染 **Files:** - Create: `packages/frontend/src/modules/agent/components/compaction-bubble.vue` - Modify: `packages/frontend/src/modules/agent/components/message-list.vue`(或对应消息渲染组件) - [ ] **Step 1: 新建 compaction-bubble.vue** ```vue ``` - [ ] **Step 2: 在 message-list.vue 中识别 role=compaction 的消息并渲染 bubble** ```vue ``` **Commit:** ```bash git add packages/frontend/src/modules/agent/components/compaction-bubble.vue \ packages/frontend/src/modules/agent/components/message-list.vue git commit -m "feat(agent-fe): 新增压缩事件气泡组件,支持进行中/完成/失败三态与摘要抽屉" ``` --- ### Task 14: 历史视图切换 — 压缩 vs 完整 **Files:** - Modify: `packages/frontend/src/modules/agent/components/message-list.vue` - Modify: `packages/frontend/src/modules/agent/views/chat.vue`(或主聊天页) - [ ] **Step 1: chat.vue 顶部工具栏新增视图切换** ```vue ``` - [ ] **Step 2: message-list.vue 根据 historyView 过滤消息** ```typescript const filteredMessages = computed(() => { if (chatStore.historyView === 'full') { return chatStore.messages; // 含压缩摘要 + 被压缩原始消息(从 DB 拉取) } // compacted:只显示摘要 + 未压缩消息 return chatStore.messages.filter((m: any) => !m.compactedAt || m.isCompactionSummary); }); ``` - [ ] **Step 3: 切换到 full 视图时通过 HTTP 拉取完整历史** 由于 WebSocket 默认只推送 compacted 视图的消息,切换到 full 时需要一次性拉取: ```typescript watch(() => chatStore.historyView, async (view) => { if (view === 'full') { const { service } = useCool(); const res = await service.request({ url: '/admin/netaclaw/session/messages', params: { sessionId: chatStore.currentSessionId, view: 'full' }, }); chatStore.setMessages(res); // 替换 messages } else { // 回到 compacted 视图也重新加载 const res = await service.request({ url: '/admin/netaclaw/session/messages', params: { sessionId: chatStore.currentSessionId, view: 'compacted' }, }); chatStore.setMessages(res); } }); ``` > 需要后端新增 `/admin/netaclaw/session/messages` 接口接受 `view` 参数;可在现有 Session Controller 中扩展。 **Commit:** ```bash git add packages/frontend/src/modules/agent/ git commit -m "feat(agent-fe): 新增压缩/完整历史视图切换" ``` --- ### Task 15: agent-edit.vue — 模型配置 Tab 新增压缩配置子区域 **Files:** - Modify: `packages/frontend/src/modules/agent/views/agent-edit.vue` - [ ] **Step 1: 在"模型配置"Tab 中追加压缩子区域** ```vue 上下文压缩
0 = 禁用自动压缩。达到阈值后将自动调用辅助模型生成摘要。
压缩时保留最后 N 条消息不动(含最近的工具调用对)。
不选则使用系统默认辅助渠道(字典表 netaclaw.auxiliary_channel_id)。
``` - [ ] **Step 2: 加载辅助渠道(isAuxiliary=true)列表** ```typescript import { onMounted, ref, watch } from 'vue'; import { useCool } from '/@/cool'; const { service } = useCool(); const auxiliaryChannels = ref([]); const auxiliaryModels = ref([]); async function loadAuxiliaryChannels() { const res = await service.netaclaw.model_channel.list({ isAuxiliary: true, status: 1 }); auxiliaryChannels.value = res || []; } watch(() => form.value.auxiliaryModelChannelId, (id) => { const ch = auxiliaryChannels.value.find(c => c.id === id); auxiliaryModels.value = ch?.models ?? []; // 如果切换了渠道且原 modelId 不在新渠道模型中,清空 if (form.value.auxiliaryModelId && !auxiliaryModels.value.find(m => m.name === form.value.auxiliaryModelId)) { form.value.auxiliaryModelId = ''; } }); onMounted(loadAuxiliaryChannels); ``` - [ ] **Step 3: 保存时携带新字段** form 结构新增 `compactionThreshold`、`compactionKeepRecent`、`auxiliaryModelChannelId`、`auxiliaryModelId`,提交时由 Cool Admin service 自动透传到后端 update 接口。 **Commit:** ```bash git add packages/frontend/src/modules/agent/views/agent-edit.vue git commit -m "feat(agent-fe): Agent 编辑页新增上下文压缩配置子区域" ``` --- ### Task 16: model-channel.vue — 渠道表单新增 isAuxiliary 勾选 **Files:** - Modify: `packages/frontend/src/modules/agent/views/model-channel.vue` - [ ] **Step 1: 在渠道编辑表单中追加 isAuxiliary 字段** ```vue
勾选后,此渠道可用于 Agent 的上下文压缩等辅助任务。辅助模型建议选用低成本的快速模型(如 deepseek-v3、gpt-4o-mini)。
``` - [ ] **Step 2: 列表页新增"辅助渠道"标签列** ```vue ``` - [ ] **Step 3: 筛选器新增"仅显示辅助渠道"筛选** ```vue ``` **Commit:** ```bash git add packages/frontend/src/modules/agent/views/model-channel.vue git commit -m "feat(agent-fe): 模型渠道管理支持 isAuxiliary 标记" ``` --- ## 验收清单 完成全部 16 个 Task 后执行以下验收: ### 后端验收 - [ ] 启动 `pnpm --filter @neta/backend dev`,TypeORM synchronize 自动新增列: - [ ] `netaclaw_message.compactedAt`, `compactedIntoId`, `isCompactionSummary` - [ ] `netaclaw_agent.auxiliaryModelChannelId`, `auxiliaryModelId`, `compactionThreshold`, `compactionKeepRecent` - [ ] `netaclaw_model_channel.isAuxiliary` - [ ] MCP 工具查询字典表,插入初始化数据(可选,也可跳过依赖 Agent 级配置): ```sql INSERT INTO dict_type (name, `key`) VALUES ('NetaClaw配置', 'netaclaw'); -- 记下 id(假设 10) INSERT INTO dict_info (typeId, name, value, orderNum) VALUES (10, 'auxiliary_channel_id', '1', 1), (10, 'auxiliary_model_id', 'deepseek-chat', 2); ``` - [ ] 手动触发压缩:前端发送 `{ type: 'compact', sessionId }`,预期收到 `compaction_start` → `compaction_done`。 - [ ] 检查 DB:旧消息 `compactedAt` 被填充,新增一条 `isCompactionSummary=true` 的摘要消息。 - [ ] 自动触发压缩:聊天到 token 占用超过 Agent 的 `compactionThreshold`,预期自动触发。 - [ ] 压缩期间发送用户消息:预期被排队,压缩完成后处理。 - [ ] 辅助模型未配置场景:收到 `compaction_error` 带 `retryable: true`。 ### 前端验收 - [ ] 底部进度条正确显示当前 token 占用百分比。 - [ ] 颜色随占用百分比变化:蓝(<阈值 * 0.75)→ 橙(接近阈值)→ 红(超阈值)。 - [ ] 输入 `/compact` 并发送,消息不发给 LLM,直接触发压缩流程。 - [ ] 压缩进行中显示进度气泡(loading 图标 + 旋转动画)。 - [ ] 压缩完成显示成功气泡,点击"查看摘要"弹出抽屉展示 Markdown 摘要。 - [ ] 压缩失败显示错误气泡,展示错误原因。 - [ ] 切换"完整历史"视图:显示被压缩的原始消息与摘要。 - [ ] 切换"压缩视图":只显示摘要 + 未压缩消息。 - [ ] Agent 编辑页"模型配置"Tab 可配置压缩阈值、保留条数、辅助渠道、辅助模型。 - [ ] 模型渠道管理页可勾选 `isAuxiliary`,列表显示"辅助/主力"标签。 ### 边界场景验证 - [ ] **超长单条系统提示**:若系统提示词 token 已接近阈值,压缩不应无限循环触发。 - [ ] **模型切换突变**:切换到 maxTokens 更小的模型后,旧会话的进度条百分比突然变大,应正常显示而非报错。 - [ ] **压缩中用户发新消息**:应排队等待,压缩完成后按序处理。 - [ ] **辅助模型调用失败**:不影响主聊天,仅标记该次压缩失败。 - [ ] **历史中无可压缩内容**(全部都在 keepRecent 内):返回 `success: false, errorMessage='没有可压缩的历史消息'`。 ### 编译与类型检查 - [ ] 后端 `pnpm --filter @neta/backend build` 通过。 - [ ] 前端 `pnpm --filter @neta/frontend lint` 与 `type-check` 通过。 - [ ] 前后端 Protocol 类型字段完全一致(token_update.data.context.thresholdPercent、compaction_done.data.summaryMessageId 等)。 --- ## 备注 ### 关键技术决策 1. **压缩入口位置**:选择在 `gateway/server.ts` 的 `handleChat` 中、`runAgent()` 之前触发,而不是在 agent.ts 的 ReAct 循环内。理由:压缩是会话级行为,应与消息持久化和 WS 推送在同一层处理,避免 runtime 层耦合 DB。 2. **视图参数**:`loadHistory` 默认 `view='compacted'` 保证所有调用方(包括 agent、skill 子调用)自然获得压缩后视图,无需额外改造。 3. **摘要消息 role**:统一使用 `user` 角色(而非 `system`),避免部分模型对 system 消息位置有严格约束。 4. **辅助模型解析优先级**:Agent 级配置 > 字典表系统默认。字典表通过 `dict_type.key='netaclaw'` + `dict_info.name='auxiliary_channel_id/model_id'` 两级查询。 5. **Token 估算系数**:字符数 / 4(旧代码用 / 3 偏保守,偏差约 33%,会过早触发压缩)。 6. **压缩排队**:压缩进行中用户发消息不丢弃,存入 `sessionState.pendingChat`,压缩完成后依序处理。 7. **完整历史视图**:通过 HTTP API 拉取(而非 WS 推送),避免 WS 流量浪费。 ### 未来扩展点 - 多轮压缩(摘要再摘要)支持(`compactionGeneration` 字段) - 压缩策略可插拔(aggressive / conservative / custom) - 摘要质量评估与自动回滚(检测摘要质量过低触发重试) - 支持按 skill 分段压缩(保留关键 skill 调用的原始输出)