SPB Git

Repository Public

message.tsx 19.9 KB 426 lines tsx

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 &amp; 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
Files d6f683a5f3ce2650916bcde9467e60c640c62fa9
Menu

Theme