/** * 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 { useDocumentTitle } from "../hooks/useDocumentTitle"; import styles from "./ChatPage.module.css"; // Matches [1], [2,3], [1,2,3] etc. — same regex as utils/citations.tsx const CITATION_RE = /\[(\d+(?:,\s*\d+)*)\]/g; interface Message { role: "user" | "assistant"; text: string; sources: ChatSource[]; done: boolean; error?: string; } /** * Parse text containing [N] markers into React nodes with citation links. */ function parseChatCitations( text: string, sources: ChatSource[], ): React.ReactNode[] { const nodes: React.ReactNode[] = []; let lastIndex = 0; for (const match of text.matchAll(CITATION_RE)) { const matchStart = match.index ?? 0; if (matchStart > lastIndex) { nodes.push(text.slice(lastIndex, matchStart)); } const rawGroup = match[1]; if (!rawGroup) continue; const indices = rawGroup.split(",").map((s) => parseInt(s.trim(), 10)); const links: React.ReactNode[] = []; for (let i = 0; i < indices.length; i++) { const idx = indices[i]!; const source = sources[idx - 1]; // 1-based if (source) { if (i > 0) links.push(", "); const href = source.section_anchor ? `/techniques/${source.slug}#${source.section_anchor}` : `/techniques/${source.slug}`; links.push( {idx} , ); } else { if (i > 0) links.push(", "); links.push(String(idx)); } } nodes.push( [{links}] , ); lastIndex = matchStart + match[0].length; } if (lastIndex < text.length) { nodes.push(text.slice(lastIndex)); } return nodes.length > 0 ? nodes : [text]; } 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 ?? crypto.randomUUID(); 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)} {!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.creator_name && ( {" "} · {src.creator_name} )}
  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 />
); }