feat: 重构对话功能

This commit is contained in:
DEV_DSW
2026-04-14 17:02:20 +08:00
parent b3f07c4cfe
commit c61e41049f
53 changed files with 5200 additions and 1982 deletions

View File

@@ -0,0 +1,519 @@
# Chat 对话功能迁移重构计划v2
## 目标
彻底抛弃 zn-ai 现有的 `WebSocketManager` 直联方式,**全面对齐 ClawX 的对话架构与协议栈**Gateway RPC + 结构化消息流。UI 视觉与交互布局**严格沿用 zn-ai 现有设计**(蓝色主题 `#2B7FFF`、头像左右布局、圆角输入框、欢迎页文案)。
---
## 参考来源
- **ClawX 源文件**
- `ClawX/src/stores/chat.ts` — Zustand Chat Store状态、会话、流式、附件缓存、错误恢复
- `ClawX/src/stores/chat/types.ts``RawMessage``ContentBlock``ToolStatus``ChatSession`
- `ClawX/src/pages/Chat/index.tsx` — 页面组装、生命周期、欢迎页、Indicator
- `ClawX/src/pages/Chat/ChatMessage.tsx` — 消息渲染markdown、thinking、tool、image、attachment
- `ClawX/src/pages/Chat/ChatInput.tsx` — 输入框附件、Agent @提及、发送/停止)
- `ClawX/src/pages/Chat/ExecutionGraphCard.tsx` — 任务执行可视化
- **zn-ai 目标文件**
- `zn-ai/src/pages/home/index.vue`
- `zn-ai/src/pages/home/ChatBox.vue`
- `zn-ai/src/pages/home/components/ChatInputArea.vue`
- `zn-ai/src/pages/home/components/ChatRoleAI.vue`
- `zn-ai/src/pages/home/components/ChatRoleMe.vue`
- `zn-ai/src/pages/home/model/ChatModel.ts`
---
## 一、协议与模型层重构
### 1.1 消息模型:从 `ChatMessage` class 迁移到 `RawMessage` 协议
zn-ai 当前 `ChatMessage` 只有 `messageContent: string` + `messageContentList: string[]`,无法承载 ClawX 的结构化内容。需重构为:
```ts
// zn-ai/src/pages/home/model/ChatModel.ts
export interface AttachedFileMeta {
fileName: string;
mimeType: string;
fileSize: number;
preview: string | null;
filePath?: string;
source?: 'user-upload' | 'tool-result' | 'message-ref';
}
export interface ContentBlock {
type: 'text' | 'image' | 'thinking' | 'tool_use' | 'tool_result' | 'toolCall' | 'toolResult';
text?: string;
thinking?: string;
source?: { type: string; media_type?: string; data?: string; url?: string };
data?: string;
mimeType?: string;
id?: string;
name?: string;
input?: unknown;
arguments?: unknown;
content?: unknown;
}
export interface RawMessage {
role: 'user' | 'assistant' | 'system' | 'toolresult';
content: string | ContentBlock[];
timestamp?: number;
id?: string;
toolCallId?: string;
toolName?: string;
details?: unknown;
isError?: boolean;
_attachedFiles?: AttachedFileMeta[];
}
export interface ToolStatus {
id?: string;
toolCallId?: string;
name: string;
status: 'running' | 'completed' | 'error';
durationMs?: number;
summary?: string;
updatedAt: number;
}
export interface ChatSession {
key: string;
label?: string;
displayName?: string;
thinkingLevel?: string;
model?: string;
updatedAt?: number;
}
```
> **说明**
> - `messageContentList` 和 `isLoading` 字段将被移除。流式渲染不再依赖数组分段,而是通过 `streamingMessage` 增量更新。
> - 历史消息加载后UI 直接使用 `md.render()` 对 `extractText(msg)` 的结果做一次性 markdown 渲染。
> - `question`(问题标签)作为 zn-ai 特有字段,可在 `ChatMessage` 组件的 footer slot 中继续保留。
---
## 二、Pinia Chat Store核心状态层
新建 `zn-ai/src/store/chat.ts`**完整复刻 ClawX Chat Store 的语义与行为**,仅将 Zustand API 替换为 Pinia `defineStore`
### 2.1 State 设计
```ts
interface ChatState {
// 消息
messages: RawMessage[];
loading: boolean; // 历史消息加载中
error: string | null; // 底部全局错误条
// 流式与运行时
sending: boolean; // 是否正在发送/等待回复
activeRunId: string | null; // 当前运行 ID用于事件去重
streamingText: string; // 纯文本流式缓存
streamingMessage: unknown | null; // 当前流式消息对象
streamingTools: ToolStatus[]; // 工具调用实时状态列表
pendingFinal: boolean; // 是否处于 tool-result 后到最终回复前的等待期
lastUserMessageAt: number | null; // 用于保留乐观用户消息
pendingToolImages: AttachedFileMeta[]; // 等待挂到最终 assistant 消息的图片
// 会话
sessions: ChatSession[];
currentSessionKey: string;
currentAgentId: string;
sessionLabels: Record<string, string>;
sessionLastActivity: Record<string, number>;
// Thinking
showThinking: boolean;
thinkingLevel: string | null;
}
```
### 2.2 Actions与 ClawX 1:1 映射)
| Action | 职责 |
|--------|------|
| `loadSessions()` | 加载会话列表,去重、排序、补全 label |
| `switchSession(key)` | 切会话,清理旧会话轮询,清空当前流式状态 |
| `newSession()` | 新建会话,清理空会话 |
| `deleteSession(key)` | 删除会话(本地 + 远端 `/api/sessions/delete` |
| `cleanupEmptySession()` | **页面切走时调用**:若当前会话无消息且无活动,从 sidebar 移除 |
| `loadHistory(quiet?)` | 加载历史消息,保留乐观用户消息、异步加载缺失图片预览 |
| `sendMessage(text, attachments?, targetAgentId?)` | 发送消息,乐观插入用户消息,启动 history poll 与安全超时 |
| `abortRun()` | 中断当前运行,调用 `chat.abort` |
| `handleChatEvent(event)` | **核心**:处理 Gateway 推送的 `started/delta/final/error/aborted` 事件 |
| `toggleThinking()` | 切换 thinking 显隐 |
| `refresh()` | 刷新 sessions + history |
| `clearError()` | 清除错误条 |
### 2.3 必须移植的内部机制ClawX 精髓)
- **History Poll Timer**:发送后若 `streamingMessage` 为空且长时间无事件,定时轮询 `chat.history` 以展示中间 tool-call 进度。
- **Safety Timeout90s**`_lastChatEventAt` 跟踪最后收到事件的时间,超 90 秒无响应自动报错并终止 `sending`
- **Error Recovery Timer15s**:收到 `error` 事件后,给 Gateway 内部重试留 15 秒宽限期,期间保持 `sending = true`
- **Chat Event Deduplication**:通过 `runId|sessionKey|seq|eventState` 去重,防止重复渲染。
- **Image Cache & Preview Loader**
- `localStorage` 缓存图片附件(`clawx:image-cache`)。
- `loadMissingPreviews()` 异步从 `/api/files/thumbnails` 加载图片缩略图。
- **Tool Result Image Enrichment**:将 `tool_result` 中的图片/文件收集到 `pendingToolImages`,最终挂载到下一个 `assistant` 消息的 `_attachedFiles`
> **后端依赖**
> Store 依赖后端提供与 ClawX 等价的 RPC / HTTP 接口:
> - `chat.history` / `chat.send` / `chat.abort`
> - `sessions.list`
> - `/api/sessions/delete`
> - `/api/files/thumbnails`
> - `/api/files/stage-paths` / `/api/files/stage-buffer`
> - `/api/sessions/transcript`(子 Agent 对话加载)
> - Gateway WebSocket 事件流(`started/delta/final/error/aborted`
---
## 三、组件拆分UI 层)
所有新组件放置在 `zn-ai/src/pages/home/components/chat/`
### 3.1 `ChatMessage.vue`(替代旧 `ChatRoleAI.vue` + `ChatRoleMe.vue`
**职责**:统一渲染单条消息(用户 / AI / 流式)。
**Props**
```ts
interface Props {
message: RawMessage;
showThinking?: boolean;
suppressToolCards?: boolean;
isStreaming?: boolean;
streamingTools?: ToolStatus[];
}
```
**内部逻辑**(参考 ClawX `ChatMessage.tsx`
- `extractText(message)` 提取主文本,`md.render()` 渲染 markdown。
- `extractThinking(message)``showThinking=true` 时显示可折叠 Thinking 块。
- `extractToolUse(message)` 渲染 Tool 调用卡片(可展开查看参数)。
- `extractImages(message)` 渲染图片用户消息上方缩略图AI 消息下方大图)。
- `_attachedFiles` 渲染附件:图片预览 / 文件卡片(可点击打开文件夹)。
- **Hover Bar**AI 消息底部显示时间戳 + 复制按钮;用户消息底部显示时间戳。
- **Streaming Cursor**`isStreaming=true` 且为 AI 消息时,末尾显示闪烁光标。
**样式约定zn-ai 风格)**
- 用户气泡:`bg-[#2B7FFF] text-white rounded-2xl px-4 py-3`(或保持 zn-ai 现有 `bg-[#f7f9fc] text-gray-700`,由设计者最终拍板,计划默认采用 `#2B7FFF` 高亮以统一品牌色)。
- AI 气泡:`bg-white border border-[#E5E8EE] text-gray-800 rounded-2xl px-4 py-3`
- 头像布局AI 左、用户右,与 zn-ai 现有完全一致。
### 3.2 `ChatInput.vue`(由 `ChatInputArea.vue` 升级)
**Props / Events**
```ts
interface Props {
modelValue: string;
sending: boolean;
disabled?: boolean;
isEmpty?: boolean;
}
// Events: send(text, attachments?, targetAgentId?), stop, update:modelValue
```
**新增能力(完整对齐 ClawX `ChatInput.tsx`**
- **发送/停止切换**`sending=true` 时按钮变为停止图标,触发 `stop`
- **附件系统**
- 点击 `Paperclip` 图标选择文件,调用 `/api/files/stage-paths`
- 粘贴文件(`Ctrl+V`)调用 `/api/files/stage-buffer`
- 拖拽文件到输入框上传。
- 附件预览条(图片缩略图 + 文件卡片 + staging 旋转遮罩 + 错误状态 + 删除按钮)。
- **Agent @提及**`@` 按钮弹出 Agent 选择器(依赖 `useAgentsStore`),选中后在输入框上方显示 chip。
- **自动增高**textarea 随内容自动增高,最大高度 200px。
- **Enter 发送 / Shift+Enter 换行**。
### 3.3 `ChatEmpty.vue`WelcomeScreen 等价物)
**职责**:当 `messages.length === 0 && !sending` 时展示。
**内容**
- 保留 zn-ai 现有大标题:“你好,我今天能帮你什么?”
- 保留快捷操作按钮(如“智能问数”、“写代码”、“查数据”等),样式沿用 zn-ai 的圆角小按钮。
- 保留 `TaskCenter` 插槽区域(若 zn-ai 引导页需要)。
### 3.4 `ChatErrorBar.vue`
**职责**:底部固定错误提示条。
**样式**`bg-red-50 border-t border-red-200`,左侧 `AlertCircle` 图标右侧“Dismiss”按钮文字 `text-red-600`
### 3.5 `ChatTypingIndicator.vue`
**职责**`sending && !pendingFinal && !hasAnyStreamContent` 时显示。
**样式**:左侧 AI 头像,右侧圆角气泡内三个跳动圆点,颜色使用 zn-ai 的 `#BEDBFF` / `#2B7FFF` 渐变或纯灰色。
### 3.6 `ChatActivityIndicator.vue`ClawX 遗漏项)
**职责**`sending && pendingFinal && !shouldRenderStreaming` 时显示。
**样式**:左侧 AI 头像,右侧气泡内显示 “Processing tool results…” + `Loader2` 旋转图标。
### 3.7 `AttachmentPreview.vue`
**职责**:输入框上方附件预览条中的单卡片。
**Props**`attachment: FileAttachment``onRemove: () => void`
**状态渲染**
- `staging`:半透明遮罩 + 旋转 loading。
- `error`:红色遮罩 + Error 文字。
- `ready`:图片显示 64×64 缩略图,文件显示图标 + 文件名 + 大小。
### 3.8 `ExecutionGraphCard.vue`
**职责**:在用户消息后展示当前 Agent 运行的任务步骤可视化。
**Props**
```ts
interface Props {
agentLabel: string;
sessionLabel: string;
steps: TaskStep[];
active: boolean;
onJumpToTrigger?: () => void;
onJumpToReply?: () => void;
}
```
> 该组件直接复刻 ClawX 的 `ExecutionGraphCard.tsx` 逻辑,但样式改用 zn-ai 的圆角卡片、蓝色高亮、灰色边框风格。
---
## 四、`ChatBox.vue` 瘦身改造
改造后 `ChatBox.vue` **仅负责 Store 订阅 + 组件拼装 + 事件透传**,代码量目标 150 行以内。
```html
<template>
<div class="flex flex-col h-full py-6 px-6 overflow-hidden">
<!-- 空状态 -->
<ChatEmpty v-if="isEmpty" />
<!-- 消息列表 -->
<div v-else ref="listRef" class="flex-1 overflow-y-auto py-6 space-y-6">
<!-- 历史消息 -->
<div
v-for="(msg, idx) in chatStore.messages"
:key="msg.id || `msg-${idx}`"
class="space-y-3"
>
<!-- 消息行 -->
<div class="flex items-start gap-3"
:class="msg.role === 'user' ? 'justify-end' : 'justify-start'">
<ChatAvatar v-if="msg.role !== 'user'" :src="aiAvatar" />
<ChatMessage
:message="msg"
:show-thinking="chatStore.showThinking"
:suppress-tool-cards="shouldSuppressToolCards(idx)"
/>
<ChatAvatar v-if="msg.role === 'user'" :src="userAvatar" />
</div>
<!-- 执行图卡片(若该用户消息触发了 agent run -->
<ExecutionGraphCard
v-if="runCardAtIndex(idx)"
v-bind="runCardAtIndex(idx)!"
/>
</div>
<!-- 流式消息 -->
<div v-if="shouldRenderStreaming" class="flex items-start gap-3 justify-start">
<ChatAvatar :src="aiAvatar" />
<ChatMessage
:message="streamMsgAsRaw"
:show-thinking="chatStore.showThinking"
:is-streaming="true"
:streaming-tools="chatStore.streamingTools"
/>
</div>
<!-- Indicator -->
<ChatActivityIndicator v-if="showActivityIndicator" />
<ChatTypingIndicator v-if="showTypingIndicator" />
</div>
<!-- 错误条 -->
<ChatErrorBar :error="chatStore.error" @dismiss="chatStore.clearError()" />
<!-- 输入区 -->
<div class="flex flex-col gap-3 mt-4">
<ChatInput
v-model="inputMessage"
:sending="chatStore.sending"
:disabled="!isGatewayRunning"
:is-empty="isEmpty"
@send="onSend"
@stop="chatStore.abortRun()"
/>
</div>
</div>
</template>
```
```ts
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useChatStore } from '@store/chat';
import { useGatewayStore } from '@store/gateway'; // 若 zn-ai 已有等价 store
// ... 引入各子组件
const chatStore = useChatStore();
const inputMessage = ref('');
const isEmpty = computed(() =>
chatStore.messages.length === 0 && !chatStore.sending
);
// Indicator 显示逻辑(与 ClawX 完全一致)
const hasAnyStreamContent = computed(() => {
const sm = chatStore.streamingMessage;
// ... 根据 streamText/streamThinking/streamTools/streamImages 判断
return false; // 省略具体实现
});
const shouldRenderStreaming = computed(() =>
chatStore.sending && hasAnyStreamContent.value
);
const showActivityIndicator = computed(() =>
chatStore.sending && chatStore.pendingFinal && !shouldRenderStreaming.value
);
const showTypingIndicator = computed(() =>
chatStore.sending && !chatStore.pendingFinal && !hasAnyStreamContent.value
);
const onSend = (text: string, attachments?: any[], targetAgentId?: string | null) => {
if (!text.trim() && (!attachments || attachments.length === 0)) return;
chatStore.sendMessage(text, attachments, targetAgentId);
inputMessage.value = '';
};
// 执行图卡片计算(与 ClawX userRunCards 逻辑对齐)
const runCardAtIndex = (idx: number) => {
// TODO: 在 ChatBox 内复刻 ClawX 的 deriveTaskSteps + subagent transcripts 逻辑
return null;
};
const shouldSuppressToolCards = (idx: number) => {
// TODO: 若该消息处于 ExecutionGraphCard 范围内,则 suppress tool cards
return false;
};
</script>
```
---
## 五、页面级生命周期与事件整合
### 5.1 `home/index.vue`
```ts
import { onMounted, onBeforeUnmount } from 'vue';
import { useChatStore } from '@store/chat';
const chatStore = useChatStore();
onMounted(() => {
chatStore.loadSessions();
});
onBeforeUnmount(() => {
chatStore.cleanupEmptySession();
});
// 选择历史会话
const handleSelectChat = (conversationId: string) => {
guide.value = false;
chatStore.switchSession(conversationId);
};
// 新建对话
const handleNewChat = () => {
guide.value = true;
chatStore.newSession();
};
```
### 5.2 Gateway 事件监听
ClawX 通过 Gateway WebSocket 接收 `chat.event`。zn-ai 需要在前端初始化 Gateway 连接(`useGatewayStore` 或等价实现),并将事件路由到 `chatStore.handleChatEvent(event)`
```ts
// 在 Gateway 连接初始化后
gateway.onChatEvent = (event: Record<string, unknown>) => {
chatStore.handleChatEvent(event);
};
```
---
## 六、`ChatHistory.vue` 联动改造
当前 `ChatHistory.vue` 自己维护 `groups``selectedConversationId`。改造后:
- **数据来源**:从 `chatStore.sessions` + `chatStore.sessionLabels` + `chatStore.sessionLastActivity` 读取。
- **选择事件**`@select-chat` 触发 `chatStore.switchSession(sessionKey)`
- **新建事件**`@new-chat` 触发 `chatStore.newSession()`
- **删除/重命名**:调用 Store Action 或直接走 API完成后触发 `chatStore.loadSessions()`
---
## 七、样式与兼容性保持
| 元素 | zn-ai 现有风格 | 迁移后保持 |
|------|---------------|-----------|
| 颜色主题 | `#2B7FFF` 蓝色高亮 | 延续 |
| 边框色 | `#E5E8EE` | 延续 |
| 头像布局 | AI 左、用户右 | 延续 |
| 用户气泡 | `bg-[#f7f9fc]` 或自定义 | 延续 zn-ai 浅灰底,若品牌要求可改为 `#2B7FFF` |
| AI 气泡 | 白色底 + 浅灰边框 | 延续 |
| 输入框 | 圆角、阴影、白色背景 | 延续,增加附件预览条和 Agent chip |
| 欢迎页 | 大标题 + TaskCenter | 延续 |
| Loading | `ChatLoading.vue` 旋转点 | `ChatTypingIndicator` 替换为跳动圆点风格,或统一为 zn-ai 旋转点 |
---
## 八、涉及文件清单
| 新建/修改 | 文件路径 | 说明 |
|-----------|----------|------|
| **重构** | `zn-ai/src/pages/home/model/ChatModel.ts` | 从 class 改为 `RawMessage` / `ContentBlock` / `ToolStatus` / `ChatSession` 接口 |
| **新建** | `zn-ai/src/store/chat.ts` | Pinia Chat Store完整复刻 ClawX 状态与机制 |
| **新建** | `zn-ai/src/pages/home/components/chat/ChatMessage.vue` | 统一消息渲染markdown、thinking、tool、image、attachment、hover bar |
| **新建** | `zn-ai/src/pages/home/components/chat/ChatInput.vue` | 输入框 + 附件 + Agent @提及 + 发送/停止 |
| **新建** | `zn-ai/src/pages/home/components/chat/ChatEmpty.vue` | 空会话欢迎页 |
| **新建** | `zn-ai/src/pages/home/components/chat/ChatErrorBar.vue` | 底部错误条 |
| **新建** | `zn-ai/src/pages/home/components/chat/ChatTypingIndicator.vue` | 发送中跳动圆点 |
| **新建** | `zn-ai/src/pages/home/components/chat/ChatActivityIndicator.vue` | tool processing 状态指示器 |
| **新建** | `zn-ai/src/pages/home/components/chat/AttachmentPreview.vue` | 附件预览卡片 |
| **新建** | `zn-ai/src/pages/home/components/chat/ExecutionGraphCard.vue` | 任务执行步骤可视化 |
| **改造** | `zn-ai/src/pages/home/components/ChatRoleAI.vue` | **废弃或大幅简化**:由 `ChatMessage.vue` 接管 |
| **改造** | `zn-ai/src/pages/home/components/ChatRoleMe.vue` | **废弃或大幅简化**:由 `ChatMessage.vue` 接管 |
| **改造** | `zn-ai/src/pages/home/components/ChatInputArea.vue` | **废弃**:由 `ChatInput.vue` 替换 |
| **重构** | `zn-ai/src/pages/home/ChatBox.vue` | 瘦身至 150 行以内,仅负责拼装 |
| **调整** | `zn-ai/src/pages/home/index.vue` | Store 生命周期、事件透传 |
| **调整** | `zn-ai/src/pages/home/ChatHistory.vue` | 数据源改为 `chatStore.sessions`,联动 `switchSession` / `newSession` |
---
## 九、验收标准
- [ ] `ChatBox.vue` 代码量降至 150 行以内,不包含任何协议细节或定时器。
- [ ] 新建 `chat.ts` Pinia Store 能正常初始化、加载会话、加载历史、发送消息、接收流式事件。
- [ ] 消息模型完全兼容 `RawMessage``content` 支持 `string | ContentBlock[]`)。
- [ ] 用户发送消息后,输入框清空,列表底部出现 `ChatTypingIndicator`;收到首包内容后切换为 `ChatMessage` 流式渲染,末尾带闪烁光标。
- [ ] Tool-use 期间正确显示 `ChatActivityIndicator``streamingTools` 状态条。
- [ ] 收到 `final` 事件后,流式消息固化到 `messages`,状态恢复正常;`tool_result` 中的图片自动附加到最终 AI 消息。
- [ ] 发生超时或错误时,底部出现 `ChatErrorBar`可点击清除90 秒 safety timeout 能自动终止卡死状态。
- [ ] 切换历史会话时,通过 `switchSession` 加载历史消息并正确渲染;新建对话时回到 `ChatEmpty`
- [ ] 离开首页时调用 `cleanupEmptySession`,无 ghost session 残留。
- [ ] `ExecutionGraphCard` 在用户消息后正确渲染任务步骤(含子 Agent 分支)。
- [ ] UI 颜色、头像布局、输入框样式与 zn-ai 现有设计保持一致。