Spaces:
Sleeping
Sleeping
| import { useState, useEffect, useRef, useMemo, useCallback, type KeyboardEvent } from "react"; | |
| import ReactMarkdown from "react-markdown"; | |
| import remarkGfm from "remark-gfm"; | |
| import { X, Send, Square, Code, Eye, Plus, Loader } from "lucide-react"; | |
| import type { UIMessage } from "ai"; | |
| import type { EmbedStore } from "../editor/embeds/embed-store"; | |
| import type { EmbedDataStore } from "../editor/embeds/embed-data-store"; | |
| import { buildDoc } from "../editor/embeds/build-doc"; | |
| import { useEmbedChat } from "../hooks/useEmbedChat"; | |
| import { useEmbedData } from "../hooks/useEmbedData"; | |
| import { useTheme } from "../hooks/useTheme"; | |
| import { isToolPart, normalizeToolPart } from "../utils/ai-tool-parts"; | |
| import { FilesSidebar } from "./FilesSidebar"; | |
| import { DataFileViewer } from "./DataFileViewer"; | |
| interface EmbedStudioProps { | |
| src: string; | |
| embedStore: EmbedStore | null; | |
| dataStore?: EmbedDataStore | null; | |
| modelRef: React.RefObject<string>; | |
| userId: string; | |
| onClose: () => void; | |
| /** | |
| * Invoked when the agent chooses a descriptive filename for a brand | |
| * new chart. Parent should update the htmlEmbed node in the doc and | |
| * lift the new src into its own state so subsequent actions (close, | |
| * reopen, etc.) reference the renamed file. | |
| */ | |
| onRename?: (oldSrc: string, newSrc: string) => void; | |
| /** | |
| * Invoked when the user picks a different chart from the sidebar. | |
| * Parent is responsible for updating the studio's `src` and (if it | |
| * wants a fresh chat scope) rotating the React key on this | |
| * component so persisted messages reload for the new file. | |
| */ | |
| onSelectChart?: (name: string) => void; | |
| } | |
| const EMBED_TOOL_LABELS: Record<string, [string, string]> = { | |
| createEmbed: ["Creating chart...", "Created chart"], | |
| patchEmbed: ["Updating chart...", "Updated chart"], | |
| readEmbed: ["Reading chart...", "Read chart"], | |
| listDataFiles: ["Listing data files...", "Listed data files"], | |
| readDataFile: ["Reading data file...", "Read data file"], | |
| }; | |
| function embedToolLabel(name: string, state: string): string { | |
| const pair = EMBED_TOOL_LABELS[name]; | |
| if (!pair) return name; | |
| return state === "result" ? pair[1] : pair[0]; | |
| } | |
| function toolSubtitle( | |
| name: string, | |
| input: unknown, | |
| ): string | null { | |
| if (!input || typeof input !== "object") return null; | |
| const args = input as Record<string, unknown>; | |
| switch (name) { | |
| case "createEmbed": | |
| return typeof args.title === "string" ? String(args.title) : null; | |
| case "readDataFile": | |
| return typeof args.name === "string" ? String(args.name) : null; | |
| case "patchEmbed": { | |
| const search = typeof args.search === "string" ? args.search : ""; | |
| const firstLine = search.split("\n")[0]?.trim() ?? ""; | |
| return firstLine ? firstLine.slice(0, 60) : null; | |
| } | |
| default: | |
| return null; | |
| } | |
| } | |
| function MessageBubble({ message }: { message: UIMessage }) { | |
| const isUser = message.role === "user"; | |
| return ( | |
| <div className={`es-message ${isUser ? "es-message--user" : "es-message--ai"}`}> | |
| {message.parts.map((part, i) => { | |
| if (part.type === "text") { | |
| return ( | |
| <div key={i} className="es-message__text"> | |
| {isUser ? part.text : ( | |
| <ReactMarkdown remarkPlugins={[remarkGfm]}>{part.text}</ReactMarkdown> | |
| )} | |
| </div> | |
| ); | |
| } | |
| if (isToolPart(part)) { | |
| const tool = normalizeToolPart(part); | |
| if (!tool) return null; | |
| const subtitle = toolSubtitle(tool.toolName, tool.input); | |
| const isDone = tool.state === "result"; | |
| return ( | |
| <div | |
| key={i} | |
| className={`es-message__tool ${isDone ? "es-message__tool--done" : "es-message__tool--running"}`} | |
| > | |
| {!isDone && <Loader size={11} className="spin" />} | |
| <span className="es-message__tool-name"> | |
| {embedToolLabel(tool.toolName, tool.state)} | |
| </span> | |
| {subtitle && ( | |
| <span className="es-message__tool-subtitle">{subtitle}</span> | |
| )} | |
| </div> | |
| ); | |
| } | |
| return null; | |
| })} | |
| </div> | |
| ); | |
| } | |
| function EmbedAgentStatus({ messages }: { messages: UIMessage[] }) { | |
| const last = messages[messages.length - 1]; | |
| if (!last) return null; | |
| if (last.role === "assistant") { | |
| const parts = last.parts ?? []; | |
| const runningTools = parts | |
| .map((p) => normalizeToolPart(p)) | |
| .filter((t): t is NonNullable<typeof t> => t !== null && t.state !== "result") | |
| .map((t) => t.toolName); | |
| if (runningTools.length > 0) { | |
| const pair = EMBED_TOOL_LABELS[runningTools[runningTools.length - 1]]; | |
| const text = pair ? pair[0] : runningTools[runningTools.length - 1]; | |
| return ( | |
| <div className="chat-panel__thinking"> | |
| <Loader size={13} className="spin" /> | |
| <span className="shimmer-text">{text}</span> | |
| </div> | |
| ); | |
| } | |
| const hasText = parts.some((p: any) => p.type === "text" && p.text?.trim()); | |
| if (hasText) return null; | |
| } | |
| return ( | |
| <div className="chat-panel__thinking"> | |
| <Loader size={13} className="spin" /> | |
| <span className="shimmer-text">Thinking...</span> | |
| </div> | |
| ); | |
| } | |
| export function EmbedStudio({ | |
| src, | |
| embedStore, | |
| dataStore = null, | |
| modelRef, | |
| userId, | |
| onClose, | |
| onRename, | |
| onSelectChart, | |
| }: EmbedStudioProps) { | |
| const { isDark, primaryColor } = useTheme(); | |
| // The banner is a special embed: it renders edge-to-edge in a 5:2 box (the | |
| // hero) rather than as a height-reporting chart. The studio must preview it | |
| // the same way (fullBleed srcdoc + constrained 5:2 stage) so it's WYSIWYG. | |
| const isBanner = src === "banner.html"; | |
| const [html, setHtml] = useState(""); | |
| const [viewMode, setViewMode] = useState<"preview" | "code">("preview"); | |
| const [selectedDataFile, setSelectedDataFile] = useState<string | null>(null); | |
| const messagesEndRef = useRef<HTMLDivElement>(null); | |
| const textareaRef = useRef<HTMLTextAreaElement>(null); | |
| // Iframe double-buffer | |
| const frameA = useRef<HTMLIFrameElement>(null); | |
| const frameB = useRef<HTMLIFrameElement>(null); | |
| const [activeFrame, setActiveFrame] = useState<"a" | "b">("a"); | |
| const activeRef = useRef<"a" | "b">("a"); | |
| activeRef.current = activeFrame; | |
| const data = useEmbedData({ dataStore, userId }); | |
| const chat = useEmbedChat({ embedStore, dataStore, src, modelRef, userId, isDark, onRename }); | |
| const selectedFile = selectedDataFile ? data.getFile(selectedDataFile) : undefined; | |
| // Sync from embed store | |
| useEffect(() => { | |
| if (!embedStore || !src) return; | |
| setHtml(embedStore.get(src)); | |
| return embedStore.observeKey(src, setHtml); | |
| }, [embedStore, src]); | |
| // Build srcdoc. Banner previews use fullBleed so the content fills the | |
| // iframe edge-to-edge, matching FrontmatterHero and the published output. | |
| const srcdoc = useMemo(() => { | |
| if (!html) return ""; | |
| return buildDoc(html, { isDark, primaryColor, fullBleed: isBanner }); | |
| }, [html, isDark, primaryColor, isBanner]); | |
| // Load srcdoc into inactive frame | |
| useEffect(() => { | |
| if (!srcdoc) return; | |
| const next = activeRef.current === "a" ? "b" : "a"; | |
| const frame = next === "a" ? frameA.current : frameB.current; | |
| if (frame) frame.srcdoc = srcdoc; | |
| }, [srcdoc]); | |
| const handleFrameLoad = useCallback((slot: "a" | "b") => { | |
| if (slot !== activeRef.current) { | |
| setActiveFrame(slot); | |
| } | |
| }, []); | |
| // Auto-scroll messages | |
| useEffect(() => { | |
| messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); | |
| }, [chat.messages]); | |
| // Dev-only hook: let demo scripts inject a fake assistant reply into | |
| // the embed chat so the recording shows the agent "responding" | |
| // without waiting for a real LLM round-trip. | |
| useEffect(() => { | |
| if (!import.meta.env.DEV) return; | |
| const handler = (event: Event) => { | |
| const detail = (event as CustomEvent).detail as | |
| | { messages?: UIMessage[] } | |
| | undefined; | |
| if (Array.isArray(detail?.messages)) { | |
| chat.setMessages(detail.messages); | |
| } | |
| }; | |
| window.addEventListener("__demo-embed-chat", handler); | |
| return () => window.removeEventListener("__demo-embed-chat", handler); | |
| }, [chat]); | |
| const handleSend = useCallback(() => { | |
| const text = chat.input.trim(); | |
| if (!text || chat.isLoading) return; | |
| chat.sendMessage(text); | |
| chat.setInput(""); | |
| }, [chat]); | |
| const handleKeyDown = useCallback( | |
| (e: KeyboardEvent<HTMLTextAreaElement>) => { | |
| if (e.key === "Enter" && !e.shiftKey) { | |
| e.preventDefault(); | |
| handleSend(); | |
| } | |
| }, | |
| [handleSend], | |
| ); | |
| const handleTextareaInput = useCallback(() => { | |
| const ta = textareaRef.current; | |
| if (ta) { | |
| ta.style.height = "auto"; | |
| ta.style.height = Math.min(ta.scrollHeight, 160) + "px"; | |
| } | |
| }, []); | |
| const frameStyle = (slot: "a" | "b"): React.CSSProperties => ({ | |
| position: "absolute", | |
| inset: 0, | |
| border: "none", | |
| width: "100%", | |
| height: "100%", | |
| transition: "opacity 120ms ease", | |
| opacity: activeFrame === slot ? 1 : 0, | |
| zIndex: activeFrame === slot ? 2 : 1, | |
| }); | |
| return ( | |
| <div className="embed-studio"> | |
| {/* Header */} | |
| <div className="es-header"> | |
| <div className="es-header__left"> | |
| <span className="es-header__icon">📊</span> | |
| <span className="es-header__title">Embed Studio</span> | |
| <code className="es-header__src">{src}</code> | |
| </div> | |
| <div className="es-header__actions"> | |
| <button | |
| className="embed-btn" | |
| onClick={() => chat.clearMessages()} | |
| aria-label="New conversation" | |
| title="New conversation" | |
| > | |
| <Plus size={16} /> | |
| </button> | |
| <button | |
| className="embed-btn" | |
| onClick={onClose} | |
| aria-label="Close Embed Studio" | |
| > | |
| <X size={16} /> | |
| </button> | |
| </div> | |
| </div> | |
| {/* Body: split panel */} | |
| <div className="es-body"> | |
| {/* Far-left: Files */} | |
| <FilesSidebar | |
| embedStore={embedStore} | |
| currentSrc={src} | |
| dataFiles={data.files} | |
| selectedDataFile={selectedDataFile} | |
| onSelectChart={onSelectChart} | |
| onSelectDataFile={setSelectedDataFile} | |
| onUploadFiles={data.uploadFiles} | |
| onRemoveDataFile={data.removeFile} | |
| /> | |
| {/* Left: Chat */} | |
| <div className="es-chat"> | |
| <div className="es-chat__messages"> | |
| {chat.messages.length === 0 && (() => { | |
| const hasChart = html.trim().length > 0; | |
| let title: string; | |
| let hint: string; | |
| if (isBanner) { | |
| title = hasChart | |
| ? "Ask for changes to the banner." | |
| : "Describe the banner you want."; | |
| hint = hasChart | |
| ? 'Example: "make it slower and more abstract"' | |
| : 'Example: "abstract flow field in the primary color, very subtle motion"'; | |
| } else if (hasChart) { | |
| title = "Ask for changes to the chart."; | |
| hint = | |
| 'Example: "switch to a sequential palette" or "highlight the biggest bar"'; | |
| } else { | |
| title = "Describe the chart you want to create."; | |
| hint = | |
| 'Example: "Create a bar chart showing model sizes with categorical colors"'; | |
| } | |
| return ( | |
| <div className="es-chat__empty"> | |
| <p>{title}</p> | |
| <p className="es-chat__hint">{hint}</p> | |
| </div> | |
| ); | |
| })()} | |
| {chat.messages.map((msg) => ( | |
| <MessageBubble key={msg.id} message={msg} /> | |
| ))} | |
| {chat.isLoading && chat.messages.length > 0 && ( | |
| <EmbedAgentStatus messages={chat.messages} /> | |
| )} | |
| <div ref={messagesEndRef} /> | |
| </div> | |
| {/* Input */} | |
| <div className="es-chat__input-area"> | |
| {chat.error && ( | |
| <div className="es-chat__error"> | |
| {chat.error.message || "An error occurred"} | |
| </div> | |
| )} | |
| <div className="es-chat__input-row"> | |
| <textarea | |
| ref={textareaRef} | |
| value={chat.input} | |
| onChange={(e) => chat.setInput(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| onInput={handleTextareaInput} | |
| placeholder={ | |
| html.trim().length > 0 | |
| ? src === "banner.html" | |
| ? "Tweak the banner..." | |
| : "Tweak the chart..." | |
| : src === "banner.html" | |
| ? "Describe your banner..." | |
| : "Describe your chart..." | |
| } | |
| rows={1} | |
| className="es-chat__textarea" | |
| /> | |
| {chat.isLoading ? ( | |
| <button | |
| className="es-chat__send-btn" | |
| onClick={chat.stop} | |
| aria-label="Stop" | |
| > | |
| <Square size={16} /> | |
| </button> | |
| ) : ( | |
| <button | |
| className="es-chat__send-btn" | |
| onClick={handleSend} | |
| disabled={!chat.input.trim()} | |
| aria-label="Send" | |
| > | |
| <Send size={16} /> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| {/* Right: Preview (chart) or data file viewer */} | |
| <div className="es-preview"> | |
| {selectedFile ? ( | |
| <DataFileViewer | |
| file={selectedFile} | |
| onClose={() => setSelectedDataFile(null)} | |
| /> | |
| ) : null} | |
| <div | |
| className={`es-preview__toolbar ${selectedFile ? "es-hidden" : ""}`} | |
| > | |
| <button | |
| className={`es-preview__tab ${viewMode === "preview" ? "es-preview__tab--active" : ""}`} | |
| onClick={() => setViewMode("preview")} | |
| > | |
| <Eye size={14} /> | |
| Preview | |
| </button> | |
| <button | |
| className={`es-preview__tab ${viewMode === "code" ? "es-preview__tab--active" : ""}`} | |
| onClick={() => setViewMode("code")} | |
| > | |
| <Code size={14} /> | |
| Code | |
| </button> | |
| <div style={{ flex: 1 }} /> | |
| <button className="embed-btn embed-btn-primary" onClick={onClose}> | |
| Save & Close | |
| </button> | |
| </div> | |
| {/* Both panels always mounted - toggle with hidden class to preserve iframe state */} | |
| <div | |
| className={`es-preview__frame-container ${isBanner ? "es-preview__frame-container--banner" : ""} ${viewMode !== "preview" || selectedFile ? "es-hidden" : ""}`} | |
| > | |
| {html ? ( | |
| <div | |
| className={`es-preview__stage ${isBanner ? "es-preview__stage--banner" : ""}`} | |
| > | |
| <iframe | |
| ref={frameA} | |
| title="Chart preview A" | |
| sandbox="allow-scripts allow-same-origin" | |
| onLoad={() => handleFrameLoad("a")} | |
| style={frameStyle("a")} | |
| /> | |
| <iframe | |
| ref={frameB} | |
| title="Chart preview B" | |
| sandbox="allow-scripts allow-same-origin" | |
| onLoad={() => handleFrameLoad("b")} | |
| style={frameStyle("b")} | |
| /> | |
| </div> | |
| ) : ( | |
| <div className="es-preview__empty"> | |
| <span style={{ fontSize: 32, opacity: 0.4 }}>📊</span> | |
| <p>Chart preview will appear here</p> | |
| </div> | |
| )} | |
| </div> | |
| <div | |
| className={`es-preview__code ${viewMode !== "code" || selectedFile ? "es-hidden" : ""}`} | |
| > | |
| <pre className="es-preview__pre"> | |
| <code>{html || "// No chart HTML yet"}</code> | |
| </pre> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |