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

61 KiB
Raw Blame History

上下文压缩 (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.tshandleChat 流程中(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 字段后追加:

@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 字段后追加:

@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 字段前追加:

@Column({ comment: '是否可作为辅助模型渠道', default: false })
isAuxiliary: boolean;
  • Step 4: 验证 — 启动后端确认 synchronize 自动建列
cd packages/backend && pnpm dev

Commit:

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

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:

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

/** 带数据库 ID 的消息,用于压缩流程定位 */
export interface LLMMessageWithId extends LLMMessage {
  /** 数据库自增 ID (来自 NetaClawMessageEntity.id) */
  id: number;
}

Commit:

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.tstestConnection 方法写法,通过 channelService.info(id) 获取渠道,用 supplierToProvider 映射获取 provider name再调用 getProvider(providerName).chat()

重要: 对外暴露 summarize(params) 方法(与设计文档一致),返回 { summary, usage }。由 AuxiliaryLLMClient 内部构造首次/迭代压缩 Prompt 并完成调用。

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:

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
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: 验证编译
cd packages/backend && npx tsc --noEmit

Commit:

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[]> 改为支持视图选择:

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保持职责单一。如果需要一个查询方法

/** 查询指定摘要消息关联的原始消息 */
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:

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 后追加:

export interface ClientCompactMessage {
  type: 'compact';
  sessionId: string;
  /** 可选的用户指令文本(用于未来扩展,当前未使用) */
  instructions?: string;
}

并更新联合类型:

export type ClientMessage =
  | ClientChatMessage
  | ClientPingMessage
  | ClientSetThinkingLevelMessage
  | ClientClarifyResponseMessage
  | ClientCompactMessage;
  • Step 2: 扩展 ServerTokenUpdateEvent — 仅在 data.context 中追加字段

修改现有 ServerTokenUpdateEvent(不重新定义结构):

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: 新增压缩相关服务端事件
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 联合类型
export type ServerEvent =
  | ServerTokenEvent
  | ServerThinkingEvent
  | ServerToolCallEvent
  | ServerToolResultEvent
  | ServerSkillStartEvent
  | ServerSkillEndEvent
  | ServerProgressEvent
  | ServerTokenUpdateEvent
  | ServerThinkingDeltaEvent
  | ServerThinkingDoneEvent
  | ServerTodoUpdateEvent
  | ServerClarifyRequestEvent
  | ServerCompactionStartEvent
  | ServerCompactionDoneEvent
  | ServerCompactionErrorEvent
  | ServerDoneEvent
  | ServerErrorEvent
  | ServerPongEvent;

Commit:

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 顶部追加:

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 锁
/** 会话级状态:思考级别 + TodoStore + 压缩进行中标记 */
private sessionState = new Map<string, {
  thinkLevel?: string;
  todoStore?: TodoStore;
  compactionInFlight?: boolean;
  /** 压缩期间排队的用户消息 */
  pendingChat?: Array<{ content: string; agentName?: string; agentId?: number }>;
}>();
  • Step 3: 封装 parseSlashCommand
/** 解析前端的斜杠命令,供未来扩展(/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 结构里追加:

if (msg.type === 'compact') {
  await this.handleCompact(msg.sessionId, 'manual');
  return;
}
  • Step 5: 实现 handleCompact
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改为

// 压缩进行中 → 排队等待
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({...}); 之前插入:

// ---- 自动压缩检测 ----
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),替换为:

const historyTokens = estimateHistoryTokens(history);

并在 token_update 事件的 context 里追加 thresholdPercent 与 estimated 字段:

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:

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 保持一致)

// 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:

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: 重写组件为进度条样式

<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:

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 中新增字段

import { CompactionState } from '../types/protocol';

// state
const compactionState = ref<CompactionState>({ inFlight: false });

// 历史视图切换(用户在抽屉中切换显示)
const historyView = ref<'compacted' | 'full'>('compacted');
  • Step 2: 新增事件处理 actions
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 新字段与方法
return {
  // ... 原有
  compactionState,
  historyView,
  onCompactionStart,
  onCompactionDone,
  onCompactionError,
  triggerCompact,
  setHistoryView,
};

Commit:

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 — 前端斜杠命令解析工具

// 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 处理器中追加:

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 封装:

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:

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

<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
<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:

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 顶部工具栏新增视图切换

<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 过滤消息
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 时需要一次性拉取:

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:

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 中追加压缩子区域

<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列表
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 结构新增 compactionThresholdcompactionKeepRecentauxiliaryModelChannelIdauxiliaryModelId,提交时由 Cool Admin service 自动透传到后端 update 接口。

Commit:

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 字段

<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: 列表页新增"辅助渠道"标签列
<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: 筛选器新增"仅显示辅助渠道"筛选
<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:

git add packages/frontend/src/modules/agent/views/model-channel.vue
git commit -m "feat(agent-fe): 模型渠道管理支持 isAuxiliary 标记"

验收清单

完成全部 16 个 Task 后执行以下验收:

后端验收

  • 启动 pnpm --filter @neta/backend devTypeORM synchronize 自动新增列:
    • netaclaw_message.compactedAt, compactedIntoId, isCompactionSummary
    • netaclaw_agent.auxiliaryModelChannelId, auxiliaryModelId, compactionThreshold, compactionKeepRecent
    • netaclaw_model_channel.isAuxiliary
  • MCP 工具查询字典表,插入初始化数据(可选,也可跳过依赖 Agent 级配置):
    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_startcompaction_done
  • 检查 DB旧消息 compactedAt 被填充,新增一条 isCompactionSummary=true 的摘要消息。
  • 自动触发压缩:聊天到 token 占用超过 Agent 的 compactionThreshold,预期自动触发。
  • 压缩期间发送用户消息:预期被排队,压缩完成后处理。
  • 辅助模型未配置场景:收到 compaction_errorretryable: 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 linttype-check 通过。
  • 前后端 Protocol 类型字段完全一致token_update.data.context.thresholdPercent、compaction_done.data.summaryMessageId 等)。

备注

关键技术决策

  1. 压缩入口位置:选择在 gateway/server.tshandleChat 中、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 调用的原始输出)