# Agent 对话页面体验优化设计

> 日期：2026-04-13
> 状态：待实施
> 范围：前端 agent 模块 + 后端 netaclaw 模块

## 1. 背景与问题

当前 Neta 项目的 agent 对话页面存在 4 个体验问题：

1. **Skill 视觉跳变**：执行中由 `skill-indicator.vue` 渲染进度条，完成后 `skillProgress` 被清空，Skill 记录转存到 `message.metadata.skillExecutions`，由 `message-item.vue` 重新渲染为"查看结果"按钮。两个完全不同的组件、不同的位置，造成视觉断裂。
2. **Token 计算不准**：前端用字符数粗估 token（中文 1.5 字符=1 token），与实际偏差大。
3. **Token UI 不直观**：统计条占据对话区顶部整行，信息密度低。
4. **缺少任务规划**：agent 每次裸跑，没有 todo/plan 工具，不会主动规划任务步骤。

5. **思考内容不流畅**：思考内容不是流式展示，等全部完成才一次性显示；默认折叠用户容易忽略；没有思考级别控制。
6. **LLM 适配器思考参数缺失**：后端已有 `thinking.ts` 基础框架和 `ThinkLevel` 类型，但 Anthropic/OpenAI 适配器未将思考参数传递给 API。

参考项目：
- **openclaw-main**：`update_plan` 工具；思考块虚线边框+半透明背景+呼吸动画；思考级别下拉选择器
- **hermes-agent-main**：`TodoStore` + `todo` 工具；多模型统一思考适配层；thinking_delta 流式推送；思考预算分级

用户选择 hermes 的 TodoStore 方案作为主要参考，思考 UI 参考 openclaw 的视觉设计。

## 2. 设计目标

- Skill 执行中→完成：同一个组件、同一个位置、CSS 过渡动画，无跳变
- Token 数据：前端零估算，100% 由后端推送真实数据
- Token UI：紧凑，不抢眼，hover 查看详情
- Todo 系统：后端 `todo` 工具 + 前端内嵌卡片，agent 主动规划，实时更新
- 思考内容：流式展示 + 呼吸动画 + 光标闪烁，思考→正文平滑过渡
- 思考级别：前端可选（off/minimal/low/medium/high/adaptive），会话级持久化
- 多模型适配：统一思考参数传递层，Anthropic/OpenAI/DeepSeek 各自映射

## 3. Todo 工具系统（后端）

### 3.1 TodoStore 类

文件：`packages/backend/src/modules/netaclaw/runtime/todo_store.ts`

完全参考 hermes 的 TodoStore 设计：

```typescript
interface TodoItem {
  id: string;        // agent 自选的唯一标识
  content: string;   // 任务描述
  status: 'pending' | 'in_progress' | 'completed' | 'cancelled';
}

interface TodoSummary {
  total: number;
  pending: number;
  in_progress: number;
  completed: number;
  cancelled: number;
}

class TodoStore {
  private items: TodoItem[] = [];

  /**
   * 写入 todo 列表
   * @param todos - 任务项数组
   * @param merge - false(默认)=全量替换，true=按 id 增量更新+追加新项
   * @returns 写入后的完整列表
   */
  write(todos: TodoItem[], merge: boolean = false): TodoItem[] {
    if (!merge) {
      // Replace 模式：全量替换，agent 重新规划时使用
      this.items = todos.map(t => this.validate(t));
    } else {
      // Merge 模式：按 id 匹配更新，未匹配的追加
      for (const todo of todos) {
        const validated = this.validate(todo);
        const existing = this.items.find(i => i.id === validated.id);
        if (existing) {
          Object.assign(existing, validated);
        } else {
          this.items.push(validated);
        }
      }
    }
    return this.read();
  }

  /** 读取当前列表（返回拷贝，防止外部篡改） */
  read(): TodoItem[] {
    return JSON.parse(JSON.stringify(this.items));
  }

  hasItems(): boolean {
    return this.items.length > 0;
  }

  getSummary(): TodoSummary {
    const s = { total: 0, pending: 0, in_progress: 0, completed: 0, cancelled: 0 };
    for (const item of this.items) {
      s.total++;
      s[item.status]++;
    }
    return s;
  }

  /**
   * 上下文压缩后注入，只保留 pending + in_progress 项
   * 返回人类可读格式，作为 user message 注入压缩后的历史
   */
  formatForInjection(): string | null {
    const active = this.items.filter(i => 
      i.status === 'pending' || i.status === 'in_progress'
    );
    if (active.length === 0) return null;

    const lines = active.map(i => {
      const marker = i.status === 'in_progress' ? '[>]' : '[ ]';
      return `- ${marker} ${i.id}. ${i.content} (${i.status})`;
    });
    return '[你的活跃任务列表在上下文压缩后被保留]\n' + lines.join('\n');
  }

  /**
   * 从历史消息中恢复 todo 状态（hydrate）
   * 倒序扫描消息，找到最近一次 todo 工具调用的返回值
   */
  static hydrateFromHistory(messages: any[]): TodoStore {
    const store = new TodoStore();
    for (let i = messages.length - 1; i >= 0; i--) {
      const msg = messages[i];
      if (msg.role === 'tool' && msg.toolName === 'todo') {
        try {
          const data = JSON.parse(msg.content);
          if (data.todos) {
            store.write(data.todos, false);
          }
        } catch {}
        break;
      }
    }
    return store;
  }

  private validate(item: Partial<TodoItem>): TodoItem {
    return {
      id: item.id || '?',
      content: item.content || '(无描述)',
      status: ['pending', 'in_progress', 'completed', 'cancelled'].includes(item.status!)
        ? item.status!
        : 'pending',
    };
  }
}
```

### 3.2 todo 工具定义

文件：`packages/backend/src/modules/netaclaw/tools/todo_tool.ts`

```typescript
const todoToolSchema = {
  name: 'todo',
  description: 
    '管理当前会话的任务列表。用于3步以上的复杂任务或用户提供多个任务时。\n'
    + '不传参数=读取当前列表。\n'
    + '传 todos 数组=写入。merge=false(默认)全量替换整个计划，merge=true 按 id 增量更新。\n'
    + '每个项: { id: string, content: string, status: pending|in_progress|completed|cancelled }\n'
    + '列表顺序=优先级。同一时间只能有一个 in_progress。\n'
    + '完成立即标记 completed。失败则 cancel 并添加修正项。\n'
    + '始终返回完整列表。',
  parameters: {
    type: 'object',
    properties: {
      todos: {
        type: 'array',
        description: '任务项数组。省略则读取当前列表。',
        items: {
          type: 'object',
          properties: {
            id: { type: 'string', description: '唯一标识' },
            content: { type: 'string', description: '任务描述' },
            status: {
              type: 'string',
              enum: ['pending', 'in_progress', 'completed', 'cancelled'],
              description: '当前状态',
            },
          },
          required: ['id', 'content', 'status'],
        },
      },
      merge: {
        type: 'boolean',
        description: 'true=按 id 增量更新+追加新项。false(默认)=全量替换整个列表。',
        default: false,
      },
    },
    required: [],
  },
};
```

工具返回值格式：
```json
{
  "todos": [
    { "id": "1", "content": "分析需求文档", "status": "completed" },
    { "id": "2", "content": "实现用户认证模块", "status": "in_progress" },
    { "id": "3", "content": "编写单元测试", "status": "pending" },
    { "id": "4", "content": "更新API文档", "status": "pending" }
  ],
  "summary": { "total": 4, "pending": 2, "in_progress": 1, "completed": 1, "cancelled": 0 }
}
```

### 3.3 集成到 ReAct 循环

- 每个 agent session 维护一个 `TodoStore` 实例
- 新建 session：空 TodoStore
- 恢复 session（gateway 模式）：`TodoStore.hydrateFromHistory(messages)` 从历史恢复
- agent 调用 `todo` 工具后：通过 WS 推送 `todo_update` 事件
- 上下文压缩时：调用 `todoStore.formatForInjection()`，非空则作为 user message 注入压缩后历史

## 4. Skill 统一组件（前端）

### 4.1 核心改动

废弃双组件方案（`skill-indicator.vue` + `message-item.vue` 内嵌历史），合并为 `skill-card.vue`。

**数据流对比**：

改造前（有跳变）：
```
skill_start → skillProgress[] 添加 → skill-indicator.vue 渲染
skill_end   → skillProgress[] 更新 status=done
done        → skillProgress[] 清空 → metadata.skillExecutions 接管
           → message-item.vue 重新渲染（跳变！）
```

改造后（无跳变）：
```
skill_start → skillProgress[] 添加 → skill-card.vue 渲染（status=running）
skill_end   → skillProgress[] 更新 status=done → skill-card.vue CSS 过渡（无跳变）
done        → skillProgress[] 保留不清空，同时写入 metadata 用于历史恢复
```

关键变化：`done` 事件**不再清空** `skillProgress`，组件始终由 `skillProgress` 驱动。

### 4.2 skill-card.vue

文件：`packages/frontend/src/modules/agent/components/skill-card.vue`

```vue
<template>
  <div class="skill-card" :class="[`skill-card--${status}`]">
    <!-- 头部：图标 + 名称 + 状态标签 -->
    <div class="skill-card__header">
      <el-icon :class="{ 'is-rotating': status === 'running' }">
        <Loading v-if="status === 'running'" />
        <CircleCheck v-else-if="status === 'done'" />
        <CircleClose v-else />
      </el-icon>
      <span class="skill-card__name">{{ label }}</span>
      <el-tag v-if="status === 'done'" size="small" type="success">完成</el-tag>
      <el-tag v-if="status === 'error'" size="small" type="danger">失败</el-tag>
    </div>

    <!-- 执行中：进度条 + 步骤描述 -->
    <transition name="fade">
      <div v-if="status === 'running'" class="skill-card__progress">
        <el-progress :percentage="percent" :stroke-width="4" />
        <span class="skill-card__step">{{ step || detail }}</span>
      </div>
    </transition>

    <!-- 完成后：查看结果按钮 -->
    <transition name="fade">
      <div v-if="status === 'done' && result" class="skill-card__result">
        <el-button text size="small" @click="showResult = !showResult">
          {{ showResult ? '收起' : '查看结果' }}
        </el-button>
      </div>
    </transition>

    <!-- 结果详情 -->
    <el-collapse-transition>
      <skill-result-viewer v-if="showResult" :result="result" :type="resultType" />
    </el-collapse-transition>
  </div>
</template>
```

CSS 过渡：
```css
.skill-card {
  transition: all 0.3s ease;
  border-left: 3px solid var(--skill-border-color);
}
.skill-card--running { --skill-border-color: var(--el-color-primary); }
.skill-card--done    { --skill-border-color: var(--el-color-success); }
.skill-card--error   { --skill-border-color: var(--el-color-danger); }

.fade-enter-active, .fade-leave-active { transition: opacity 0.3s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
```

### 4.3 store/chat.ts 改动

```typescript
// done 事件：不再清空 skillProgress
case 'done': {
  // 将 skillProgress 写入 metadata 用于历史恢复
  if (assistantMsg) {
    assistantMsg.metadata.skillExecutions = [...skillProgress.value];
    // 不再执行 skillProgress.value = []
  }
  break;
}

// 切换会话时才清空
function switchSession(newSessionId: string) {
  skillProgress.value = [];  // 切换时清空
  // ...加载新会话消息
}

// 加载历史消息时从 metadata 恢复
function restoreSkillProgress(messages: ChatMessage[]) {
  skillProgress.value = [];
  for (const msg of messages) {
    if (msg.metadata?.skillExecutions) {
      skillProgress.value.push(...msg.metadata.skillExecutions);
    }
  }
}
```

## 5. Token 统计系统改造

### 5.1 后端改动

`token_update` WS 事件增加 `context` 字段：

```typescript
interface TokenUpdateEvent {
  // 本轮对话的实时累计
  current: {
    inputTokens: number;
    outputTokens: number;
    totalTokens: number;
    apiCalls: number;
  };
  // 上下文窗口状态（新增）
  context: {
    usedTokens: number;   // 当前上下文已用 token
    maxTokens: number;    // 模型上下文窗口上限
    percent: number;      // 使用百分比（后端算好）
  };
}
```

推送时机：每次 LLM API 返回后推送。

### 5.2 前端改动

删除本地 token 估算逻辑：

```typescript
// 删除：estimateContextTokens 相关代码
// 删除：中文字符/其他字符的 token 估算公式

// 简化为：
const tokenUsage = ref<TokenUpdateEvent | null>(null);

case 'token_update': {
  tokenUsage.value = event.data;
  break;
}

case 'done': {
  if (assistantMsg && tokenUsage.value) {
    assistantMsg.metadata.tokenUsage = tokenUsage.value.current;
  }
  tokenUsage.value = null;
  break;
}
```

### 5.3 Token UI 重设计

从顶部统计条改为输入区右下角紧凑展示：

```
┌─ 对话区 ────────────────────────────────────┐
│                                              │
│  消息列表...                                 │
│                                              │
├──────────────────────────────────────────────┤
│ 输入框                                       │
│                                              │
│ [Agent选择]  [附件]         上下文 32% ●  2K │
└──────────────────────────────────────────────┘
```

文件：`packages/frontend/src/modules/agent/components/token-stats.vue`

```vue
<template>
  <div class="token-stats" :class="{ 'is-active': isRunning }">
    <!-- 执行中：实时数据 -->
    <template v-if="isRunning && tokenUsage">
      <span class="token-stats__realtime">
        ↑{{ formatTokens(tokenUsage.current.inputTokens) }}
        ↓{{ formatTokens(tokenUsage.current.outputTokens) }}
        · {{ tokenUsage.current.apiCalls }}次
      </span>
      <span class="token-stats__divider">|</span>
    </template>

    <!-- 上下文占比（始终显示） -->
    <el-tooltip :content="contextTooltip" placement="top">
      <span class="token-stats__context">
        上下文 {{ contextPercent }}%
        <span class="token-stats__dot" :style="{ background: dotColor }" />
      </span>
    </el-tooltip>
  </div>
</template>
```

格式化规则：`1234` → `1.2K`，`12345` → `12.3K`
颜色规则：≤50% 绿，50-80% 黄，>80% 红。

## 6. Todo 卡片前端组件

### 6.1 store/chat.ts 新增

```typescript
const todoItems = ref<TodoItem[]>([]);
const todoSummary = ref<TodoSummary | null>(null);

case 'todo_update': {
  todoItems.value = event.data.todos;
  todoSummary.value = event.data.summary;
  break;
}

// 切换会话时从历史恢复
function restoreTodoFromHistory(messages: ChatMessage[]) {
  for (let i = messages.length - 1; i >= 0; i--) {
    const msg = messages[i];
    if (msg.role === 'tool' && msg.metadata?.toolName === 'todo') {
      try {
        const data = JSON.parse(msg.content);
        todoItems.value = data.todos;
        todoSummary.value = data.summary;
      } catch {}
      return;
    }
  }
  todoItems.value = [];
  todoSummary.value = null;
}
```

### 6.2 todo-card.vue

文件：`packages/frontend/src/modules/agent/components/todo-card.vue`

```vue
<template>
  <div v-if="items.length" class="todo-card">
    <!-- 头部 -->
    <div class="todo-card__header" @click="collapsed = !collapsed">
      <span class="todo-card__icon">📋</span>
      <span class="todo-card__title">任务规划</span>
      <span class="todo-card__summary">
        {{ summary.completed }}/{{ summary.total }} 完成
      </span>
      <div class="todo-card__progress-bar">
        <div class="todo-card__progress-fill"
             :style="{ width: (summary.completed / summary.total * 100) + '%' }" />
      </div>
      <el-icon class="todo-card__toggle">
        <ArrowUp v-if="!collapsed" /><ArrowDown v-else />
      </el-icon>
    </div>

    <!-- 任务列表 -->
    <el-collapse-transition>
      <div v-if="!collapsed" class="todo-card__list">
        <transition-group name="todo-item">
          <div v-for="item in items" :key="item.id"
               class="todo-card__item"
               :class="[`todo-card__item--${item.status}`]">
            <span class="todo-card__status-icon">
              <template v-if="item.status === 'completed'">✅</template>
              <template v-else-if="item.status === 'in_progress'">
                <span class="todo-card__spinner" />
              </template>
              <template v-else-if="item.status === 'cancelled'">❌</template>
              <template v-else>⬚</template>
            </span>
            <span class="todo-card__content" :class="{
              'is-done': item.status === 'completed',
              'is-cancelled': item.status === 'cancelled',
              'is-active': item.status === 'in_progress'
            }">
              {{ item.content }}
            </span>
            <el-tag v-if="item.status === 'in_progress'"
                    size="small" type="primary" effect="light">
              进行中
            </el-tag>
          </div>
        </transition-group>
      </div>
    </el-collapse-transition>
  </div>
</template>
```

CSS 要点：
```css
.todo-card {
  margin: 8px 0;
  border-radius: 8px;
  border: 1px solid var(--el-border-color-lighter);
  background: var(--el-fill-color-lighter);
  overflow: hidden;
}
.todo-card__item { transition: all 0.3s ease; }
.todo-card__item--in_progress { background: var(--el-color-primary-light-9); }
.todo-card__content.is-done { text-decoration: line-through; color: var(--el-text-color-placeholder); }
.todo-card__content.is-active { font-weight: 600; color: var(--el-color-primary); }
.todo-card__spinner {
  display: inline-block; width: 14px; height: 14px;
  border: 2px solid var(--el-color-primary);
  border-top-color: transparent; border-radius: 50%;
  animation: spin 1s linear infinite;
}
```

### 6.3 chat.vue 布局

```vue
<div class="chat-messages" ref="messagesRef">
  <!-- 历史消息 -->
  <message-item v-for="msg in displayMessages" :key="msg.id" :message="msg" />

  <!-- Todo 卡片 -->
  <todo-card v-if="todoItems.length" :items="todoItems" :summary="todoSummary" />

  <!-- Skill 执行卡片 -->
  <skill-card v-for="sp in skillProgress" :key="sp.name" v-bind="sp" />

  <!-- 流式 assistant 消息 -->
  <message-item v-if="streamingMsg" :message="streamingMsg" />
</div>
```

## 7. 思考内容流式展示

### 7.1 问题现状

当前 Neta 后端已有的基础：
- `runtime/thinking.ts`：`resolveThinkingDefault(model)`、`isThinkingSupported(model)`、`ThinkLevel` 类型
- `plugins/plugin_entry.ts`：`LLMProviderConfig` 包含 `thinkLevel?: ThinkLevel`
- `plugins/llm_providers/anthropic.ts`：能提取 `block.type === 'thinking'` 块
- `plugins/llm_providers/openai.ts`：能提取 `reasoning_content` 字段
- `runtime/attempt.ts`：有 `onThinking?.(response.thinking)` 回调

缺失的部分：
- 适配器未将思考参数传递给 LLM API（Anthropic 没传 `thinking_budget`，OpenAI 没传 `reasoning_effort`）
- 前端不是流式展示思考内容
- 没有思考级别控制 UI
- 没有多模型统一适配层

### 7.2 后端：思考流式推送

将当前的一次性 `thinking` 事件改为流式 `thinking_delta`：

```typescript
// 新增 WS 事件
type: 'thinking_delta'   // 增量文本片段
data: { text: string }

type: 'thinking_done'    // 思考结束标记
data: {}

// 废弃
type: 'thinking'         // 被 thinking_delta + thinking_done 替代
```

**当前限制：** 现有 LLM 适配器（anthropic.ts/openai.ts）使用非流式 API 调用，`attempt.ts:38` 的 `onThinking?.(response.thinking)` 是一次性返回完整 thinking 文本。因此本次实现中 `thinking_delta` 实际为"收到即推"（一次性推送完整文本），而非逐字流式。前端组件已按流式设计，后续迭代将 LLM 适配器改为 streaming 模式后可无缝升级为真正逐字流式。

### 7.3 前端 store 改动

```typescript
// 新增状态
const isThinking = ref(false);
const thinkingStream = ref('');

case 'thinking_delta': {
  if (!isThinking.value) {
    isThinking.value = true;
    thinkingStream.value = '';
  }
  thinkingStream.value += event.data.text;
  break;
}

case 'thinking_done': {
  isThinking.value = false;
  if (assistantMsg) {
    assistantMsg.thinking = thinkingStream.value;
  }
  break;
}

// 正文 token 到达时自动结束思考状态
case 'token': {
  if (isThinking.value) {
    isThinking.value = false;
    if (assistantMsg) {
      assistantMsg.thinking = thinkingStream.value;
    }
  }
  // ...原有追加 content 逻辑
}
```

### 7.4 thinking-block.vue 组件

参考 openclaw 的视觉设计（虚线边框 + 半透明背景 + 呼吸动画）：

```vue
<template>
  <div class="thinking-block" :class="{ 'is-streaming': isStreaming }">
    <div class="thinking-block__header" @click="collapsed = !collapsed">
      <el-icon :class="{ 'is-rotating': isStreaming }">
        <Loading v-if="isStreaming" />
        <InfoFilled v-else />
      </el-icon>
      <span class="thinking-block__title">思考过程</span>
      <el-icon class="thinking-block__toggle">
        <ArrowUp v-if="!collapsed" /><ArrowDown v-else />
      </el-icon>
    </div>

    <el-collapse-transition>
      <div v-if="!collapsed" class="thinking-block__content" ref="contentRef">
        <div v-html="renderedMarkdown" />
        <span v-if="isStreaming" class="thinking-block__cursor" />
      </div>
    </el-collapse-transition>
  </div>
</template>
```

交互行为：
- 流式思考中：默认展开，内容逐字出现，末尾闪烁光标
- 思考完成后：自动折叠（CSS transition 0.3s），用户可点击重新展开
- 历史消息中的思考：默认折叠

CSS 样式（参考 openclaw `chat-thinking`）：

```css
.thinking-block {
  margin: 8px 0;
  padding: 10px 12px;
  border-radius: 8px;
  border: 1px dashed var(--el-border-color);
  background: var(--el-fill-color-lighter);
  font-size: 12px;
  line-height: 1.5;
  color: var(--el-text-color-secondary);
  transition: all 0.3s ease;
}

.thinking-block.is-streaming {
  animation: thinking-pulse 1.5s ease-in-out infinite;
}

@keyframes thinking-pulse {
  0%, 100% { border-color: var(--el-border-color); }
  50% { border-color: var(--el-color-primary-light-3); }
}

.thinking-block__cursor {
  display: inline-block;
  width: 2px; height: 14px;
  background: var(--el-color-primary);
  animation: blink 1s step-end infinite;
  vertical-align: text-bottom;
  margin-left: 2px;
}

@keyframes blink {
  50% { opacity: 0; }
}
```

### 7.5 在 chat.vue 中的位置

思考块嵌入在流式 assistant 消息内部，位于正文之前：

```vue
<message-item v-if="streamingMsg" :message="streamingMsg">
  <template #before-content>
    <thinking-block
      v-if="thinkingStream || streamingMsg.thinking"
      :content="isThinking ? thinkingStream : streamingMsg.thinking"
      :is-streaming="isThinking"
    />
  </template>
</message-item>
```

历史消息中同理，`message-item.vue` 检测 `msg.thinking` 字段渲染 `thinking-block`（默认折叠）。

## 8. 多模型思考适配层（后端）

参考 hermes 的多模型统一适配架构，补全 Neta 后端的思考参数传递链路。

### 8.1 统一思考配置接口

文件：`packages/backend/src/modules/netaclaw/runtime/thinking.ts`（已有，需扩展）

```typescript
// 已有的 ThinkLevel 类型
type ThinkLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'adaptive';

// 新增：各提供商的思考预算映射（参考 hermes THINKING_BUDGET）
const ANTHROPIC_BUDGET_MAP: Record<string, number> = {
  minimal: 2000,
  low: 4000,
  medium: 8000,
  high: 16000,
};

// 新增：Anthropic adaptive effort 映射（参考 hermes ADAPTIVE_EFFORT_MAP）
const ANTHROPIC_ADAPTIVE_MAP: Record<string, string> = {
  minimal: 'low',
  low: 'low',
  medium: 'medium',
  high: 'high',
  adaptive: 'medium',  // adaptive 模式下由模型自行决定
};

// 新增：检测模型是否支持 adaptive thinking（Claude 4.6+）
function supportsAdaptiveThinking(model: string): boolean {
  return /4[-.]6/.test(model);
}

// 新增：构建各提供商的思考参数
interface ThinkingParams {
  anthropic?: {
    thinking: { type: string; budget_tokens?: number; effort?: string };
    temperature?: number;
    maxTokensAdjust?: number;
    betas?: string[];
  };
  openai?: {
    reasoning_effort?: string;
    reasoning?: { enabled: boolean; effort?: string };
  };
}

function buildThinkingParams(
  supplier: string,
  model: string,
  level: ThinkLevel
): ThinkingParams {
  if (level === 'off') return {};

  const params: ThinkingParams = {};

  switch (supplier) {
    case 'anthropic': {
      if (supportsAdaptiveThinking(model)) {
        // Claude 4.6：adaptive thinking
        params.anthropic = {
          thinking: { type: 'adaptive' },
          betas: ['interleaved-thinking-2025-05-14'],
        };
        // adaptive 模式下通过 output_config.effort 控制
      } else {
        // 旧模型：手动 budget
        const budget = ANTHROPIC_BUDGET_MAP[level] || 8000;
        params.anthropic = {
          thinking: { type: 'enabled', budget_tokens: budget },
          temperature: 1.0,
          maxTokensAdjust: budget + 4096,
          betas: ['interleaved-thinking-2025-05-14'],
        };
      }
      break;
    }

    case 'openai':
    case 'deepseek': {
      // OpenAI 兼容：reasoning_effort 直接传递
      // 支持 o1/o3/deepseek-r1 等模型
      const effortMap: Record<string, string> = {
        minimal: 'low', low: 'low', medium: 'medium', high: 'high',
      };
      params.openai = {
        reasoning_effort: effortMap[level] || 'medium',
        reasoning: { enabled: true, effort: effortMap[level] || 'medium' },
      };
      break;
    }

    default: {
      // 其他提供商：不传思考参数，依赖模型自身行为
      break;
    }
  }

  return params;
}
```

### 8.2 Anthropic 适配器改动

文件：`plugins/llm_providers/anthropic.ts`

```typescript
// 当前：只提取思考块，没传思考参数
// 改为：根据 config.thinkLevel 构建并传递参数

import { buildThinkingParams, supportsAdaptiveThinking } from '../../runtime/thinking';

// 在构建 API 请求时：
const thinkParams = buildThinkingParams('anthropic', model, config.thinkLevel || 'off');

if (thinkParams.anthropic) {
  requestBody.thinking = thinkParams.anthropic.thinking;
  
  if (thinkParams.anthropic.temperature !== undefined) {
    requestBody.temperature = thinkParams.anthropic.temperature;
  }
  if (thinkParams.anthropic.maxTokensAdjust) {
    requestBody.max_tokens = Math.max(requestBody.max_tokens, thinkParams.anthropic.maxTokensAdjust);
  }
  
  // Beta headers
  if (thinkParams.anthropic.betas?.length) {
    headers['anthropic-beta'] = thinkParams.anthropic.betas.join(',');
  }
}

// 流式响应中提取 thinking_delta：
// 当收到 type === 'thinking_delta' 的 SSE 事件时
// 立即通过 onThinkingDelta?.(delta.thinking) 回调推送给 WS 层
```

### 8.3 OpenAI 适配器改动

文件：`plugins/llm_providers/openai.ts`

```typescript
import { buildThinkingParams } from '../../runtime/thinking';

const thinkParams = buildThinkingParams(config.supplier, model, config.thinkLevel || 'off');

if (thinkParams.openai) {
  // OpenAI / DeepSeek：通过 extra_body 传递
  requestBody.reasoning_effort = thinkParams.openai.reasoning_effort;
  // 或通过 reasoning 对象（部分提供商）
  // requestBody.reasoning = thinkParams.openai.reasoning;
}

// 流式响应中提取 reasoning delta：
// 检查 delta.reasoning_content 或 delta.reasoning 字段
// 通过 onThinkingDelta?.(text) 回调推送
```

### 8.4 Agent 运行时：会话级思考级别

文件：`runtime/agent.ts`

```typescript
// 当前：只用 resolveThinkingDefault(model) 硬编码默认值
// 改为：优先级链
const thinkLevel = session.thinkLevel          // 1. 用户在前端选择的（会话级）
  ?? agent.defaultThinkLevel                    // 2. agent 配置的默认值
  ?? resolveThinkingDefault(model);             // 3. 模型默认值

// 传入 LLMProviderConfig
const llmConfig: LLMProviderConfig = {
  ...baseConfig,
  thinkLevel,
};
```

### 8.5 模型能力检测增强

```typescript
// thinking.ts 扩展

interface ModelThinkingCapability {
  supported: boolean;
  adaptive: boolean;           // 是否支持 adaptive thinking
  levels: ThinkLevel[];        // 支持的级别列表
  defaultLevel: ThinkLevel;    // 默认级别
}

function getModelThinkingCapability(supplier: string, model: string): ModelThinkingCapability {
  // Anthropic Claude 4.6+
  if (supplier === 'anthropic' && supportsAdaptiveThinking(model)) {
    return {
      supported: true, adaptive: true,
      levels: ['off', 'minimal', 'low', 'medium', 'high', 'adaptive'],
      defaultLevel: 'adaptive',
    };
  }
  // Anthropic Claude 3.5/4.5（非 adaptive）
  if (supplier === 'anthropic') {
    return {
      supported: true, adaptive: false,
      levels: ['off', 'low', 'medium', 'high'],
      defaultLevel: 'medium',
    };
  }
  // OpenAI o1/o3
  if (supplier === 'openai' && /^(o1|o3)/.test(model)) {
    return {
      supported: true, adaptive: false,
      levels: ['off', 'low', 'medium', 'high'],
      defaultLevel: 'medium',
    };
  }
  // DeepSeek R1
  if (supplier === 'deepseek' && model.includes('r1')) {
    return {
      supported: true, adaptive: false,
      levels: ['off', 'low', 'medium', 'high'],
      defaultLevel: 'medium',
    };
  }
  // 不支持思考的模型
  return {
    supported: false, adaptive: false,
    levels: ['off'],
    defaultLevel: 'off',
  };
}
```

## 9. 思考级别控制（前端）

### 9.1 WS 协议：动态切换思考级别

```typescript
// 新增客户端→服务端消息
{
  type: 'set_thinking_level',
  data: { level: ThinkLevel }
}

// 后端收到后更新 session.thinkLevel，下次 LLM 调用生效
```

### 9.2 thinking-level-selector.vue

放在输入区域，与 Agent 选择器、附件按钮同行：

```
┌─ 对话区 ────────────────────────────────────────┐
│  消息列表...                                     │
├──────────────────────────────────────────────────┤
│ 输入框                                           │
│                                                  │
│ [Agent] [附件] [💭 中等 ▾]      上下文 32% ● 2K │
└──────────────────────────────────────────────────┘
```

```vue
<template>
  <el-select
    v-model="currentLevel"
    size="small"
    class="thinking-level-selector"
    :disabled="isRunning"
    @change="onLevelChange"
    placeholder="思考级别"
  >
    <el-option
      v-for="opt in availableLevels"
      :key="opt.value"
      :label="opt.label"
      :value="opt.value"
    />
  </el-select>
</template>

<script setup>
// 根据当前模型能力动态生成可选级别
const availableLevels = computed(() => {
  const cap = modelThinkingCapability.value;
  const labelMap = {
    off: '关闭', minimal: '极简', low: '低',
    medium: '中等', high: '高', adaptive: '自适应',
  };
  return cap.levels.map(l => ({ value: l, label: labelMap[l] }));
});
</script>
```

### 9.3 store/chat.ts 新增

```typescript
const thinkLevel = ref<ThinkLevel>('medium');
const modelThinkingCapability = ref<ModelThinkingCapability>({ ... });

function setThinkLevel(level: ThinkLevel) {
  thinkLevel.value = level;
  sendWSMessage({ type: 'set_thinking_level', data: { level } });
}

// 切换会话时恢复
function switchSession(sessionId: string) {
  // ...
  thinkLevel.value = session.thinkLevel ?? 'medium';
}

// 切换模型/Agent 时更新可选级别
function updateModelCapability(supplier: string, model: string) {
  // 通过 API 或本地映射获取模型思考能力
  modelThinkingCapability.value = getModelThinkingCapability(supplier, model);
  // 如果当前级别不在新模型支持范围内，回退到默认值
  if (!modelThinkingCapability.value.levels.includes(thinkLevel.value)) {
    setThinkLevel(modelThinkingCapability.value.defaultLevel);
  }
}
```

## 10. 对话列表适配

`chat-sidebar.vue` 会话项增加摘要行：

```vue
<div class="session-item__subtitle">
  <template v-if="session.todoSummary">
    <span class="session-item__todo-badge">
      📋 {{ session.todoSummary.completed }}/{{ session.todoSummary.total }}
    </span>
  </template>
  <template v-if="session.isRunning">
    <span class="session-item__running-dot" />
    <span>执行中...</span>
  </template>
  <template v-else>
    <span class="session-item__last-msg">{{ session.lastMessage }}</span>
  </template>
</div>
```

## 11. WS 事件协议变更汇总

| 事件类型 | 方向 | 变更 | 数据结构 |
|---------|------|------|---------|
| `todo_update` | 服务端→客户端 | **新增** | `{ todos: TodoItem[], summary: TodoSummary }` |
| `token_update` | 服务端→客户端 | **修改** | 增加 `context: { usedTokens, maxTokens, percent }` |
| `thinking_delta` | 服务端→客户端 | **新增** | `{ text: string }` |
| `thinking_done` | 服务端→客户端 | **新增** | `{}` |
| `set_thinking_level` | 客户端→服务端 | **新增** | `{ level: ThinkLevel }` |
| `thinking` | 服务端→客户端 | **废弃** | 被 `thinking_delta` + `thinking_done` 替代 |
| `skill_start` | 服务端→客户端 | 不变 | — |
| `skill_end` | 服务端→客户端 | 不变 | — |
| `done` | 服务端→客户端 | **修改** | 前端不再清空 skillProgress |

## 12. 完整文件改动清单

### 后端新增
| 文件 | 说明 |
|------|------|
| `modules/netaclaw/runtime/todo_store.ts` | TodoStore 类 |
| `modules/netaclaw/tools/todo_tool.ts` | todo 工具定义 + Schema + handler |

### 后端修改
| 文件 | 说明 |
|------|------|
| `modules/netaclaw/runtime/thinking.ts` | 扩展：`buildThinkingParams()`、`getModelThinkingCapability()`、预算映射常量 |
| `modules/netaclaw/plugins/llm_providers/anthropic.ts` | 传递 `thinking` 参数 + beta headers + 流式 `thinking_delta` 提取 |
| `modules/netaclaw/plugins/llm_providers/openai.ts` | 传递 `reasoning_effort` 参数 + 流式 reasoning delta 提取 |
| `modules/netaclaw/runtime/agent.ts` | 会话级思考级别优先级链、todo 工具注册 |
| `modules/netaclaw/runtime/attempt.ts` | `onThinkingDelta` 回调改为流式推送 |
| ReAct 循环主文件 | todo hydrate、压缩注入、推送 `todo_update` 事件 |
| WS gateway | 处理 `set_thinking_level` 客户端消息、`token_update` 增加 context 字段 |

### 前端新增
| 文件 | 说明 |
|------|------|
| `components/skill-card.vue` | 统一 Skill 卡片 |
| `components/todo-card.vue` | Todo 任务规划卡片 |
| `components/token-stats.vue` | 紧凑 Token 统计组件 |
| `components/thinking-block.vue` | 思考内容流式展示组件 |
| `components/thinking-level-selector.vue` | 思考级别下拉选择器 |

### 前端修改
| 文件 | 说明 |
|------|------|
| `store/chat.ts` | 新增 todo/thinking/thinkLevel 状态、`todo_update`/`thinking_delta`/`thinking_done`/`set_thinking_level` 事件处理、删除本地 token 估算、`done` 不清空 skillProgress |
| `views/chat.vue` | 布局：插入 todo-card + skill-card + thinking-block、token 统计移到输入区、思考级别选择器 |
| `components/message-item.vue` | 删除 Skill 执行历史渲染代码、删除旧思考折叠区块、改用 thinking-block 组件 |
| `components/chat-sidebar.vue` | 会话项增加 todo 进度和运行状态 |
| `hooks/websocket.ts` | 类型定义增加 `todo_update`、`thinking_delta`、`thinking_done`、`set_thinking_level` |
| `types/index.d.ts` | 新增 TodoItem、TodoSummary、ThinkLevel、ModelThinkingCapability 类型 |

### 前端删除
| 文件 | 说明 |
|------|------|
| `components/skill-indicator.vue` | 被 skill-card.vue 替代 |

## 13. 不在本次范围

- 上下文压缩/截断机制（后续迭代）
- 后端 ReAct 循环核心逻辑（只注册新工具 + 接入思考参数，不改执行流程）
- skill-result-viewer 及子组件（skill-card 内部复用）
- 语音对话功能
- Agent 列表和编辑页面
- 思考块签名管理（Anthropic 签名验证/剥离，参考 hermes 但复杂度高，后续迭代）
