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
This commit is contained in:
jlightner 2026-04-04 07:53:50 +00:00
parent 29e60bbc99
commit 6a6305e8d1
4 changed files with 332 additions and 127 deletions

View file

@ -21,10 +21,15 @@ export interface ChatSource {
section_heading: string; section_heading: string;
} }
export interface ChatDoneMeta {
cascade_tier: string;
conversation_id: string;
}
export interface ChatCallbacks { export interface ChatCallbacks {
onSources: (sources: ChatSource[]) => void; onSources: (sources: ChatSource[]) => void;
onToken: (text: string) => void; onToken: (text: string) => void;
onDone: (meta: { cascade_tier: string }) => void; onDone: (meta: ChatDoneMeta) => void;
onError: (message: string) => void; onError: (message: string) => void;
} }
@ -36,6 +41,7 @@ export function streamChat(
query: string, query: string,
callbacks: ChatCallbacks, callbacks: ChatCallbacks,
creator?: string, creator?: string,
conversationId?: string,
): AbortController { ): AbortController {
const controller = new AbortController(); const controller = new AbortController();
@ -57,7 +63,11 @@ export function streamChat(
fetch(`${BASE}/chat`, { fetch(`${BASE}/chat`, {
method: "POST", method: "POST",
headers, headers,
body: JSON.stringify({ query, creator: creator ?? null }), body: JSON.stringify({
query,
creator: creator ?? null,
conversation_id: conversationId ?? null,
}),
signal: controller.signal, signal: controller.signal,
}) })
.then(async (res) => { .then(async (res) => {
@ -116,7 +126,7 @@ export function streamChat(
callbacks.onToken(JSON.parse(rawData) as string); callbacks.onToken(JSON.parse(rawData) as string);
break; break;
case "done": case "done":
callbacks.onDone(JSON.parse(rawData) as { cascade_tier: string }); callbacks.onDone(JSON.parse(rawData) as ChatDoneMeta);
break; break;
case "error": { case "error": {
const err = JSON.parse(rawData) as { message?: string }; const err = JSON.parse(rawData) as { message?: string };

View file

@ -120,6 +120,7 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps)
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [streaming, setStreaming] = useState(false); const [streaming, setStreaming] = useState(false);
const [conversationId, setConversationId] = useState<string | undefined>(undefined);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
@ -151,15 +152,24 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps)
}; };
}, []); }, []);
const handleClose = useCallback(() => {
abortRef.current?.abort();
setOpen(false);
setMessages([]);
setInput("");
setStreaming(false);
setConversationId(undefined);
}, []);
// Escape key closes panel // Escape key closes panel
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false); if (e.key === "Escape") handleClose();
}; };
document.addEventListener("keydown", handleKey); document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey);
}, [open]); }, [open, handleClose]);
const sendMessage = useCallback( const sendMessage = useCallback(
(text: string) => { (text: string) => {
@ -168,6 +178,10 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps)
abortRef.current?.abort(); 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 userMsg: Message = { role: "user", text: q, sources: [], done: true };
const assistantMsg: Message = { role: "assistant", text: "", sources: [], done: false }; const assistantMsg: Message = { role: "assistant", text: "", sources: [], done: false };
const newMessages = [...messages, userMsg, assistantMsg]; const newMessages = [...messages, userMsg, assistantMsg];
@ -192,7 +206,8 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps)
), ),
); );
}, },
onDone: () => { onDone: (meta) => {
setConversationId(meta.conversation_id);
setMessages((prev) => setMessages((prev) =>
prev.map((m, i) => (i === assistantIndex ? { ...m, done: true } : m)), prev.map((m, i) => (i === assistantIndex ? { ...m, done: true } : m)),
); );
@ -208,9 +223,10 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps)
}, },
}, },
creatorName, creatorName,
cid,
); );
}, },
[messages, streaming, creatorName], [messages, streaming, creatorName, conversationId],
); );
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
@ -249,7 +265,7 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps)
</div> </div>
<button <button
className={styles.closeBtn} className={styles.closeBtn}
onClick={() => setOpen(false)} onClick={handleClose}
aria-label="Close chat" aria-label="Close chat"
> >

View file

@ -1,4 +1,4 @@
/* ChatPage — dark theme matching existing Chrysopedia styles */ /* ChatPage — dark theme, multi-message conversation layout */
.container { .container {
max-width: 800px; max-width: 800px;
@ -9,11 +9,39 @@
min-height: 60vh; min-height: 60vh;
} }
/* ── Header ───────────────────────────────────────────────── */
.headerRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
}
.title { .title {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 600; font-weight: 600;
color: var(--color-text-primary); color: var(--color-text-primary);
margin: 0 0 1.5rem; margin: 0;
}
.newConversationBtn {
padding: 0.375rem 0.75rem;
background: transparent;
color: var(--color-accent);
border: 1px solid var(--color-accent);
border-radius: 6px;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
}
.newConversationBtn:hover {
background: var(--color-accent);
color: #0f0f14;
} }
/* ── Input area ───────────────────────────────────────────── */ /* ── Input area ───────────────────────────────────────────── */
@ -21,7 +49,11 @@
.inputRow { .inputRow {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 1.5rem; margin-top: auto;
padding-top: 1rem;
position: sticky;
bottom: 0;
background: var(--color-bg-page);
} }
.input { .input {
@ -68,17 +100,40 @@
cursor: not-allowed; cursor: not-allowed;
} }
/* ── Response area ────────────────────────────────────────── */ /* ── Messages area ────────────────────────────────────────── */
.responseArea { .messagesArea {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
overflow-y: auto;
padding-bottom: 0.5rem;
}
.userMsg {
align-self: flex-end;
max-width: 80%;
background: var(--color-accent);
color: #0f0f14;
padding: 0.625rem 0.875rem;
border-radius: 12px 12px 2px 12px;
font-size: 0.9375rem;
line-height: 1.5;
word-break: break-word;
}
.assistantMsg {
align-self: flex-start;
max-width: 90%;
background: var(--color-bg-surface); background: var(--color-bg-surface);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 12px; border-radius: 12px 12px 12px 2px;
padding: 1.5rem; padding: 1rem;
margin-bottom: 1.5rem;
min-height: 100px;
} }
/* ── Response text ────────────────────────────────────────── */
.responseText { .responseText {
color: var(--color-text-primary); color: var(--color-text-primary);
font-size: 0.9375rem; font-size: 0.9375rem;
@ -133,13 +188,14 @@
border-radius: 8px; border-radius: 8px;
padding: 0.75rem; padding: 0.75rem;
font-size: 0.875rem; font-size: 0.875rem;
margin-bottom: 1rem;
} }
/* ── Citations / Sources ──────────────────────────────────── */ /* ── Citations / Sources ──────────────────────────────────── */
.sourcesSection { .sourcesSection {
margin-top: 0.5rem; margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--color-border);
} }
.sourcesTitle { .sourcesTitle {
@ -148,7 +204,7 @@
color: var(--color-text-secondary); color: var(--color-text-secondary);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
margin: 0 0 0.75rem; margin: 0 0 0.5rem;
} }
.sourceList { .sourceList {
@ -157,27 +213,27 @@
padding: 0; padding: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.375rem;
} }
.sourceItem { .sourceItem {
display: flex; display: flex;
gap: 0.625rem; gap: 0.625rem;
align-items: baseline; align-items: baseline;
font-size: 0.875rem; font-size: 0.8125rem;
} }
.sourceNumber { .sourceNumber {
flex-shrink: 0; flex-shrink: 0;
width: 1.5rem; width: 1.25rem;
height: 1.5rem; height: 1.25rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: var(--color-accent); background: var(--color-accent);
color: #0f0f14; color: #0f0f14;
border-radius: 4px; border-radius: 4px;
font-size: 0.75rem; font-size: 0.6875rem;
font-weight: 700; font-weight: 700;
} }
@ -193,7 +249,7 @@
.sourceMeta { .sourceMeta {
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: 0.8125rem; font-size: 0.75rem;
} }
/* ── Citation superscript links in text ───────────────────── */ /* ── Citation superscript links in text ───────────────────── */
@ -222,6 +278,7 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex: 1;
min-height: 200px; min-height: 200px;
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: 0.9375rem; font-size: 0.9375rem;
@ -241,6 +298,12 @@
padding: 1rem 0.75rem; padding: 1rem 0.75rem;
} }
.headerRow {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.inputRow { .inputRow {
flex-direction: column; flex-direction: column;
} }
@ -248,4 +311,8 @@
.submitBtn { .submitBtn {
width: 100%; width: 100%;
} }
.userMsg {
max-width: 90%;
}
} }

View file

@ -1,9 +1,10 @@
/** /**
* ChatPage ask a question, receive a streamed encyclopedic response with citations. * ChatPage multi-turn chat with streamed encyclopedic responses and citations.
* *
* Uses SSE streaming via api/chat.ts to display tokens in real-time. * Uses SSE streaming via api/chat.ts to display tokens in real-time.
* Citation markers [N] in the streamed text become superscript links to * Citation markers [N] in the streamed text become superscript links to
* the source list rendered below the response. * 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 React, { useState, useRef, useCallback, useEffect } from "react";
@ -15,9 +16,16 @@ import styles from "./ChatPage.module.css";
// Matches [1], [2,3], [1,2,3] etc. — same regex as utils/citations.tsx // Matches [1], [2,3], [1,2,3] etc. — same regex as utils/citations.tsx
const CITATION_RE = /\[(\d+(?:,\s*\d+)*)\]/g; 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. * Parse text containing [N] markers into React nodes with citation links.
* Links scroll to the source list item and reference ChatSource metadata.
*/ */
function parseChatCitations( function parseChatCitations(
text: string, text: string,
@ -80,16 +88,16 @@ function parseChatCitations(
export default function ChatPage() { export default function ChatPage() {
useDocumentTitle("Chat — Chrysopedia"); useDocumentTitle("Chat — Chrysopedia");
const [query, setQuery] = useState(""); const [messages, setMessages] = useState<Message[]>([]);
const [sources, setSources] = useState<ChatSource[]>([]); const [input, setInput] = useState("");
const [responseText, setResponseText] = useState("");
const [streaming, setStreaming] = useState(false); const [streaming, setStreaming] = useState(false);
const [loading, setLoading] = useState(false); const [conversationId, setConversationId] = useState<string | undefined>(
const [error, setError] = useState<string | null>(null); undefined,
const [done, setDone] = useState(false); );
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Cleanup on unmount // Cleanup on unmount
useEffect(() => { useEffect(() => {
@ -98,104 +106,177 @@ export default function ChatPage() {
}; };
}, []); }, []);
// 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( const handleSubmit = useCallback(
(e: React.FormEvent) => { (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const q = query.trim(); sendMessage(input);
if (!q || streaming) return;
// Reset state
abortRef.current?.abort();
setSources([]);
setResponseText("");
setError(null);
setDone(false);
setLoading(true);
setStreaming(true);
abortRef.current = streamChat(q, {
onSources: (s) => {
setSources(s);
setLoading(false);
}, },
onToken: (text) => { [input, sendMessage],
setResponseText((prev) => prev + text);
},
onDone: () => {
setStreaming(false);
setDone(true);
},
onError: (msg) => {
setError(msg);
setStreaming(false);
setLoading(false);
},
});
},
[query, streaming],
); );
const hasResponse = responseText.length > 0 || sources.length > 0; const handleNewConversation = useCallback(() => {
abortRef.current?.abort();
setMessages([]);
setInput("");
setStreaming(false);
setConversationId(undefined);
inputRef.current?.focus();
}, []);
const hasMessages = messages.length > 0;
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.headerRow}>
<h1 className={styles.title}>Ask Chrysopedia</h1> <h1 className={styles.title}>Ask Chrysopedia</h1>
{hasMessages && (
<form onSubmit={handleSubmit} className={styles.inputRow}>
<input
ref={inputRef}
type="text"
className={styles.input}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Ask about music production techniques…"
disabled={streaming}
autoFocus
/>
<button <button
type="submit" className={styles.newConversationBtn}
className={styles.submitBtn} onClick={handleNewConversation}
disabled={!query.trim() || streaming} title="Start a new conversation"
> >
{streaming ? "Streaming…" : "Ask"} + New conversation
</button> </button>
</form> )}
</div>
{error && <div className={styles.error}>{error}</div>} {!hasMessages && !streaming && (
<div className={styles.placeholder}>
{loading && ( <span className={styles.placeholderIcon}>💬</span>
<div className={styles.loading}> <p>Ask any question about music production techniques.</p>
<div className={styles.spinner} /> <p>Responses include citations linking to source pages and videos.</p>
Searching for sources
</div> </div>
)} )}
{hasResponse && ( {hasMessages && (
<div className={styles.responseArea}> <div className={styles.messagesArea}>
<div className={styles.responseText}> {messages.map((msg, i) => {
{parseChatCitations(responseText, sources)} if (msg.role === "user") {
{streaming && <span className={styles.cursor} />} return (
<div key={i} className={styles.userMsg}>
{msg.text}
</div> </div>
);
}
{sources.length > 0 && (done || !streaming) && ( // 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}> <div className={styles.sourcesSection}>
<h2 className={styles.sourcesTitle}>Sources</h2> <h3 className={styles.sourcesTitle}>Sources</h3>
<ol className={styles.sourceList}> <ol className={styles.sourceList}>
{sources.map((src) => { {msg.sources.map((src) => {
const href = src.section_anchor const href = src.section_anchor
? `/techniques/${src.slug}#${src.section_anchor}` ? `/techniques/${src.slug}#${src.section_anchor}`
: `/techniques/${src.slug}`; : `/techniques/${src.slug}`;
return ( return (
<li key={src.number} className={styles.sourceItem}> <li
<span className={styles.sourceNumber}>{src.number}</span> key={src.number}
className={styles.sourceItem}
>
<span className={styles.sourceNumber}>
{src.number}
</span>
<div> <div>
<Link to={href} className={styles.sourceLink}> <Link
to={href}
className={styles.sourceLink}
>
{src.title} {src.title}
{src.section_heading ? `${src.section_heading}` : ""} {src.section_heading
? `${src.section_heading}`
: ""}
</Link> </Link>
{src.creator_name && ( {src.creator_name && (
<span className={styles.sourceMeta}> <span className={styles.sourceMeta}>
{" "}· {src.creator_name} {" "}
· {src.creator_name}
</span> </span>
)} )}
</div> </div>
@ -205,16 +286,47 @@ export default function ChatPage() {
</ol> </ol>
</div> </div>
)} )}
</div> </>
)}
{!hasResponse && !loading && !error && (
<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>
)} )}
</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>
);
} }