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