carbon-tokenization / frontend /src /hooks /useEmbedChat.ts
tfrere's picture
tfrere HF Staff
feat(embed-studio): make agent-generated charts theme-aware
27abbe5
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,
};
}