feat: Built floating ChatWidget with streaming responses, citation link…
- "frontend/src/components/ChatWidget.tsx" - "frontend/src/components/ChatWidget.module.css" - "frontend/src/pages/CreatorDetail.tsx" GSD-Task: S03/T01
This commit is contained in:
parent
19a6ff660c
commit
0098254fdd
4 changed files with 751 additions and 1 deletions
388
frontend/src/components/ChatWidget.module.css
Normal file
388
frontend/src/components/ChatWidget.module.css
Normal file
|
|
@ -0,0 +1,388 @@
|
||||||
|
/* ChatWidget — floating chat bubble + expandable panel */
|
||||||
|
|
||||||
|
/* ── Bubble ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #0f0f14;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
box-shadow: 0 4px 16px var(--color-shadow-heavy);
|
||||||
|
transition: transform 0.15s, background 0.15s;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble:hover {
|
||||||
|
background: var(--color-accent-hover);
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Panel ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
width: 400px;
|
||||||
|
max-height: 60vh;
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 8px 32px var(--color-shadow-heavy);
|
||||||
|
z-index: 1001;
|
||||||
|
animation: slideUp 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(16px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerTitle {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerLabel {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background: var(--color-bg-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Messages area ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userMsg {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #0f0f14;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 12px 12px 4px 12px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
max-width: 85%;
|
||||||
|
word-break: break-word;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistantMsg {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: var(--color-bg-page);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
border-radius: 12px 12px 12px 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
max-width: 90%;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cursor ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Typing indicator ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
.typing {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typingDot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-text-secondary);
|
||||||
|
animation: bounce 1.2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typingDot:nth-child(2) {
|
||||||
|
animation-delay: 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typingDot:nth-child(3) {
|
||||||
|
animation-delay: 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounce {
|
||||||
|
0%, 60%, 100% { transform: translateY(0); }
|
||||||
|
30% { transform: translateY(-4px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sources ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.sources {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourcesLabel {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin: 0 0 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceList {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceItem {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.375rem;
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceNum {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #0f0f14;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceLink {
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sourceLink:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Citation superscripts ────────────────────────────────── */
|
||||||
|
|
||||||
|
.citationGroup {
|
||||||
|
font-size: 0.7em;
|
||||||
|
vertical-align: super;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.citationLink {
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.citationLink:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Error ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.errorMsg {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #f87171;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Suggestions ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.suggestions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionsLabel {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionBtn {
|
||||||
|
background: var(--color-bg-page);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestionBtn:hover {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
background: var(--color-bg-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Input row ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.inputRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
background: var(--color-bg-input);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sendBtn {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #0f0f14;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sendBtn:hover:not(:disabled) {
|
||||||
|
background: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sendBtn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive (mobile) ──────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.bubble {
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 75vh;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
359
frontend/src/components/ChatWidget.tsx
Normal file
359
frontend/src/components/ChatWidget.tsx
Normal file
|
|
@ -0,0 +1,359 @@
|
||||||
|
/**
|
||||||
|
* ChatWidget — floating chat bubble + expandable conversation panel.
|
||||||
|
*
|
||||||
|
* Renders a fixed-position bubble (bottom-right). Clicking opens a slide-up
|
||||||
|
* panel with message history, streaming responses, citation rendering,
|
||||||
|
* suggested questions, and a typing indicator. Scoped to a specific creator.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useRef, useCallback, useEffect, useMemo } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { streamChat, type ChatSource } from "../api/chat";
|
||||||
|
import styles from "./ChatWidget.module.css";
|
||||||
|
|
||||||
|
// Same citation regex as ChatPage / utils/citations.tsx
|
||||||
|
const CITATION_RE = /\[(\d+(?:,\s*\d+)*)\]/g;
|
||||||
|
|
||||||
|
interface Technique {
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
topic_category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatWidgetProps {
|
||||||
|
creatorName: string;
|
||||||
|
techniques: Technique[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
text: string;
|
||||||
|
sources: ChatSource[];
|
||||||
|
done: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate 3 suggested questions from the creator's techniques. */
|
||||||
|
function buildSuggestions(creatorName: string, techniques: Technique[]): string[] {
|
||||||
|
if (techniques.length === 0) {
|
||||||
|
return [`What techniques does ${creatorName} use?`];
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions: string[] = [];
|
||||||
|
|
||||||
|
// First technique by name
|
||||||
|
if (techniques[0]) {
|
||||||
|
suggestions.push(`How does ${creatorName} approach ${techniques[0].title.toLowerCase()}?`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category-based question if there are multiple categories
|
||||||
|
const categories = [...new Set(techniques.map((t) => t.topic_category).filter(Boolean))];
|
||||||
|
if (categories.length > 1 && categories[0]) {
|
||||||
|
suggestions.push(`What ${categories[0].toLowerCase()} techniques does ${creatorName} teach?`);
|
||||||
|
} else if (techniques[1]) {
|
||||||
|
suggestions.push(`Tell me about ${techniques[1].title.toLowerCase()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// General question
|
||||||
|
suggestions.push(`What makes ${creatorName}'s production style unique?`);
|
||||||
|
|
||||||
|
return suggestions.slice(0, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse citation markers [N] into React nodes with superscript links. */
|
||||||
|
function parseCitations(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];
|
||||||
|
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 ChatWidget({ creatorName, techniques }: ChatWidgetProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [streaming, setStreaming] = useState(false);
|
||||||
|
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const suggestions = useMemo(
|
||||||
|
() => buildSuggestions(creatorName, techniques),
|
||||||
|
[creatorName, techniques],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Scroll to bottom when messages update
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// Focus input when panel opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
// Small delay to let animation settle
|
||||||
|
const t = setTimeout(() => inputRef.current?.focus(), 100);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Escape key closes panel
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKey);
|
||||||
|
return () => document.removeEventListener("keydown", handleKey);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const sendMessage = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
const q = text.trim();
|
||||||
|
if (!q || streaming) return;
|
||||||
|
|
||||||
|
abortRef.current?.abort();
|
||||||
|
|
||||||
|
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: () => {
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
creatorName,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[messages, streaming, creatorName],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
sendMessage(input);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSuggestion = (q: string) => {
|
||||||
|
sendMessage(q);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={styles.bubble}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
aria-label={`Chat with ${creatorName}`}
|
||||||
|
title={`Ask about ${creatorName}'s techniques`}
|
||||||
|
>
|
||||||
|
💬
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const showSuggestions = messages.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Keep bubble underneath for z-index stacking, hidden visually */}
|
||||||
|
<div className={styles.panel}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div>
|
||||||
|
<span className={styles.headerLabel}>Chat with</span>
|
||||||
|
<h3 className={styles.headerTitle}>{creatorName}</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={styles.closeBtn}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
aria-label="Close chat"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div className={styles.messages}>
|
||||||
|
{showSuggestions && (
|
||||||
|
<div className={styles.suggestions}>
|
||||||
|
<p className={styles.suggestionsLabel}>Suggested questions</p>
|
||||||
|
{suggestions.map((q) => (
|
||||||
|
<button
|
||||||
|
key={q}
|
||||||
|
className={styles.suggestionBtn}
|
||||||
|
onClick={() => handleSuggestion(q)}
|
||||||
|
>
|
||||||
|
{q}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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.errorMsg}>{msg.error}</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{parseCitations(msg.text, msg.sources)}
|
||||||
|
{!msg.done && <span className={styles.cursor} />}
|
||||||
|
{msg.done && msg.sources.length > 0 && (
|
||||||
|
<div className={styles.sources}>
|
||||||
|
<p className={styles.sourcesLabel}>Sources</p>
|
||||||
|
<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.sourceNum}>{src.number}</span>
|
||||||
|
<Link to={href} className={styles.sourceLink}>
|
||||||
|
{src.title}
|
||||||
|
{src.section_heading ? ` — ${src.section_heading}` : ""}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Typing indicator during loading before first token */}
|
||||||
|
{streaming &&
|
||||||
|
messages.length > 0 &&
|
||||||
|
messages[messages.length - 1]?.role === "assistant" &&
|
||||||
|
messages[messages.length - 1]?.text === "" &&
|
||||||
|
!messages[messages.length - 1]?.error && (
|
||||||
|
<div className={styles.typing}>
|
||||||
|
<span className={styles.typingDot} />
|
||||||
|
<span className={styles.typingDot} />
|
||||||
|
<span className={styles.typingDot} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<form className={styles.inputRow} onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
className={styles.input}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder="Ask a question…"
|
||||||
|
disabled={streaming}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={styles.sendBtn}
|
||||||
|
disabled={!input.trim() || streaming}
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import CreatorAvatar from "../components/CreatorAvatar";
|
import CreatorAvatar from "../components/CreatorAvatar";
|
||||||
import { SocialIcon } from "../components/SocialIcons";
|
import { SocialIcon } from "../components/SocialIcons";
|
||||||
|
import ChatWidget from "../components/ChatWidget";
|
||||||
import SortDropdown from "../components/SortDropdown";
|
import SortDropdown from "../components/SortDropdown";
|
||||||
import TagList from "../components/TagList";
|
import TagList from "../components/TagList";
|
||||||
import { catSlug } from "../utils/catSlug";
|
import { catSlug } from "../utils/catSlug";
|
||||||
|
|
@ -478,6 +479,8 @@ export default function CreatorDetail() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<ChatWidget creatorName={creator.name} techniques={creator.techniques} />
|
||||||
</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/chat.ts","./src/api/client.ts","./src/api/consent.ts","./src/api/creator-dashboard.ts","./src/api/creators.ts","./src/api/follows.ts","./src/api/highlights.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/AudioWaveform.tsx","./src/components/CategoryIcons.tsx","./src/components/ChapterMarkers.tsx","./src/components/ConfirmModal.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/AdminAuditLog.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/AdminUsers.tsx","./src/pages/ChapterReview.tsx","./src/pages/ChatPage.tsx","./src/pages/ConsentDashboard.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorTiers.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/HighlightQueue.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/follows.ts","./src/api/highlights.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/AudioWaveform.tsx","./src/components/CategoryIcons.tsx","./src/components/ChapterMarkers.tsx","./src/components/ChatWidget.tsx","./src/components/ConfirmModal.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/AdminAuditLog.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/AdminUsers.tsx","./src/pages/ChapterReview.tsx","./src/pages/ChatPage.tsx","./src/pages/ConsentDashboard.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorTiers.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/HighlightQueue.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