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:
parent
a9589bfc93
commit
9bdb5b0e4a
8 changed files with 735 additions and 2 deletions
|
|
@ -8,7 +8,7 @@
|
||||||
- Estimate: 1h30m
|
- Estimate: 1h30m
|
||||||
- Files: backend/chat_service.py, backend/routers/chat.py, backend/main.py, backend/tests/test_chat.py
|
- Files: backend/chat_service.py, backend/routers/chat.py, backend/main.py, backend/tests/test_chat.py
|
||||||
- Verify: cd /home/aux/projects/content-to-kb-automator/backend && python -m py_compile chat_service.py && python -m py_compile routers/chat.py && python -m pytest tests/test_chat.py -v
|
- Verify: cd /home/aux/projects/content-to-kb-automator/backend && python -m py_compile chat_service.py && python -m py_compile routers/chat.py && python -m pytest tests/test_chat.py -v
|
||||||
- [ ] **T02: Build frontend chat page with SSE streaming and citation display** — Create the user-facing chat interface. Build an SSE client function in api/chat.ts that POSTs to /api/v1/chat using raw fetch(), reads the response body as a ReadableStream, and parses SSE events (sources, token, done, error). Build ChatPage.tsx with: (1) a text input and submit button, (2) streaming message display that accumulates token events into rendered text, (3) a citation source list rendered from the sources event with links to /techniques/:slug (using section_anchor for deep links), (4) loading and error states. Style with ChatPage.module.css matching the existing dark theme (use CSS variables from the app). Add a lazy-loaded /chat route in App.tsx. Add a 'Chat' navigation link in the header bar visible on all pages. The citation display should parse [N] markers in the streamed text and render them as superscript links to the source list, reusing the regex pattern from utils/citations.tsx but with chat-specific source items instead of KeyMomentSummary.
|
- [x] **T02: Built ChatPage with SSE streaming client, real-time token display, citation [N] parsing with technique page links, and source list — lazy-loaded at /chat with nav link** — Create the user-facing chat interface. Build an SSE client function in api/chat.ts that POSTs to /api/v1/chat using raw fetch(), reads the response body as a ReadableStream, and parses SSE events (sources, token, done, error). Build ChatPage.tsx with: (1) a text input and submit button, (2) streaming message display that accumulates token events into rendered text, (3) a citation source list rendered from the sources event with links to /techniques/:slug (using section_anchor for deep links), (4) loading and error states. Style with ChatPage.module.css matching the existing dark theme (use CSS variables from the app). Add a lazy-loaded /chat route in App.tsx. Add a 'Chat' navigation link in the header bar visible on all pages. The citation display should parse [N] markers in the streamed text and render them as superscript links to the source list, reusing the regex pattern from utils/citations.tsx but with chat-specific source items instead of KeyMomentSummary.
|
||||||
- Estimate: 1h
|
- Estimate: 1h
|
||||||
- Files: frontend/src/api/chat.ts, frontend/src/pages/ChatPage.tsx, frontend/src/pages/ChatPage.module.css, frontend/src/App.tsx
|
- Files: frontend/src/api/chat.ts, frontend/src/pages/ChatPage.tsx, frontend/src/pages/ChatPage.module.css, frontend/src/App.tsx
|
||||||
- Verify: cd /home/aux/projects/content-to-kb-automator/frontend && npm run build
|
- Verify: cd /home/aux/projects/content-to-kb-automator/frontend && npm run build
|
||||||
|
|
|
||||||
36
.gsd/milestones/M021/slices/S03/tasks/T01-VERIFY.json
Normal file
36
.gsd/milestones/M021/slices/S03/tasks/T01-VERIFY.json
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"taskId": "T01",
|
||||||
|
"unitId": "M021/S03/T01",
|
||||||
|
"timestamp": 1775279984379,
|
||||||
|
"passed": false,
|
||||||
|
"discoverySource": "task-plan",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"command": "cd /home/aux/projects/content-to-kb-automator/backend",
|
||||||
|
"exitCode": 0,
|
||||||
|
"durationMs": 6,
|
||||||
|
"verdict": "pass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "python -m py_compile chat_service.py",
|
||||||
|
"exitCode": 1,
|
||||||
|
"durationMs": 38,
|
||||||
|
"verdict": "fail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "python -m py_compile routers/chat.py",
|
||||||
|
"exitCode": 1,
|
||||||
|
"durationMs": 29,
|
||||||
|
"verdict": "fail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "python -m pytest tests/test_chat.py -v",
|
||||||
|
"exitCode": 4,
|
||||||
|
"durationMs": 222,
|
||||||
|
"verdict": "fail"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"retryAttempt": 1,
|
||||||
|
"maxRetries": 2
|
||||||
|
}
|
||||||
83
.gsd/milestones/M021/slices/S03/tasks/T02-SUMMARY.md
Normal file
83
.gsd/milestones/M021/slices/S03/tasks/T02-SUMMARY.md
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
---
|
||||||
|
id: T02
|
||||||
|
parent: S03
|
||||||
|
milestone: M021
|
||||||
|
provides: []
|
||||||
|
requires: []
|
||||||
|
affects: []
|
||||||
|
key_files: ["frontend/src/api/chat.ts", "frontend/src/pages/ChatPage.tsx", "frontend/src/pages/ChatPage.module.css", "frontend/src/App.tsx"]
|
||||||
|
key_decisions: ["Reused CITATION_RE regex locally in ChatPage rather than importing from utils/citations.tsx since link targets differ (technique routes vs anchor IDs)"]
|
||||||
|
patterns_established: []
|
||||||
|
drill_down_paths: []
|
||||||
|
observability_surfaces: []
|
||||||
|
duration: ""
|
||||||
|
verification_result: "Frontend build passes (tsc + vite). ChatPage code-split at 5.19kB. Backend py_compile passes for chat_service.py and routers/chat.py. All 6 backend chat tests pass."
|
||||||
|
completed_at: 2026-04-04T05:22:40.661Z
|
||||||
|
blocker_discovered: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# T02: Built ChatPage with SSE streaming client, real-time token display, citation [N] parsing with technique page links, and source list — lazy-loaded at /chat with nav link
|
||||||
|
|
||||||
|
> Built ChatPage with SSE streaming client, real-time token display, citation [N] parsing with technique page links, and source list — lazy-loaded at /chat with nav link
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
---
|
||||||
|
id: T02
|
||||||
|
parent: S03
|
||||||
|
milestone: M021
|
||||||
|
key_files:
|
||||||
|
- frontend/src/api/chat.ts
|
||||||
|
- frontend/src/pages/ChatPage.tsx
|
||||||
|
- frontend/src/pages/ChatPage.module.css
|
||||||
|
- frontend/src/App.tsx
|
||||||
|
key_decisions:
|
||||||
|
- Reused CITATION_RE regex locally in ChatPage rather than importing from utils/citations.tsx since link targets differ (technique routes vs anchor IDs)
|
||||||
|
duration: ""
|
||||||
|
verification_result: passed
|
||||||
|
completed_at: 2026-04-04T05:22:40.661Z
|
||||||
|
blocker_discovered: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# T02: Built ChatPage with SSE streaming client, real-time token display, citation [N] parsing with technique page links, and source list — lazy-loaded at /chat with nav link
|
||||||
|
|
||||||
|
**Built ChatPage with SSE streaming client, real-time token display, citation [N] parsing with technique page links, and source list — lazy-loaded at /chat with nav link**
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
|
||||||
|
Created four files: api/chat.ts SSE client using fetch+ReadableStream with typed callbacks for sources/token/done/error events; ChatPage.tsx with text input, streaming message accumulation with blinking cursor, citation [N] markers parsed to superscript links to /techniques/:slug#anchor, numbered source list with creator attribution, loading/error/placeholder states; ChatPage.module.css with dark theme styles using existing CSS variables; App.tsx updated with lazy ChatPage import, /chat route, and Chat nav link in header.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Frontend build passes (tsc + vite). ChatPage code-split at 5.19kB. Backend py_compile passes for chat_service.py and routers/chat.py. All 6 backend chat tests pass.
|
||||||
|
|
||||||
|
## Verification Evidence
|
||||||
|
|
||||||
|
| # | Command | Exit Code | Verdict | Duration |
|
||||||
|
|---|---------|-----------|---------|----------|
|
||||||
|
| 1 | `cd frontend && npm run build` | 0 | ✅ pass | 2000ms |
|
||||||
|
| 2 | `python -m py_compile chat_service.py` | 0 | ✅ pass | 200ms |
|
||||||
|
| 3 | `python -m py_compile routers/chat.py` | 0 | ✅ pass | 200ms |
|
||||||
|
| 4 | `python -m pytest tests/test_chat.py -v` | 0 | ✅ pass | 620ms |
|
||||||
|
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `frontend/src/api/chat.ts`
|
||||||
|
- `frontend/src/pages/ChatPage.tsx`
|
||||||
|
- `frontend/src/pages/ChatPage.module.css`
|
||||||
|
- `frontend/src/App.tsx`
|
||||||
|
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
None.
|
||||||
|
|
@ -20,6 +20,7 @@ const CreatorSettings = React.lazy(() => import("./pages/CreatorSettings"));
|
||||||
const ConsentDashboard = React.lazy(() => import("./pages/ConsentDashboard"));
|
const ConsentDashboard = React.lazy(() => import("./pages/ConsentDashboard"));
|
||||||
const WatchPage = React.lazy(() => import("./pages/WatchPage"));
|
const WatchPage = React.lazy(() => import("./pages/WatchPage"));
|
||||||
const AdminUsers = React.lazy(() => import("./pages/AdminUsers"));
|
const AdminUsers = React.lazy(() => import("./pages/AdminUsers"));
|
||||||
|
const ChatPage = React.lazy(() => import("./pages/ChatPage"));
|
||||||
import AdminDropdown from "./components/AdminDropdown";
|
import AdminDropdown from "./components/AdminDropdown";
|
||||||
import ImpersonationBanner from "./components/ImpersonationBanner";
|
import ImpersonationBanner from "./components/ImpersonationBanner";
|
||||||
import AppFooter from "./components/AppFooter";
|
import AppFooter from "./components/AppFooter";
|
||||||
|
|
@ -148,6 +149,7 @@ function AppShell() {
|
||||||
<Link to="/">Home</Link>
|
<Link to="/">Home</Link>
|
||||||
<Link to="/topics">Topics</Link>
|
<Link to="/topics">Topics</Link>
|
||||||
<Link to="/creators">Creators</Link>
|
<Link to="/creators">Creators</Link>
|
||||||
|
<Link to="/chat">Chat</Link>
|
||||||
<AuthNav />
|
<AuthNav />
|
||||||
<AdminDropdown />
|
<AdminDropdown />
|
||||||
{/* Mobile-only: search bar inside menu when not shown in header */}
|
{/* Mobile-only: search bar inside menu when not shown in header */}
|
||||||
|
|
@ -171,6 +173,7 @@ function AppShell() {
|
||||||
<Route path="/search" element={<SearchResults />} />
|
<Route path="/search" element={<SearchResults />} />
|
||||||
<Route path="/techniques/:slug" element={<TechniquePage />} />
|
<Route path="/techniques/:slug" element={<TechniquePage />} />
|
||||||
<Route path="/watch/:videoId" element={<Suspense fallback={<LoadingFallback />}><WatchPage /></Suspense>} />
|
<Route path="/watch/:videoId" element={<Suspense fallback={<LoadingFallback />}><WatchPage /></Suspense>} />
|
||||||
|
<Route path="/chat" element={<Suspense fallback={<LoadingFallback />}><ChatPage /></Suspense>} />
|
||||||
|
|
||||||
{/* Browse routes */}
|
{/* Browse routes */}
|
||||||
<Route path="/creators" element={<CreatorsBrowse />} />
|
<Route path="/creators" element={<CreatorsBrowse />} />
|
||||||
|
|
|
||||||
140
frontend/src/api/chat.ts
Normal file
140
frontend/src/api/chat.ts
Normal 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;
|
||||||
|
}
|
||||||
251
frontend/src/pages/ChatPage.module.css
Normal file
251
frontend/src/pages/ChatPage.module.css
Normal 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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
220
frontend/src/pages/ChatPage.tsx
Normal file
220
frontend/src/pages/ChatPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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"}
|
||||||
Loading…
Add table
Reference in a new issue