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:
parent
d13d6c3aa1
commit
34acf468c6
7 changed files with 430 additions and 128 deletions
|
|
@ -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
|
||||
|
|
|
|||
16
.gsd/milestones/M022/slices/S04/tasks/T01-VERIFY.json
Normal file
16
.gsd/milestones/M022/slices/S04/tasks/T01-VERIFY.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
81
.gsd/milestones/M022/slices/S04/tasks/T02-SUMMARY.md
Normal file
81
.gsd/milestones/M022/slices/S04/tasks/T02-SUMMARY.md
Normal 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.
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
✕
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue