chrysopedia/frontend/src/pages/ChatPage.tsx
jlightner 6a6305e8d1 feat: Updated streamChat() API, ChatWidget, and ChatPage to thread conv…
- "frontend/src/api/chat.ts"
- "frontend/src/components/ChatWidget.tsx"
- "frontend/src/pages/ChatPage.tsx"
- "frontend/src/pages/ChatPage.module.css"

GSD-Task: S04/T02
2026-04-04 07:53:50 +00:00

332 lines
10 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 { 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(
<Link
key={`${matchStart}-${idx}`}
to={href}
className={styles.citationLink}
title={`${source.title}${source.section_heading ? `${source.section_heading}` : ""}`}
>
{idx}
</Link>,
);
} else {
if (i > 0) links.push(", ");
links.push(String(idx));
}
}
nodes.push(
<sup key={`cite-${matchStart}`} className={styles.citationGroup}>
[{links}]
</sup>,
);
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<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 ?? 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 (
<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)}
{!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.creator_name && (
<span className={styles.sourceMeta}>
{" "}
· {src.creator_name}
</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>
);
}