"use client"; import dynamic from "next/dynamic"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { BookOpen, Bot, Brain, Check, ChevronLeft, ChevronRight, ClipboardList, Coins, Copy, AlertCircle, Database, Loader2, MessageSquare, Pencil, RefreshCcw, Square, UserRound, Volume2, X, Trash2, type LucideIcon, } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { SelectedHistorySession } from "@/components/chat/HistorySessionPicker"; import type { SelectedQuestionEntry } from "@/components/chat/QuestionBankPicker"; import AssistantResponse from "@/components/common/AssistantResponse"; import { InlineFileCardProvider, mergeGeneratedFiles, } from "@/components/common/InlineFileCard"; import Tooltip from "@/components/common/Tooltip"; import type { MessageAttachment, MessageRequestSnapshot, } from "@/context/UnifiedChatContext"; import { apiFetch, apiUrl } from "@/lib/api"; import { docIconFor } from "@/lib/doc-attachments"; import { useVoiceAutoplay } from "@/hooks/useVoiceAutoplay"; import { extractMathAnimatorResult } from "@/lib/math-animator-types"; import { extractQuizQuestions, extractStreamingQuizQuestions, } from "@/lib/quiz-types"; import { extractVisualizeResult } from "@/lib/visualize-types"; import type { StreamEvent } from "@/lib/unified-ws"; import { hasVisibleMarkdownContent } from "@/lib/markdown-display"; import type { SelectedBookReference } from "@/lib/book-references"; import { buildVisiblePath, type SiblingInfo } from "@/lib/message-branches"; import type { SpaceMemoryFile } from "@/lib/space-items"; import { AskUserOptions, extractAskUserPayload, extractMessageSegments, } from "./AskUserOptions"; import ContextReferenceTree, { type ContextTreeItem, } from "./ContextReferenceTree"; import { AssistantActivity } from "./TracePanels"; import { agentGlyph } from "@/components/agents/agent-icons"; import { useConnectedAgentKinds } from "@/hooks/useConnectedAgentKinds"; const MathAnimatorViewer = dynamic( () => import("@/components/math-animator/MathAnimatorViewer"), { ssr: false }, ); const QuizViewer = dynamic(() => import("@/components/quiz/QuizViewer"), { ssr: false, }); const ResearchOutlineEditor = dynamic( () => import("@/components/research/ResearchOutlineEditor"), { ssr: false }, ); const VisualizationViewer = dynamic( () => import("@/components/visualize/VisualizationViewer"), { ssr: false }, ); interface ChatMessageItem { id?: number; role: "user" | "assistant" | "system"; content: string; capability?: string; events?: StreamEvent[]; attachments?: MessageAttachment[]; requestSnapshot?: MessageRequestSnapshot; parentMessageId?: number | null; } interface NotebookReferenceGroup { notebookId: string; notebookName: string; count: number; } // Returns the i18n key (and a sensible fallback) for the capability badge // shown above the user's message. Callers must run `t(...)` on the result. function getModeBadgeLabel(capability?: string | null): string { if (!capability || capability === "chat") return "Chat"; if (capability === "deep_solve") return "Deep Solve"; if (capability === "deep_question") return "Quiz Generation"; if (capability === "deep_research") return "Deep Research"; if (capability === "math_animator") return "Math Animator"; if (capability === "visualize") return "Visualize"; if (capability === "mastery_path") return "Mastery Path"; return capability; } function imageSrcForAttachment(attachment: MessageAttachment): string | null { if (attachment.url) { if ( attachment.url.startsWith("http") || attachment.url.startsWith("blob:") || attachment.url.startsWith("data:") ) { return attachment.url; } return apiUrl(attachment.url); } const base64 = attachment.base64?.trim(); if (!base64) return null; if (base64.startsWith("data:")) return base64; return `data:${attachment.mime_type || "image/png"};base64,${base64}`; } /** Format a byte count for a file card subtitle (e.g. "14 KB"). */ function formatFileSize(bytes?: number): string { if (!bytes || bytes <= 0) return ""; const units = ["B", "KB", "MB", "GB"]; let value = bytes; let unit = 0; while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; } return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`; } /** "DeepTutor_Introduction.pdf" → "DeepTutor Introduction" — the card title * reads like a document name; the extension already shows in the subtitle. */ function humanizeFilename(filename: string): string { const stem = filename.replace(/\.[A-Za-z0-9]{1,8}$/, ""); return ( stem .replace(/[_-]+/g, " ") .replace(/\s{2,}/g, " ") .trim() || filename ); } /** * Files the assistant produced this turn (exec/code/media artifacts), * rendered as openable cards under the message — click to open in the Viewer * side panel, same path as user uploads. Sources: persisted ``generated`` * attachments on the message (durable) merged with artifacts from streamed * tool_result events (live, while the turn is still running), deduped by URL. */ function GeneratedFileCards({ attachments, events, onOpen, }: { attachments: MessageAttachment[]; events?: StreamEvent[]; onOpen?: (attachment: MessageAttachment) => void; }) { const { t } = useTranslation(); const files = useMemo( () => mergeGeneratedFiles(attachments, events), [attachments, events], ); if (!files.length) return null; return (
{files.map((a, i) => { const filename = a.filename || t("File"); const key = a.id || a.url || `gen-${i}`; const mime = a.mime_type || ""; const mediaSrc = imageSrcForAttachment(a); // Generated images / videos render inline (preview the moment they // arrive); everything else stays a compact openable file card. if (mime.startsWith("image/") && mediaSrc) { return ( ); } if (mime.startsWith("video/") && mediaSrc) { return (
); } const spec = docIconFor(filename); const Icon = spec.Icon; const size = formatFileSize(a.size_bytes); return ( ); })}
); } const AssistantMessage = memo(function AssistantMessage({ msg, isStreaming, outlineStatus, sessionId, language, onConfirmOutline, onSubmitUserReply, researchRequestSnapshot, }: { msg: { content: string; capability?: string; events?: StreamEvent[] }; isStreaming?: boolean; outlineStatus?: "editing" | "researching" | "done"; sessionId?: string | null; language?: string; researchRequestSnapshot?: MessageRequestSnapshot | null; onConfirmOutline?: ( outline: Array<{ title: string; overview: string }>, topic: string, researchConfig?: Record | null, requestSnapshot?: MessageRequestSnapshot | null, ) => void; /** * Submit a reply for a turn that is paused on ``ask_user``. Wired * through from the page so the card's option-buttons / free-text * input can deliver the user's selection back to the backend over * the unified WebSocket. Triggers a same-turn resume (no new user * bubble). Accepts either a flat string (legacy single-question) or * a structured object with per-question ``answers`` (v2 path). */ onSubmitUserReply?: ( reply: | string | { text?: string; answers?: Array<{ questionId: string; text: string }>; }, ) => void; }) { const events = useMemo(() => msg.events ?? [], [msg.events]); const resultEvent = useMemo( () => msg.events?.find((event) => event.type === "result") ?? null, [msg.events], ); const outlinePreview = useMemo(() => { if (msg.capability !== "deep_research" || !resultEvent) return null; const meta = resultEvent.metadata as Record | undefined; if (!meta?.outline_preview) return null; return { sub_topics: (meta.sub_topics ?? []) as Array<{ title: string; overview: string; }>, topic: String(meta.topic ?? ""), research_config: (meta.research_config ?? null) as Record< string, unknown > | null, }; }, [msg.capability, resultEvent]); const quizQuestions = useMemo(() => { if (msg.capability !== "deep_question") return null; // Once the final result event lands, it's authoritative — it carries // the canonical summary.results[]. Until then, accumulate questions // from the live ``quiz_question_emitted`` content events so the // QuizViewer can render each card the moment it's generated. if (resultEvent) return extractQuizQuestions(resultEvent.metadata); return extractStreamingQuizQuestions(msg.events ?? []); }, [msg.capability, msg.events, resultEvent]); const mathAnimatorResult = useMemo(() => { if (msg.capability !== "math_animator" || !resultEvent) return null; return extractMathAnimatorResult(resultEvent.metadata); }, [msg.capability, resultEvent]); const visualizeResult = useMemo(() => { if (msg.capability !== "visualize" || !resultEvent) return null; return extractVisualizeResult(resultEvent.metadata); }, [msg.capability, resultEvent]); // Detect the ``ask_user`` terminator payload: when the assistant turn // ended via the ``ask_user`` tool, this is the question the user is // expected to answer next. Render option chips below the message. const askUserPayload = useMemo( () => extractAskUserPayload(msg.events), [msg.events], ); // Interleaved segments for the default chat surface — text emitted // before the ask_user call renders above the card; text emitted by // the resumed iteration renders below it. Only walked when this // message will actually render through the default branch (the // research / quiz / animator / visualize branches have their own // layout and pin the card elsewhere). const useInlineAskUserSegments = !outlinePreview && !mathAnimatorResult && !visualizeResult && !(quizQuestions && quizQuestions.length > 0); const messageSegments = useMemo( () => (useInlineAskUserSegments ? extractMessageSegments(msg.events) : []), [useInlineAskUserSegments, msg.events], ); const hasInlineAskUser = useInlineAskUserSegments && messageSegments.some((seg) => seg.kind === "ask_user"); const researchInProgress = outlineStatus === "researching" || outlineStatus === "done"; const showResearchBody = Boolean(outlinePreview) && researchInProgress && Boolean(msg.content); return ( <> {/* Activity block pinned to the TOP: the status header ("DeepTutor Exploring… · 8s" → "DeepTutor responded. · 10s") with the exploring trace nested beneath it — expanded while DeepTutor is still working, collapsed once it settles into the final answer. */} {outlinePreview && outlinePreview.sub_topics.length > 0 ? ( <> {/* Layout for the merged research bubble: 1. trace rows (above, via TraceFlow) 2. ask_user Q&A summary (collapsible once research starts) 3. Outline editor (auto-collapses once locked) 4. Final report body (only after research is underway) The Q&A intentionally sits ABOVE the outline so the user sees the path that produced the outline before the outline itself. */} {askUserPayload ? ( { if (!onSubmitUserReply) return; onSubmitUserReply(reply); }} collapsible={researchInProgress} defaultCollapsed={researchInProgress} /> ) : null} onConfirmOutline?.( items, outlinePreview.topic, outlinePreview.research_config, researchRequestSnapshot, ) } status={outlineStatus} /> {showResearchBody ? ( ) : null} ) : mathAnimatorResult ? ( ) : visualizeResult ? ( ) : quizQuestions && quizQuestions.length > 0 ? ( <> {/* The quiz preface (the "I researched X, now let me quiz you on Y" sentence the user watched stream in) rides along ABOVE the quiz card. Without this, the streamed text vanishes from the bubble the moment the first card appears because the branch above is mutually exclusive with . The body is already free of the per-question markdown — the pipeline trims that out of ``msg.content`` since the QuizViewer renders the cards themselves. */} {msg.content ? ( ) : null} ) : hasInlineAskUser ? ( // Default chat surface with one or more ask_user calls: render // text and cards in the exact order they were streamed, so the // pre-ask_user narration sits above the card and the resumed // iteration's text sits below. messageSegments.map((seg) => seg.kind === "text" ? ( ) : ( { if (!onSubmitUserReply) return; onSubmitUserReply(reply); }} /> ), ) ) : ( )} {/* Non-default branches (quiz, math animator, visualize) keep ask_user below the body. The default branch inlines the card via ``messageSegments``; the research branch renders its own card above the outline editor — both skip this fallback. */} {!outlinePreview && !hasInlineAskUser && askUserPayload ? ( { if (!onSubmitUserReply) return; onSubmitUserReply(reply); }} /> ) : null} ); }); AssistantMessage.displayName = "AssistantMessage"; function CostFooter({ cost, tokens, calls, }: { cost: number; tokens: number; calls: number; }) { const { t } = useTranslation(); const formatCost = (usd: number) => { if (usd < 0.01) return `$${usd.toFixed(4)}`; return `$${usd.toFixed(2)}`; }; const formatTokens = (n: number) => { if (n >= 1000) return `${(n / 1000).toFixed(1)}k`; return String(n); }; return (
{formatCost(cost)} · {formatTokens(tokens)} {t("tokens")} · {calls} {t("calls")}
); } // Claude-style icon-only message action: a quiet 15px glyph with the label // in an instant tooltip, brightening on hover. function RoughActionButton({ icon: Icon, label, onClick, disabled, }: { icon: LucideIcon; label: string; onClick: () => void; disabled?: boolean; }) { return ( ); } function CopyActionButton({ content, onCopy, }: { content: string; onCopy: (content: string) => void | Promise; }) { const { t } = useTranslation(); const [copied, setCopied] = useState(false); const timerRef = useRef | null>(null); useEffect(() => { return () => { if (timerRef.current) clearTimeout(timerRef.current); }; }, []); const handleClick = useCallback(() => { void Promise.resolve(onCopy(content)).then(() => { setCopied(true); if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(() => setCopied(false), 1600); }); }, [content, onCopy]); return ( ); } // Speaker button: synthesizes the reply via the configured TTS provider and // plays it. On the first manual play of a session it offers to auto-play the // rest; `autoPlayFresh` triggers playback automatically for a reply that just // finished generating when auto-play is on. function PlayAudioButton({ content, conversationKey, autoPlayFresh, }: { content: string; conversationKey?: string; autoPlayFresh: boolean; }) { const { t } = useTranslation(); const { autoplayEnabled, enableForSession, markPrompted, shouldPromptOnFirstPlay, } = useVoiceAutoplay(conversationKey); const [state, setState] = useState<"idle" | "loading" | "playing">("idle"); const [showPrompt, setShowPrompt] = useState(false); const audioRef = useRef(null); const urlRef = useRef(null); const autoPlayedRef = useRef(false); const cleanup = useCallback(() => { if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; } if (urlRef.current) { URL.revokeObjectURL(urlRef.current); urlRef.current = null; } }, []); const play = useCallback(async () => { setState("loading"); try { const resp = await apiFetch(apiUrl("/api/v1/voice/tts"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: content }), }); if (!resp.ok) { cleanup(); setState("idle"); return; } const blob = await resp.blob(); cleanup(); const url = URL.createObjectURL(blob); urlRef.current = url; const audio = new Audio(url); audioRef.current = audio; audio.onended = () => { setState("idle"); cleanup(); }; audio.onerror = () => { setState("idle"); cleanup(); }; await audio.play(); setState("playing"); } catch { cleanup(); setState("idle"); } }, [cleanup, content]); const handleClick = useCallback(() => { if (state === "playing" || state === "loading") { cleanup(); setState("idle"); return; } const willPrompt = shouldPromptOnFirstPlay(); void play(); if (willPrompt) { markPrompted(); setShowPrompt(true); } }, [cleanup, markPrompted, play, shouldPromptOnFirstPlay, state]); // Auto-play a freshly-generated reply when enabled, exactly once. Deferred // to a timer so synthesis (which sets state) starts off the effect body. useEffect(() => { if (!autoPlayFresh || !autoplayEnabled) return; if (autoPlayedRef.current) return; if (!content.trim()) return; autoPlayedRef.current = true; const id = window.setTimeout(() => void play(), 0); return () => window.clearTimeout(id); }, [autoPlayFresh, autoplayEnabled, content, play]); useEffect(() => cleanup, [cleanup]); return (
{showPrompt && (

{t("Auto-play replies in this conversation?")}

)}
); } function BranchNavigator({ info, onSwitch, }: { info: SiblingInfo; onSwitch: (childId: number) => void; }) { const { t } = useTranslation(); const prevIdx = info.index - 2; // 0-based prev index const nextIdx = info.index; // 0-based next index const prevId = prevIdx >= 0 ? info.siblingIds[prevIdx] : null; const nextId = nextIdx < info.siblingIds.length ? info.siblingIds[nextIdx] : null; return (
{info.index} / {info.total}
); } function DeleteTurnButton({ onDelete }: { onDelete: () => void }) { const { t } = useTranslation(); const [confirm, setConfirm] = useState(false); if (!confirm) { return ( setConfirm(true)} /> ); } return (
{t("Delete this turn?")}
); } const UserMessage = memo(function UserMessage({ msg, index, onPreviewAttachment, onCopy, onEdit, editDisabled, siblingInfo, onSwitchBranch, }: { msg: ChatMessageItem; index: number; onPreviewAttachment?: (attachment: MessageAttachment) => void; onCopy?: (content: string) => void | Promise; onEdit?: (messageId: number, newContent: string) => void; editDisabled?: boolean; siblingInfo?: SiblingInfo; onSwitchBranch?: (parentMessageId: number | null, childId: number) => void; }) { const { t } = useTranslation(); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(msg.content); // Connected subagents ride in knowledge_bases (same selection path) but are // agents, not KBs — this maps a selected name to its backend kind so the // reference chip can badge it with the agent's brand icon. const agentKinds = useConnectedAgentKinds(); if (msg.content.startsWith("[Quiz Performance]")) return null; // ``msg.id`` can be a negative client-side sentinel for optimistic // (just-sent, not yet reconciled with the server) rows. We still allow // the Edit button to surface — ``editMessage`` in the context handles // the optimistic case by triggering a session reload to resolve the // real id before submitting the branch. const canEdit = Boolean(onEdit) && typeof msg.id === "number" && !editDisabled; const startEdit = () => { if (!canEdit) return; setDraft(msg.content); setEditing(true); }; const cancelEdit = () => { setEditing(false); setDraft(msg.content); }; const submitEdit = () => { const trimmed = draft.trim(); if (!trimmed || trimmed === msg.content) { cancelEdit(); return; } if (typeof msg.id !== "number") return; onEdit?.(msg.id, trimmed); setEditing(false); }; // Everything this turn carried — file attachments plus the request // snapshot's Space references — rendered as one collapsed tree under // the bubble (the sent-message mirror of the composer's tree). const snap = msg.requestSnapshot; const refTreeItems: ContextTreeItem[] = [ ...(msg.attachments ?? []).map((a, ai): ContextTreeItem => { const filename = a.filename || t("Attachment"); const spec = docIconFor(filename); const src = a.type === "image" ? imageSrcForAttachment(a) : null; return { key: `att-${ai}`, icon: spec.Icon, kind: spec.label, label: filename, thumbnailUrl: src ?? undefined, onClick: onPreviewAttachment ? () => onPreviewAttachment(a) : undefined, }; }), ...(snap?.knowledgeBases ?? []).map((name): ContextTreeItem => { const agentKind = agentKinds[name]; if (agentKind) { return { key: `agent-${name}`, // Brand SVG marks share the lucide call signature (size/strokeWidth/ // className); cast bridges the structural-variance gap. icon: (agentGlyph(agentKind) ?? Bot) as unknown as LucideIcon, kind: t("Agent"), label: name, }; } return { key: `kb-${name}`, icon: Database, kind: t("Knowledge"), label: name, }; }), ...(snap?.bookReferences ?? []).map( (ref): ContextTreeItem => ({ key: `book-${ref.book_id}`, icon: BookOpen, kind: t("Book"), label: `${ref.page_ids.length} ${t("chapters")}`, }), ), ...(snap?.notebookReferences ?? []).map( (ref): ContextTreeItem => ({ key: `nb-${ref.notebook_id}`, icon: BookOpen, kind: t("Notebook"), label: `${ref.record_ids.length} ${t("records")}`, }), ), // Imported agent conversations are folded into the same history_references // payload but carry the `imported_` id prefix — split them back out so they // read as "My Agents" rather than "Chat History" (mirrors the composer). ...(snap?.historyReferences ?? []) .filter((sid) => !sid.startsWith("imported_")) .map( (sid): ContextTreeItem => ({ key: `hist-${sid}`, icon: MessageSquare, kind: t("Chat History"), label: "", }), ), ...(snap?.historyReferences ?? []) .filter((sid) => sid.startsWith("imported_")) .map( (sid): ContextTreeItem => ({ key: `agent-${sid}`, icon: Bot, kind: t("My Agents"), label: "", }), ), ...(snap?.questionNotebookReferences?.length ? [ { key: "qb", icon: ClipboardList, kind: t("Question Bank"), label: `${snap.questionNotebookReferences.length} ${t("items")}`, } satisfies ContextTreeItem, ] : []), ...(snap?.persona ? [ { key: "persona", icon: UserRound, kind: t("Persona"), label: snap.persona, } satisfies ContextTreeItem, ] : []), ...(snap?.memoryReferences ?? []).map( (file): ContextTreeItem => ({ key: `mem-${file}`, icon: Brain, kind: t("Memory"), label: file === "summary" ? t("Summary") : t("Profile"), }), ), ]; return (
{t(getModeBadgeLabel(msg.capability))}
{editing ? (