GPU_GUARD_MONOREPO/docs/superpowers/specs/2026-04-13-agent-chat-ux-overhaul-design.md

1122 lines
36 KiB
Markdown
Raw Normal View History

2026-05-20 21:39:12 +08:00
# 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
- 恢复 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`
```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 APIAnthropic 没传 `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 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`
```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 但复杂度高,后续迭代)