"use client"; import * as React from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { AlertTriangle, ArrowDown, Braces, Columns3, Download, EyeOff, Globe, Menu, MoreHorizontal, Pin, Settings2, Share2, Swords, Terminal, Trash2, Wrench } from "lucide-react"; import Link from "next/link"; import { useApp, invalidateConversations, AUTO_MODEL_KEY } from "@/components/app/store"; import { providerName } from "@/lib/client/providers"; import { api, streamEvents, ClientApiError } from "@/lib/client/api"; import type { ChatAdoptResponse, ChatMetaExtras, ChatStreamEvent, ConversationDetail, PublicConversation, PublicMessage, PolyModel, ModelPreset, PromptPreset } from "@/lib/client/types"; import { analyzePrompt, routeModels, explainRoute, type RouteResult, type RouteCandidate, type RouterMode } from "@/lib/client/router"; import { estimateContext, estimateCost, estimateTextTokens, estimateAttachmentTokens, COST_CONFIRM_THRESHOLD_USD, type CostEstimate } from "@/lib/client/tokens"; import { useIsMobile, useLocalStorage } from "@/lib/client/hooks"; import { deprecationNotice, suggestReplacement, largerContextModel } from "@/lib/chat/deprecation"; import { MessageItem, type LiveState, type MessageActions } from "./message"; import { Composer, type PendingAttachment, type ComposerAction, type ComposerHandle } from "./composer"; import { ModelSelector } from "./model-selector"; import { ModelConfig, countActiveSettings, type ChatSettings } from "./model-config"; import { ChatEmptyState } from "./empty-state"; import { ContextIndicator } from "./context-indicator"; import { RouterCard } from "./router-card"; import { CostConfirm } from "./cost-confirm"; import { CompareInline } from "./compare-inline"; import { ModelPickerLauncher, type ModelPickerLauncherHandle } from "./model-launcher"; import { StructuredOutputSheet, SystemPromptSheet, ToolsSheet } from "./composer-sheets"; import { ConfirmDialog } from "@/components/common/confirm-dialog"; import { openShareSheet } from "@/components/share/share-sheet"; import { ExportMenu } from "@/components/share/export-menu"; import { exportConversation } from "@/components/share/export"; import { usePromptInsert, consumePendingPromptInsert, decodeVars, type PromptInsertDetail } from "@/components/prompts/insert"; import { consumePendingAttachments } from "@/components/library/use-in-chat"; import { ActionSheet, type ActionSheetItem } from "@/components/ui/sheet"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Tooltip } from "@/components/ui/tooltip"; import { toast } from "@/components/ui/toast"; import { cn, formatUsd } from "@/lib/utils"; interface Props { conversationId?: string; initial?: ConversationDetail; } interface Pending { text: string; attachments: PendingAttachment[]; } interface ProjectLite { id: string; name: string; instructions: string | null; preferredModelKeys: string[]; } type MetaEvent = Extract & ChatMetaExtras; const LS_ROUTER_ALWAYS = "polyllm:router-always"; const LS_ROUTER_MODE = "polyllm:router-mode"; const SUMMARY_PROMPT = "Summarize this conversation so far for hand-off to a fresh session: goals, key facts, decisions, constraints, open questions and the exact state of any code or data. Be complete but compact, in Markdown, without preamble."; const emptyLive = (): LiveState => ({ text: "", reasoning: "", tools: [], serverTools: [], citations: [], startedAt: Date.now() }); function mkMessage(base: Partial & Pick): PublicMessage { return { conversationId: "", parts: [], modelKey: null, provider: null, status: "complete", finishReason: null, error: null, usage: null, settings: null, latencyMs: null, ttftMs: null, costUsd: null, parentMessageId: null, version: 1, active: true, createdAt: new Date().toISOString(), ...base } as PublicMessage; } function attachmentsOf(m: PublicMessage) { return (m.parts as { type: string; kind?: string; sizeBytes?: number; width?: number | null; height?: number | null }[]).filter((p) => p.type === "attachment").map((p) => ({ kind: p.kind ?? "file", sizeBytes: p.sizeBytes ?? 0, width: p.width, height: p.height })); } export function ChatView({ conversationId, initial }: Props) { const router = useRouter(); const search = useSearchParams(); const isMobile = useIsMobile(); const { modelsByKey, models, selectedModelKey, setSelectedModelKey, preferences, setSidebarOpen, connectedProviders, favorites, activeProjectId } = useApp(); const [conversation, setConversation] = React.useState(initial?.conversation ?? null); const [messages, setMessages] = React.useState(() => (initial?.messages ?? []).filter((m) => (m as unknown as { active?: boolean }).active !== false)); const [modelKey, setModelKey] = React.useState(initial?.conversation.modelKey ?? null); const [settings, setSettings] = React.useState(() => ((initial?.conversation.settings as ChatSettings) ?? {})); const [systemPrompt, setSystemPrompt] = React.useState(initial?.conversation.systemPrompt ?? preferences.defaultSystemPrompt ?? ""); const [attachments, setAttachments] = React.useState([]); const [live, setLive] = React.useState(null); const [busy, setBusy] = React.useState(false); const [draft, setDraft] = React.useState(""); const [ephemeral, setEphemeral] = React.useState(() => !conversationId && search.get("temporary") === "1"); const [turnMeta, setTurnMeta] = React.useState>({}); const [project, setProject] = React.useState(null); const [routerState, setRouterState] = React.useState<{ result: RouteResult; pending: Pending } | null>(null); const [costState, setCostState] = React.useState<{ pending: Pending; modelKey: string; estimate: CostEstimate; alternatives: RouteCandidate[] } | null>(null); const [compare, setCompare] = React.useState<{ prompt: string; editable: boolean; initialModels: string[] } | null>(null); const [pick, setPick] = React.useState<{ onPick: (key: string) => void } | null>(null); const [sysOpen, setSysOpen] = React.useState(false); const [structOpen, setStructOpen] = React.useState(false); const [toolsOpen, setToolsOpen] = React.useState(false); const [moreOpen, setMoreOpen] = React.useState(false); const [deleteOpen, setDeleteOpen] = React.useState(false); const [summarizing, setSummarizing] = React.useState(false); const [alwaysAuto, setAlwaysAuto] = useLocalStorage(LS_ROUTER_ALWAYS, false); const [routerMode, setRouterMode] = useLocalStorage(LS_ROUTER_MODE, "balanced"); const [now] = React.useState(() => Date.now()); const abortRef = React.useRef(null); const scrollRef = React.useRef(null); const composerRef = React.useRef(null); const pickerRef = React.useRef(null); const messagesRef = React.useRef(messages); const userPickedModel = React.useRef(false); const [atBottom, setAtBottom] = React.useState(true); const presetApplied = React.useRef(false); React.useEffect(() => { messagesRef.current = messages; }, [messages]); // ?temporary=1 (also when navigating from a normal new chat via the command palette) React.useEffect(() => { if (!conversationId && search.get("temporary") === "1") { // eslint-disable-next-line react-hooks/set-state-in-effect setEphemeral(true); } }, [conversationId, search]); // Model for new chats follows the global selection; existing conversations keep theirs. React.useEffect(() => { if (!conversationId && !modelKey && selectedModelKey) { // eslint-disable-next-line react-hooks/set-state-in-effect setModelKey(selectedModelKey); } }, [conversationId, modelKey, selectedModelKey]); // Project instructions + preferred model for new chats in the active project (degrades silently on 404). React.useEffect(() => { if (conversationId || !activeProjectId) return; let cancelled = false; api<{ project: ProjectLite }>(`/api/projects/${activeProjectId}`) .then((res) => { if (cancelled || !res?.project) return; setProject({ id: res.project.id, name: res.project.name, instructions: res.project.instructions ?? null, preferredModelKeys: res.project.preferredModelKeys ?? [] }); const pref = res.project.preferredModelKeys?.[0]; if (pref && modelsByKey.has(pref) && !userPickedModel.current && messagesRef.current.length === 0) setModelKey(pref); }) .catch(() => { /* Projects API not available yet or project removed: no instructions */ }); return () => { cancelled = true; }; }, [conversationId, activeProjectId, modelsByKey]); const activeProject = !conversationId && activeProjectId && project?.id === activeProjectId ? project : null; // Apply ?preset= / ?prompt= / ?promptId= / ?model= for new chats. React.useEffect(() => { if (conversationId || presetApplied.current) return; const presetId = search.get("preset"); const promptId = search.get("prompt"); const libraryPromptId = search.get("promptId"); const m = search.get("model"); if (!presetId && !promptId && !m && !libraryPromptId) return; presetApplied.current = true; (async () => { if (m && (modelsByKey.has(m) || m === AUTO_MODEL_KEY)) { userPickedModel.current = true; setModelKey(m); } if (presetId || promptId) { const res = await api<{ modelPresets: ModelPreset[]; promptPresets: PromptPreset[] }>("/api/presets"); if (presetId) { const p = res.modelPresets.find((x) => x.id === presetId); if (p) { userPickedModel.current = true; setModelKey(p.modelKey); const tools = (p.tools as { builtin?: string[] })?.builtin; setSettings({ ...(p.parameters as ChatSettings), ...(tools?.length ? { tools } : {}) }); if (p.systemPrompt) setSystemPrompt(p.systemPrompt); toast.info(`Preset “${p.name}” applied`); } } if (promptId) { const p = res.promptPresets.find((x) => x.id === promptId); if (p) { setSystemPrompt(p.systemPrompt); if (p.defaultModelKey && modelsByKey.has(p.defaultModelKey)) setModelKey(p.defaultModelKey); setSettings((s) => ({ ...s, ...(p.parameters as ChatSettings) })); toast.info(`Prompt “${p.name}” applied`); } } } if (libraryPromptId) { // Prompt library (Projects workstream). 404 → ignore. const res = await api<{ prompt?: { content?: string; body?: string; text?: string } }>(`/api/prompts/${libraryPromptId}`).catch(() => null); const content = res?.prompt?.content ?? res?.prompt?.body ?? res?.prompt?.text; if (content) setDraft(content); } })().catch(() => {}); }, [conversationId, search, modelsByKey]); const isAuto = modelKey === AUTO_MODEL_KEY; const model: PolyModel | undefined = modelKey && !isAuto ? modelsByKey.get(modelKey) : undefined; const modelUsable = Boolean(model && connectedProviders.has(model.provider)); const usableModels = React.useMemo(() => models.filter((m) => connectedProviders.has(m.provider) && m.status !== "deprecated"), [models, connectedProviders]); const fullSystemPrompt = React.useMemo(() => [activeProject?.instructions?.trim(), systemPrompt.trim()].filter(Boolean).join("\n\n") || null, [activeProject, systemPrompt]); const notice = React.useMemo(() => deprecationNotice(model, now), [model, now]); const replacement = React.useMemo(() => (model && notice ? suggestReplacement(model, models, connectedProviders) : null), [model, notice, models, connectedProviders]); // --- context + cost estimate ---------------------------------------------------------------------- const expectedOut = settings.maxTokens && settings.maxTokens < 4000 ? settings.maxTokens : 600; const estimate = React.useMemo( () => estimateContext({ historyText: messages.filter((m) => m.status !== "streaming").map((m) => m.content), historyAttachments: messages.flatMap(attachmentsOf), draft, attachments, systemPrompt: fullSystemPrompt, model, expectedOutput: expectedOut, }), [messages, draft, attachments, fullSystemPrompt, model, expectedOut], ); const cost = React.useMemo(() => estimateCost(model, estimate.total, expectedOut), [model, estimate.total, expectedOut]); const largerModel = React.useMemo(() => (estimate.level !== "ok" ? largerContextModel(model, models, connectedProviders) : null), [estimate.level, model, models, connectedProviders]); // --- scrolling -------------------------------------------------------------------------------------- const scrollToBottom = React.useCallback((smooth = false) => { const el = scrollRef.current; if (!el) return; el.scrollTo({ top: el.scrollHeight, behavior: smooth ? "smooth" : "auto" }); }, []); React.useEffect(() => { if (atBottom) scrollToBottom(); }, [messages, live, atBottom, scrollToBottom, compare]); React.useEffect(() => { const hash = window.location.hash.slice(1); if (hash) document.getElementById(hash)?.scrollIntoView({ block: "center" }); else scrollToBottom(); }, [conversationId, scrollToBottom]); const onScroll = () => { const el = scrollRef.current; if (!el) return; setAtBottom(el.scrollHeight - el.scrollTop - el.clientHeight < 80); }; // --- model picker launcher (retry with…, switch model, choose another) ------------------------------ React.useEffect(() => { if (pick) requestAnimationFrame(() => pickerRef.current?.open()); }, [pick]); const persistConversationMeta = React.useCallback( async (patch: Record) => { if (!conversation) return; try { const res = await api<{ conversation: PublicConversation }>(`/api/conversations/${conversation.id}`, { method: "PATCH", json: patch }); setConversation(res.conversation); invalidateConversations(); } catch { /* non-fatal */ } }, [conversation], ); const changeModel = React.useCallback( (key: string, opts: { keepGlobal?: boolean } = {}) => { userPickedModel.current = true; setModelKey(key); if (!opts.keepGlobal) setSelectedModelKey(key); if (conversation && key !== AUTO_MODEL_KEY) void persistConversationMeta({ modelKey: key }); }, [conversation, persistConversationMeta, setSelectedModelKey], ); // --- cross-area integrations (prompt library, file library, onboarding, command palette) ------------ const applyPromptInsert = React.useCallback( (d: PromptInsertDetail) => { if (d.kind === "system") { setSystemPrompt(d.systemPrompt ?? d.text ?? ""); toast.info(`System prompt “${d.name}” applied`); } else if (d.text) { composerRef.current?.insert(d.text); } if (d.kind === "structured" && d.schema) setSettings((s) => ({ ...s, responseFormat: { type: "json_schema", schema: d.schema!, schemaName: d.name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) } })); if (d.defaultModelKey && modelsByKey.has(d.defaultModelKey) && connectedProviders.has(modelsByKey.get(d.defaultModelKey)!.provider)) changeModel(d.defaultModelKey); }, [modelsByKey, connectedProviders, changeModel], ); usePromptInsert(applyPromptInsert); const integrationsApplied = React.useRef(false); React.useEffect(() => { if (integrationsApplied.current) return; integrationsApplied.current = true; // Onboarding / palette: prefill the draft. const q = search.get("q"); // eslint-disable-next-line react-hooks/set-state-in-effect -- one-shot URL hand-off if (q && !conversationId) setDraft(q); if (conversationId) return; // Prompt library hand-off (sessionStorage fast path, URL fallback for reloads/shared links). const pending = consumePendingPromptInsert(); const promptInsert = search.get("promptInsert"); if (pending) applyPromptInsert(pending); else if (promptInsert) { api<{ prompt: { id: string; name: string }; kind: PromptInsertDetail["kind"]; text: string; systemPrompt: string | null; schema: Record | null; defaultModelKey: string | null }>(`/api/prompts/${promptInsert}/use`, { method: "POST", json: { variables: decodeVars(search.get("vars")) } }) .then((res) => applyPromptInsert({ promptId: res.prompt.id, name: res.prompt.name, kind: res.kind, text: res.text, systemPrompt: res.systemPrompt, schema: res.schema, defaultModelKey: res.defaultModelKey, variables: {}, at: Date.now() })) .catch(() => toast.warning("Prompt not found", "It may have been deleted from your library.")); } // Library files → composer chips. if (search.get("attachments")) { const list = consumePendingAttachments(); if (list?.length) setAttachments((prev) => [...prev, ...list.filter((a) => !prev.some((p) => p.id === a.id))]); else toast.warning("Attachments expired", "Pick the files again from the library."); } }, [search, conversationId, applyPromptInsert]); React.useEffect(() => { const onAttach = () => composerRef.current?.openFilePicker("document"); const onSwitch = (e: Event) => { const key = (e as CustomEvent<{ modelKey?: string }>).detail?.modelKey; if (key) changeModel(key); }; window.addEventListener("polyllm:open-attach", onAttach); window.addEventListener("polyllm:switch-model", onSwitch); return () => { window.removeEventListener("polyllm:open-attach", onAttach); window.removeEventListener("polyllm:switch-model", onSwitch); }; }, [changeModel]); const updateSettings = (s: ChatSettings) => { setSettings(s); if (conversation) void persistConversationMeta({ settings: s }); }; const updateSystemPrompt = (v: string) => { setSystemPrompt(v); if (conversation) void persistConversationMeta({ systemPrompt: v || null }); }; // --- streaming turn --------------------------------------------------------------------------------- const run = React.useCallback( async (body: Record, opts: { optimisticUser?: PublicMessage | null; replaceAssistantId?: string; truncateAfterIndex?: number; modelKey?: string; historyUntil?: number } = {}) => { const key = opts.modelKey ?? modelKey; if (!key || key === AUTO_MODEL_KEY) return toast.warning("Pick a model first"); if (busy) return; const keyModel = modelsByKey.get(key); setBusy(true); const controller = new AbortController(); abortRef.current = controller; const liveState = emptyLive(); setLive(liveState); let assistantId: string | null = null; let convId = ephemeral ? null : conversation?.id ?? null; let flushTimer: ReturnType | null = null; const flush = () => { flushTimer = null; setLive({ ...liveState, tools: [...liveState.tools], serverTools: [...liveState.serverTools], citations: [...liveState.citations] }); }; const schedule = () => { if (!flushTimer) flushTimer = setTimeout(flush, 40); }; // Temporary chats have no server history: replay prior turns. const snapshot = messagesRef.current; const historyEnd = opts.historyUntil ?? opts.truncateAfterIndex ?? snapshot.length; const history = ephemeral ? snapshot.slice(0, historyEnd).filter((m) => (m.role === "user" || m.role === "assistant") && m.content && m.status !== "streaming" && !m.id.startsWith("tmp_")).map((m) => ({ role: m.role as "user" | "assistant", content: m.content })) : undefined; // optimistic UI setMessages((prev) => { let next = opts.truncateAfterIndex !== undefined ? prev.slice(0, opts.truncateAfterIndex) : [...prev]; if (opts.replaceAssistantId) next = next.filter((m) => m.id !== opts.replaceAssistantId); if (opts.optimisticUser) next.push(opts.optimisticUser); next.push(mkMessage({ id: "pending-assistant", conversationId: convId ?? "", role: "assistant", content: "", modelKey: key, provider: keyModel?.provider ?? null, status: "streaming" })); return next; }); try { await streamEvents( "/api/chat", { ...body, modelKey: key, conversationId: convId ?? undefined, systemPrompt: fullSystemPrompt, settings: Object.keys(settings).length ? settings : undefined, projectId: !convId && !ephemeral && activeProjectId ? activeProjectId : undefined, ephemeral: ephemeral || undefined, history, }, (raw) => { const ev = raw as ChatStreamEvent & ChatMetaExtras; switch (ev.type) { case "meta": { const meta = ev as MetaEvent; assistantId = meta.assistantMessageId; if (!meta.ephemeral) convId = meta.conversationId; setMessages((prev) => prev.map((m) => { if (m.id === "pending-assistant") return { ...m, id: meta.assistantMessageId, conversationId: meta.conversationId }; if (opts.optimisticUser && m.id === opts.optimisticUser.id && meta.userMessage) return meta.userMessage; return m; }), ); if (meta.requestId) setTurnMeta((t) => ({ ...t, [meta.assistantMessageId]: { ...t[meta.assistantMessageId], requestId: meta.requestId } })); if (meta.isNewConversation && !meta.ephemeral) { window.history.replaceState(null, "", `/app/chat/${meta.conversationId}`); setConversation({ id: meta.conversationId, title: "New chat", folderId: null, pinned: false, archived: false, modelKey: key, provider: keyModel?.provider ?? null, systemPrompt: fullSystemPrompt, settings, messageCount: 0, totalCostUsd: 0, totalInputTokens: 0, totalOutputTokens: 0, parentConversationId: null, lastMessageAt: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } as PublicConversation); } break; } case "text-delta": if (!liveState.firstTokenAt) liveState.firstTokenAt = Date.now(); liveState.text += ev.text; schedule(); break; case "reasoning-delta": if (!liveState.firstTokenAt) liveState.firstTokenAt = Date.now(); liveState.reasoning += ev.text; schedule(); break; case "tool-start": liveState.tools.push({ id: ev.id, name: ev.name, args: "" }); schedule(); break; case "tool-delta": { const t = liveState.tools.find((x) => x.id === ev.id); if (t) t.args += ev.argumentsDelta; schedule(); break; } case "tool-end": { const t = liveState.tools.find((x) => x.id === ev.id); if (t) t.args = ev.argumentsText ?? JSON.stringify(ev.arguments); else liveState.tools.push({ id: ev.id, name: ev.name, args: ev.argumentsText ?? JSON.stringify(ev.arguments) }); schedule(); break; } case "tool-result": { const t = liveState.tools.find((x) => x.id === ev.id); if (t) { t.result = ev.result; t.isError = ev.isError; t.durationMs = ev.durationMs; } if (liveState.text && !liveState.text.endsWith("\n\n")) liveState.text += "\n\n"; schedule(); break; } case "server-tool": liveState.serverTools.push({ name: ev.name, status: ev.status }); schedule(); break; case "citation": liveState.citations.push(ev.citation); schedule(); break; case "refusal": break; case "error": { const id = assistantId ?? "pending-assistant"; setTurnMeta((t) => ({ ...t, [id]: { ...t[id], errorAt: Date.now() } })); break; } case "done": { if (flushTimer) clearTimeout(flushTimer); setMessages((prev) => prev.map((m) => (m.id === (assistantId ?? "pending-assistant") ? ev.message : m))); if (ev.error) setTurnMeta((t) => ({ ...t, [ev.message.id]: { ...t[ev.message.id], errorAt: t[ev.message.id]?.errorAt ?? Date.now() } })); if (!ephemeral) { if (ev.title) setConversation((c) => (c ? { ...c, title: ev.title! } : c)); invalidateConversations(); setConversation((c) => (c ? { ...c, totalCostUsd: (c.totalCostUsd ?? 0) + (ev.costUsd ?? 0), messageCount: c.messageCount + 1 } : c)); } break; } } }, controller.signal, ); } catch (e) { if ((e as Error).name === "AbortError") { // Stopped by the user: the server persists the partial message; reload it (not for temporary chats). if (convId) { const res = await api(`/api/conversations/${convId}`).catch(() => null); if (res) setMessages(res.messages.filter((m) => (m as unknown as { active?: boolean }).active !== false)); } else { setMessages((prev) => prev.map((m) => (m.id === (assistantId ?? "pending-assistant") ? { ...m, content: liveState.text, status: "stopped" as const, finishReason: "cancelled" } : m))); } } else { const err = e as ClientApiError; toast.error(err.message, err.code === "NO_PROVIDER_KEY" ? "Add the key in Settings → Providers." : undefined); 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_")))); if (err.code === "NO_PROVIDER_KEY") router.push("/app/settings/providers"); } } finally { if (flushTimer) clearTimeout(flushTimer); setLive(null); setBusy(false); abortRef.current = null; } }, [busy, conversation, modelKey, modelsByKey, settings, fullSystemPrompt, router, ephemeral, activeProjectId], ); // --- send pipeline: Smart Router → cost confirm → run ---------------------------------------------- const restorePending = (p: Pending) => { setDraft(p.text); setAttachments(p.attachments); }; const doSend = (key: string, pending: Pending) => { if (key !== modelKey) changeModel(key, { keepGlobal: isAuto }); const km = modelsByKey.get(key); const optimistic = mkMessage({ id: `tmp_${Date.now()}`, conversationId: conversation?.id ?? "", role: "user", content: pending.text, parts: [...(pending.text ? [{ type: "text" as const, text: pending.text }] : []), ...pending.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 }))], }); setAttachments([]); void km; void run({ action: "send", message: { text: pending.text, attachmentIds: pending.attachments.map((a) => a.id) } }, { optimisticUser: optimistic, modelKey: key }); }; const routeFor = (text: string, atts: PendingAttachment[], mode: RouterMode = routerMode): RouteResult => { const analysis = analyzePrompt(text, atts, { historyTokens: estimate.history, systemPrompt: fullSystemPrompt, webSearch: settings.webSearch, responseFormat: Boolean(settings.responseFormat) }); return routeModels(usableModels, analysis, mode, { favorites, budgetCapUsd: COST_CONFIRM_THRESHOLD_USD }); }; const proceed = (key: string, pending: Pending) => { const km = modelsByKey.get(key); const inputTokens = estimate.history + estimateTextTokens(pending.text) + pending.attachments.reduce((n, a) => n + estimateAttachmentTokens(a), 0) + estimateTextTokens(fullSystemPrompt ?? ""); const est = estimateCost(km, inputTokens, expectedOut); if (est.usd !== null && est.usd > COST_CONFIRM_THRESHOLD_USD) { const cheap = routeFor(pending.text, pending.attachments, "cheapest"); const alternatives = [cheap.recommended, ...cheap.alternatives].filter((c): c is RouteCandidate => Boolean(c) && c!.model.key !== key && c!.estimatedUsd !== null && (c!.estimatedUsd as number) < (est.usd as number)).slice(0, 3); setCostState({ pending, modelKey: key, estimate: est, alternatives }); return; } doSend(key, pending); }; const send = (text: string) => { if (!text && attachments.length === 0) return; const pending: Pending = { text, attachments: [...attachments] }; if (isAuto) { if (!usableModels.length) return toast.warning("No provider connected", "Add a key in Settings → Providers."); const result = routeFor(text, pending.attachments); if (!result.recommended) { restorePending(pending); return toast.warning("No compatible model", "None of your connected models can take this prompt (vision, files or context)."); } if (alwaysAuto && !result.needsConfirmation) { toast.info(`Auto-routed to ${result.recommended.model.displayName}`, explainRoute(result.recommended)); proceed(result.recommended.model.key, pending); } else { setRouterState({ result, pending }); } return; } if (!modelKey) return toast.warning("Pick a model first"); if (!modelUsable) { restorePending(pending); return toast.warning("This model's provider isn't connected", "Add a key in Settings → Providers."); } proceed(modelKey, pending); }; const stop = () => abortRef.current?.abort(); // --- message actions --------------------------------------------------------------------------------- const lastUserBefore = (idx: number) => { for (let i = idx - 1; i >= 0; i--) if (messages[i].role === "user") return i; return -1; }; const regenerate = (m: PublicMessage, key?: string) => { const idx = messages.findIndex((x) => x.id === m.id); if (key && key !== modelKey) changeModel(key, { keepGlobal: isAuto }); if (ephemeral) { const ui = lastUserBefore(idx); if (ui < 0) return; const u = messages[ui]; void run({ action: "send", message: { text: u.content, attachmentIds: [] } }, { truncateAfterIndex: idx, historyUntil: ui, modelKey: key }); return; } void run({ action: "regenerate", targetMessageId: m.id }, { truncateAfterIndex: idx, modelKey: key }); }; const regenerateWith = (m: PublicMessage) => setPick({ onPick: (key) => regenerate(m, key) }); const retry = (m: PublicMessage) => { const idx = messages.findIndex((x) => x.id === m.id); if (ephemeral) return regenerate(m); void run({ action: "retry", targetMessageId: m.id }, { truncateAfterIndex: idx }); }; const edit = (m: PublicMessage, text: string) => { const idx = messages.findIndex((x) => x.id === m.id); const optimistic = mkMessage({ ...m, id: `tmp_${Date.now()}`, content: text, parts: [{ type: "text", text }, ...m.parts.filter((p) => p.type === "attachment")] }); if (ephemeral) { void run({ action: "send", message: { text, attachmentIds: [] } }, { optimisticUser: optimistic, truncateAfterIndex: idx, historyUntil: idx }); return; } void run({ action: "edit", targetMessageId: m.id, message: { text } }, { optimisticUser: optimistic, truncateAfterIndex: idx }); }; const cont = (m: PublicMessage) => { const idx = messages.findIndex((x) => x.id === m.id); void run({ action: "continue", targetMessageId: m.id }, { truncateAfterIndex: idx }); }; const branch = async (m: PublicMessage) => { if (!conversation) return; const res = await api<{ conversation: PublicConversation }>(`/api/conversations/${conversation.id}/actions`, { method: "POST", json: { action: "branch", messageId: m.id } }); invalidateConversations(); toast.success("Branch created"); router.push(`/app/chat/${res.conversation.id}`); }; const del = async (m: PublicMessage) => { if (ephemeral) { setMessages((prev) => prev.filter((x) => x.id !== m.id)); return; } if (!conversation) return; await api(`/api/conversations/${conversation.id}/actions`, { method: "POST", json: { action: "delete-message", messageId: m.id } }); setMessages((prev) => prev.filter((x) => x.id !== m.id)); }; const quote = (m: PublicMessage) => { const q = m.content .trim() .split("\n") .map((l) => `> ${l}`) .join("\n"); composerRef.current?.insert(`${q}\n\n`); }; const saveAsPrompt = async (m: PublicMessage) => { const name = m.content.replace(/\s+/g, " ").trim().slice(0, 60) || "Saved prompt"; try { await api("/api/prompts", { method: "POST", json: { name, content: m.content, source: "chat" } }); toast.success("Saved to your prompt library"); } catch (e) { const err = e as ClientApiError; if (err.status === 404 || err.status === 405) toast.info("Prompt library not available yet"); else toast.error("Could not save the prompt", err.message); } }; const exportMessage = async (m: PublicMessage) => { const who = m.role === "user" ? "User" : modelsByKey.get(m.modelKey ?? "")?.displayName ?? m.modelKey ?? "Assistant"; const md = `### ${who} · ${new Date(m.createdAt).toLocaleString()}\n\n${m.content}\n`; await navigator.clipboard.writeText(md).catch(() => {}); toast.success("Message copied as Markdown"); }; const compareFrom = (m: PublicMessage) => { const idx = messages.findIndex((x) => x.id === m.id); const ui = lastUserBefore(idx); const prompt = ui >= 0 ? messages[ui].content : ""; if (!prompt.trim()) return toast.warning("Nothing to compare", "This response has no user prompt before it."); const base = m.modelKey && modelsByKey.has(m.modelKey) ? [m.modelKey] : model ? [model.key] : []; setCompare({ prompt, editable: false, initialModels: base }); setAtBottom(true); }; const openCompareFromComposer = () => { setCompare({ prompt: draft, editable: true, initialModels: model ? [model.key] : [] }); setAtBottom(true); }; const onAdopted = (res: ChatAdoptResponse, key: string) => { setCompare(null); if (res.isNewConversation) { invalidateConversations(); setSelectedModelKey(key); router.push(`/app/chat/${res.conversation.id}`); return; } setMessages((prev) => [...prev, res.message]); setConversation(res.conversation); changeModel(key, { keepGlobal: isAuto }); invalidateConversations(); toast.success(`Continuing with ${modelsByKey.get(key)?.displayName ?? key}`); }; const share = () => { if (!conversation) return; openShareSheet({ conversationId: conversation.id, title: conversation.title, messages }); }; const deleteConversation = async () => { if (!conversation) return; await api(`/api/conversations/${conversation.id}`, { method: "DELETE" }); invalidateConversations(); router.push("/app/chat"); }; const toggleTemporary = () => { if (conversation || messages.length) { router.push("/app/chat?temporary=1"); return; } const next = !ephemeral; setEphemeral(next); window.history.replaceState(null, "", next ? "/app/chat?temporary=1" : "/app/chat"); }; // Summarize the context with the current model, then start a new chat seeded with the summary. const summarize = async () => { if (!model || !modelKey || summarizing || busy) return; setSummarizing(true); try { const history = messages.filter((m) => (m.role === "user" || m.role === "assistant") && m.content && m.status !== "streaming").map((m) => ({ role: m.role as "user" | "assistant", content: m.content })); let summary = ""; let failure: string | null = null; await streamEvents("/api/chat", { modelKey, ephemeral: true, history, message: { text: SUMMARY_PROMPT }, systemPrompt: null }, (raw) => { const ev = raw as ChatStreamEvent; if (ev.type === "text-delta") summary += ev.text; if (ev.type === "error") failure = ev.error.message; }); if (!summary.trim()) throw new Error(failure ?? "The model returned an empty summary"); const sys = [systemPrompt.trim(), `Context carried over from a previous conversation (summarized):\n\n${summary.trim()}`].filter(Boolean).join("\n\n"); const res = await api<{ conversation: PublicConversation }>("/api/conversations", { method: "POST", json: { title: `${conversation?.title ?? "Chat"} (continued)`, modelKey, systemPrompt: sys, settings } }); invalidateConversations(); toast.success("Context summarized", "A new chat was started with the summary as system prompt."); router.push(`/app/chat/${res.conversation.id}`); } catch (e) { toast.error("Could not summarize the context", (e as Error).message); } finally { setSummarizing(false); } }; const actions = React.useMemo( () => ({ onEdit: edit, onRegenerate: regenerate, onRegenerateWith: regenerateWith, onRetry: retry, onContinue: ephemeral ? undefined : cont, onBranch: conversation && !ephemeral ? branch : undefined, onDelete: conversation || ephemeral ? del : undefined, onCompare: usableModels.length > 1 ? compareFrom : undefined, onQuote: quote, onSaveAsPrompt: saveAsPrompt, onExport: exportMessage, onSwitchModel: (key) => changeModel(key), }), // eslint-disable-next-line react-hooks/exhaustive-deps [conversation, messages, ephemeral, modelKey, usableModels.length], ); const lastAssistantIdx = messages.map((m) => m.role).lastIndexOf("assistant"); const composerDisabled = isAuto ? usableModels.length === 0 : !model || (!modelUsable && models.length > 0); const activeSettings = countActiveSettings(settings); // --- `+` menu extras --------------------------------------------------------------------------------- const extraActions: (ComposerAction | "separator")[] = [ ...(model?.capabilities.tools ? [{ key: "tools", label: "Tools", icon: , hint: settings.tools?.length ? `${settings.tools.length} on` : undefined, selected: Boolean(settings.tools?.length), onSelect: () => setToolsOpen(true) }] : []), ...(model?.capabilities.webSearch ? [{ key: "web", label: "Web search", icon: , selected: Boolean(settings.webSearch), onSelect: () => updateSettings(settings.webSearch ? (({ webSearch: _w, ...rest }) => rest)(settings) : { ...settings, webSearch: true }) }] : []), ...(model?.capabilities.structuredOutput ? [{ key: "json", label: "Structured output", icon: , hint: settings.responseFormat?.type === "json_schema" ? "schema" : settings.responseFormat?.type === "json" ? "JSON" : undefined, selected: Boolean(settings.responseFormat), onSelect: () => setStructOpen(true) }] : []), { key: "system", label: "System prompt", icon: , selected: Boolean(systemPrompt.trim()), onSelect: () => setSysOpen(true) }, "separator", { key: "temporary", label: "Temporary chat", icon: , hint: conversation || messages.length ? "New" : undefined, selected: ephemeral, onSelect: toggleTemporary }, ]; const moreItems: (ActionSheetItem | "separator")[] = [ { key: "config", label: "Model settings", icon: , hint: activeSettings ? `${activeSettings} set` : undefined, onSelect: () => document.getElementById("chat-model-config")?.click() }, { key: "compare", label: "Compare with…", icon: , onSelect: openCompareFromComposer, disabled: usableModels.length < 2 }, ...(conversation && !ephemeral ? ([ { key: "pin", label: conversation.pinned ? "Unpin" : "Pin", icon: , onSelect: () => persistConversationMeta({ pinned: !conversation.pinned }) }, { key: "share", label: "Share…", icon: , onSelect: share }, { key: "export-md", label: "Export Markdown", icon: , onSelect: () => void exportConversation(conversation.id, "markdown") }, { key: "export-pdf", label: "Export PDF (print)", icon: , onSelect: () => void exportConversation(conversation.id, "pdf") }, ] as ActionSheetItem[]) : []), "separator", { key: "temporary", label: "New temporary chat", icon: , onSelect: () => router.push("/app/chat?temporary=1") }, { key: "arena", label: "Open Arena", icon: , onSelect: () => router.push("/app/arena") }, ...(conversation && !ephemeral ? (["separator", { key: "delete", label: "Delete conversation", icon: , destructive: true, onSelect: () => setDeleteOpen(true) }] as (ActionSheetItem | "separator")[]) : []), ]; const title = ephemeral ? "Temporary chat" : conversation?.title ?? (activeProject ? activeProject.name : "New chat"); return (
{/* Header — 48 px */}
{ephemeral ? ( Temporary chat — not stored in history ) : ( {title} )} {activeProject && !ephemeral && conversation ? {activeProject.name} : null}
{conversation && !ephemeral && preferences.showCosts && (conversation.totalCostUsd ?? 0) > 0 ? ( ≈ {formatUsd(conversation.totalCostUsd, { precise: conversation.totalCostUsd < 0.01 })} ) : null} {/* Phone: everything under "more" */} {/* Desktop actions */}
{usableModels.length > 1 ? ( ) : null} {conversation && !ephemeral ? ( <> ) : ( <> )}
{/* Messages */}
{messages.length === 0 && !compare ? ( { setDraft(t); composerRef.current?.focus(); }} onAnalyzeFile={() => composerRef.current?.openFilePicker()} onCompare={usableModels.length > 1 ? openCompareFromComposer : undefined} /> ) : (
{messages.map((m, i) => ( ))} {compare ? (
setCompare((c) => (c ? { ...c, prompt: p } : c)) : undefined} systemPrompt={fullSystemPrompt} settings={settings} initialModelKeys={compare.initialModels} conversationId={ephemeral ? null : conversation?.id ?? null} projectId={activeProjectId} onClose={() => setCompare(null)} onAdopted={onAdopted} />
) : null}
)} {!atBottom && (messages.length > 0 || compare) ? ( ) : null}
{/* Composer */}
{notice && replacement && !ephemeral ? (
{model?.displayName} is {notice.kind === "deprecated" ? "deprecated" : `retiring on ${notice.shutdownDate}`}.
) : null} 0} projectId={activeProjectId} placeholder={isAuto ? "Ask anything — Auto picks the model…" : undefined} topSlot={
changeModel(k)} size="sm" className="max-w-[46vw] rounded-full sm:max-w-[280px]" buttonLabel={isAuto ? "Auto" : undefined} /> {activeSettings > 0 ? {activeSettings} : null} } />
router.push("/app/chat")} onSummarize={model && messages.length > 1 && !ephemeral ? summarize : undefined} summarizing={summarizing} largerModel={largerModel} onSwitchLarger={(k) => changeModel(k)} />
} bottomSlot={

{ephemeral ? "Temporary chat — messages are not saved; usage is still counted." : model ? <>Responses come straight from {providerName(model.provider)} using your key · costs are estimates : isAuto ? "Smart Router recommends a model before each request — nothing is sent without your confirmation." : "Choose a model to start"}

} />
{/* Sheets & dialogs */} {routerState ? ( { if (!o) { restorePending(routerState.pending); setRouterState(null); } }} result={routerState.result} mode={routerMode} onModeChange={(m) => { setRouterMode(m); setRouterState((s) => (s ? { ...s, result: routeFor(s.pending.text, s.pending.attachments, m) } : s)); }} alwaysAuto={alwaysAuto} onAlwaysAutoChange={setAlwaysAuto} onUse={(key) => { const p = routerState.pending; setRouterState(null); proceed(key, p); }} /> ) : null} { if (!o && costState) { restorePending(costState.pending); setCostState(null); } }} model={costState ? modelsByKey.get(costState.modelKey) : undefined} estimate={costState?.estimate ?? null} alternatives={costState?.alternatives ?? []} onSend={() => { if (!costState) return; const s = costState; setCostState(null); doSend(s.modelKey, s.pending); }} onSwitch={(key) => { if (!costState) return; const s = costState; setCostState(null); doSend(key, s.pending); }} /> {pick ? ( { const p = pick; setPick(null); p.onPick(key); }} /> ) : null} {isMobile ? : null}
); }