SPB Git

Repository Public

chat-view.tsx 21.3 KB 437 lines tsx

1"use client";2import * as React from "react";3import { useRouter, useSearchParams } from "next/navigation";4import { ArrowDown, Menu, Pin, Share2, Sparkles, Swords, Trash2 } from "lucide-react";5import Link from "next/link";6import { useApp, invalidateConversations } from "@/components/app/store";7import { api, streamEvents, ClientApiError } from "@/lib/client/api";8import type { ChatStreamEvent, ConversationDetail, PublicConversation, PublicMessage, PolyModel, ModelPreset, PromptPreset } from "@/lib/client/types";9import { MessageItem, type LiveState } from "./message";10import { Composer, type PendingAttachment } from "./composer";11import { ModelSelector } from "./model-selector";12import { ModelConfig, type ChatSettings } from "./model-config";13import { ChatEmptyState } from "./empty-state";14import { Button } from "@/components/ui/button";15import { Tooltip } from "@/components/ui/tooltip";16import { toast } from "@/components/ui/toast";17import { cn, formatUsd } from "@/lib/utils";1819interface Props {20  conversationId?: string;21  initial?: ConversationDetail;22}2324const emptyLive = (): LiveState => ({ text: "", reasoning: "", tools: [], serverTools: [], citations: [], startedAt: Date.now() });2526export function ChatView({ conversationId, initial }: Props) {27  const router = useRouter();28  const search = useSearchParams();29  const { modelsByKey, selectedModelKey, setSelectedModelKey, preferences, setSidebarOpen, models, connectedProviders } = useApp();3031  const [conversation, setConversation] = React.useState<PublicConversation | null>(initial?.conversation ?? null);32  const [messages, setMessages] = React.useState<PublicMessage[]>(() => (initial?.messages ?? []).filter((m) => (m as unknown as { active?: boolean }).active !== false));33  const [modelKey, setModelKey] = React.useState<string | null>(initial?.conversation.modelKey ?? null);34  const [settings, setSettings] = React.useState<ChatSettings>(() => ((initial?.conversation.settings as ChatSettings) ?? {}));35  const [systemPrompt, setSystemPrompt] = React.useState<string>(initial?.conversation.systemPrompt ?? preferences.defaultSystemPrompt ?? "");36  const [attachments, setAttachments] = React.useState<PendingAttachment[]>([]);37  const [live, setLive] = React.useState<LiveState | null>(null);38  const [busy, setBusy] = React.useState(false);39  const [draft, setDraft] = React.useState("");40  const abortRef = React.useRef<AbortController | null>(null);41  const scrollRef = React.useRef<HTMLDivElement>(null);42  const [atBottom, setAtBottom] = React.useState(true);43  const presetApplied = React.useRef(false);4445  // Model for new chats follows the global selection; existing conversations keep theirs.46  React.useEffect(() => {47    if (!conversationId && !modelKey && selectedModelKey) {48      // eslint-disable-next-line react-hooks/set-state-in-effect49      setModelKey(selectedModelKey);50    }51  }, [conversationId, modelKey, selectedModelKey]);5253  // Apply ?preset= / ?prompt= / ?model= for new chats.54  React.useEffect(() => {55    if (conversationId || presetApplied.current) return;56    const presetId = search.get("preset");57    const promptId = search.get("prompt");58    const m = search.get("model");59    if (!presetId && !promptId && !m) return;60    presetApplied.current = true;61    (async () => {62      if (m && modelsByKey.has(m)) setModelKey(m);63      if (presetId || promptId) {64        const res = await api<{ modelPresets: ModelPreset[]; promptPresets: PromptPreset[] }>("/api/presets");65        if (presetId) {66          const p = res.modelPresets.find((x) => x.id === presetId);67          if (p) {68            setModelKey(p.modelKey);69            const tools = (p.tools as { builtin?: string[] })?.builtin;70            setSettings({ ...(p.parameters as ChatSettings), ...(tools?.length ? { tools } : {}) });71            if (p.systemPrompt) setSystemPrompt(p.systemPrompt);72            toast.info(`Preset “${p.name}” applied`);73          }74        }75        if (promptId) {76          const p = res.promptPresets.find((x) => x.id === promptId);77          if (p) {78            setSystemPrompt(p.systemPrompt);79            if (p.defaultModelKey && modelsByKey.has(p.defaultModelKey)) setModelKey(p.defaultModelKey);80            setSettings((s) => ({ ...s, ...(p.parameters as ChatSettings) }));81            toast.info(`Prompt “${p.name}” applied`);82          }83        }84      }85    })().catch(() => {});86  }, [conversationId, search, modelsByKey]);8788  const model: PolyModel | undefined = modelKey ? modelsByKey.get(modelKey) : undefined;89  const modelUsable = Boolean(model && connectedProviders.has(model.provider));9091  // Scrolling92  const scrollToBottom = React.useCallback((smooth = false) => {93    const el = scrollRef.current;94    if (!el) return;95    el.scrollTo({ top: el.scrollHeight, behavior: smooth ? "smooth" : "auto" });96  }, []);97  React.useEffect(() => {98    if (atBottom) scrollToBottom();99  }, [messages, live, atBottom, scrollToBottom]);100  React.useEffect(() => {101    const hash = window.location.hash.slice(1);102    if (hash) document.getElementById(hash)?.scrollIntoView({ block: "center" });103    else scrollToBottom();104  }, [conversationId, scrollToBottom]);105106  const onScroll = () => {107    const el = scrollRef.current;108    if (!el) return;109    setAtBottom(el.scrollHeight - el.scrollTop - el.clientHeight < 80);110  };111112  const persistConversationMeta = React.useCallback(113    async (patch: Record<string, unknown>) => {114      if (!conversation) return;115      try {116        const res = await api<{ conversation: PublicConversation }>(`/api/conversations/${conversation.id}`, { method: "PATCH", json: patch });117        setConversation(res.conversation);118        invalidateConversations();119      } catch {120        /* non-fatal */121      }122    },123    [conversation],124  );125126  const changeModel = (key: string) => {127    setModelKey(key);128    setSelectedModelKey(key);129    if (conversation) void persistConversationMeta({ modelKey: key });130  };131132  const run = React.useCallback(133    async (body: Record<string, unknown>, opts: { optimisticUser?: PublicMessage | null; replaceAssistantId?: string; truncateAfterIndex?: number } = {}) => {134      if (!modelKey) return toast.warning("Pick a model first");135      if (busy) return;136      setBusy(true);137      const controller = new AbortController();138      abortRef.current = controller;139      const liveState = emptyLive();140      setLive(liveState);141      let assistantId: string | null = null;142      let convId = conversation?.id ?? null;143      let flushTimer: ReturnType<typeof setTimeout> | null = null;144      const flush = () => {145        flushTimer = null;146        setLive({ ...liveState, tools: [...liveState.tools], serverTools: [...liveState.serverTools], citations: [...liveState.citations] });147      };148      const schedule = () => {149        if (!flushTimer) flushTimer = setTimeout(flush, 40);150      };151152      // optimistic UI153      setMessages((prev) => {154        let next = opts.truncateAfterIndex !== undefined ? prev.slice(0, opts.truncateAfterIndex) : [...prev];155        if (opts.replaceAssistantId) next = next.filter((m) => m.id !== opts.replaceAssistantId);156        if (opts.optimisticUser) next.push(opts.optimisticUser);157        next.push({ id: "pending-assistant", conversationId: convId ?? "", role: "assistant", content: "", parts: [], modelKey, provider: model?.provider ?? null, status: "streaming", finishReason: null, error: null, usage: null, latencyMs: null, ttftMs: null, costUsd: null, parentMessageId: null, version: 1, active: true, createdAt: new Date().toISOString() });158        return next;159      });160161      try {162        await streamEvents(163          "/api/chat",164          { ...body, modelKey, conversationId: convId ?? undefined, systemPrompt: systemPrompt || null, settings: Object.keys(settings).length ? settings : undefined },165          (raw) => {166            const ev = raw as ChatStreamEvent;167            switch (ev.type) {168              case "meta":169                convId = ev.conversationId;170                assistantId = ev.assistantMessageId;171                setMessages((prev) =>172                  prev.map((m) => {173                    if (m.id === "pending-assistant") return { ...m, id: ev.assistantMessageId, conversationId: ev.conversationId };174                    if (opts.optimisticUser && m.id === opts.optimisticUser.id && ev.userMessage) return ev.userMessage;175                    return m;176                  }),177                );178                if (ev.isNewConversation) {179                  window.history.replaceState(null, "", `/app/chat/${ev.conversationId}`);180                  setConversation({ id: ev.conversationId, title: "New chat", folderId: null, pinned: false, archived: false, modelKey, provider: model?.provider ?? null, systemPrompt: systemPrompt || null, settings, messageCount: 0, totalCostUsd: 0, totalInputTokens: 0, totalOutputTokens: 0, parentConversationId: null, lastMessageAt: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() });181                }182                break;183              case "text-delta":184                if (!liveState.firstTokenAt) liveState.firstTokenAt = Date.now();185                liveState.text += ev.text;186                schedule();187                break;188              case "reasoning-delta":189                if (!liveState.firstTokenAt) liveState.firstTokenAt = Date.now();190                liveState.reasoning += ev.text;191                schedule();192                break;193              case "tool-start":194                liveState.tools.push({ id: ev.id, name: ev.name, args: "" });195                schedule();196                break;197              case "tool-delta": {198                const t = liveState.tools.find((x) => x.id === ev.id);199                if (t) t.args += ev.argumentsDelta;200                schedule();201                break;202              }203              case "tool-end": {204                const t = liveState.tools.find((x) => x.id === ev.id);205                if (t) t.args = ev.argumentsText ?? JSON.stringify(ev.arguments);206                else liveState.tools.push({ id: ev.id, name: ev.name, args: ev.argumentsText ?? JSON.stringify(ev.arguments) });207                schedule();208                break;209              }210              case "tool-result": {211                const t = liveState.tools.find((x) => x.id === ev.id);212                if (t) {213                  t.result = ev.result;214                  t.isError = ev.isError;215                  t.durationMs = ev.durationMs;216                }217                // Tool loop: the next round appends more text — separate visually.218                if (liveState.text && !liveState.text.endsWith("\n\n")) liveState.text += "\n\n";219                schedule();220                break;221              }222              case "server-tool":223                liveState.serverTools.push({ name: ev.name, status: ev.status });224                schedule();225                break;226              case "citation":227                liveState.citations.push(ev.citation);228                schedule();229                break;230              case "refusal":231                break;232              case "error":233                if (!liveState.text) toast.error("Generation failed", ev.error.message);234                break;235              case "done": {236                if (flushTimer) clearTimeout(flushTimer);237                setMessages((prev) => prev.map((m) => (m.id === (assistantId ?? "pending-assistant") ? ev.message : m)));238                if (ev.title) {239                  setConversation((c) => (c ? { ...c, title: ev.title! } : c));240                  invalidateConversations();241                } else {242                  invalidateConversations();243                }244                setConversation((c) => (c ? { ...c, totalCostUsd: (c.totalCostUsd ?? 0) + (ev.costUsd ?? 0), messageCount: c.messageCount + 1 } : c));245                break;246              }247            }248          },249          controller.signal,250        );251      } catch (e) {252        if ((e as Error).name === "AbortError") {253          // Stopped by the user: server persists the partial message; reload it.254          if (convId) {255            const res = await api<ConversationDetail>(`/api/conversations/${convId}`).catch(() => null);256            if (res) setMessages(res.messages.filter((m) => (m as unknown as { active?: boolean }).active !== false));257          }258        } else {259          const err = e as ClientApiError;260          toast.error(err.message, err.code === "NO_PROVIDER_KEY" ? "Add the key in Settings → Providers." : undefined);261          setMessages((prev) => prev.filter((m) => m.id !== "pending-assistant" && m.id !== assistantId).filter((m) => !(opts.optimisticUser && m.id === opts.optimisticUser.id && m.id.startsWith("tmp_"))));262          if (err.code === "NO_PROVIDER_KEY") router.push("/app/settings/providers");263        }264      } finally {265        if (flushTimer) clearTimeout(flushTimer);266        setLive(null);267        setBusy(false);268        abortRef.current = null;269      }270    },271    [busy, conversation, model, modelKey, settings, systemPrompt, router],272  );273274  const send = (text: string) => {275    if (!modelUsable) return toast.warning("This model's provider isn't connected", "Add a key in Settings → Providers.");276    const optimistic: PublicMessage = {277      id: `tmp_${Date.now()}`,278      conversationId: conversation?.id ?? "",279      role: "user",280      content: text,281      parts: [{ type: "text", text }, ...attachments.map((a) => ({ type: "attachment" as const, attachmentId: a.id, kind: a.kind, name: a.name, mimeType: a.mimeType, sizeBytes: a.sizeBytes, width: a.width, height: a.height }))],282      modelKey: null,283      provider: null,284      status: "complete",285      finishReason: null,286      error: null,287      usage: null,288      latencyMs: null,289      ttftMs: null,290      costUsd: null,291      parentMessageId: null,292      version: 1,293      active: true,294      createdAt: new Date().toISOString(),295    };296    const attIds = attachments.map((a) => a.id);297    setAttachments([]);298    void run({ action: "send", message: { text, attachmentIds: attIds } }, { optimisticUser: optimistic });299  };300301  const stop = () => abortRef.current?.abort();302303  const regenerate = (m: PublicMessage) => {304    const idx = messages.findIndex((x) => x.id === m.id);305    void run({ action: "regenerate", targetMessageId: m.id }, { truncateAfterIndex: idx });306  };307  const retry = (m: PublicMessage) => {308    const idx = messages.findIndex((x) => x.id === m.id);309    void run({ action: "retry", targetMessageId: m.id }, { truncateAfterIndex: idx });310  };311  const edit = (m: PublicMessage, text: string) => {312    const idx = messages.findIndex((x) => x.id === m.id);313    const optimistic: PublicMessage = { ...m, id: `tmp_${Date.now()}`, content: text, parts: [{ type: "text", text }, ...m.parts.filter((p) => p.type === "attachment")] };314    void run({ action: "edit", targetMessageId: m.id, message: { text } }, { optimisticUser: optimistic, truncateAfterIndex: idx });315  };316  const cont = (m: PublicMessage) => {317    const idx = messages.findIndex((x) => x.id === m.id);318    void run({ action: "continue", targetMessageId: m.id }, { truncateAfterIndex: idx });319  };320  const branch = async (m: PublicMessage) => {321    if (!conversation) return;322    const res = await api<{ conversation: PublicConversation }>(`/api/conversations/${conversation.id}/actions`, { method: "POST", json: { action: "branch", messageId: m.id } });323    invalidateConversations();324    toast.success("Branch created");325    router.push(`/app/chat/${res.conversation.id}`);326  };327  const del = async (m: PublicMessage) => {328    if (!conversation) return;329    await api(`/api/conversations/${conversation.id}/actions`, { method: "POST", json: { action: "delete-message", messageId: m.id } });330    setMessages((prev) => prev.filter((x) => x.id !== m.id));331  };332333  const share = async () => {334    if (!conversation) return;335    const res = await api<{ id: string }>(`/api/conversations/${conversation.id}/actions`, { method: "POST", json: { action: "share" } });336    const url = `${window.location.origin}/share/${res.id}`;337    await navigator.clipboard.writeText(url).catch(() => {});338    toast.success("Share link copied", url);339  };340341  const actions = React.useMemo(() => ({ onEdit: edit, onRegenerate: regenerate, onRetry: retry, onContinue: cont, onBranch: conversation ? branch : undefined, onDelete: conversation ? del : undefined }), [conversation, messages]); // eslint-disable-line react-hooks/exhaustive-deps342343  const lastAssistantIdx = messages.map((m) => m.role).lastIndexOf("assistant");344345  return (346    <div className="flex h-full min-h-0 flex-col">347      {/* Header */}348      <header className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-2 sm:px-4">349        <Button variant="ghost" size="icon-sm" className="md:hidden" onClick={() => setSidebarOpen(true)} aria-label="Open sidebar">350          <Menu />351        </Button>352        <ModelSelector value={modelKey} onChange={changeModel} size="sm" className="max-w-[46vw] sm:max-w-xs" />353        <ModelConfig model={model} settings={settings} onChange={(s) => { setSettings(s); if (conversation) void persistConversationMeta({ settings: s }); }} systemPrompt={systemPrompt} onSystemPromptChange={(v) => { setSystemPrompt(v); if (conversation) void persistConversationMeta({ systemPrompt: v || null }); }} />354        <div className="min-w-0 flex-1 truncate text-center text-[13px] font-medium text-fg-muted hidden md:block">{conversation?.title ?? ""}</div>355        <div className="ml-auto flex items-center gap-0.5">356          {conversation && preferences.showCosts && (conversation.totalCostUsd ?? 0) > 0 ? (357            <Tooltip content="Estimated conversation cost">358              <span className="hidden rounded-md bg-bg-muted px-2 py-1 font-mono text-[11px] tabular-nums text-fg-muted sm:inline">≈ {formatUsd(conversation.totalCostUsd, { precise: conversation.totalCostUsd < 0.01 })}</span>359            </Tooltip>360          ) : null}361          {conversation ? (362            <>363              <Tooltip content={conversation.pinned ? "Unpin" : "Pin"}>364                <Button variant="ghost" size="icon-sm" onClick={() => persistConversationMeta({ pinned: !conversation.pinned })} aria-label="Pin conversation">365                  <Pin className={cn(conversation.pinned && "fill-current text-accent")} />366                </Button>367              </Tooltip>368              <Tooltip content="Share">369                <Button variant="ghost" size="icon-sm" onClick={share} aria-label="Share conversation">370                  <Share2 />371                </Button>372              </Tooltip>373              <Tooltip content="Delete conversation">374                <Button375                  variant="ghost"376                  size="icon-sm"377                  aria-label="Delete conversation"378                  onClick={async () => {379                    if (!confirm("Delete this conversation?")) return;380                    await api(`/api/conversations/${conversation.id}`, { method: "DELETE" });381                    invalidateConversations();382                    router.push("/app/chat");383                  }}384                >385                  <Trash2 />386                </Button>387              </Tooltip>388            </>389          ) : (390            <Tooltip content="Compare models in the Arena">391              <Button asChild variant="ghost" size="icon-sm" aria-label="Open Arena">392                <Link href="/app/arena">393                  <Swords />394                </Link>395              </Button>396            </Tooltip>397          )}398        </div>399      </header>400401      {/* Messages */}402      <div ref={scrollRef} onScroll={onScroll} className="relative min-h-0 flex-1 overflow-y-auto scrollbar-thin">403        {messages.length === 0 ? (404          <ChatEmptyState model={model} onPick={(t) => setDraft(t)} />405        ) : (406          <div className="mx-auto flex w-full max-w-3xl flex-col gap-6 px-0 py-6 sm:px-4">407            {messages.map((m, i) => (408              <MessageItem key={m.id} message={m} model={m.modelKey ? modelsByKey.get(m.modelKey) : undefined} live={m.status === "streaming" ? live : null} isLast={i === lastAssistantIdx} wrapCode={preferences.codeWrap} showReasoning={preferences.showReasoning} showCosts={preferences.showCosts} actions={actions} busy={busy} />409            ))}410          </div>411        )}412        {!atBottom && messages.length > 0 ? (413          <button onClick={() => scrollToBottom(true)} className="sticky bottom-3 left-1/2 -translate-x-1/2 rounded-full border border-border bg-bg-elevated p-1.5 shadow-md hover:bg-bg-subtle" aria-label="Scroll to bottom">414            <ArrowDown className="size-4" />415          </button>416        ) : null}417      </div>418419      {/* Composer */}420      <div className="shrink-0 px-3 pb-[calc(env(safe-area-inset-bottom)+10px)] pt-2 sm:px-4">421        <div className="mx-auto w-full max-w-3xl">422          <Composer model={model} busy={busy} disabled={!model || (!modelUsable && models.length > 0)} enterToSend={preferences.enterToSend} attachments={attachments} onAttachmentsChange={setAttachments} onSend={send} onStop={stop} autoFocus value={draft} onValueChange={setDraft} />423          <p className="mt-1.5 hidden text-center text-[11px] text-fg-subtle sm:block">424            {model ? (425              <>426                Responses come straight from {model.provider === "openai" ? "OpenAI" : model.provider === "anthropic" ? "Anthropic" : model.provider === "gemini" ? "Google" : "xAI"} using your key · <Sparkles className="inline size-3" /> costs are estimates427              </>428            ) : (429              "Choose a model to start"430            )}431          </p>432        </div>433      </div>434    </div>435  );436}437
Files d6f683a5f3ce2650916bcde9467e60c640c62fa9
Menu

Theme