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;
|
section_heading: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChatDoneMeta {
|
||||||
|
cascade_tier: string;
|
||||||
|
conversation_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatCallbacks {
|
export interface ChatCallbacks {
|
||||||
onSources: (sources: ChatSource[]) => void;
|
onSources: (sources: ChatSource[]) => void;
|
||||||
onToken: (text: string) => void;
|
onToken: (text: string) => void;
|
||||||
onDone: (meta: { cascade_tier: string }) => void;
|
onDone: (meta: ChatDoneMeta) => void;
|
||||||
onError: (message: string) => void;
|
onError: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,6 +41,7 @@ export function streamChat(
|
||||||
query: string,
|
query: string,
|
||||||
callbacks: ChatCallbacks,
|
callbacks: ChatCallbacks,
|
||||||
creator?: string,
|
creator?: string,
|
||||||
|
conversationId?: string,
|
||||||
): AbortController {
|
): AbortController {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
|
@ -57,7 +63,11 @@ export function streamChat(
|
||||||
fetch(`${BASE}/chat`, {
|
fetch(`${BASE}/chat`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({ query, creator: creator ?? null }),
|
body: JSON.stringify({
|
||||||
|
query,
|
||||||
|
creator: creator ?? null,
|
||||||
|
conversation_id: conversationId ?? null,
|
||||||
|
}),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
|
|
@ -116,7 +126,7 @@ export function streamChat(
|
||||||
callbacks.onToken(JSON.parse(rawData) as string);
|
callbacks.onToken(JSON.parse(rawData) as string);
|
||||||
break;
|
break;
|
||||||
case "done":
|
case "done":
|
||||||
callbacks.onDone(JSON.parse(rawData) as { cascade_tier: string });
|
callbacks.onDone(JSON.parse(rawData) as ChatDoneMeta);
|
||||||
break;
|
break;
|
||||||
case "error": {
|
case "error": {
|
||||||
const err = JSON.parse(rawData) as { message?: string };
|
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 [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [streaming, setStreaming] = useState(false);
|
const [streaming, setStreaming] = useState(false);
|
||||||
|
const [conversationId, setConversationId] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(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
|
// Escape key closes panel
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") setOpen(false);
|
if (e.key === "Escape") handleClose();
|
||||||
};
|
};
|
||||||
document.addEventListener("keydown", handleKey);
|
document.addEventListener("keydown", handleKey);
|
||||||
return () => document.removeEventListener("keydown", handleKey);
|
return () => document.removeEventListener("keydown", handleKey);
|
||||||
}, [open]);
|
}, [open, handleClose]);
|
||||||
|
|
||||||
const sendMessage = useCallback(
|
const sendMessage = useCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
|
|
@ -168,6 +178,10 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps)
|
||||||
|
|
||||||
abortRef.current?.abort();
|
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 userMsg: Message = { role: "user", text: q, sources: [], done: true };
|
||||||
const assistantMsg: Message = { role: "assistant", text: "", sources: [], done: false };
|
const assistantMsg: Message = { role: "assistant", text: "", sources: [], done: false };
|
||||||
const newMessages = [...messages, userMsg, assistantMsg];
|
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) =>
|
setMessages((prev) =>
|
||||||
prev.map((m, i) => (i === assistantIndex ? { ...m, done: true } : m)),
|
prev.map((m, i) => (i === assistantIndex ? { ...m, done: true } : m)),
|
||||||
);
|
);
|
||||||
|
|
@ -208,9 +223,10 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
creatorName,
|
creatorName,
|
||||||
|
cid,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[messages, streaming, creatorName],
|
[messages, streaming, creatorName, conversationId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
|
@ -249,7 +265,7 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps)
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className={styles.closeBtn}
|
className={styles.closeBtn}
|
||||||
onClick={() => setOpen(false)}
|
onClick={handleClose}
|
||||||
aria-label="Close chat"
|
aria-label="Close chat"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
/* ChatPage — dark theme matching existing Chrysopedia styles */
|
/* ChatPage — dark theme, multi-message conversation layout */
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
|
|
@ -9,11 +9,39 @@
|
||||||
min-height: 60vh;
|
min-height: 60vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Header ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.headerRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text-primary);
|
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 ───────────────────────────────────────────── */
|
/* ── Input area ───────────────────────────────────────────── */
|
||||||
|
|
@ -21,7 +49,11 @@
|
||||||
.inputRow {
|
.inputRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-top: auto;
|
||||||
|
padding-top: 1rem;
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--color-bg-page);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
|
|
@ -68,17 +100,40 @@
|
||||||
cursor: not-allowed;
|
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);
|
background: var(--color-bg-surface);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 12px;
|
border-radius: 12px 12px 12px 2px;
|
||||||
padding: 1.5rem;
|
padding: 1rem;
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
min-height: 100px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Response text ────────────────────────────────────────── */
|
||||||
|
|
||||||
.responseText {
|
.responseText {
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
|
|
@ -133,13 +188,14 @@
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Citations / Sources ──────────────────────────────────── */
|
/* ── Citations / Sources ──────────────────────────────────── */
|
||||||
|
|
||||||
.sourcesSection {
|
.sourcesSection {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.75rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sourcesTitle {
|
.sourcesTitle {
|
||||||
|
|
@ -148,7 +204,7 @@
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
margin: 0 0 0.75rem;
|
margin: 0 0 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sourceList {
|
.sourceList {
|
||||||
|
|
@ -157,27 +213,27 @@
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sourceItem {
|
.sourceItem {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.625rem;
|
gap: 0.625rem;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
font-size: 0.875rem;
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sourceNumber {
|
.sourceNumber {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 1.5rem;
|
width: 1.25rem;
|
||||||
height: 1.5rem;
|
height: 1.25rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: var(--color-accent);
|
background: var(--color-accent);
|
||||||
color: #0f0f14;
|
color: #0f0f14;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.6875rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,7 +249,7 @@
|
||||||
|
|
||||||
.sourceMeta {
|
.sourceMeta {
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: 0.8125rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Citation superscript links in text ───────────────────── */
|
/* ── Citation superscript links in text ───────────────────── */
|
||||||
|
|
@ -222,6 +278,7 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
|
|
@ -241,6 +298,12 @@
|
||||||
padding: 1rem 0.75rem;
|
padding: 1rem 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.headerRow {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.inputRow {
|
.inputRow {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
@ -248,4 +311,8 @@
|
||||||
.submitBtn {
|
.submitBtn {
|
||||||
width: 100%;
|
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.
|
* Uses SSE streaming via api/chat.ts to display tokens in real-time.
|
||||||
* Citation markers [N] in the streamed text become superscript links to
|
* 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";
|
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
|
// Matches [1], [2,3], [1,2,3] etc. — same regex as utils/citations.tsx
|
||||||
const CITATION_RE = /\[(\d+(?:,\s*\d+)*)\]/g;
|
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.
|
* Parse text containing [N] markers into React nodes with citation links.
|
||||||
* Links scroll to the source list item and reference ChatSource metadata.
|
|
||||||
*/
|
*/
|
||||||
function parseChatCitations(
|
function parseChatCitations(
|
||||||
text: string,
|
text: string,
|
||||||
|
|
@ -80,16 +88,16 @@ function parseChatCitations(
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
useDocumentTitle("Chat — Chrysopedia");
|
useDocumentTitle("Chat — Chrysopedia");
|
||||||
|
|
||||||
const [query, setQuery] = useState("");
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [sources, setSources] = useState<ChatSource[]>([]);
|
const [input, setInput] = useState("");
|
||||||
const [responseText, setResponseText] = useState("");
|
|
||||||
const [streaming, setStreaming] = useState(false);
|
const [streaming, setStreaming] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [conversationId, setConversationId] = useState<string | undefined>(
|
||||||
const [error, setError] = useState<string | null>(null);
|
undefined,
|
||||||
const [done, setDone] = useState(false);
|
);
|
||||||
|
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -98,104 +106,177 @@ 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(
|
const handleSubmit = useCallback(
|
||||||
(e: React.FormEvent) => {
|
(e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const q = query.trim();
|
sendMessage(input);
|
||||||
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) => {
|
[input, sendMessage],
|
||||||
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;
|
const handleNewConversation = useCallback(() => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
setMessages([]);
|
||||||
|
setInput("");
|
||||||
|
setStreaming(false);
|
||||||
|
setConversationId(undefined);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasMessages = messages.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
|
<div className={styles.headerRow}>
|
||||||
<h1 className={styles.title}>Ask Chrysopedia</h1>
|
<h1 className={styles.title}>Ask Chrysopedia</h1>
|
||||||
|
{hasMessages && (
|
||||||
<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
|
<button
|
||||||
type="submit"
|
className={styles.newConversationBtn}
|
||||||
className={styles.submitBtn}
|
onClick={handleNewConversation}
|
||||||
disabled={!query.trim() || streaming}
|
title="Start a new conversation"
|
||||||
>
|
>
|
||||||
{streaming ? "Streaming…" : "Ask"}
|
+ New conversation
|
||||||
</button>
|
</button>
|
||||||
</form>
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <div className={styles.error}>{error}</div>}
|
{!hasMessages && !streaming && (
|
||||||
|
<div className={styles.placeholder}>
|
||||||
{loading && (
|
<span className={styles.placeholderIcon}>💬</span>
|
||||||
<div className={styles.loading}>
|
<p>Ask any question about music production techniques.</p>
|
||||||
<div className={styles.spinner} />
|
<p>Responses include citations linking to source pages and videos.</p>
|
||||||
Searching for sources…
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasResponse && (
|
{hasMessages && (
|
||||||
<div className={styles.responseArea}>
|
<div className={styles.messagesArea}>
|
||||||
<div className={styles.responseText}>
|
{messages.map((msg, i) => {
|
||||||
{parseChatCitations(responseText, sources)}
|
if (msg.role === "user") {
|
||||||
{streaming && <span className={styles.cursor} />}
|
return (
|
||||||
|
<div key={i} className={styles.userMsg}>
|
||||||
|
{msg.text}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{sources.length > 0 && (done || !streaming) && (
|
// 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}>
|
<div className={styles.sourcesSection}>
|
||||||
<h2 className={styles.sourcesTitle}>Sources</h2>
|
<h3 className={styles.sourcesTitle}>Sources</h3>
|
||||||
<ol className={styles.sourceList}>
|
<ol className={styles.sourceList}>
|
||||||
{sources.map((src) => {
|
{msg.sources.map((src) => {
|
||||||
const href = src.section_anchor
|
const href = src.section_anchor
|
||||||
? `/techniques/${src.slug}#${src.section_anchor}`
|
? `/techniques/${src.slug}#${src.section_anchor}`
|
||||||
: `/techniques/${src.slug}`;
|
: `/techniques/${src.slug}`;
|
||||||
return (
|
return (
|
||||||
<li key={src.number} className={styles.sourceItem}>
|
<li
|
||||||
<span className={styles.sourceNumber}>{src.number}</span>
|
key={src.number}
|
||||||
|
className={styles.sourceItem}
|
||||||
|
>
|
||||||
|
<span className={styles.sourceNumber}>
|
||||||
|
{src.number}
|
||||||
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<Link to={href} className={styles.sourceLink}>
|
<Link
|
||||||
|
to={href}
|
||||||
|
className={styles.sourceLink}
|
||||||
|
>
|
||||||
{src.title}
|
{src.title}
|
||||||
{src.section_heading ? ` — ${src.section_heading}` : ""}
|
{src.section_heading
|
||||||
|
? ` — ${src.section_heading}`
|
||||||
|
: ""}
|
||||||
</Link>
|
</Link>
|
||||||
{src.creator_name && (
|
{src.creator_name && (
|
||||||
<span className={styles.sourceMeta}>
|
<span className={styles.sourceMeta}>
|
||||||
{" "}· {src.creator_name}
|
{" "}
|
||||||
|
· {src.creator_name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -205,16 +286,47 @@ export default function ChatPage() {
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</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>
|
</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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue