feat: Built ChatPage with SSE streaming client, real-time token display…

- "frontend/src/api/chat.ts"
- "frontend/src/pages/ChatPage.tsx"
- "frontend/src/pages/ChatPage.module.css"
- "frontend/src/App.tsx"

GSD-Task: S03/T02
This commit is contained in:
jlightner 2026-04-04 05:22:43 +00:00
parent 5e0ce753a5
commit 90c24d8bf9
5 changed files with 615 additions and 1 deletions

View file

@ -20,6 +20,7 @@ const CreatorSettings = React.lazy(() => import("./pages/CreatorSettings"));
const ConsentDashboard = React.lazy(() => import("./pages/ConsentDashboard"));
const WatchPage = React.lazy(() => import("./pages/WatchPage"));
const AdminUsers = React.lazy(() => import("./pages/AdminUsers"));
const ChatPage = React.lazy(() => import("./pages/ChatPage"));
import AdminDropdown from "./components/AdminDropdown";
import ImpersonationBanner from "./components/ImpersonationBanner";
import AppFooter from "./components/AppFooter";
@ -148,6 +149,7 @@ function AppShell() {
<Link to="/">Home</Link>
<Link to="/topics">Topics</Link>
<Link to="/creators">Creators</Link>
<Link to="/chat">Chat</Link>
<AuthNav />
<AdminDropdown />
{/* Mobile-only: search bar inside menu when not shown in header */}
@ -171,6 +173,7 @@ function AppShell() {
<Route path="/search" element={<SearchResults />} />
<Route path="/techniques/:slug" element={<TechniquePage />} />
<Route path="/watch/:videoId" element={<Suspense fallback={<LoadingFallback />}><WatchPage /></Suspense>} />
<Route path="/chat" element={<Suspense fallback={<LoadingFallback />}><ChatPage /></Suspense>} />
{/* Browse routes */}
<Route path="/creators" element={<CreatorsBrowse />} />

140
frontend/src/api/chat.ts Normal file
View file

@ -0,0 +1,140 @@
/**
* SSE client for POST /api/v1/chat.
*
* Uses raw fetch() with ReadableStream to parse Server-Sent Events:
* - event: sources citation metadata array (sent first)
* - event: token streamed text chunk (repeated)
* - event: done completion metadata with cascade_tier
* - event: error error message on failure
*/
import { BASE, AUTH_TOKEN_KEY } from "./client";
export interface ChatSource {
number: number;
title: string;
slug: string;
creator_name: string;
topic_category: string;
summary: string;
section_anchor: string;
section_heading: string;
}
export interface ChatCallbacks {
onSources: (sources: ChatSource[]) => void;
onToken: (text: string) => void;
onDone: (meta: { cascade_tier: string }) => void;
onError: (message: string) => void;
}
/**
* Stream a chat response from the API via SSE.
* Returns an AbortController so the caller can cancel.
*/
export function streamChat(
query: string,
callbacks: ChatCallbacks,
creator?: string,
): AbortController {
const controller = new AbortController();
const token = (() => {
try {
return localStorage.getItem(AUTH_TOKEN_KEY);
} catch {
return null;
}
})();
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
fetch(`${BASE}/chat`, {
method: "POST",
headers,
body: JSON.stringify({ query, creator: creator ?? null }),
signal: controller.signal,
})
.then(async (res) => {
if (!res.ok) {
let detail = res.statusText;
try {
const body = await res.json();
if (body?.detail) {
detail =
typeof body.detail === "string"
? body.detail
: JSON.stringify(body.detail);
}
} catch {
/* body not JSON */
}
callbacks.onError(`Request failed: ${detail}`);
return;
}
const reader = res.body?.getReader();
if (!reader) {
callbacks.onError("No response body");
return;
}
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Parse complete SSE events (separated by double newline)
const parts = buffer.split("\n\n");
// Keep the last incomplete part in the buffer
buffer = parts.pop() ?? "";
for (const part of parts) {
if (!part.trim()) continue;
const eventMatch = part.match(/^event:\s*(.+)$/m);
const dataMatch = part.match(/^data:\s*(.+)$/m);
if (!eventMatch?.[1] || !dataMatch?.[1]) continue;
const eventType = eventMatch[1].trim();
const rawData = dataMatch[1];
try {
switch (eventType) {
case "sources":
callbacks.onSources(JSON.parse(rawData) as ChatSource[]);
break;
case "token":
callbacks.onToken(JSON.parse(rawData) as string);
break;
case "done":
callbacks.onDone(JSON.parse(rawData) as { cascade_tier: string });
break;
case "error": {
const err = JSON.parse(rawData) as { message?: string };
callbacks.onError(err.message ?? "Unknown error");
break;
}
}
} catch {
// Malformed JSON — skip this event
}
}
}
})
.catch((err: Error) => {
if (err.name !== "AbortError") {
callbacks.onError(err.message || "Network error");
}
});
return controller;
}

View file

@ -0,0 +1,251 @@
/* ChatPage — dark theme matching existing Chrysopedia styles */
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
display: flex;
flex-direction: column;
min-height: 60vh;
}
.title {
font-size: 1.5rem;
font-weight: 600;
color: var(--color-text-primary);
margin: 0 0 1.5rem;
}
/* ── Input area ───────────────────────────────────────────── */
.inputRow {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.input {
flex: 1;
padding: 0.625rem 0.75rem;
background: var(--color-bg-input);
border: 1px solid var(--color-border);
border-radius: 8px;
color: var(--color-text-primary);
font-size: 0.9375rem;
transition: border-color 0.15s;
}
.input:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px var(--color-accent-focus);
}
.input::placeholder {
color: var(--color-text-secondary);
opacity: 0.7;
}
.submitBtn {
padding: 0.625rem 1.25rem;
background: var(--color-accent);
color: #0f0f14;
border: none;
border-radius: 8px;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
}
.submitBtn:hover:not(:disabled) {
background: var(--color-accent-hover);
}
.submitBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* ── Response area ────────────────────────────────────────── */
.responseArea {
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
min-height: 100px;
}
.responseText {
color: var(--color-text-primary);
font-size: 0.9375rem;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
.cursor {
display: inline-block;
width: 2px;
height: 1em;
background: var(--color-accent);
margin-left: 2px;
vertical-align: text-bottom;
animation: blink 0.8s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
/* ── Loading state ────────────────────────────────────────── */
.loading {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--color-text-secondary);
font-size: 0.875rem;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid var(--color-border);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ── Error state ──────────────────────────────────────────── */
.error {
background: rgba(239, 68, 68, 0.1);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 8px;
padding: 0.75rem;
font-size: 0.875rem;
margin-bottom: 1rem;
}
/* ── Citations / Sources ──────────────────────────────────── */
.sourcesSection {
margin-top: 0.5rem;
}
.sourcesTitle {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 0.75rem;
}
.sourceList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.sourceItem {
display: flex;
gap: 0.625rem;
align-items: baseline;
font-size: 0.875rem;
}
.sourceNumber {
flex-shrink: 0;
width: 1.5rem;
height: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-accent);
color: #0f0f14;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 700;
}
.sourceLink {
color: var(--color-accent);
text-decoration: none;
font-weight: 500;
}
.sourceLink:hover {
text-decoration: underline;
}
.sourceMeta {
color: var(--color-text-secondary);
font-size: 0.8125rem;
}
/* ── Citation superscript links in text ───────────────────── */
.citationGroup {
font-size: 0.75em;
vertical-align: super;
line-height: 0;
}
.citationLink {
color: var(--color-accent);
text-decoration: none;
font-weight: 600;
cursor: pointer;
}
.citationLink:hover {
text-decoration: underline;
}
/* ── Empty / placeholder state ────────────────────────────── */
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 200px;
color: var(--color-text-secondary);
font-size: 0.9375rem;
text-align: center;
gap: 0.5rem;
}
.placeholderIcon {
font-size: 2rem;
opacity: 0.5;
}
/* ── Responsive ───────────────────────────────────────────── */
@media (max-width: 640px) {
.container {
padding: 1rem 0.75rem;
}
.inputRow {
flex-direction: column;
}
.submitBtn {
width: 100%;
}
}

View file

@ -0,0 +1,220 @@
/**
* ChatPage ask a question, receive a streamed encyclopedic response with 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.
*/
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;
/**
* 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,
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 [query, setQuery] = useState("");
const [sources, setSources] = useState<ChatSource[]>([]);
const [responseText, setResponseText] = useState("");
const [streaming, setStreaming] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Cleanup on unmount
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
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);
},
});
},
[query, streaming],
);
const hasResponse = responseText.length > 0 || sources.length > 0;
return (
<div className={styles.container}>
<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
/>
<button
type="submit"
className={styles.submitBtn}
disabled={!query.trim() || streaming}
>
{streaming ? "Streaming…" : "Ask"}
</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>
);
}

View file

@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/consent.ts","./src/api/creator-dashboard.ts","./src/api/creators.ts","./src/api/index.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/topics.ts","./src/api/videos.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ImpersonationBanner.tsx","./src/components/PlayerControls.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/components/ToggleSwitch.tsx","./src/components/TranscriptSidebar.tsx","./src/components/VideoPlayer.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useMediaSync.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/AdminUsers.tsx","./src/pages/ConsentDashboard.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/pages/WatchPage.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/chat.ts","./src/api/client.ts","./src/api/consent.ts","./src/api/creator-dashboard.ts","./src/api/creators.ts","./src/api/index.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/topics.ts","./src/api/videos.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/CategoryIcons.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ImpersonationBanner.tsx","./src/components/PlayerControls.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/components/ToggleSwitch.tsx","./src/components/TranscriptSidebar.tsx","./src/components/VideoPlayer.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useMediaSync.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/AdminUsers.tsx","./src/pages/ChatPage.tsx","./src/pages/ConsentDashboard.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/pages/WatchPage.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"}