Repository Public
1"use client";2import * as React from "react";3import { AlertTriangle, Brain, Check, ChevronDown, ChevronRight, Copy, FileText, GitBranch, Globe, Loader2, Pencil, RefreshCw, StepForward, Trash2, Wrench, X, ExternalLink } from "lucide-react";4import type { PublicMessage, StoredPart, PolyModel } from "@/lib/client/types";5import { Markdown } from "@/components/markdown/markdown";6import { ProviderIcon } from "@/components/brand/provider-icon";7import { Button } from "@/components/ui/button";8import { Textarea } from "@/components/ui/input";9import { Tooltip } from "@/components/ui/tooltip";10import { Badge } from "@/components/ui/badge";11import { cn, formatMs, formatTokens, formatUsd } from "@/lib/utils";12import { PROVIDERS } from "@/lib/client/providers";1314export interface LiveState {15 text: string;16 reasoning: string;17 tools: { id: string; name: string; args: string; result?: unknown; isError?: boolean; durationMs?: number }[];18 serverTools: { name: string; status: string }[];19 citations: { url?: string; title?: string; snippet?: string }[];20 startedAt: number;21 firstTokenAt?: number;22}2324export interface MessageActions {25 onCopy?: (text: string) => void;26 onEdit?: (m: PublicMessage, text: string) => void;27 onRegenerate?: (m: PublicMessage) => void;28 onRetry?: (m: PublicMessage) => void;29 onContinue?: (m: PublicMessage) => void;30 onBranch?: (m: PublicMessage) => void;31 onDelete?: (m: PublicMessage) => void;32}3334interface Props {35 message: PublicMessage;36 model?: PolyModel;37 live?: LiveState | null;38 isLast?: boolean;39 wrapCode?: boolean;40 showReasoning?: boolean;41 showCosts?: boolean;42 actions?: MessageActions;43 busy?: boolean;44}4546export const MessageItem = React.memo(function MessageItem({ message: m, model, live, isLast, wrapCode, showReasoning = true, showCosts = true, actions, busy }: Props) {47 const isUser = m.role === "user";48 const streaming = m.status === "streaming" && Boolean(live);49 const [editing, setEditing] = React.useState(false);50 const [draft, setDraft] = React.useState(m.content);51 const [copied, setCopied] = React.useState(false);5253 const text = streaming ? live!.text : m.content;54 const reasoningParts = (m.parts as StoredPart[]).filter((p): p is Extract<StoredPart, { type: "reasoning" }> => p.type === "reasoning");55 const reasoning = streaming ? live!.reasoning : reasoningParts.map((p) => p.text).join("\n\n");56 const attachments = (m.parts as StoredPart[]).filter((p): p is Extract<StoredPart, { type: "attachment" }> => p.type === "attachment");57 const toolCalls = streaming ? live!.tools : (m.parts as StoredPart[]).filter((p): p is Extract<StoredPart, { type: "tool-call" }> => p.type === "tool-call").map((p) => ({ id: p.id, name: p.name, args: p.argumentsText ?? JSON.stringify(p.arguments), result: p.result, isError: p.isError, durationMs: p.durationMs }));58 const citations = streaming ? live!.citations : (m.parts as StoredPart[]).filter((p): p is Extract<StoredPart, { type: "citation" }> => p.type === "citation");59 const serverTools = streaming ? live!.serverTools : (m.parts as StoredPart[]).filter((p): p is Extract<StoredPart, { type: "server-tool" }> => p.type === "server-tool");60 const refusal = (m.parts as StoredPart[]).find((p): p is Extract<StoredPart, { type: "refusal" }> => p.type === "refusal");61 const usage = m.usage as { inputTokens?: number; outputTokens?: number; reasoningTokens?: number; cachedInputTokens?: number } | null;62 const tps = m.latencyMs && usage?.outputTokens ? Math.round((usage.outputTokens / Math.max(1, m.latencyMs - (m.ttftMs ?? 0))) * 1000) : null;6364 const copy = async () => {65 await navigator.clipboard.writeText(text).catch(() => {});66 setCopied(true);67 setTimeout(() => setCopied(false), 1400);68 };6970 if (isUser) {71 return (72 <div id={m.id} className="group flex w-full justify-end px-3 sm:px-0">73 <div className="flex max-w-[92%] flex-col items-end gap-1.5 sm:max-w-[78%]">74 {attachments.length ? (75 <div className="flex flex-wrap justify-end gap-1.5">76 {attachments.map((a) => (77 <AttachmentChip key={a.attachmentId} a={a} />78 ))}79 </div>80 ) : null}81 {editing ? (82 <div className="w-full min-w-[280px] space-y-2 rounded-2xl border border-border bg-bg-elevated p-2">83 <Textarea value={draft} onChange={(e) => setDraft(e.target.value)} className="min-h-[80px] border-0 shadow-none focus:ring-0" autoFocus />84 <div className="flex justify-end gap-2">85 <Button variant="ghost" size="xs" onClick={() => setEditing(false)}>86 Cancel87 </Button>88 <Button89 size="xs"90 onClick={() => {91 setEditing(false);92 if (draft.trim() && draft !== m.content) actions?.onEdit?.(m, draft);93 }}94 >95 Save & resend96 </Button>97 </div>98 </div>99 ) : (100 <div className="whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-bg-muted px-4 py-2.5 text-[15px] leading-relaxed">{m.content}</div>101 )}102 <div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">103 <IconBtn label={copied ? "Copied" : "Copy"} onClick={copy}>104 {copied ? <Check className="text-success" /> : <Copy />}105 </IconBtn>106 {actions?.onEdit && !busy ? (107 <IconBtn label="Edit & resend" onClick={() => setEditing(true)}>108 <Pencil />109 </IconBtn>110 ) : null}111 {actions?.onBranch ? (112 <IconBtn label="Branch from here" onClick={() => actions.onBranch?.(m)}>113 <GitBranch />114 </IconBtn>115 ) : null}116 {actions?.onDelete && !busy ? (117 <IconBtn label="Delete" onClick={() => actions.onDelete?.(m)}>118 <Trash2 />119 </IconBtn>120 ) : null}121 <span className="ml-1 text-[11px] text-fg-subtle">{new Date(m.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</span>122 </div>123 </div>124 </div>125 );126 }127128 const provider = m.provider ?? model?.provider;129 const modelName = model?.displayName ?? m.modelKey?.split("/").slice(1).join("/") ?? "model";130 const waiting = streaming && !live!.text && !live!.reasoning && live!.tools.length === 0 && live!.serverTools.length === 0;131132 return (133 <div id={m.id} className="group flex w-full gap-3 px-3 sm:px-0">134 <div className="mt-1 hidden size-7 shrink-0 items-center justify-center rounded-lg border border-border bg-bg-elevated sm:flex">135 <ProviderIcon provider={provider} size={15} />136 </div>137 <div className="min-w-0 flex-1 space-y-2">138 <div className="flex flex-wrap items-center gap-1.5 text-[12px] text-fg-muted">139 <ProviderIcon provider={provider} size={13} className="sm:hidden" />140 <span className="font-medium text-fg">{modelName}</span>141 {provider ? <span className="text-fg-subtle">· {PROVIDERS[provider as keyof typeof PROVIDERS]?.shortName ?? provider}</span> : null}142 {m.status === "stopped" ? <Badge variant="warning">Stopped</Badge> : null}143 {m.status === "error" ? <Badge variant="danger">Failed</Badge> : null}144 {m.finishReason === "length" ? <Badge variant="warning">Cut off (max tokens)</Badge> : null}145 </div>146147 {(reasoning || (streaming && live!.reasoning)) && showReasoning ? <ReasoningBlock text={reasoning} streaming={streaming && !live!.text} durationMs={reasoningParts[0]?.durationMs} /> : null}148149 {serverTools.length ? (150 <div className="flex flex-wrap gap-1.5">151 {dedupeServerTools(serverTools).map((t, i) => (152 <Badge key={i} variant="info" className="gap-1">153 {t.name === "web_search" ? <Globe /> : <Wrench />} {t.name.replace(/_/g, " ")} {t.status === "started" ? <Loader2 className="animate-spin" /> : null}154 </Badge>155 ))}156 </div>157 ) : null}158159 {toolCalls.map((t) => (160 <ToolCallCard key={t.id} call={t} />161 ))}162163 {waiting ? (164 <div className="flex items-center gap-2 py-1 text-[13px] text-fg-muted">165 <span className="inline-flex gap-1">166 <i className="size-1.5 animate-pulse-soft rounded-full bg-fg-subtle" />167 <i className="size-1.5 animate-pulse-soft rounded-full bg-fg-subtle [animation-delay:200ms]" />168 <i className="size-1.5 animate-pulse-soft rounded-full bg-fg-subtle [animation-delay:400ms]" />169 </span>170 {model?.capabilities.reasoning ? "Thinking…" : "Waiting for the model…"}171 </div>172 ) : null}173174 {text ? <Markdown content={text} wrap={wrapCode} streaming={streaming} /> : null}175176 {refusal ? (177 <div className="flex items-start gap-2 rounded-lg border border-warning/30 bg-warning-soft px-3 py-2 text-[13px] text-warning">178 <AlertTriangle className="mt-0.5 size-4 shrink-0" />179 <div>180 The provider declined this request{refusal.category ? ` (${refusal.category})` : ""}.{refusal.explanation ? ` ${refusal.explanation}` : ""}181 </div>182 </div>183 ) : null}184185 {m.error && m.status === "error" ? (186 <div className="flex items-start gap-2 rounded-lg border border-danger/30 bg-danger-soft px-3 py-2 text-[13px] text-danger">187 <X className="mt-0.5 size-4 shrink-0" />188 <div className="min-w-0">189 <div className="font-medium">{m.error.message}</div>190 <div className="font-mono text-[11px] opacity-80">{m.error.code}</div>191 </div>192 {actions?.onRetry && !busy ? (193 <Button size="xs" variant="outline" className="ml-auto shrink-0" onClick={() => actions.onRetry?.(m)}>194 <RefreshCw /> Retry195 </Button>196 ) : null}197 </div>198 ) : null}199200 {citations.length ? <Citations items={citations} /> : null}201202 {!streaming ? (203 <div className="flex flex-wrap items-center gap-x-1 gap-y-1 pt-0.5 text-[11.5px] text-fg-subtle">204 <div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100">205 <IconBtn label={copied ? "Copied" : "Copy response"} onClick={copy}>206 {copied ? <Check className="text-success" /> : <Copy />}207 </IconBtn>208 {actions?.onRegenerate && !busy ? (209 <IconBtn label="Regenerate" onClick={() => actions.onRegenerate?.(m)}>210 <RefreshCw />211 </IconBtn>212 ) : null}213 {actions?.onContinue && isLast && !busy && (m.finishReason === "length" || m.status === "stopped") ? (214 <IconBtn label="Continue" onClick={() => actions.onContinue?.(m)}>215 <StepForward />216 </IconBtn>217 ) : null}218 {actions?.onBranch ? (219 <IconBtn label="Branch from here" onClick={() => actions.onBranch?.(m)}>220 <GitBranch />221 </IconBtn>222 ) : null}223 {actions?.onDelete && !busy ? (224 <IconBtn label="Delete" onClick={() => actions.onDelete?.(m)}>225 <Trash2 />226 </IconBtn>227 ) : null}228 </div>229 <span className="ml-1 tabular-nums">{new Date(m.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</span>230 {usage ? (231 <>232 <Dot />233 <Tooltip content={<UsageTip usage={usage} />}>234 <span className="tabular-nums">235 {formatTokens(usage.inputTokens ?? 0)} in · {formatTokens(usage.outputTokens ?? 0)} out236 </span>237 </Tooltip>238 </>239 ) : null}240 {m.latencyMs ? (241 <>242 <Dot />243 <span className="tabular-nums" title={m.ttftMs ? `Time to first token ${formatMs(m.ttftMs)}` : undefined}>244 {formatMs(m.latencyMs)}245 {m.ttftMs ? ` · TTFT ${formatMs(m.ttftMs)}` : ""}246 </span>247 </>248 ) : null}249 {tps ? (250 <>251 <Dot />252 <span className="tabular-nums">{tps} tok/s</span>253 </>254 ) : null}255 {showCosts && m.costUsd !== null && m.costUsd !== undefined ? (256 <>257 <Dot />258 <span className="tabular-nums" title="Estimated from the provider's list price">259 ≈ {formatUsd(m.costUsd, { precise: m.costUsd < 0.01 })}260 </span>261 </>262 ) : null}263 </div>264 ) : null}265 </div>266 </div>267 );268});269270function Dot() {271 return <span className="text-border-strong">·</span>;272}273274function UsageTip({ usage }: { usage: { inputTokens?: number; outputTokens?: number; reasoningTokens?: number; cachedInputTokens?: number } }) {275 return (276 <div className="space-y-0.5 tabular-nums">277 <div>Input: {formatTokens(usage.inputTokens ?? 0)}</div>278 {usage.cachedInputTokens ? <div>Cached: {formatTokens(usage.cachedInputTokens)}</div> : null}279 <div>Output: {formatTokens(usage.outputTokens ?? 0)}</div>280 {usage.reasoningTokens ? <div>Reasoning: {formatTokens(usage.reasoningTokens)}</div> : null}281 </div>282 );283}284285function IconBtn({ label, onClick, children }: { label: string; onClick: () => void; children: React.ReactNode }) {286 return (287 <Tooltip content={label}>288 <button onClick={onClick} className="rounded p-1 text-fg-subtle transition-colors hover:bg-bg-muted hover:text-fg [&_svg]:size-3.5" aria-label={label}>289 {children}290 </button>291 </Tooltip>292 );293}294295function ReasoningBlock({ text, streaming, durationMs }: { text: string; streaming: boolean; durationMs?: number }) {296 const [open, setOpen] = React.useState(streaming);297 React.useEffect(() => {298 if (!streaming) {299 // collapse automatically once the answer starts300 // eslint-disable-next-line react-hooks/set-state-in-effect301 setOpen(false);302 }303 }, [streaming]);304 const ref = React.useRef<HTMLDivElement>(null);305 React.useEffect(() => {306 if (open && streaming && ref.current) ref.current.scrollTop = ref.current.scrollHeight;307 }, [text, open, streaming]);308 return (309 <div className="rounded-lg border border-border bg-bg-subtle/60">310 <button onClick={() => setOpen((o) => !o)} className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12.5px] text-fg-muted hover:text-fg">311 <Brain className={cn("size-3.5", streaming && "animate-pulse-soft text-accent")} />312 <span className="font-medium">{streaming ? "Thinking…" : "Reasoning"}</span>313 {durationMs ? <span className="text-fg-subtle">· {formatMs(durationMs)}</span> : null}314 <span className="ml-auto">{open ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}</span>315 </button>316 {open ? (317 <div ref={ref} className="max-h-72 overflow-y-auto border-t border-border px-3 py-2 text-[13px] leading-relaxed text-fg-muted scrollbar-thin">318 <Markdown content={text} className="text-[13px] text-fg-muted [&_p]:text-fg-muted" />319 </div>320 ) : null}321 </div>322 );323}324325function ToolCallCard({ call }: { call: { id: string; name: string; args: string; result?: unknown; isError?: boolean; durationMs?: number } }) {326 const [open, setOpen] = React.useState(false);327 const pending = call.result === undefined;328 return (329 <div className="rounded-lg border border-border bg-bg-subtle/60 text-[12.5px]">330 <button onClick={() => setOpen((o) => !o)} className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-fg-muted hover:text-fg">331 <Wrench className={cn("size-3.5", pending && "animate-pulse-soft text-accent")} />332 <span className="font-medium font-mono">{call.name}</span>333 {pending ? <span className="text-fg-subtle">running…</span> : call.isError ? <Badge variant="danger">error</Badge> : <Badge variant="success">done</Badge>}334 {call.durationMs !== undefined ? <span className="text-fg-subtle">· {formatMs(call.durationMs)}</span> : null}335 <span className="ml-auto">{open ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}</span>336 </button>337 {open ? (338 <div className="grid gap-2 border-t border-border p-3 sm:grid-cols-2">339 <div>340 <div className="mb-1 text-[11px] uppercase tracking-wide text-fg-subtle">Arguments</div>341 <pre className="overflow-x-auto rounded-md bg-bg-muted p-2 font-mono text-[11.5px] scrollbar-thin">{pretty(call.args)}</pre>342 </div>343 <div>344 <div className="mb-1 text-[11px] uppercase tracking-wide text-fg-subtle">Result</div>345 <pre className="overflow-x-auto rounded-md bg-bg-muted p-2 font-mono text-[11.5px] scrollbar-thin">{pending ? "…" : pretty(JSON.stringify(call.result))}</pre>346 </div>347 </div>348 ) : null}349 </div>350 );351}352353function pretty(s: string): string {354 try {355 return JSON.stringify(JSON.parse(s), null, 2);356 } catch {357 return s;358 }359}360361function Citations({ items }: { items: { url?: string; title?: string; snippet?: string }[] }) {362 const unique = React.useMemo(() => {363 const seen = new Set<string>();364 return items.filter((c) => {365 const k = c.url ?? c.title ?? "";366 if (!k || seen.has(k)) return false;367 seen.add(k);368 return true;369 });370 }, [items]);371 if (!unique.length) return null;372 return (373 <div className="flex flex-wrap gap-1.5 pt-1">374 {unique.slice(0, 12).map((c, i) => (375 <a key={i} href={c.url} target="_blank" rel="noopener noreferrer nofollow" className="inline-flex max-w-[260px] items-center gap-1 rounded-md border border-border bg-bg-subtle px-2 py-1 text-[11.5px] text-fg-muted hover:border-border-strong hover:text-fg" title={c.snippet}>376 <ExternalLink className="size-3 shrink-0" />377 <span className="truncate">{c.title || safeHost(c.url)}</span>378 </a>379 ))}380 </div>381 );382}383384function safeHost(url?: string): string {385 try {386 return url ? new URL(url).hostname : "source";387 } catch {388 return "source";389 }390}391392function dedupeServerTools(list: { name: string; status: string }[]) {393 const map = new Map<string, { name: string; status: string }>();394 for (const t of list) map.set(t.name, t);395 return [...map.values()];396}397398export function AttachmentChip({ a, onRemove }: { a: { attachmentId?: string; id?: string; kind: string; name: string; mimeType: string; sizeBytes: number }; onRemove?: () => void }) {399 const id = a.attachmentId ?? a.id;400 if (a.kind === "image" && id) {401 return (402 <div className="relative">403 {/* eslint-disable-next-line @next/next/no-img-element */}404 <img src={`/api/attachments?id=${id}`} alt={a.name} className="max-h-40 max-w-[240px] rounded-lg border border-border object-cover" />405 {onRemove ? (406 <button onClick={onRemove} className="absolute -right-1.5 -top-1.5 rounded-full border border-border bg-bg-elevated p-0.5 text-fg-muted hover:text-danger" aria-label="Remove attachment">407 <X className="size-3" />408 </button>409 ) : null}410 </div>411 );412 }413 return (414 <div className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-bg-elevated px-2.5 py-1.5 text-[12px]">415 <FileText className="size-3.5 text-fg-subtle" />416 <span className="max-w-[180px] truncate">{a.name}</span>417 <span className="text-fg-subtle">{(a.sizeBytes / 1024).toFixed(0)} KB</span>418 {onRemove ? (419 <button onClick={onRemove} className="ml-0.5 rounded p-0.5 text-fg-subtle hover:text-danger" aria-label="Remove attachment">420 <X className="size-3" />421 </button>422 ) : null}423 </div>424 );425}426