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
29e60bbc99
commit
6a6305e8d1
4 changed files with 332 additions and 127 deletions
|
|
@ -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