| 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; |
| |
| |
| |
| |
| |
| |
| onRename?: (oldSrc: string, newSrc: string) => void; |
| |
| |
| |
| |
| |
| |
| 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(); |
| 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); |
|
|
| |
| 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; |
|
|
| |
| useEffect(() => { |
| if (!embedStore || !src) return; |
| setHtml(embedStore.get(src)); |
| return embedStore.observeKey(src, setHtml); |
| }, [embedStore, src]); |
|
|
| |
| const srcdoc = useMemo(() => { |
| if (!html) return ""; |
| return buildDoc(html, { isDark, primaryColor }); |
| }, [html, isDark, primaryColor]); |
|
|
| |
| 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); |
| } |
| }, []); |
|
|
| |
| useEffect(() => { |
| messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); |
| }, [chat.messages]); |
|
|
| |
| |
| |
| 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 isBanner = src === "banner.html"; |
| 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 ${viewMode !== "preview" || selectedFile ? "es-hidden" : ""}`} |
| > |
| {html ? ( |
| <> |
| <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 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> |
| ); |
| } |
|
|