GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-04-17-context-compaction.md

1992 lines
61 KiB
Markdown
Raw Normal View History

2026-05-20 21:39:12 +08:00
# 上下文压缩 (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<DictTypeEntity>;
@InjectEntityModel(DictInfoEntity)
dictInfoRepo: Repository<DictInfoEntity>;
/** 供应商 → LLM Provider 映射(与 model_channel.ts 保持一致) */
private readonly supplierToProvider: Record<string, string> = {
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<string | null> {
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<number> {
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<SummarizeResult> {
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<NetaClawMessageEntity>;
/**
* 执行压缩
* @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<CompactionResult> {
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<string | null> {
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<LLMMessage[]>` 改为支持视图选择:
```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<LLMMessage[]> {
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<LLMMessageWithId[]> {
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<string | null> {
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<string, {
thinkLevel?: string;
todoStore?: TodoStore;
compactionInFlight?: boolean;
/** 压缩期间排队的用户消息 */
pendingChat?: Array<{ content: string; agentName?: string; agentId?: number }>;
}>();
```
- [ ] **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
<template>
<div class="token-stats">
<div class="progress-bar-wrapper" :title="tooltipText">
<div class="progress-label">
<span class="label-text">上下文</span>
<span class="label-value">
{{ formatNum(usedTokens) }} / {{ formatNum(maxTokens) }}
<span class="label-percent">({{ percent }}%)</span>
<el-icon v-if="estimated" class="estimate-icon" title="本地估算值">
<QuestionFilled />
</el-icon>
</span>
</div>
<el-progress
:percentage="percent"
:stroke-width="6"
:show-text="false"
:color="progressColor"
/>
</div>
<el-button
v-if="canCompact"
size="small"
link
:loading="compactionInFlight"
@click="$emit('compact')"
>
{{ compactionInFlight ? '压缩中...' : '手动压缩' }}
</el-button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { QuestionFilled } from '@element-plus/icons-vue';
const props = defineProps<{
usedTokens: number;
maxTokens: number;
percent: number;
thresholdPercent?: number;
estimated?: boolean;
compactionInFlight?: boolean;
}>();
defineEmits<{ (e: 'compact'): void }>();
const canCompact = computed(() => props.usedTokens > 0);
const progressColor = computed(() => {
const threshold = props.thresholdPercent ?? 80;
if (props.percent >= threshold) return '#f56c6c';
if (props.percent >= threshold * 0.75) return '#e6a23c';
return '#409eff';
});
const tooltipText = computed(() => {
const parts = [
`已使用 ${formatNum(props.usedTokens)} / ${formatNum(props.maxTokens)} tokens`,
`占比 ${props.percent}%`,
];
if (props.thresholdPercent) parts.push(`自动压缩阈值 ${props.thresholdPercent}%`);
if (props.estimated) parts.push('(本地估算)');
return parts.join('\n');
});
function formatNum(n: number): string {
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
return String(n);
}
</script>
<style scoped>
.token-stats {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 12px;
font-size: 12px;
}
.progress-bar-wrapper {
flex: 1;
min-width: 200px;
}
.progress-label {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
color: var(--el-text-color-regular);
}
.label-percent {
margin-left: 4px;
color: var(--el-text-color-secondary);
}
.estimate-icon {
margin-left: 4px;
color: var(--el-text-color-placeholder);
font-size: 12px;
}
</style>
```
**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<CompactionState>({ 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
<template>
<div class="compaction-bubble" :class="statusClass">
<el-icon class="icon">
<Loading v-if="message.status === 'running'" class="spin" />
<CircleCheck v-else-if="message.status === 'done'" />
<CircleClose v-else-if="message.status === 'error'" />
</el-icon>
<div class="content">
<div class="title">
<template v-if="message.status === 'running'">
{{ message.reason === 'manual' ? '手动' : '自动' }}压缩进行中... ({{ message.messageCount }} 条消息)
</template>
<template v-else-if="message.status === 'done'">
上下文已压缩 — 压缩 {{ message.compactedCount }} 条 / 保留 {{ message.keepRecentCount }} 条 ·
{{ formatNum(message.tokensBefore) }} → {{ formatNum(message.tokensAfter) }} tokens
</template>
<template v-else>
压缩失败:{{ message.errorMessage }}
</template>
</div>
<el-button
v-if="message.status === 'done'"
size="small"
link
@click="showSummary = true"
>
查看摘要
</el-button>
</div>
<el-drawer v-model="showSummary" title="压缩摘要" size="40%" direction="rtl">
<div class="summary-content" v-html="renderedSummary" />
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { Loading, CircleCheck, CircleClose } from '@element-plus/icons-vue';
import MarkdownIt from 'markdown-it';
const props = defineProps<{
message: {
status: 'running' | 'done' | 'error';
reason?: 'manual' | 'auto';
messageCount?: number;
compactedCount?: number;
keepRecentCount?: number;
tokensBefore?: number;
tokensAfter?: number;
errorMessage?: string;
summaryContent?: string;
};
}>();
const showSummary = ref(false);
const md = new MarkdownIt({ html: false, linkify: true });
const renderedSummary = computed(() => md.render(props.message.summaryContent || ''));
const statusClass = computed(() => `status-${props.message.status}`);
function formatNum(n?: number): string {
if (!n) return '0';
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
return String(n);
}
</script>
<style scoped>
.compaction-bubble {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
margin: 8px 0;
border-radius: 6px;
background: var(--el-fill-color-light);
font-size: 13px;
color: var(--el-text-color-regular);
}
.status-error {
background: var(--el-color-danger-light-9);
color: var(--el-color-danger);
}
.status-done {
background: var(--el-color-success-light-9);
}
.icon {
font-size: 16px;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.content {
flex: 1;
display: flex;
align-items: center;
gap: 12px;
}
.summary-content {
padding: 16px;
line-height: 1.6;
}
</style>
```
- [ ] **Step 2: 在 message-list.vue 中识别 role=compaction 的消息并渲染 bubble**
```vue
<template v-for="msg in filteredMessages" :key="msg.id">
<compaction-bubble v-if="msg.role === 'compaction'" :message="msg" />
<!-- 原有消息渲染 -->
<chat-bubble v-else :message="msg" />
</template>
<script setup>
import CompactionBubble from './compaction-bubble.vue';
</script>
```
**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
<template>
<div class="chat-toolbar">
<!-- 原有工具栏 -->
<el-radio-group v-model="chatStore.historyView" size="small">
<el-radio-button value="compacted">压缩视图</el-radio-button>
<el-radio-button value="full">完整历史</el-radio-button>
</el-radio-group>
</div>
<!-- ... -->
</template>
<script setup>
import { useChatStore } from '../store/chat';
const chatStore = useChatStore();
</script>
```
- [ ] **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
<el-divider content-position="left">上下文压缩</el-divider>
<el-form-item label="自动压缩阈值">
<el-slider
v-model="form.compactionThreshold"
:min="0"
:max="100"
:step="5"
:marks="{ 0: '禁用', 60: '60%', 80: '80%', 95: '95%' }"
style="max-width: 420px"
/>
<div class="form-help">0 = 禁用自动压缩。达到阈值后将自动调用辅助模型生成摘要。</div>
</el-form-item>
<el-form-item label="保留最近消息">
<el-input-number
v-model="form.compactionKeepRecent"
:min="2"
:max="20"
:step="1"
/>
<div class="form-help">压缩时保留最后 N 条消息不动(含最近的工具调用对)。</div>
</el-form-item>
<el-form-item label="辅助模型渠道">
<el-select
v-model="form.auxiliaryModelChannelId"
placeholder="跟随系统默认"
clearable
style="width: 240px"
>
<el-option
v-for="ch in auxiliaryChannels"
:key="ch.id"
:label="ch.name"
:value="ch.id"
/>
</el-select>
<div class="form-help">不选则使用系统默认辅助渠道(字典表 netaclaw.auxiliary_channel_id</div>
</el-form-item>
<el-form-item label="辅助模型ID" v-if="form.auxiliaryModelChannelId">
<el-select v-model="form.auxiliaryModelId" placeholder="选择模型" style="width: 240px">
<el-option
v-for="m in auxiliaryModels"
:key="m.name"
:label="m.name"
:value="m.name"
/>
</el-select>
</el-form-item>
```
- [ ] **Step 2: 加载辅助渠道isAuxiliary=true列表**
```typescript
import { onMounted, ref, watch } from 'vue';
import { useCool } from '/@/cool';
const { service } = useCool();
const auxiliaryChannels = ref<any[]>([]);
const auxiliaryModels = ref<any[]>([]);
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
<el-form-item label="可作为辅助模型">
<el-switch v-model="form.isAuxiliary" />
<div class="form-help">勾选后,此渠道可用于 Agent 的上下文压缩等辅助任务。辅助模型建议选用低成本的快速模型(如 deepseek-v3、gpt-4o-mini</div>
</el-form-item>
```
- [ ] **Step 2: 列表页新增"辅助渠道"标签列**
```vue
<el-table-column label="类型" width="120">
<template #default="{ row }">
<el-tag v-if="row.isAuxiliary" type="info" size="small">辅助</el-tag>
<el-tag v-else size="small">主力</el-tag>
</template>
</el-table-column>
```
- [ ] **Step 3: 筛选器新增"仅显示辅助渠道"筛选**
```vue
<el-form-item label="类型">
<el-select v-model="filter.isAuxiliary" clearable placeholder="全部">
<el-option :value="true" label="辅助渠道" />
<el-option :value="false" label="主力渠道" />
</el-select>
</el-form-item>
```
**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 调用的原始输出)