| 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; |
| |
| src: string; |
| modelRef: React.RefObject<string>; |
| userId: string; |
| |
| |
| |
| |
| |
| |
| isDark?: boolean; |
| |
| |
| |
| |
| |
| |
| onRename?: (oldSrc: string, newSrc: string) => void; |
| } |
|
|
| |
| |
| |
| |
| 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"; |
| } |
|
|
| |
| |
| |
| |
| 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; |
| |
| |
| |
| |
| |
| |
| 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(""); |
| |
| |
| |
| |
| |
| const srcRef = useRef(src); |
| |
| |
| |
| |
| if (src !== srcRef.current) { |
| srcRef.current = src; |
| } |
| const userIdRef = useRef(userId); |
| userIdRef.current = userId; |
| const onRenameRef = useRef(onRename); |
| onRenameRef.current = onRename; |
|
|
| |
| |
| |
| const userHasSentRef = useRef(false); |
|
|
| |
| |
| 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"; |
| |
| |
| |
| |
| 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.`; |
| } |
|
|
| |
| |
| |
| 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) { |
| |
| embedStore.set(targetSrc, stripped); |
| embedStore.remove(currentSrc); |
| |
| |
| 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 { |
| |
| } |
| srcRef.current = targetSrc; |
| |
| |
| 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, |
| |
| |
| 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); |
| }, |
| }); |
|
|
| |
| 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); |
| } |
| |
| |
| }, []); |
|
|
| |
| 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, []); |
| } |
| }, []); |
|
|
| |
| 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]); |
|
|
| |
| 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, |
| |
| |
| |
| |
| |
| |
| setMessages: chatSetMessages, |
| }; |
| } |
|
|