20 KiB
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、ChatSessionClawX/src/pages/Chat/index.tsx— 页面组装、生命周期、欢迎页、IndicatorClawX/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.vuezn-ai/src/pages/home/ChatBox.vuezn-ai/src/pages/home/components/ChatInputArea.vuezn-ai/src/pages/home/components/ChatRoleAI.vuezn-ai/src/pages/home/components/ChatRoleMe.vuezn-ai/src/pages/home/model/ChatModel.ts
一、协议与模型层重构
1.1 消息模型:从 ChatMessage class 迁移到 RawMessage 协议
zn-ai 当前 ChatMessage 只有 messageContent: string + messageContentList: string[],无法承载 ClawX 的结构化内容。需重构为:
// 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 设计
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 Timeout(90s):
_lastChatEventAt跟踪最后收到事件的时间,超 90 秒无响应自动报错并终止sending。 - Error Recovery Timer(15s):收到
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.abortsessions.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:
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:
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:
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 行以内。
<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>
<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
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)。
// 在 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.tsPinia 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 现有设计保持一致。