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

1122 lines
36 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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