Files
socweb/doc/cot/frontend.md
T
gongzhiyongandClaude Sonnet 4.6 c6ca9dc126 fix: restore workspace components accidentally dropped from git index
workspace/ files existed on disk but were not included in previous
incremental commit, causing git to record them as deleted. Re-adding
all workspace card components, AgentWorkspace, ActivityTimeline, and
WorkspaceCardRenderer to properly track them.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 03:09:41 +08:00

11 KiB
Raw Blame History

CoT 前端实现方案

改动文件清单

文件 改动性质
lib/api.ts 修复 SSE event: 行解析;扩展 event type
components/gemini/GeminiMessage.tsx Message 类型扩展;插入 ThinkingBlock + ToolCallIndicator
components/gemini/GeminiChat.tsx 流回调处理新事件;传 selectedModel 给 GeminiMessage
components/gemini/ThinkingBlock.tsx 新建:CoT 折叠展示组件
components/gemini/ToolCallIndicator.tsx 新建:工具调用状态指示器

1. lib/api.ts — 类型扩展 + SSE 解析修复

类型扩展

export interface ChatStreamEvent {
  type: "token" | "tool_start" | "tool_end" | "done" | "error" | "thinking" | "answer";
  content?: string;
  tool?: string;
}

SSE 解析器修复

后端标准 SSE 格式:

event: thinking
data: {"content": "让我先分析..."}

当前解析器只处理 data: 行,忽略 event: 行。需修复 buffer 解析逻辑:

// 在 streamChat 的 buffer 解析循环中
let currentEventName: string | null = null;

for (const line of lines) {
  const trimmed = line.trim();

  if (trimmed.startsWith("event: ")) {
    currentEventName = trimmed.slice(7).trim();
    continue;
  }

  if (trimmed.startsWith("data: ")) {
    const json = trimmed.slice(6);
    if (!json) { currentEventName = null; continue; }
    try {
      const parsed = JSON.parse(json);
      // event: 行的类型覆盖 data 内的 type 字段
      const event: ChatStreamEvent = currentEventName
        ? { ...parsed, type: currentEventName as ChatStreamEvent["type"] }
        : parsed;
      currentEventName = null;
      onEvent(event);
      if (event.type === "done") { onDone(); return; }
    } catch {
      currentEventName = null;
    }
    continue;
  }

  if (trimmed === "") {
    currentEventName = null; // SSE 事件边界
  }
}

向后兼容:如果后端仍发 data: {"type": "token", ...}(无 event: 行),走 parsed.type 分支,完全兼容。


2. GeminiMessage.tsx — 类型扩展

Message 类型新增字段

export interface Message {
  id: string;
  role: "user" | "assistant";
  content: string;
  timestamp?: Date;
  attachments?: AttachmentData[];
  // CoT 新增字段
  thinking?: string;           // 推理过程全文(流式追加)
  thinkingDone?: boolean;      // thinking 流是否结束
  toolCalls?: ToolCallRecord[]; // 工具调用历史
}

export interface ToolCallRecord {
  tool: string;
  startedAt: number;  // Date.now()
  endedAt?: number;
}

渲染区插入新组件

在 assistant 消息内容区域顶部插入(现有内容渲染不变):

interface GeminiMessageProps {
  message: Message;
  onRegenerate?: (id: string) => void;
  selectedModel?: "flash" | "auto" | "pro"; // 新增可选参数
}

// 在 assistant 消息的 flex-1 div 内,内容渲染前插入:
{message.thinking !== undefined && selectedModel !== "flash" && (
  <ThinkingBlock
    content={message.thinking}
    isDone={message.thinkingDone ?? false}
    model={selectedModel ?? "auto"}
  />
)}
{message.toolCalls && message.toolCalls.length > 0 && (
  <ToolCallIndicator toolCalls={message.toolCalls} />
)}

3. GeminiChat.tsx — 流事件处理

在 streamChat 的 onEvent 回调中新增:

// thinking 事件:追加推理文本
if (event.type === "thinking" && event.content) {
  setConversations((prev) =>
    prev.map((c) => {
      if (c.id !== streamConvId) return c;
      return {
        ...c,
        messages: c.messages.map((m) =>
          m.id === aiMsgId
            ? { ...m, thinking: (m.thinking ?? "") + event.content! }
            : m
        ),
      };
    })
  );
}

// answer/token 事件:标记 thinkingDone,追加回答文本
if ((event.type === "token" || event.type === "answer") && event.content) {
  setConversations((prev) =>
    prev.map((c) => {
      if (c.id !== streamConvId) return c;
      return {
        ...c,
        messages: c.messages.map((m) =>
          m.id === aiMsgId
            ? {
                ...m,
                thinkingDone: true,
                content: m.content + event.content,
              }
            : m
        ),
      };
    })
  );
}

// tool_start:记录工具调用开始
if (event.type === "tool_start" && event.tool) {
  setConversations((prev) =>
    prev.map((c) => {
      if (c.id !== streamConvId) return c;
      return {
        ...c,
        messages: c.messages.map((m) =>
          m.id === aiMsgId
            ? {
                ...m,
                toolCalls: [
                  ...(m.toolCalls ?? []),
                  { tool: event.tool!, startedAt: Date.now() },
                ],
              }
            : m
        ),
      };
    })
  );
}

// tool_end:记录工具调用结束时间
if (event.type === "tool_end" && event.tool) {
  setConversations((prev) =>
    prev.map((c) => {
      if (c.id !== streamConvId) return c;
      return {
        ...c,
        messages: c.messages.map((m) => {
          if (m.id !== aiMsgId) return m;
          const calls = [...(m.toolCalls ?? [])];
          const idx = calls
            .map((tc, i) => ({ tc, i }))
            .reverse()
            .find(({ tc }) => tc.tool === event.tool && !tc.endedAt)?.i ?? -1;
          if (idx !== -1) calls[idx] = { ...calls[idx], endedAt: Date.now() };
          return { ...m, toolCalls: calls };
        }),
      };
    })
  );
}

在 onDone 回调中确保 thinkingDone = true:

// done 时
setConversations((prev) =>
  prev.map((c) => ({
    ...c,
    messages: c.messages.map((m) =>
      m.id === aiMsgId ? { ...m, thinkingDone: true } : m
    ),
  }))
);

将 selectedModel 传给 GeminiMessage:

<GeminiMessage
  key={msg.id}
  message={msg}
  onRegenerate={msg.role === "assistant" ? handleRegenerate : undefined}
  selectedModel={selectedModel}
/>

4. ThinkingBlock.tsx(新建)

"use client";

import { useState } from "react";
import { ChevronDown, ChevronRight, Brain } from "lucide-react";
import { cn } from "@/lib/utils";

interface ThinkingBlockProps {
  content: string;
  isDone: boolean;
  model: "auto" | "pro";
}

export function ThinkingBlock({ content, isDone, model }: ThinkingBlockProps) {
  const [expanded, setExpanded] = useState(false);

  // Auto 模式:只显示脉冲动画,thinking 结束后消失
  if (model === "auto") {
    if (isDone) return null;
    return (
      <div className="flex items-center gap-1.5 mb-3 text-xs text-[var(--gem-text-muted)]">
        <span className="w-1.5 h-1.5 rounded-full bg-[#4285f4] animate-pulse" />
        <span
          className="w-1.5 h-1.5 rounded-full bg-[#7c55f0] animate-pulse"
          style={{ animationDelay: "150ms" }}
        />
        <span
          className="w-1.5 h-1.5 rounded-full bg-[#a855f7] animate-pulse"
          style={{ animationDelay: "300ms" }}
        />
        <span className="ml-1">正在思考...</span>
      </div>
    );
  }

  // Pro 模式:完整 thinking block,可折叠
  return (
    <div className="mb-3">
      <button
        onClick={() => setExpanded((v) => !v)}
        className="flex items-center gap-1.5 text-xs text-[var(--gem-text-muted)] hover:text-[var(--gem-text)] transition-colors duration-150 cursor-pointer"
        aria-expanded={expanded}
      >
        <Brain
          size={13}
          className={cn(!isDone && "animate-pulse text-[#4285f4]")}
        />
        <span>{isDone ? "已完成思考" : "正在思考..."}</span>
        {expanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
        {!isDone && (
          <span className="ml-1 text-[10px] opacity-60">{content.length} 字</span>
        )}
      </button>

      {expanded && (
        <div className="mt-2 pl-3 border-l-2 border-[var(--gem-border)] max-h-48 overflow-y-auto">
          <p className="text-xs text-[var(--gem-text-muted)] leading-relaxed whitespace-pre-wrap font-mono">
            {content}
            {!isDone && (
              <span className="inline-block w-1.5 h-3.5 bg-[var(--gem-text-muted)] ml-0.5 animate-pulse align-middle" />
            )}
          </p>
        </div>
      )}
    </div>
  );
}

5. ToolCallIndicator.tsx(新建)

"use client";

import { Database, Search, FileText, Code2, Box, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ToolCallRecord } from "./GeminiMessage";

const TOOL_META: Record<string, { label: string; Icon: React.ElementType }> = {
  kb_search:         { label: "查询知识库", Icon: Database },
  web_search:        { label: "搜索网络",   Icon: Search },
  generate_document: { label: "生成文档",   Icon: FileText },
  sandbox_run:       { label: "执行代码",   Icon: Code2 },
  ticket_list:       { label: "查询工单列表", Icon: Box },
  ticket_detail:     { label: "获取工单详情", Icon: Box },
};

interface ToolCallIndicatorProps {
  toolCalls: ToolCallRecord[];
}

export function ToolCallIndicator({ toolCalls }: ToolCallIndicatorProps) {
  if (toolCalls.length === 0) return null;

  return (
    <div className="flex flex-col gap-1.5 mb-3">
      {toolCalls.map((call, idx) => {
        const meta = TOOL_META[call.tool] ?? { label: call.tool, Icon: Box };
        const { label, Icon } = meta;
        const isDone = call.endedAt !== undefined;
        const duration = isDone
          ? ((call.endedAt! - call.startedAt) / 1000).toFixed(1)
          : null;

        return (
          <div
            key={idx}
            className={cn(
              "flex items-center gap-2 text-xs rounded-lg px-3 py-1.5 w-fit",
              isDone
                ? "text-[var(--gem-text-muted)] bg-[var(--gem-surface-2)]"
                : "text-[#4285f4] bg-[#4285f4]/10"
            )}
          >
            {isDone ? (
              <Icon size={13} className="flex-shrink-0 opacity-60" />
            ) : (
              <Loader2 size={13} className="flex-shrink-0 animate-spin" />
            )}
            <span>
              {isDone ? `已${label}` : `正在${label}...`}
            </span>
            {isDone && duration && (
              <span className="opacity-50">{duration}s</span>
            )}
          </div>
        );
      })}
    </div>
  );
}

视觉效果示意

Auto 模式(thinking 进行中):
● ● ●  正在思考...
[第一个 token 到达后自动消失]

──────────────────────────────
Pro 模式(thinking 进行中):
🧠 正在思考... ▶  128字

Pro 模式(展开后):
🧠 已完成思考 ▼
│ 让我先分析这个问题的关键点...
│ 考虑到用户提到了X,应该从Y角度
│ 分析。工单系统的...▌
──────────────────────────────

工具调用进行中:
⟳ 正在查询知识库...   [蓝色背景]

工具调用完成:
✓ 已查询知识库  1.2s  [灰色背景]

[最终回答正常流式打字...]

实施顺序

  1. lib/api.ts — 类型扩展 + SSE 解析器修复
  2. components/gemini/ThinkingBlock.tsx — 新建
  3. components/gemini/ToolCallIndicator.tsx — 新建
  4. components/gemini/GeminiMessage.tsx — 类型扩展 + 渲染插入
  5. components/gemini/GeminiChat.tsx — 流事件处理 + 传参