"use client"; import { useEffect, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Send, Square, Sparkles, Plus, History, Copy, FilePlus2, BookOpen, ChevronDown, Paperclip, X } from "lucide-react"; import { useRouter } from "next/navigation"; import { api, sse } from "@/lib/client/api"; import { Markdown } from "@/components/markdown"; import { Button } from "@/components/ui/button"; import { Menu, MenuTrigger, MenuContent, MenuItem, MenuSeparator, Badge, Spinner } from "@/components/ui/primitives"; import { toast } from "@/components/ui/toast"; import { cn } from "@/lib/utils"; interface Msg { id?: string; role: "user" | "assistant"; content: string; model?: string | null; sources?: any[]; costUsd?: number | null; latencyMs?: number | null; streaming?: boolean; reason?: string } const TASKS = [["auto", "Auto"], ["chat", "Chat"], ["write", "Write"], ["reason", "Reason"], ["deep", "Deep"], ["code", "Code"]] as const; export function Chat({ pageId, conversationId: initialConv, prefill, compact, onInsert }: { pageId?: string | null; conversationId?: string | null; prefill?: string | null; compact?: boolean; onInsert?: (md: string) => void }) { const r = useRouter(); const qc = useQueryClient(); const [conv, setConv] = useState(initialConv ?? null); const [msgs, setMsgs] = useState([]); const [input, setInput] = useState(prefill ?? ""); const [busy, setBusy] = useState(false); const [model, setModel] = useState(null); const [task, setTask] = useState("auto"); const [useWs, setUseWs] = useState(true); const [images, setImages] = useState<{ mime: string; dataBase64: string; name: string }[]>([]); const abort = useRef(null); const bottom = useRef(null); const ta = useRef(null); const { data: models } = useQuery({ queryKey: ["ai-models"], queryFn: () => api("/api/ai/models"), staleTime: 60_000 }); const { data: convs = [] } = useQuery({ queryKey: ["convs", pageId ?? "all"], queryFn: () => api(`/api/ai/conversations${pageId ? `?pageId=${pageId}` : ""}`), staleTime: 10_000 }); useEffect(() => { if (prefill) { setInput(prefill); ta.current?.focus(); } }, [prefill]); useEffect(() => { if (!conv) { setMsgs([]); return; } api(`/api/ai/conversations?id=${conv}`).then((c) => { setMsgs(c.messages.map((m: any) => ({ id: m.id, role: m.role, content: m.content, model: m.model, sources: m.sources, costUsd: m.costUsd, latencyMs: m.latencyMs }))); if (c.model) setModel(c.model); }).catch(() => {}); }, [conv]); useEffect(() => { bottom.current?.scrollIntoView({ block: "end" }); }, [msgs]); async function send() { const text = input.trim(); if (!text || busy) return; setInput(""); setBusy(true); const imgs = images; setImages([]); setMsgs((m) => [...m, { role: "user", content: text }, { role: "assistant", content: "", streaming: true }]); const ac = new AbortController(); abort.current = ac; let sources: any[] = []; await sse("/api/ai/chat", { conversationId: conv, message: text, pageId: pageId ?? null, model, task: task === "auto" ? null : task, useWorkspace: useWs, images: imgs.map(({ mime, dataBase64 }) => ({ mime, dataBase64 })) }, (ev) => { if (ev.type === "meta") { if (!conv) setConv(ev.conversationId); sources = ev.sources; setMsgs((m) => { const c = [...m]; c[c.length - 1] = { ...c[c.length - 1], model: ev.model, reason: ev.reason, sources: ev.sources, content: ev.reason.startsWith("retry") ? "" : c[c.length - 1].content }; return c; }); } else if (ev.type === "text") setMsgs((m) => { const c = [...m]; c[c.length - 1] = { ...c[c.length - 1], content: c[c.length - 1].content + ev.delta }; return c; }); else if (ev.type === "done") setMsgs((m) => { const c = [...m]; const last = c[c.length - 1]; const cited = new Set([...last.content.matchAll(/\[(\d{1,2})\]/g)].map((x) => Number(x[1]))); c[c.length - 1] = { ...last, id: ev.messageId, streaming: false, costUsd: ev.costUsd, latencyMs: ev.latencyMs, sources: (last.sources ?? []).filter((s: any) => cited.has(s.n)) }; return c; }); else if (ev.type === "error") { toast("AI error", { description: ev.message, tone: "danger" }); setMsgs((m) => { const c = [...m]; c[c.length - 1] = { ...c[c.length - 1], streaming: false, content: c[c.length - 1].content || `⚠️ ${ev.message}` }; return c; }); } }, ac.signal).catch(() => {}); setBusy(false); qc.invalidateQueries({ queryKey: ["convs"] }); } async function saveToNote(m: Msg) { const p = await api("/api/pages", { method: "POST", json: { title: m.content.split("\n")[0].replace(/^#+\s*/, "").slice(0, 80) || "AI answer", content: { type: "doc", content: (await import("@/lib/editor/markdown-client")).markdownToDoc(m.content) } } }); qc.invalidateQueries({ queryKey: ["tree"] }); toast("Saved as a note", { tone: "success" }); r.push(`/p/${p.id}`); } const addImage = (f: File) => { const rd = new FileReader(); rd.onload = () => setImages((s) => [...s, { mime: f.type, dataBase64: String(rd.result).split(",")[1], name: f.name }]); rd.readAsDataURL(f); }; const modelLabel = model ?? "Auto"; return (
setModel(null)}>Auto (measured routing) {model === null && "✓"}{(models?.models ?? []).map((m: any) => setModel(m.id)}>{m.id}{m.benchmark?.pass_rate != null && {Math.round(m.benchmark.pass_rate * 100)}%}{model === m.id && "✓"})}
{TASKS.map(([id, l]) => )}
{(convs as any[]).length ? (convs as any[]).map((c: any) => setConv(c.id)}>{c.title}{c.id === conv && "✓"}) :
No conversations yet
}
{!msgs.length &&

Ask anything about your workspace

Answers cite your notes and documents. {pageId ? "This page is in context." : ""}

{["Summarize this page", "What are my open research questions?", "Find contradictions in my notes", "Suggest next steps"].map((s) => )}
} {msgs.map((m, i) => m.role === "user" ? (
{m.content}
) : (
{ const s = m.sources?.find((x: any) => x.n === n); if (s?.pageId) r.push(`/p/${s.pageId}${s.blockId ? `#block-${s.blockId}` : ""}`); }} /> {m.streaming && ▍} {!!m.sources?.length &&
{m.sources.map((s: any) => )}
} {!m.streaming && m.content &&
{m.model}{m.latencyMs != null && · {(m.latencyMs / 1000).toFixed(1)} s}{m.costUsd != null && · ${m.costUsd.toFixed(4)}}{m.reason && · {m.reason}}{onInsert && }
}
))}
{images.length > 0 &&
{images.map((im, i) => {im.name})}
}