diff --git a/frontend/components/gemini/GeminiChat.tsx b/frontend/components/gemini/GeminiChat.tsx index 5c4d102..5a4a61e 100644 --- a/frontend/components/gemini/GeminiChat.tsx +++ b/frontend/components/gemini/GeminiChat.tsx @@ -18,6 +18,7 @@ import { deleteConversation, type TicketSummaryData, type AttachmentData, + type TraceItem, } from "@/lib/api"; // ── Types ──────────────────────────────────────────────────────────────────── @@ -63,6 +64,58 @@ const INITIAL_EXTENSIONS: Extension[] = [ }, ]; +// ── Trace helpers ───────────────────────────────────────────────────────────── + +function appendTraceItem( + convId: string, + msgId: string, + item: TraceItem, + setConversations: React.Dispatch> +) { + setConversations((prev) => + prev.map((c) => { + if (c.id !== convId) return c; + return { + ...c, + messages: c.messages.map((m) => + m.id === msgId + ? { ...m, traceItems: [...(m.traceItems ?? []), item] } + : m + ), + }; + }) + ); +} + +function updateTraceItem( + convId: string, + msgId: string, + tool: string, + updates: Partial, + setConversations: React.Dispatch> +) { + setConversations((prev) => + prev.map((c) => { + if (c.id !== convId) return c; + return { + ...c, + messages: c.messages.map((m) => { + if (m.id !== msgId) return m; + const items = [...(m.traceItems ?? [])]; + // Update the last running entry for this tool + for (let i = items.length - 1; i >= 0; i--) { + if (items[i].tool === tool && items[i].itemStatus === "running") { + items[i] = { ...items[i], ...updates }; + break; + } + } + return { ...m, traceItems: items }; + }), + }; + }) + ); +} + // ── Main Component ──────────────────────────────────────────────────────────── export function GeminiChat() { const [sidebarOpen, setSidebarOpen] = useState(true); @@ -245,7 +298,8 @@ export function GeminiChat() { if (c.id !== streamConvId) return c; const exists = c.messages.some((m) => m.id === aiMsgId); if (!exists) { - // First token: create the assistant message + // First token: create the assistant message (preserve accumulated traceItems) + const traceItems = c.messages.find((m) => m.id === aiMsgId)?.traceItems ?? []; return { ...c, messages: [ @@ -255,6 +309,7 @@ export function GeminiChat() { role: "assistant" as const, content: event.content!, timestamp: new Date(), + traceItems, }, ], }; @@ -270,6 +325,87 @@ export function GeminiChat() { }; }) ); + + } else if (event.type === "status") { + const item: TraceItem = { + id: `status-${event.ts ?? Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + type: "status", + title: event.stage ?? "处理中", + message: event.message, + itemStatus: "info", + startTs: event.ts ?? Date.now(), + }; + // Ensure assistant message placeholder exists before appending trace + setConversations((prev) => + prev.map((c) => { + if (c.id !== streamConvId) return c; + const exists = c.messages.some((m) => m.id === aiMsgId); + if (!exists) { + return { + ...c, + messages: [ + ...c.messages, + { id: aiMsgId, role: "assistant" as const, content: "", timestamp: new Date(), traceItems: [item] }, + ], + }; + } + return { + ...c, + messages: c.messages.map((m) => + m.id === aiMsgId ? { ...m, traceItems: [...(m.traceItems ?? []), item] } : m + ), + }; + }) + ); + + } else if (event.type === "tool_start" && event.tool) { + const item: TraceItem = { + id: `${event.tool}-${event.ts ?? Date.now()}`, + type: "tool_start", + tool: event.tool, + title: event.title ?? event.tool, + inputSummary: event.input_summary, + itemStatus: "running", + startTs: event.ts ?? Date.now(), + }; + // Ensure assistant message placeholder exists + setConversations((prev) => + prev.map((c) => { + if (c.id !== streamConvId) return c; + const exists = c.messages.some((m) => m.id === aiMsgId); + if (!exists) { + return { + ...c, + messages: [ + ...c.messages, + { id: aiMsgId, role: "assistant" as const, content: "", timestamp: new Date(), traceItems: [item] }, + ], + }; + } + return { + ...c, + messages: c.messages.map((m) => + m.id === aiMsgId ? { ...m, traceItems: [...(m.traceItems ?? []), item] } : m + ), + }; + }) + ); + + } else if (event.type === "tool_end" && event.tool) { + updateTraceItem(streamConvId, aiMsgId, event.tool, { + type: "tool_end", + outputSummary: event.output_summary, + itemStatus: "success", + durationMs: event.duration_ms, + }, setConversations); + + } else if (event.type === "tool_error" && event.tool) { + updateTraceItem(streamConvId, aiMsgId, event.tool, { + type: "tool_error", + errorSummary: event.error_summary, + itemStatus: "error", + durationMs: event.duration_ms, + }, setConversations); } }, () => { @@ -308,7 +444,7 @@ export function GeminiChat() { return { ...c, messages: c.messages.map((m) => - m.id === msgId ? { ...m, content: "", id: newAiMsgId } : m + m.id === msgId ? { ...m, content: "", id: newAiMsgId, traceItems: [] } : m ), }; }) @@ -336,6 +472,41 @@ export function GeminiChat() { }; }) ); + } else if (event.type === "status") { + const item: TraceItem = { + id: `status-${event.ts ?? Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + type: "status", + title: event.stage ?? "处理中", + message: event.message, + itemStatus: "info", + startTs: event.ts ?? Date.now(), + }; + appendTraceItem(regenConvId, newAiMsgId, item, setConversations); + } else if (event.type === "tool_start" && event.tool) { + const item: TraceItem = { + id: `${event.tool}-${event.ts ?? Date.now()}`, + type: "tool_start", + tool: event.tool, + title: event.title ?? event.tool, + inputSummary: event.input_summary, + itemStatus: "running", + startTs: event.ts ?? Date.now(), + }; + appendTraceItem(regenConvId, newAiMsgId, item, setConversations); + } else if (event.type === "tool_end" && event.tool) { + updateTraceItem(regenConvId, newAiMsgId, event.tool, { + type: "tool_end", + outputSummary: event.output_summary, + itemStatus: "success", + durationMs: event.duration_ms, + }, setConversations); + } else if (event.type === "tool_error" && event.tool) { + updateTraceItem(regenConvId, newAiMsgId, event.tool, { + type: "tool_error", + errorSummary: event.error_summary, + itemStatus: "error", + durationMs: event.duration_ms, + }, setConversations); } }, () => { @@ -402,6 +573,7 @@ export function GeminiChat() { ); diff --git a/frontend/components/gemini/GeminiMessage.tsx b/frontend/components/gemini/GeminiMessage.tsx index c08e051..07de223 100644 --- a/frontend/components/gemini/GeminiMessage.tsx +++ b/frontend/components/gemini/GeminiMessage.tsx @@ -3,7 +3,8 @@ import { useState } from "react"; import { ThumbsUp, ThumbsDown, Copy, RefreshCw, Check, Paperclip, Download } from "lucide-react"; import { cn } from "@/lib/utils"; -import { getAttachmentDownloadUrl, type AttachmentData } from "@/lib/api"; +import { getAttachmentDownloadUrl, type AttachmentData, type TraceItem } from "@/lib/api"; +import { TracePanel } from "./TracePanel"; export interface Message { id: string; @@ -11,10 +12,12 @@ export interface Message { content: string; timestamp?: Date; attachments?: AttachmentData[]; + traceItems?: TraceItem[]; } interface GeminiMessageProps { message: Message; + model?: "flash" | "auto" | "pro"; onRegenerate?: (id: string) => void; } @@ -165,7 +168,7 @@ const GemIcon = ({ size = 18 }: { size?: number }) => ( ); -export function GeminiMessage({ message, onRegenerate }: GeminiMessageProps) { +export function GeminiMessage({ message, model = "auto", onRegenerate }: GeminiMessageProps) { const [copied, setCopied] = useState(false); const [feedback, setFeedback] = useState<"up" | "down" | null>(null); @@ -195,6 +198,13 @@ export function GeminiMessage({ message, onRegenerate }: GeminiMessageProps) { {/* Content */}
+ {message.traceItems && message.traceItems.length > 0 && ( + + )}
{renderContent(message.content)}
{/* Attachments */} diff --git a/frontend/components/gemini/TracePanel.tsx b/frontend/components/gemini/TracePanel.tsx new file mode 100644 index 0000000..f9deb04 --- /dev/null +++ b/frontend/components/gemini/TracePanel.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { useState } from "react"; +import { + CheckCircle2, + XCircle, + ChevronDown, + ChevronRight, + Loader2, + Zap, + Brain, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import type { TraceItem } from "@/lib/api"; + +const TOOL_ICONS: Record = { + kb_search: "🗂️", + web_search: "🌐", + ticket_list: "🎫", + ticket_detail: "🎫", + generate_document: "📄", + sandbox_run: "⚙️", +}; + +function StatusIcon({ status }: { status: TraceItem["itemStatus"] }) { + switch (status) { + case "running": + return ( + + ); + case "success": + return ( + + ); + case "error": + return ; + case "info": + return ( + + ); + } +} + +function formatDuration(ms?: number): string { + if (!ms) return ""; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +function TraceItemRow({ + item, + showDetails, +}: { + item: TraceItem; + showDetails: boolean; +}) { + const icon = item.tool ? (TOOL_ICONS[item.tool] ?? "🔧") : null; + + return ( +
+
+ +
+ +
+
+ {icon && {icon}} + + {item.title} + + {item.durationMs !== undefined && ( + + {formatDuration(item.durationMs)} + + )} +
+ + {showDetails && ( +
+ {item.inputSummary && ( +

+ {item.inputSummary} +

+ )} + {item.outputSummary && ( +

+ {item.outputSummary} +

+ )} + {item.errorSummary && ( +

{item.errorSummary}

+ )} + {item.message && item.type === "status" && ( +

{item.message}

+ )} +
+ )} +
+
+ ); +} + +interface TracePanelProps { + items: TraceItem[]; + model: "flash" | "auto" | "pro"; + className?: string; +} + +export function TracePanel({ items, model, className }: TracePanelProps) { + const isPro = model === "pro"; + const [open, setOpen] = useState(isPro); + + const hasRunning = items.some((i) => i.itemStatus === "running"); + + const summaryText = (() => { + const running = items.filter((i) => i.itemStatus === "running"); + if (running.length > 0) { + return `正在 ${running[running.length - 1].title}...`; + } + const errors = items.filter((i) => i.type === "tool_error"); + const tools = items.filter((i) => i.type === "tool_end"); + if (errors.length > 0) { + return `已完成(${errors.length} 个工具调用失败)`; + } + if (tools.length > 0) { + const names = tools.map((t) => t.title).join("、"); + return `已完成:${names}`; + } + return "正在分析..."; + })(); + + return ( + + + + + + +
+ {items.map((item) => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index f6addda..0b530e4 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -112,9 +112,35 @@ export function getAttachmentDownloadUrl(attachmentId: string): string { // ── SSE Chat Stream ────────────────────────────────────────────────────────── export interface ChatStreamEvent { - type: "token" | "tool_start" | "tool_end" | "done"; + type: "token" | "status" | "tool_start" | "tool_end" | "tool_error" | "done" | "error"; + // token content?: string; + // status + stage?: string; + message?: string; + // tool_start / tool_end / tool_error tool?: string; + title?: string; + input_summary?: string; + output_summary?: string; + error_summary?: string; + status?: "success" | "error"; + duration_ms?: number; + ts?: number; +} + +export interface TraceItem { + id: string; + type: "status" | "tool_start" | "tool_end" | "tool_error"; + tool?: string; + title: string; + message?: string; + inputSummary?: string; + outputSummary?: string; + errorSummary?: string; + itemStatus: "running" | "success" | "error" | "info"; + durationMs?: number; + startTs: number; } export function streamChat(