chrysopedia/frontend/src/pages/ChatPage.tsx
jlightner 5184856d50 fix: crypto.randomUUID fallback for HTTP contexts
Chat page and ChatWidget used crypto.randomUUID() for conversation IDs,
which is only available in secure contexts (HTTPS). On HTTP, this throws
'crypto.randomUUID is not a function'. Added generateUUID() utility with
Math.random-based fallback.
2026-04-05 06:05:17 +00:00

287 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<Message[]>([]);
const [input, setInput] = useState("");
const [streaming, setStreaming] = useState(false);
const [conversationId, setConversationId] = useState<string | undefined>(
undefined,
);
const abortRef = useRef<AbortController | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(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 (
<div className={styles.container}>
<div className={styles.headerRow}>
<h1 className={styles.title}>Ask Chrysopedia</h1>
{hasMessages && (
<button
className={styles.newConversationBtn}
onClick={handleNewConversation}
title="Start a new conversation"
>
+ New conversation
</button>
)}
</div>
{!hasMessages && !streaming && (
<div className={styles.placeholder}>
<span className={styles.placeholderIcon}>💬</span>
<p>Ask any question about music production techniques.</p>
<p>Responses include citations linking to source pages and videos.</p>
</div>
)}
{hasMessages && (
<div className={styles.messagesArea}>
{messages.map((msg, i) => {
if (msg.role === "user") {
return (
<div key={i} className={styles.userMsg}>
{msg.text}
</div>
);
}
// Assistant message
return (
<div key={i} className={styles.assistantMsg}>
{msg.error ? (
<div className={styles.error}>{msg.error}</div>
) : (
<>
<div className={styles.responseText}>
{parseChatCitations(msg.text, msg.sources, styles)}
{!msg.done && <span className={styles.cursor} />}
</div>
{msg.done && msg.sources.length > 0 && (
<div className={styles.sourcesSection}>
<h3 className={styles.sourcesTitle}>Sources</h3>
<ol className={styles.sourceList}>
{msg.sources.map((src) => {
const href = src.section_anchor
? `/techniques/${src.slug}#${src.section_anchor}`
: `/techniques/${src.slug}`;
return (
<li
key={src.number}
className={styles.sourceItem}
>
<span className={styles.sourceNumber}>
{src.number}
</span>
<div>
<Link
to={href}
className={styles.sourceLink}
>
{src.title}
{src.section_heading
? `${src.section_heading}`
: ""}
</Link>
{src.start_time != null && src.source_video_id && (
<Link
to={`/watch/${src.source_video_id}?t=${Math.floor(src.start_time)}`}
className={styles.timestampBadge}
title="Jump to video timestamp"
>
{formatTime(src.start_time)}
{src.end_time != null && `${formatTime(src.end_time)}`}
</Link>
)}
{src.creator_name && (
<span className={styles.sourceMeta}>
{" "}
· {src.creator_name}
</span>
)}
{src.video_filename && (
<span className={styles.videoMeta}>
{" "}
· {src.video_filename}
</span>
)}
</div>
</li>
);
})}
</ol>
</div>
)}
</>
)}
</div>
);
})}
{/* 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 && (
<div className={styles.loading}>
<div className={styles.spinner} />
Thinking
</div>
)}
<div ref={messagesEndRef} />
</div>
)}
<form onSubmit={handleSubmit} className={styles.inputRow}>
<input
ref={inputRef}
type="text"
className={styles.input}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask about music production techniques…"
disabled={streaming}
autoFocus
/>
<button
type="submit"
className={styles.submitBtn}
disabled={!input.trim() || streaming}
>
{streaming ? "Streaming…" : "Send"}
</button>
</form>
</div>
);
}