GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-04-17-context-compaction.md
2026-05-20 21:39:12 +08:00

1992 lines
61 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 上下文压缩 (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-v3gpt-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 调用的原始输出)