Files
socweb/frontend/components/gemini/GeminiMessage.tsx
T
gongzhiyongandClaude Sonnet 4.6 0207f34902 feat(frontend): CoT execution trace UI — TracePanel + full SSE event handling
Add TracePanel component for collapsible execution trace display,
extend Message interface with traceItems, and implement complete
SSE event processing (trace/status/content/error/done) in GeminiChat.

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

279 lines
9.0 KiB
TypeScript

"use client";
import { useState } from "react";
import { ThumbsUp, ThumbsDown, Copy, RefreshCw, Check, Paperclip, Download } from "lucide-react";
import { cn } from "@/lib/utils";
import { getAttachmentDownloadUrl, type AttachmentData, type TraceItem } from "@/lib/api";
import { TracePanel } from "./TracePanel";
export interface Message {
id: string;
role: "user" | "assistant";
content: string;
timestamp?: Date;
attachments?: AttachmentData[];
traceItems?: TraceItem[];
}
interface GeminiMessageProps {
message: Message;
model?: "flash" | "auto" | "pro";
onRegenerate?: (id: string) => void;
}
// Simple markdown-like renderer
function renderContent(content: string) {
const lines = content.split("\n");
const elements: React.ReactNode[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Code block
if (line.startsWith("```")) {
const lang = line.slice(3).trim();
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith("```")) {
codeLines.push(lines[i]);
i++;
}
elements.push(
<pre
key={i}
className="bg-[var(--gem-surface)] border border-[var(--gem-border)] rounded-xl p-4 overflow-x-auto my-3 text-sm font-mono text-[var(--gem-text-secondary)]"
>
{lang && (
<div className="text-xs text-[var(--gem-text-muted)] mb-2 uppercase tracking-wide">{lang}</div>
)}
<code>{codeLines.join("\n")}</code>
</pre>
);
}
// H2
else if (line.startsWith("## ")) {
elements.push(
<h2 key={i} className="text-lg font-semibold text-[var(--gem-text)] mt-4 mb-2">
{line.slice(3)}
</h2>
);
}
// H3
else if (line.startsWith("### ")) {
elements.push(
<h3 key={i} className="text-base font-semibold text-[var(--gem-text)] mt-3 mb-1.5">
{line.slice(4)}
</h3>
);
}
// Bullet
else if (line.startsWith("- ") || line.startsWith("* ")) {
const items: string[] = [];
while (i < lines.length && (lines[i].startsWith("- ") || lines[i].startsWith("* "))) {
items.push(lines[i].slice(2));
i++;
}
elements.push(
<ul key={i} className="list-disc list-inside space-y-1 my-2 text-[var(--gem-text-secondary)] text-sm leading-relaxed">
{items.map((item, idx) => (
<li key={idx} dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
))}
</ul>
);
continue;
}
// Numbered list
else if (/^\d+\.\s/.test(line)) {
const items: string[] = [];
while (i < lines.length && /^\d+\.\s/.test(lines[i])) {
items.push(lines[i].replace(/^\d+\.\s/, ""));
i++;
}
elements.push(
<ol key={i} className="list-decimal list-inside space-y-1 my-2 text-[var(--gem-text-secondary)] text-sm leading-relaxed">
{items.map((item, idx) => (
<li key={idx} dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
))}
</ol>
);
continue;
}
// Empty line
else if (line.trim() === "") {
elements.push(<div key={i} className="h-2" />);
}
// Paragraph
else {
elements.push(
<p
key={i}
className="text-[var(--gem-text-secondary)] text-sm leading-relaxed"
dangerouslySetInnerHTML={{ __html: inlineFormat(line) }}
/>
);
}
i++;
}
return elements;
}
function inlineFormat(text: string): string {
return text
.replace(/\*\*(.+?)\*\*/g, '<strong style="color:var(--gem-text);font-weight:600">$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code style="background:var(--gem-surface-2);color:#a8c4f5;padding:2px 6px;border-radius:4px;font-size:0.75rem;font-family:monospace">$1</code>');
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function AttachmentList({ attachments }: { attachments: AttachmentData[] }) {
return (
<div className="flex flex-wrap gap-2 mt-2">
{attachments.map((att) => (
<a
key={att.id}
href={getAttachmentDownloadUrl(att.id)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--gem-surface-2)] border border-[var(--gem-border)] hover:border-[var(--gem-border-hover)] transition-colors duration-150 text-xs max-w-[240px]"
>
<Paperclip size={14} className="text-[var(--gem-text-muted)] flex-shrink-0" />
<span className="truncate text-[var(--gem-text-secondary)]">{att.filename}</span>
<span className="text-[var(--gem-text-muted)] flex-shrink-0">{formatFileSize(att.size_bytes)}</span>
<Download size={12} className="text-[var(--gem-text-muted)] flex-shrink-0" />
</a>
))}
</div>
);
}
const GemIcon = ({ size = 18 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" className="flex-shrink-0 mt-0.5">
<defs>
<linearGradient id="gemGradientMsg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#4285f4" />
<stop offset="100%" stopColor="#a855f7" />
</linearGradient>
</defs>
<path
d="M14 2C14 2 16.5 9.5 20 13C23.5 16.5 26 14 26 14C26 14 23.5 16.5 20 20C16.5 23.5 14 26 14 26C14 26 11.5 23.5 8 20C4.5 16.5 2 14 2 14C2 14 4.5 11.5 8 8C11.5 4.5 14 2 14 2Z"
fill="url(#gemGradientMsg)"
/>
</svg>
);
export function GeminiMessage({ message, model = "auto", onRegenerate }: GeminiMessageProps) {
const [copied, setCopied] = useState(false);
const [feedback, setFeedback] = useState<"up" | "down" | null>(null);
const handleCopy = async () => {
await navigator.clipboard.writeText(message.content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
if (message.role === "user") {
return (
<div className="flex justify-end mb-4" role="article" aria-label="Your message">
<div className="max-w-[70%] bg-[var(--gem-surface-2)] rounded-2xl px-5 py-3.5 text-[var(--gem-text)] text-sm leading-relaxed">
<div className="whitespace-pre-wrap">{message.content}</div>
{message.attachments && message.attachments.length > 0 && (
<AttachmentList attachments={message.attachments} />
)}
</div>
</div>
);
}
return (
<div className="mb-6 group" role="article" aria-label="Gemini response">
<div className="flex gap-3">
{/* Gem icon */}
<GemIcon />
{/* Content */}
<div className="flex-1 min-w-0">
{message.traceItems && message.traceItems.length > 0 && (
<TracePanel
items={message.traceItems}
model={model}
className="mb-3"
/>
)}
<div className="space-y-0.5">{renderContent(message.content)}</div>
{/* Attachments */}
{message.attachments && message.attachments.length > 0 && (
<AttachmentList attachments={message.attachments} />
)}
{/* Action buttons */}
<div
className={cn(
"flex items-center gap-1 mt-3 transition-opacity duration-150",
"opacity-0 group-hover:opacity-100"
)}
role="toolbar"
aria-label="Message actions"
>
<ActionButton
onClick={() => setFeedback("up")}
active={feedback === "up"}
label="Thumbs up"
>
<ThumbsUp size={15} />
</ActionButton>
<ActionButton
onClick={() => setFeedback("down")}
active={feedback === "down"}
label="Thumbs down"
>
<ThumbsDown size={15} />
</ActionButton>
<ActionButton onClick={handleCopy} label="Copy to clipboard">
{copied ? <Check size={15} className="text-green-400" /> : <Copy size={15} />}
</ActionButton>
{onRegenerate && (
<ActionButton onClick={() => onRegenerate(message.id)} label="Regenerate response">
<RefreshCw size={15} />
</ActionButton>
)}
</div>
</div>
</div>
</div>
);
}
function ActionButton({
onClick,
active,
label,
children,
}: {
onClick: () => void;
active?: boolean;
label: string;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
aria-label={label}
className={cn(
"p-1.5 rounded-lg transition-colors duration-150 cursor-pointer",
active
? "text-[var(--gem-blue)] bg-[var(--gem-active-bg)]"
: "text-[var(--gem-text-muted)] hover:text-[var(--gem-text)] hover:bg-[var(--gem-surface-2)]"
)}
>
{children}
</button>
);
}