GPU_GUARD_MONOREPO/docs/superpowers/specs/2026-04-13-agent-chat-ux-overhaul-design.md
2026-05-20 21:39:12 +08:00

36 KiB
Raw Permalink Blame History

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-mainupdate_plan 工具;思考块虚线边框+半透明背景+呼吸动画;思考级别下拉选择器
  • hermes-agent-mainTodoStore + 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 设计:

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

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: [],
  },
};

工具返回值格式:

{
  "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
  • 恢复 sessiongateway 模式):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

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

.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 改动

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

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

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

5.2 前端改动

删除本地 token 估算逻辑:

// 删除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

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

格式化规则:12341.2K1234512.3K 颜色规则≤50% 绿50-80% 黄,>80% 红。

6. Todo 卡片前端组件

6.1 store/chat.ts 新增

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

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

.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 布局

<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.tsresolveThinkingDefault(model)isThinkingSupported(model)ThinkLevel 类型
  • plugins/plugin_entry.tsLLMProviderConfig 包含 thinkLevel?: ThinkLevel
  • plugins/llm_providers/anthropic.ts:能提取 block.type === 'thinking'
  • plugins/llm_providers/openai.ts:能提取 reasoning_content 字段
  • runtime/attempt.ts:有 onThinking?.(response.thinking) 回调

缺失的部分:

  • 适配器未将思考参数传递给 LLM APIAnthropic 没传 thinking_budgetOpenAI 没传 reasoning_effort
  • 前端不是流式展示思考内容
  • 没有思考级别控制 UI
  • 没有多模型统一适配层

7.2 后端:思考流式推送

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

// 新增 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:38onThinking?.(response.thinking) 是一次性返回完整 thinking 文本。因此本次实现中 thinking_delta 实际为"收到即推"(一次性推送完整文本),而非逐字流式。前端组件已按流式设计,后续迭代将 LLM 适配器改为 streaming 模式后可无缝升级为真正逐字流式。

7.3 前端 store 改动

// 新增状态
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 的视觉设计(虚线边框 + 半透明背景 + 呼吸动画):

<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

.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 消息内部,位于正文之前:

<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(已有,需扩展)

// 已有的 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 thinkingClaude 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.6adaptive 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

// 当前:只提取思考块,没传思考参数
// 改为:根据 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

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

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

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

8.5 模型能力检测增强

// 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 协议:动态切换思考级别

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

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

9.2 thinking-level-selector.vue

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

┌─ 对话区 ────────────────────────────────────────┐
│  消息列表...                                     │
├──────────────────────────────────────────────────┤
│ 输入框                                           │
│                                                  │
│ [Agent] [附件] [💭 中等 ▾]      上下文 32% ● 2K │
└──────────────────────────────────────────────────┘
<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 新增

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 会话项增加摘要行:

<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_updatethinking_deltathinking_doneset_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 但复杂度高,后续迭代)