Repository Public
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