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;
}
export interface ChatDoneMeta {
cascade_tier: string;
conversation_id: string;
}
export interface ChatCallbacks {
onSources: (sources: ChatSource[]) => void;
onToken: (text: string) => void;
onDone: (meta: { cascade_tier: string }) => void;
onDone: (meta: ChatDoneMeta) => void;
onError: (message: string) => void;
}
@ -36,6 +41,7 @@ export function streamChat(
query: string,
callbacks: ChatCallbacks,
creator?: string,
conversationId?: string,
): AbortController {
const controller = new AbortController();
@ -57,7 +63,11 @@ export function streamChat(
fetch(`${BASE}/chat`, {
method: "POST",
headers,
body: JSON.stringify({ query, creator: creator ?? null }),
body: JSON.stringify({
query,
creator: creator ?? null,
conversation_id: conversationId ?? null,
}),
signal: controller.signal,
})
.then(async (res) => {
@ -116,7 +126,7 @@ export function streamChat(
callbacks.onToken(JSON.parse(rawData) as string);
break;
case "done":
callbacks.onDone(JSON.parse(rawData) as { cascade_tier: string });
callbacks.onDone(JSON.parse(rawData) as ChatDoneMeta);
break;
case "error": {
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 [input, setInput] = useState("");
const [streaming, setStreaming] = useState(false);
const [conversationId, setConversationId] = useState<string | undefined>(undefined);
const abortRef = useRef<AbortController | null>(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
useEffect(() => {
if (!open) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
if (e.key === "Escape") handleClose();
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [open]);
}, [open, handleClose]);
const sendMessage = useCallback(
(text: string) => {
@ -168,6 +178,10 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps)
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];
@ -192,7 +206,8 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps)
),
);
},
onDone: () => {
onDone: (meta) => {
setConversationId(meta.conversation_id);
setMessages((prev) =>
prev.map((m, i) => (i === assistantIndex ? { ...m, done: true } : m)),
);
@ -208,9 +223,10 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps)
},
},
creatorName,
cid,
);
},
[messages, streaming, creatorName],
[messages, streaming, creatorName, conversationId],
);
const handleSubmit = (e: React.FormEvent) => {
@ -249,7 +265,7 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps)
</div>
<button
className={styles.closeBtn}
onClick={() => setOpen(false)}
onClick={handleClose}
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 {
max-width: 800px;
@ -9,11 +9,39 @@
min-height: 60vh;
}
/* ── Header ───────────────────────────────────────────────── */
.headerRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
}
.title {
font-size: 1.5rem;
font-weight: 600;
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 ───────────────────────────────────────────── */
@ -21,7 +49,11 @@
.inputRow {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
margin-top: auto;
padding-top: 1rem;
position: sticky;
bottom: 0;
background: var(--color-bg-page);
}
.input {
@ -68,17 +100,40 @@
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);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
min-height: 100px;
border-radius: 12px 12px 12px 2px;
padding: 1rem;
}
/* ── Response text ────────────────────────────────────────── */
.responseText {
color: var(--color-text-primary);
font-size: 0.9375rem;
@ -133,13 +188,14 @@
border-radius: 8px;
padding: 0.75rem;
font-size: 0.875rem;
margin-bottom: 1rem;
}
/* ── Citations / Sources ──────────────────────────────────── */
.sourcesSection {
margin-top: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--color-border);
}
.sourcesTitle {
@ -148,7 +204,7 @@
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 0.75rem;
margin: 0 0 0.5rem;
}
.sourceList {
@ -157,27 +213,27 @@
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: 0.375rem;
}
.sourceItem {
display: flex;
gap: 0.625rem;
align-items: baseline;
font-size: 0.875rem;
font-size: 0.8125rem;
}
.sourceNumber {
flex-shrink: 0;
width: 1.5rem;
height: 1.5rem;
width: 1.25rem;
height: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-accent);
color: #0f0f14;
border-radius: 4px;
font-size: 0.75rem;
font-size: 0.6875rem;
font-weight: 700;
}
@ -193,7 +249,7 @@
.sourceMeta {
color: var(--color-text-secondary);
font-size: 0.8125rem;
font-size: 0.75rem;
}
/* ── Citation superscript links in text ───────────────────── */
@ -222,6 +278,7 @@
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
min-height: 200px;
color: var(--color-text-secondary);
font-size: 0.9375rem;
@ -241,6 +298,12 @@
padding: 1rem 0.75rem;
}
.headerRow {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.inputRow {
flex-direction: column;
}
@ -248,4 +311,8 @@
.submitBtn {
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.
* 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";
@ -15,9 +16,16 @@ 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.
* Links scroll to the source list item and reference ChatSource metadata.
*/
function parseChatCitations(
text: string,
@ -80,16 +88,16 @@ function parseChatCitations(
export default function ChatPage() {
useDocumentTitle("Chat — Chrysopedia");
const [query, setQuery] = useState("");
const [sources, setSources] = useState<ChatSource[]>([]);
const [responseText, setResponseText] = useState("");
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [streaming, setStreaming] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = 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(() => {
@ -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(
(e: React.FormEvent) => {
e.preventDefault();
const q = query.trim();
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);
sendMessage(input);
},
onToken: (text) => {
setResponseText((prev) => prev + text);
},
onDone: () => {
setStreaming(false);
setDone(true);
},
onError: (msg) => {
setError(msg);
setStreaming(false);
setLoading(false);
},
});
},
[query, streaming],
[input, sendMessage],
);
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 (
<div className={styles.container}>
<div className={styles.headerRow}>
<h1 className={styles.title}>Ask Chrysopedia</h1>
<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
/>
{hasMessages && (
<button
type="submit"
className={styles.submitBtn}
disabled={!query.trim() || streaming}
className={styles.newConversationBtn}
onClick={handleNewConversation}
title="Start a new conversation"
>
{streaming ? "Streaming…" : "Ask"}
+ New conversation
</button>
</form>
)}
</div>
{error && <div className={styles.error}>{error}</div>}
{loading && (
<div className={styles.loading}>
<div className={styles.spinner} />
Searching for sources
{!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>
)}
{hasResponse && (
<div className={styles.responseArea}>
<div className={styles.responseText}>
{parseChatCitations(responseText, sources)}
{streaming && <span className={styles.cursor} />}
{hasMessages && (
<div className={styles.messagesArea}>
{messages.map((msg, i) => {
if (msg.role === "user") {
return (
<div key={i} className={styles.userMsg}>
{msg.text}
</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}>
<h2 className={styles.sourcesTitle}>Sources</h2>
<h3 className={styles.sourcesTitle}>Sources</h3>
<ol className={styles.sourceList}>
{sources.map((src) => {
{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>
<li
key={src.number}
className={styles.sourceItem}
>
<span className={styles.sourceNumber}>
{src.number}
</span>
<div>
<Link to={href} className={styles.sourceLink}>
<Link
to={href}
className={styles.sourceLink}
>
{src.title}
{src.section_heading ? `${src.section_heading}` : ""}
{src.section_heading
? `${src.section_heading}`
: ""}
</Link>
{src.creator_name && (
<span className={styles.sourceMeta}>
{" "}· {src.creator_name}
{" "}
· {src.creator_name}
</span>
)}
</div>
@ -205,16 +286,47 @@ export default function ChatPage() {
</ol>
</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>
);
})}
{/* 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>
);
}