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 d13d6c3aa1
commit 34acf468c6
7 changed files with 430 additions and 128 deletions

View file

@ -8,7 +8,7 @@
- Estimate: 1.5h
- Files: backend/routers/chat.py, backend/chat_service.py, backend/tests/test_chat.py
- Verify: cd backend && python -m pytest tests/test_chat.py -v 2>&1 | tail -30
- [ ] **T02: Thread conversation_id through frontend chat API and both chat UIs** — Update the frontend to support multi-turn conversations. (1) `streamChat()` in `api/chat.ts` gains a `conversationId` param sent in the POST body and parsed from the `done` event. (2) ChatWidget already has a local messages array — add `conversationId` state (generated on first send via `crypto.randomUUID()`), thread it through `streamChat()` calls, update it from `done` event response, reset on panel close. (3) ChatPage converts from single-response to multi-message UI: replace single `responseText`/`sources` state with a `messages[]` array (same shape as ChatWidget), add `conversationId` state, render message history with existing `parseChatCitations()`, add a 'New conversation' button. Reuse ChatWidget's message rendering pattern.
- [x] **T02: Updated streamChat() API, ChatWidget, and ChatPage to thread conversation_id for multi-turn conversations with per-message rendering** — Update the frontend to support multi-turn conversations. (1) `streamChat()` in `api/chat.ts` gains a `conversationId` param sent in the POST body and parsed from the `done` event. (2) ChatWidget already has a local messages array — add `conversationId` state (generated on first send via `crypto.randomUUID()`), thread it through `streamChat()` calls, update it from `done` event response, reset on panel close. (3) ChatPage converts from single-response to multi-message UI: replace single `responseText`/`sources` state with a `messages[]` array (same shape as ChatWidget), add `conversationId` state, render message history with existing `parseChatCitations()`, add a 'New conversation' button. Reuse ChatWidget's message rendering pattern.
- Estimate: 1.5h
- Files: frontend/src/api/chat.ts, frontend/src/components/ChatWidget.tsx, frontend/src/pages/ChatPage.tsx, frontend/src/pages/ChatPage.module.css
- Verify: cd frontend && npm run build 2>&1 | tail -20

View file

@ -0,0 +1,16 @@
{
"schemaVersion": 1,
"taskId": "T01",
"unitId": "M022/S04/T01",
"timestamp": 1775289031003,
"passed": true,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd backend",
"exitCode": 0,
"durationMs": 7,
"verdict": "pass"
}
]
}

View file

@ -0,0 +1,81 @@
---
id: T02
parent: S04
milestone: M022
provides: []
requires: []
affects: []
key_files: ["frontend/src/api/chat.ts", "frontend/src/components/ChatWidget.tsx", "frontend/src/pages/ChatPage.tsx", "frontend/src/pages/ChatPage.module.css"]
key_decisions: ["ChatWidget resets conversation state on close rather than preserving across open/close cycles", "ChatDoneMeta exported type for typed done event parsing across consumers"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "cd frontend && npm run build — clean build with 0 errors, all 4 modified files compile and bundle correctly."
completed_at: 2026-04-04T07:53:47.090Z
blocker_discovered: false
---
# T02: Updated streamChat() API, ChatWidget, and ChatPage to thread conversation_id for multi-turn conversations with per-message rendering
> Updated streamChat() API, ChatWidget, and ChatPage to thread conversation_id for multi-turn conversations with per-message rendering
## What Happened
---
id: T02
parent: S04
milestone: M022
key_files:
- frontend/src/api/chat.ts
- frontend/src/components/ChatWidget.tsx
- frontend/src/pages/ChatPage.tsx
- frontend/src/pages/ChatPage.module.css
key_decisions:
- ChatWidget resets conversation state on close rather than preserving across open/close cycles
- ChatDoneMeta exported type for typed done event parsing across consumers
duration: ""
verification_result: passed
completed_at: 2026-04-04T07:53:47.090Z
blocker_discovered: false
---
# T02: Updated streamChat() API, ChatWidget, and ChatPage to thread conversation_id for multi-turn conversations with per-message rendering
**Updated streamChat() API, ChatWidget, and ChatPage to thread conversation_id for multi-turn conversations with per-message rendering**
## What Happened
Three changes across four files: (1) api/chat.ts gained conversationId param, ChatDoneMeta type, and conversation_id in POST body. (2) ChatWidget.tsx added conversationId state generated on first send via crypto.randomUUID(), threaded through streamChat(), updated from done event, reset on close via new handleClose callback. (3) ChatPage.tsx converted from single-response state to multi-message messages[] array with conversationId threading, new-conversation button, per-message citation rendering, typing indicator, and auto-scroll. (4) ChatPage.module.css replaced with conversation bubble layout, headerRow, sticky input, responsive styles.
## Verification
cd frontend && npm run build — clean build with 0 errors, all 4 modified files compile and bundle correctly.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `cd frontend && npm run build` | 0 | ✅ pass | 2150ms |
## Deviations
ChatWidget close now resets messages array in addition to conversationId for a clean slate on reopen. Plan didn't specify preservation behavior; chose reset for simplicity.
## Known Issues
None.
## Files Created/Modified
- `frontend/src/api/chat.ts`
- `frontend/src/components/ChatWidget.tsx`
- `frontend/src/pages/ChatPage.tsx`
- `frontend/src/pages/ChatPage.module.css`
## Deviations
ChatWidget close now resets messages array in addition to conversationId for a clean slate on reopen. Plan didn't specify preservation behavior; chose reset for simplicity.
## Known Issues
None.

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,56 +106,215 @@ 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);
},
onToken: (text) => {
setResponseText((prev) => prev + text);
},
onDone: () => {
setStreaming(false);
setDone(true);
},
onError: (msg) => {
setError(msg);
setStreaming(false);
setLoading(false);
},
});
sendMessage(input);
},
[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}>
<h1 className={styles.title}>Ask Chrysopedia</h1>
<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={query}
onChange={(e) => setQuery(e.target.value)}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask about music production techniques…"
disabled={streaming}
autoFocus
@ -155,66 +322,11 @@ export default function ChatPage() {
<button
type="submit"
className={styles.submitBtn}
disabled={!query.trim() || streaming}
disabled={!input.trim() || streaming}
>
{streaming ? "Streaming…" : "Ask"}
{streaming ? "Streaming…" : "Send"}
</button>
</form>
{error && <div className={styles.error}>{error}</div>}
{loading && (
<div className={styles.loading}>
<div className={styles.spinner} />
Searching for sources
</div>
)}
{hasResponse && (
<div className={styles.responseArea}>
<div className={styles.responseText}>
{parseChatCitations(responseText, sources)}
{streaming && <span className={styles.cursor} />}
</div>
{sources.length > 0 && (done || !streaming) && (
<div className={styles.sourcesSection}>
<h2 className={styles.sourcesTitle}>Sources</h2>
<ol className={styles.sourceList}>
{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>
)}
{!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>
);
}