1992 lines
61 KiB
Markdown
1992 lines
61 KiB
Markdown
|
|
# 上下文压缩 (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 调用的原始输出)
|