/** * ChatPage — multi-turn chat with streamed encyclopedic responses and citations. * * Uses SSE streaming via api/chat.ts to display tokens in real-time. * Citation markers [N] in the streamed text become superscript links to * the source list rendered below the response. Conversations are threaded * via conversation_id backed by Redis history on the server. */ import React, { useState, useRef, useCallback, useEffect } from "react"; import { Link } from "react-router-dom"; import { streamChat, type ChatSource } from "../api/chat"; import { parseChatCitations } from "../utils/chatCitations"; import { formatTime } from "../utils/formatTime"; import { generateUUID } from "../utils/uuid"; import { useDocumentTitle } from "../hooks/useDocumentTitle"; import styles from "./ChatPage.module.css"; interface Message { role: "user" | "assistant"; text: string; sources: ChatSource[]; done: boolean; error?: string; } export default function ChatPage() { useDocumentTitle("Chat — Chrysopedia"); const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [streaming, setStreaming] = useState(false); const [conversationId, setConversationId] = useState( undefined, ); const abortRef = useRef(null); const inputRef = useRef(null); const messagesEndRef = useRef(null); // Cleanup on unmount useEffect(() => { return () => { abortRef.current?.abort(); }; }, []); // Scroll to bottom when messages update useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]); const sendMessage = useCallback( (text: string) => { const q = text.trim(); if (!q || streaming) return; abortRef.current?.abort(); // Generate conversation_id on first message const cid = conversationId ?? generateUUID(); if (!conversationId) setConversationId(cid); const userMsg: Message = { role: "user", text: q, sources: [], done: true, }; const assistantMsg: Message = { role: "assistant", text: "", sources: [], done: false, }; const newMessages = [...messages, userMsg, assistantMsg]; setMessages(newMessages); setInput(""); setStreaming(true); const assistantIndex = newMessages.length - 1; abortRef.current = streamChat( q, { onSources: (sources) => { setMessages((prev) => prev.map((m, i) => i === assistantIndex ? { ...m, sources } : m, ), ); }, onToken: (token) => { setMessages((prev) => prev.map((m, i) => i === assistantIndex ? { ...m, text: m.text + token } : m, ), ); }, onDone: (meta) => { setConversationId(meta.conversation_id); setMessages((prev) => prev.map((m, i) => i === assistantIndex ? { ...m, done: true } : m, ), ); setStreaming(false); }, onError: (msg) => { setMessages((prev) => prev.map((m, i) => i === assistantIndex ? { ...m, done: true, error: msg } : m, ), ); setStreaming(false); }, }, undefined, // no creator scope on the main chat page cid, ); }, [messages, streaming, conversationId], ); const handleSubmit = useCallback( (e: React.FormEvent) => { e.preventDefault(); sendMessage(input); }, [input, sendMessage], ); const handleNewConversation = useCallback(() => { abortRef.current?.abort(); setMessages([]); setInput(""); setStreaming(false); setConversationId(undefined); inputRef.current?.focus(); }, []); const hasMessages = messages.length > 0; return (

Ask Chrysopedia

{hasMessages && ( )}
{!hasMessages && !streaming && (
💬

Ask any question about music production techniques.

Responses include citations linking to source pages and videos.

)} {hasMessages && (
{messages.map((msg, i) => { if (msg.role === "user") { return (
{msg.text}
); } // Assistant message return (
{msg.error ? (
{msg.error}
) : ( <>
{parseChatCitations(msg.text, msg.sources, styles)} {!msg.done && }
{msg.done && msg.sources.length > 0 && (

Sources

    {msg.sources.map((src) => { const href = src.section_anchor ? `/techniques/${src.slug}#${src.section_anchor}` : `/techniques/${src.slug}`; return (
  1. {src.number}
    {src.title} {src.section_heading ? ` — ${src.section_heading}` : ""} {src.start_time != null && src.source_video_id && ( ▶ {formatTime(src.start_time)} {src.end_time != null && `–${formatTime(src.end_time)}`} )} {src.creator_name && ( {" "} · {src.creator_name} )} {src.video_filename && ( {" "} · {src.video_filename} )}
  2. ); })}
)} )}
); })} {/* Typing indicator — before first token arrives */} {streaming && messages.length > 0 && messages[messages.length - 1]?.role === "assistant" && messages[messages.length - 1]?.text === "" && !messages[messages.length - 1]?.error && (
Thinking…
)}
)}
setInput(e.target.value)} placeholder="Ask about music production techniques…" disabled={streaming} autoFocus />
); }