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