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

20 KiB
Raw Blame History

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.tsRawMessageContentBlockToolStatusChatSession
    • 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 的结构化内容。需重构为:

// 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;
}

说明

  • messageContentListisLoading 字段将被移除。流式渲染不再依赖数组分段,而是通过 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 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

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 BarAI 消息底部显示时间戳 + 复制按钮;用户消息底部显示时间戳。
  • Streaming CursorisStreaming=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.vueWelcomeScreen 等价物)

职责:当 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.vueClawX 遗漏项)

职责sending && pendingFinal && !shouldRenderStreaming 时显示。

样式:左侧 AI 头像,右侧气泡内显示 “Processing tool results…” + Loader2 旋转图标。

3.7 AttachmentPreview.vue

职责:输入框上方附件预览条中的单卡片。

Propsattachment: FileAttachmentonRemove: () => 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 自己维护 groupsselectedConversationId。改造后:

  • 数据来源:从 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 能正常初始化、加载会话、加载历史、发送消息、接收流式事件。
  • 消息模型完全兼容 RawMessagecontent 支持 string | ContentBlock[])。
  • 用户发送消息后,输入框清空,列表底部出现 ChatTypingIndicator;收到首包内容后切换为 ChatMessage 流式渲染,末尾带闪烁光标。
  • Tool-use 期间正确显示 ChatActivityIndicatorstreamingTools 状态条。
  • 收到 final 事件后,流式消息固化到 messages,状态恢复正常;tool_result 中的图片自动附加到最终 AI 消息。
  • 发生超时或错误时,底部出现 ChatErrorBar可点击清除90 秒 safety timeout 能自动终止卡死状态。
  • 切换历史会话时,通过 switchSession 加载历史消息并正确渲染;新建对话时回到 ChatEmpty
  • 离开首页时调用 cleanupEmptySession,无 ghost session 残留。
  • ExecutionGraphCard 在用户消息后正确渲染任务步骤(含子 Agent 分支)。
  • UI 颜色、头像布局、输入框样式与 zn-ai 现有设计保持一致。