- "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
332 lines
10 KiB
TypeScript
332 lines
10 KiB
TypeScript
/**
|
|
* 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. Conversations are threaded
|
|
* via conversation_id backed by Redis history on the server.
|
|
*/
|
|
|
|
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;
|
|
|
|
interface Message {
|
|
role: "user" | "assistant";
|
|
text: string;
|
|
sources: ChatSource[];
|
|
done: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
/**
|
|
* Parse text containing [N] markers into React nodes with citation links.
|
|
*/
|
|
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 [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 inputRef = useRef<HTMLInputElement>(null);
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Cleanup on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
abortRef.current?.abort();
|
|
};
|
|
}, []);
|
|
|
|
// 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();
|
|
sendMessage(input);
|
|
},
|
|
[input, sendMessage],
|
|
);
|
|
|
|
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}>
|
|
<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={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
placeholder="Ask about music production techniques…"
|
|
disabled={streaming}
|
|
autoFocus
|
|
/>
|
|
<button
|
|
type="submit"
|
|
className={styles.submitBtn}
|
|
disabled={!input.trim() || streaming}
|
|
>
|
|
{streaming ? "Streaming…" : "Send"}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|