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>
11 KiB
11 KiB
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 [灰色背景]
[最终回答正常流式打字...]
实施顺序
lib/api.ts— 类型扩展 + SSE 解析器修复components/gemini/ThinkingBlock.tsx— 新建components/gemini/ToolCallIndicator.tsx— 新建components/gemini/GeminiMessage.tsx— 类型扩展 + 渲染插入components/gemini/GeminiChat.tsx— 流事件处理 + 传参