Spaces:
Sleeping
Sleeping
| import { useChat } from "@ai-sdk/react"; | |
| import { | |
| DefaultChatTransport, | |
| lastAssistantMessageIsCompleteWithToolCalls, | |
| } from "ai"; | |
| import { useCallback, useEffect, useRef, useState } from "react"; | |
| import type { UIMessage } from "ai"; | |
| import type { EmbedStore } from "../editor/embeds/embed-store"; | |
| import type { EmbedDataStore } from "../editor/embeds/embed-data-store"; | |
| import { loadMessages, saveMessages } from "../utils/chat-persistence"; | |
| interface UseEmbedChatOptions { | |
| embedStore: EmbedStore | null; | |
| dataStore?: EmbedDataStore | null; | |
| /** Current embed src key (filename) */ | |
| src: string; | |
| modelRef: React.RefObject<string>; | |
| userId: string; | |
| /** | |
| * Current article theme (light/dark). Forwarded to the agent so it | |
| * can sanity-check its colour choices and bake theme-aware CSS into | |
| * generated charts (instead of hardcoding axis/text colours that | |
| * only happen to look right under one theme). | |
| */ | |
| isDark?: boolean; | |
| /** | |
| * Called when the agent picks a descriptive filename via createEmbed. | |
| * Parent is expected to update the htmlEmbed node's src attribute in | |
| * the doc and lift the new src into its own state. The hook already | |
| * moves the content in embedStore and migrates persisted messages. | |
| */ | |
| onRename?: (oldSrc: string, newSrc: string) => void; | |
| } | |
| /** | |
| * Slugify an arbitrary filename hint into a safe kebab-case slug. | |
| * Keeps ASCII letters/digits/dashes, collapses whitespace, lowercases. | |
| */ | |
| function slugify(raw: string): string { | |
| const base = raw | |
| .toLowerCase() | |
| .replace(/\.html?$/i, "") | |
| .normalize("NFKD") | |
| .replace(/[\u0300-\u036f]/g, "") | |
| .replace(/[^a-z0-9]+/g, "-") | |
| .replace(/^-+|-+$/g, "") | |
| .slice(0, 60); | |
| return base || "chart"; | |
| } | |
| /** | |
| * Given a slug, find a unique .html filename in `embedStore`. If the | |
| * desired name is free, return it; otherwise append `-2`, `-3`, ... | |
| */ | |
| function ensureUniqueFilename( | |
| slug: string, | |
| embedStore: EmbedStore, | |
| currentSrc: string, | |
| ): string { | |
| const base = `${slug}.html`; | |
| if (base === currentSrc) return base; | |
| if (!embedStore.has(base)) return base; | |
| let i = 2; | |
| while (embedStore.has(`${slug}-${i}.html`)) i++; | |
| return `${slug}-${i}.html`; | |
| } | |
| const MAX_EMBED_SIZE = 512_000; | |
| /** | |
| * Hard cap on the number of characters we inline when the agent reads | |
| * a data file. Above this, the tool returns a truncated preview with | |
| * a warning so the model doesn't blow past its context window on a | |
| * single tool call. | |
| */ | |
| const MAX_DATA_INLINE = 120_000; | |
| const transport = new DefaultChatTransport({ api: "/api/embed-chat" }); | |
| function scopeKey(src: string): string { | |
| return `embed:${src}`; | |
| } | |
| export function useEmbedChat({ embedStore, dataStore, src, modelRef, userId, isDark, onRename }: UseEmbedChatOptions) { | |
| const [input, setInput] = useState(""); | |
| // srcRef is the source of truth for the active embed filename. It | |
| // starts from the `src` prop but may change mid-session when the | |
| // agent renames the chart via createEmbed({ filename }). We do NOT | |
| // overwrite it from the prop on every render to avoid clobbering a | |
| // freshly-chosen name before the parent has caught up. | |
| const srcRef = useRef(src); | |
| // Sync srcRef whenever the incoming `src` prop changes from outside. | |
| // After an internal rename, we already set srcRef to the new name and | |
| // asked the parent to lift it; when the parent's state arrives here | |
| // the values match and this is a no-op. | |
| if (src !== srcRef.current) { | |
| srcRef.current = src; | |
| } | |
| const userIdRef = useRef(userId); | |
| userIdRef.current = userId; | |
| const onRenameRef = useRef(onRename); | |
| onRenameRef.current = onRename; | |
| // Guard: only allow sendAutomaticallyWhen AFTER the user has sent at | |
| // least one message in this session. Prevents auto-send on mount when | |
| // restored messages end with completed tool calls. | |
| const userHasSentRef = useRef(false); | |
| // Track the current theme via ref so getEmbedContext always reads the | |
| // latest value without forcing a callback identity churn on toggle. | |
| const isDarkRef = useRef(!!isDark); | |
| isDarkRef.current = !!isDark; | |
| const getEmbedContext = useCallback(() => { | |
| if (!embedStore || !srcRef.current) return {}; | |
| const currentSrc = srcRef.current; | |
| const embedHtml = embedStore.get(currentSrc); | |
| const isBanner = currentSrc === "banner.html"; | |
| // A "scratch" file is a newly-created placeholder whose name is a | |
| // hash-based auto-generated slug (see SlashMenu). When the chart is | |
| // still empty under such a name, we explicitly ask the agent to | |
| // pick a descriptive filename on the first createEmbed call. | |
| const isScratch = | |
| !isBanner && !embedHtml && /^d3-chart-[a-z0-9]+\.html$/i.test(currentSrc); | |
| const dataFiles = dataStore | |
| ? dataStore.list().map((m) => ({ | |
| name: m.name, | |
| ext: m.ext, | |
| size: m.size, | |
| rowCount: m.rowCount, | |
| columns: m.columns, | |
| })) | |
| : []; | |
| return { | |
| embedHtml: embedHtml || undefined, | |
| isBanner, | |
| isScratch, | |
| dataFiles, | |
| theme: isDarkRef.current ? "dark" : "light", | |
| }; | |
| }, [embedStore, dataStore]); | |
| const executeToolCall = useCallback( | |
| (toolCall: { toolName: string; args: unknown; toolCallId: string }) => { | |
| if (!embedStore) return "Embed store not available"; | |
| const currentSrc = srcRef.current; | |
| if (!currentSrc) return "No embed src specified"; | |
| switch (toolCall.toolName) { | |
| case "createEmbed": { | |
| const { html, title, filename } = toolCall.args as { | |
| html: string; | |
| title?: string; | |
| source?: string; | |
| filename?: string; | |
| }; | |
| if (!html?.trim()) return "ERROR: html cannot be empty"; | |
| const stripped = html.trim(); | |
| if (stripped.length > MAX_EMBED_SIZE) { | |
| return `ERROR: chart too large (${stripped.length} bytes, max ${MAX_EMBED_SIZE}). Simplify the code.`; | |
| } | |
| // Resolve the target src: if the agent proposed a filename | |
| // slug, slugify + ensure uniqueness. Never rename the banner | |
| // (its name is load-bearing for the article layout). | |
| let targetSrc = currentSrc; | |
| const isBanner = currentSrc === "banner.html"; | |
| if (filename && !isBanner) { | |
| const slug = slugify(filename); | |
| const candidate = ensureUniqueFilename(slug, embedStore, currentSrc); | |
| if (candidate !== currentSrc) { | |
| targetSrc = candidate; | |
| } | |
| } | |
| if (targetSrc !== currentSrc) { | |
| // Write html under the new key and remove the old one atomically. | |
| embedStore.set(targetSrc, stripped); | |
| embedStore.remove(currentSrc); | |
| // Migrate persisted chat messages so the localStorage scope | |
| // tracks the new file name. | |
| try { | |
| const uid = userIdRef.current; | |
| const existing = loadMessages(uid, scopeKey(currentSrc)); | |
| if (existing && existing.length > 0) { | |
| saveMessages(uid, scopeKey(targetSrc), existing); | |
| } | |
| saveMessages(uid, scopeKey(currentSrc), []); | |
| } catch { | |
| // Non-fatal: persistence is best-effort. | |
| } | |
| srcRef.current = targetSrc; | |
| // Let the parent update the htmlEmbed node's src attribute | |
| // in the ProseMirror doc and lift the new src into state. | |
| onRenameRef.current?.(currentSrc, targetSrc); | |
| } else { | |
| embedStore.set(currentSrc, stripped); | |
| } | |
| const lines = stripped.split("\n").length; | |
| const renamed = targetSrc !== currentSrc ? ` [renamed to ${targetSrc}]` : ""; | |
| return `chart created (${lines} lines, ${stripped.length} chars)${title ? ` - "${title}"` : ""}${renamed}`; | |
| } | |
| case "patchEmbed": { | |
| const { search, replace } = toolCall.args as { | |
| search: string; | |
| replace: string; | |
| }; | |
| const current = embedStore.get(currentSrc); | |
| if (!current) { | |
| return "ERROR: no chart exists yet. Use createEmbed first."; | |
| } | |
| if (!search) { | |
| return "ERROR: search block cannot be empty"; | |
| } | |
| if (!current.includes(search)) { | |
| return ( | |
| "ERROR: search block not found in current chart. " + | |
| "Call readEmbed to see the exact current content, then retry." | |
| ); | |
| } | |
| const patched = current.replace(search, replace); | |
| if (patched.length > MAX_EMBED_SIZE) { | |
| return `ERROR: patch would exceed size limit (${patched.length} bytes, max ${MAX_EMBED_SIZE}).`; | |
| } | |
| embedStore.set(currentSrc, patched); | |
| return "patch applied successfully"; | |
| } | |
| case "readEmbed": { | |
| const current = embedStore.get(currentSrc); | |
| if (!current) return "(no chart yet)"; | |
| return current; | |
| } | |
| case "listDataFiles": { | |
| if (!dataStore) return "(no data files - data store unavailable)"; | |
| const files = dataStore.list(); | |
| if (files.length === 0) return "(no data files attached)"; | |
| return files | |
| .map((f) => { | |
| const cols = f.columns && f.columns.length > 0 | |
| ? ` columns=[${f.columns.join(", ")}]` | |
| : ""; | |
| const rows = f.rowCount !== undefined ? ` rows=${f.rowCount}` : ""; | |
| return `- ${f.name} (${f.ext}, ${f.size} bytes)${rows}${cols}`; | |
| }) | |
| .join("\n"); | |
| } | |
| case "readDataFile": { | |
| if (!dataStore) return "ERROR: data store unavailable"; | |
| const { name } = toolCall.args as { name: string }; | |
| if (!name) return "ERROR: missing file name"; | |
| const file = dataStore.get(name); | |
| if (!file) return `ERROR: data file "${name}" not found`; | |
| const content = file.content; | |
| if (content.length <= MAX_DATA_INLINE) return content; | |
| return ( | |
| content.slice(0, MAX_DATA_INLINE) + | |
| `\n\n[TRUNCATED - full file is ${content.length} chars, showing first ${MAX_DATA_INLINE}]` | |
| ); | |
| } | |
| default: | |
| return `Unknown tool: ${toolCall.toolName}`; | |
| } | |
| }, | |
| [embedStore, dataStore], | |
| ); | |
| const { addToolOutput, setMessages: chatSetMessages, ...chat } = useChat({ | |
| transport, | |
| // No initialMessages - we restore via setMessages below to match | |
| // the proven pattern used by the global chat. | |
| sendAutomaticallyWhen(ctx) { | |
| if (!userHasSentRef.current) return false; | |
| return lastAssistantMessageIsCompleteWithToolCalls(ctx); | |
| }, | |
| async onToolCall({ toolCall }) { | |
| if (toolCall.dynamic) return; | |
| const result = executeToolCall({ | |
| toolName: toolCall.toolName as string, | |
| args: toolCall.input, | |
| toolCallId: toolCall.toolCallId, | |
| }); | |
| (addToolOutput as (args: { tool: string; toolCallId: string; output: unknown }) => void)({ | |
| tool: toolCall.toolName as string, | |
| toolCallId: toolCall.toolCallId, | |
| output: result, | |
| }); | |
| }, | |
| onError(error) { | |
| console.error("[embed-chat] error:", error); | |
| }, | |
| }); | |
| // --- Restore persisted messages on mount (same pattern as global chat) --- | |
| const chatSetMessagesRef = useRef(chatSetMessages); | |
| chatSetMessagesRef.current = chatSetMessages; | |
| useEffect(() => { | |
| const key = scopeKey(src); | |
| const stored = loadMessages(userId, key); | |
| if (stored && stored.length > 0) { | |
| chatSetMessagesRef.current(stored); | |
| } | |
| // Only run once on mount (src/userId are stable per keyed instance) | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, []); | |
| // --- Persistence: save to localStorage --------------------------------- | |
| const messagesRef = useRef(chat.messages); | |
| messagesRef.current = chat.messages; | |
| const persist = useCallback(() => { | |
| const msgs = messagesRef.current as UIMessage[]; | |
| const key = scopeKey(srcRef.current); | |
| const uid = userIdRef.current; | |
| if (msgs.length > 0) { | |
| saveMessages(uid, key, msgs); | |
| } else { | |
| saveMessages(uid, key, []); | |
| } | |
| }, []); | |
| // Save after each completed round (streaming/submitted -> ready) | |
| const prevStatusRef = useRef(chat.status); | |
| useEffect(() => { | |
| const prev = prevStatusRef.current; | |
| const curr = chat.status; | |
| prevStatusRef.current = curr; | |
| const wasActive = prev === "streaming" || prev === "submitted"; | |
| if (wasActive && curr === "ready" && chat.messages.length > 0) { | |
| persist(); | |
| } | |
| }, [chat.status, chat.messages, persist]); | |
| // Save on unmount (covers close-while-streaming) | |
| useEffect(() => () => persist(), [persist]); | |
| const sendMessage = useCallback( | |
| (content: string) => { | |
| userHasSentRef.current = true; | |
| const context = getEmbedContext(); | |
| const model = modelRef.current; | |
| chat.sendMessage({ text: content }, { body: { context, model } }); | |
| }, | |
| [chat, getEmbedContext, modelRef], | |
| ); | |
| const isLoading = | |
| chat.status === "streaming" || chat.status === "submitted"; | |
| const clearMessages = useCallback(() => { | |
| chatSetMessages([]); | |
| saveMessages(userIdRef.current, scopeKey(srcRef.current), []); | |
| }, [chatSetMessages]); | |
| return { | |
| messages: chat.messages as UIMessage[], | |
| isLoading, | |
| error: chat.error, | |
| sendMessage, | |
| clearMessages, | |
| input, | |
| setInput, | |
| stop: chat.stop, | |
| /** | |
| * Imperative setter on the underlying chat. Exposed so dev-only demo | |
| * scripts can inject a fake assistant reply (used to record the demo | |
| * video without waiting for a real LLM round-trip). DO NOT use in | |
| * production code paths. | |
| */ | |
| setMessages: chatSetMessages, | |
| }; | |
| } | |