Files
upstream-excalidraw/excalidraw-app/components/AI.tsx
2026-09-16 05:18:53 +05:00

175 lines
5.0 KiB
TypeScript

import {
DiagramToCodePlugin,
exportToBlob,
getNonDeletedElements,
getTextFromElements,
MIME_TYPES,
parseSSEStream,
TTDDialog,
TTDStreamFetch,
} from "@excalidraw/excalidraw";
import { getDataURL } from "@excalidraw/excalidraw/data/blob";
import { safelyParseJSON } from "@excalidraw/common";
import type { StreamChunk } from "@excalidraw/excalidraw";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import { TTDIndexedDBAdapter } from "../data/TTDStorage";
export const AIComponents = ({
excalidrawAPI,
}: {
excalidrawAPI: ExcalidrawImperativeAPI;
}) => {
return (
<>
<DiagramToCodePlugin
generate={async ({ frame, children, onPartial }) => {
const appState = excalidrawAPI.getAppState();
// SAFETY: This should never happen, but log it just in case
if (children.some((el) => el.isDeleted)) {
console.error(
"[NONDELETED][INVARIANT] Generated children elements should not be `isDeleted: true`",
);
}
const blob = await exportToBlob({
elements: getNonDeletedElements(children),
appState: {
...appState,
exportBackground: true,
viewBackgroundColor: appState.viewBackgroundColor,
},
exportingFrame: frame,
files: excalidrawAPI.getFiles(),
mimeType: MIME_TYPES.jpg,
});
const dataURL = await getDataURL(blob);
const textFromFrameChildren = getTextFromElements(children);
const response = await fetch(
`${
import.meta.env.VITE_APP_AI_BACKEND
}/v1/ai/diagram-to-code/generate-streaming`,
{
method: "POST",
headers: {
Accept: "text/event-stream",
"Content-Type": "application/json",
},
body: JSON.stringify({
texts: textFromFrameChildren,
image: dataURL,
theme: appState.theme,
}),
},
);
if (!response.ok) {
const text = await response.text();
const errorJSON = safelyParseJSON(text);
if (!errorJSON) {
throw new Error(text);
}
if (errorJSON.statusCode === 429) {
return {
html: `<html>
<body style="margin: 0; text-align: center">
<div style="display: flex; align-items: center; justify-content: center; flex-direction: column; height: 100vh; padding: 0 60px">
<div style="color:red">Too many requests today,</br>please try again tomorrow!</div>
</br>
</br>
<div>You can also try <a href="${
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noopener">Excalidraw+</a> to get more requests.</div>
</div>
</body>
</html>`,
};
}
throw new Error(errorJSON.message || text);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("Generation failed (invalid response)");
}
let html = "";
let streamError: Error | null = null;
for await (const data of parseSSEStream(reader)) {
if (data === "[DONE]") {
break;
}
const chunk = safelyParseJSON(data) as StreamChunk | null;
if (!chunk) {
continue;
}
switch (chunk.type) {
case "content": {
if (chunk.delta) {
html += chunk.delta;
onPartial?.(html);
}
break;
}
case "error": {
streamError = new Error(
chunk.error.message || "Generation failed",
);
break;
}
case "done": {
break;
}
}
}
if (streamError) {
throw streamError;
}
if (!html.trim()) {
throw new Error("Generation failed (invalid response)");
}
return {
html,
};
}}
/>
<TTDDialog
onTextSubmit={async (props) => {
const { onChunk, onStreamCreated, signal, messages } = props;
const result = await TTDStreamFetch({
url: `${
import.meta.env.VITE_APP_AI_BACKEND
}/v1/ai/text-to-diagram/chat-streaming`,
messages,
onChunk,
onStreamCreated,
extractRateLimits: true,
signal,
});
return result;
}}
persistenceAdapter={TTDIndexedDBAdapter}
/>
</>
);
};