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.
287 lines
9.5 KiB
TypeScript
287 lines
9.5 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|