Files
zn-ai/docs/ChatPageMigrationPlan.md
2026-04-14 17:02:20 +08:00

520 lines
20 KiB
Markdown
Raw 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.

# 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 现有设计保持一致。