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>
279 lines
9.0 KiB
TypeScript
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>
|
|
);
|
|
}
|