"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { Check, Loader2, MessageSquare, NotebookPen, Sparkles, User, } from "lucide-react"; import { useTranslation } from "react-i18next"; import PickerShell from "@/components/common/PickerShell"; import PickerHeader from "@/components/common/PickerHeader"; import { apiFetch, apiUrl } from "@/lib/api"; import { listNotebooks, type NotebookSummary as RealNotebookSummary, } from "@/lib/notebook-api"; type RecordType = | "solve" | "question" | "research" | "chat" | "co_writer" | "tutorbot"; export interface NotebookSavePayload { recordType: RecordType; title: string; userQuery: string; output: string; metadata?: Record; kbName?: string | null; } export interface NotebookSaveMessage { role: "user" | "assistant" | "system"; content: string; capability?: string; } interface SaveToNotebookModalProps { open: boolean; payload: NotebookSavePayload | null; /** * Optional list of chat messages. When provided, the modal switches to * "selection mode" and lets the user pick which messages to include in the * saved notebook record. The transcript / userQuery in the final request * are rebuilt from the selected subset, while other fields (recordType, * metadata, kbName) come from `payload`. */ messages?: NotebookSaveMessage[] | null; onClose: () => void; onSaved?: (result: { summary: string }) => void; } function parseSseEvents( buffer: string, ): Array<{ payload: Record }> { const events: Array<{ payload: Record }> = []; const chunks = buffer.split("\n\n"); for (let i = 0; i < chunks.length - 1; i += 1) { const lines = chunks[i] .split("\n") .map((line) => line.trim()) .filter(Boolean); const dataLine = lines.find((line) => line.startsWith("data:")); if (!dataLine) continue; try { const payload = JSON.parse(dataLine.slice(5).trim()) as Record< string, unknown >; events.push({ payload }); } catch { continue; } } return events; } function roleLabelKey(role: NotebookSaveMessage["role"]): string { if (role === "user") return "User"; if (role === "assistant") return "Assistant"; return "System"; } function buildTranscript(messages: NotebookSaveMessage[]): string { return messages .map((msg) => { const role = msg.role === "user" ? "User" : msg.role === "assistant" ? "Assistant" : "System"; return `## ${role}\n${msg.content}`; }) .join("\n\n"); } function buildUserQuery(messages: NotebookSaveMessage[]): string { return messages .filter((msg) => msg.role === "user") .map((msg) => msg.content) .join("\n\n"); } function deriveTitle( messages: NotebookSaveMessage[], fallback: string, ): string { const firstUser = messages.find((msg) => msg.role === "user"); const candidate = firstUser?.content.trim(); if (!candidate) return fallback; return candidate.slice(0, 80); } /** * Compute the indexes of the most recent N "turns". A turn is loosely * defined as a user message plus any assistant/system messages that follow * it until the next user message. When N exceeds the available turn count * we just return all message indexes. */ function indexesForLastTurns( messages: NotebookSaveMessage[], turnCount: number, ): number[] { if (messages.length === 0 || turnCount <= 0) return []; const userPositions: number[] = []; messages.forEach((msg, idx) => { if (msg.role === "user") userPositions.push(idx); }); if (userPositions.length === 0) { return messages.map((_, idx) => idx); } const startUserIdx = Math.max(0, userPositions.length - turnCount); const startMessageIdx = userPositions[startUserIdx]; const result: number[] = []; for (let i = startMessageIdx; i < messages.length; i += 1) result.push(i); return result; } export default function SaveToNotebookModal({ open, payload, messages, onClose, onSaved, }: SaveToNotebookModalProps) { const { t } = useTranslation(); const [notebooks, setNotebooks] = useState([]); const [selectedIds, setSelectedIds] = useState([]); const [title, setTitle] = useState(""); const [titleEdited, setTitleEdited] = useState(false); const [summaryPreview, setSummaryPreview] = useState(""); const [isLoading, setIsLoading] = useState(false); const [isLoadingNotebooks, setIsLoadingNotebooks] = useState(false); const [error, setError] = useState(""); const [selectedMessageIdx, setSelectedMessageIdx] = useState>( new Set(), ); const abortRef = useRef(null); const hasMessageSelection = Array.isArray(messages) && messages.length > 0; useEffect(() => { if (!open) { abortRef.current?.abort(); return; } setSummaryPreview(""); setError(""); setSelectedIds([]); setTitleEdited(false); if (hasMessageSelection && messages) { setSelectedMessageIdx(new Set(messages.map((_, idx) => idx))); setTitle(deriveTitle(messages, payload?.title || "")); } else { setSelectedMessageIdx(new Set()); setTitle(payload?.title || ""); } setIsLoadingNotebooks(true); void (async () => { try { const list = await listNotebooks(); setNotebooks(list); } catch { setNotebooks([]); } finally { setIsLoadingNotebooks(false); } })(); }, [open, payload, messages, hasMessageSelection]); const orderedSelectedMessages = useMemo(() => { if (!hasMessageSelection || !messages) return []; const indexes = Array.from(selectedMessageIdx).sort((a, b) => a - b); return indexes .map((idx) => messages[idx]) .filter((msg): msg is NotebookSaveMessage => Boolean(msg)); }, [hasMessageSelection, messages, selectedMessageIdx]); // When the user hasn't manually edited the title yet, keep it in sync // with the first selected user message so it stays meaningful as the // selection changes. useEffect(() => { if (!open || !hasMessageSelection || titleEdited) return; const next = deriveTitle(orderedSelectedMessages, payload?.title || ""); setTitle(next); }, [ open, hasMessageSelection, titleEdited, orderedSelectedMessages, payload?.title, ]); const effectiveOutput = useMemo(() => { if (hasMessageSelection) { return buildTranscript(orderedSelectedMessages); } return payload?.output || ""; }, [hasMessageSelection, orderedSelectedMessages, payload?.output]); const effectiveUserQuery = useMemo(() => { if (hasMessageSelection) { return buildUserQuery(orderedSelectedMessages); } return payload?.userQuery || ""; }, [hasMessageSelection, orderedSelectedMessages, payload?.userQuery]); const canSave = useMemo( () => Boolean( payload && title.trim() && selectedIds.length > 0 && effectiveOutput.trim() && (!hasMessageSelection || orderedSelectedMessages.length > 0), ), [ payload, title, selectedIds.length, effectiveOutput, hasMessageSelection, orderedSelectedMessages.length, ], ); const toggleNotebook = (id: string) => { setSelectedIds((prev) => prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id], ); }; const toggleMessage = (idx: number) => { setSelectedMessageIdx((prev) => { const next = new Set(prev); if (next.has(idx)) { next.delete(idx); } else { next.add(idx); } return next; }); }; const selectAllMessages = () => { if (!messages) return; setSelectedMessageIdx(new Set(messages.map((_, idx) => idx))); }; const clearMessages = () => { setSelectedMessageIdx(new Set()); }; const selectLastTurns = (turnCount: number) => { if (!messages) return; setSelectedMessageIdx(new Set(indexesForLastTurns(messages, turnCount))); }; const handleSave = async () => { if (!payload || !canSave) return; abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; setIsLoading(true); setError(""); setSummaryPreview(""); const metadata: Record = { ...(payload.metadata || {}) }; if (hasMessageSelection && messages) { metadata.message_count = orderedSelectedMessages.length; metadata.total_message_count = messages.length; metadata.selected_message_indexes = Array.from(selectedMessageIdx).sort( (a, b) => a - b, ); } try { const response = await apiFetch( apiUrl("/api/v1/notebook/add_record_with_summary"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ notebook_ids: selectedIds, record_type: payload.recordType, title: title.trim(), user_query: effectiveUserQuery, output: effectiveOutput, metadata, kb_name: payload.kbName || null, }), signal: controller.signal, }, ); if (!response.ok || !response.body) { throw new Error(t("Failed to save to notebook.")); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let finalSummary = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lastSeparator = buffer.lastIndexOf("\n\n"); if (lastSeparator === -1) continue; const consumable = buffer.slice(0, lastSeparator + 2); buffer = buffer.slice(lastSeparator + 2); for (const event of parseSseEvents(consumable)) { const type = String(event.payload.type || ""); if (type === "summary_chunk") { const chunk = String(event.payload.content || ""); finalSummary += chunk; setSummaryPreview(finalSummary); } else if (type === "error") { throw new Error( String(event.payload.detail || t("Failed to save to notebook.")), ); } else if (type === "result") { const summary = String(event.payload.summary || finalSummary); setSummaryPreview(summary); onSaved?.({ summary }); setIsLoading(false); onClose(); return; } } } throw new Error(t("Notebook save stream ended unexpectedly.")); } catch (err) { if (controller.signal.aborted) return; setError( err instanceof Error ? err.message : t("Failed to save to notebook."), ); setIsLoading(false); } }; // payload may be null while the parent is preparing the save context. // Treat that as "not open" so the shell never renders without content. const isOpen = open && !!payload; const totalMessages = messages?.length ?? 0; const selectedMessageCount = selectedMessageIdx.size; const allMessagesSelected = totalMessages > 0 && selectedMessageCount === totalMessages; return (
{ setTitle(e.target.value); setTitleEdited(true); }} className="w-full rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-2.5 text-sm text-[var(--foreground)] outline-none transition focus:border-[var(--primary)]/60 focus:ring-2 focus:ring-[var(--primary)]/15" />
{hasMessageSelection && messages && (
{t("{{selected}} of {{total}} selected", { selected: selectedMessageCount, total: totalMessages, })}
{messages.map((msg, idx) => { const selected = selectedMessageIdx.has(idx); const Icon = msg.role === "user" ? User : msg.role === "assistant" ? Sparkles : MessageSquare; const preview = msg.content.replace(/\s+/g, " ").trim(); const empty = preview.length === 0; return ( ); })}
)}
{selectedIds.length > 0 && ( {selectedIds.length} {t("selected")} )}
{isLoadingNotebooks ? (
) : notebooks.length === 0 ? (
{t("No notebooks found.")} {t("Create one from the Knowledge → Notebooks page.")}
) : ( notebooks.map((notebook) => { const selected = selectedIds.includes(notebook.id); return ( ); }) )}
{t("Summary preview")}
{summaryPreview || ( {t("The generated summary will appear here during saving.")} )}
{error && (
{error}
)}
); }