diff --git a/apps/builder/app/builder/builder.tsx b/apps/builder/app/builder/builder.tsx
index 080121adc610..5248544ce35d 100644
--- a/apps/builder/app/builder/builder.tsx
+++ b/apps/builder/app/builder/builder.tsx
@@ -41,7 +41,6 @@ import { BlockingAlerts } from "./features/blocking-alerts";
import { useSyncPageUrl } from "~/shared/pages";
import { useMount, useUnmount } from "~/shared/hook-utils/use-mount";
import { subscribeCommands } from "~/builder/shared/commands";
-import { AiCommandBar } from "./features/ai/ai-command-bar";
import { ProjectSettings } from "./features/project-settings";
import type { UserPlanFeatures } from "~/shared/db/user-plan-features.server";
import {
@@ -437,7 +436,6 @@ export const Builder = ({
}}
/>
- {isDesignMode && }
diff --git a/apps/builder/app/builder/features/ai/ai-button.tsx b/apps/builder/app/builder/features/ai/ai-button.tsx
deleted file mode 100644
index 1f48911d489b..000000000000
--- a/apps/builder/app/builder/features/ai/ai-button.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-/**
- * AI Button has own style override https://www.figma.com/file/xCBegXEWxROLqA1Y31z2Xo/%F0%9F%93%96-Webstudio-Design-Docs?node-id=7579%3A43452&mode=dev
- * what make it different from Button component styling
- */
-
-import { CommandBarButton, styled, theme } from "@webstudio-is/design-system";
-import { forwardRef, type ComponentProps } from "react";
-
-type CommandButtonProps = ComponentProps;
-
-// @todo: Move to design system, if no additional changes are needed
-const AiCommandBarButtonStyled = styled(CommandBarButton, {
- "&[data-state=disabled]": {
- "&[data-state=disabled]": {
- background: "#1D1D1D",
- color: "#646464",
- outline: "1px solid #646464",
- },
- "&[data-pending=true]": {
- background: "#1D1D1D",
- color: theme.colors.foregroundContrastMain,
- outline: "none",
- },
- },
-});
-
-export const AiCommandBarButton = forwardRef<
- HTMLButtonElement,
- CommandButtonProps
->((props, ref) => {
- return ;
-});
-
-AiCommandBarButton.displayName = "AiCommandBarButton";
diff --git a/apps/builder/app/builder/features/ai/ai-command-bar.tsx b/apps/builder/app/builder/features/ai/ai-command-bar.tsx
deleted file mode 100644
index 49375b711e8b..000000000000
--- a/apps/builder/app/builder/features/ai/ai-command-bar.tsx
+++ /dev/null
@@ -1,497 +0,0 @@
-import {
- AutogrowTextArea,
- Box,
- Button,
- CommandBar,
- CommandBarButton,
- CommandBarContentPrompt,
- CommandBarContentSection,
- CommandBarContentSeparator,
- CommandBarTrigger,
- Grid,
- ScrollArea,
- Text,
- Tooltip,
- theme,
- toast,
- useDisableCanvasPointerEvents,
-} from "@webstudio-is/design-system";
-import {
- AiIcon,
- MicIcon,
- ChevronUpIcon,
- ExternalLinkIcon,
- StopIcon,
- LargeXIcon,
- AiLoadingIcon,
-} from "@webstudio-is/icons";
-import { useRef, useState, type ComponentPropsWithoutRef } from "react";
-import {
- $collaborativeInstanceSelector,
- $selectedInstanceSelector,
-} from "~/shared/nano-states";
-import { useMediaRecorder } from "./hooks/media-recorder";
-import { useLongPressToggle } from "./hooks/long-press-toggle";
-import { AiCommandBarButton } from "./ai-button";
-import { fetchTranscription } from "./ai-fetch-transcription";
-import { fetchResult } from "./ai-fetch-result";
-import { useEffectEvent } from "~/shared/hook-utils/effect-event";
-import { AiApiException, RateLimitException } from "./api-exceptions";
-import { getSetting, setSetting } from "~/builder/shared/client-settings";
-import { flushSync } from "react-dom";
-import { $selectedPage } from "~/shared/awareness";
-import { RelativeTime } from "~/builder/shared/relative-time";
-
-type PartialButtonProps> = {
- [key in keyof T]?: T[key];
-};
-
-const useSelectText = () => {
- const ref = useRef(null);
- const selectText = () => {
- ref.current?.focus();
- ref.current?.select();
- };
- return [ref, selectText] as const;
-};
-
-const initialPrompts = [
- "Create a hero section with a heading, subheading in white, CTA button and an image of a mountain in the background, add light blur to the image",
- "Create a two column feature section with a heading and subheading in the left column, and an image that covers the right column",
- "Create a testimonials section on 2 rows. The first row has a heading and subheading, the second row has 3 testimonial cards with an image, headline, description and link.",
-];
-
-export const AiCommandBar = () => {
- const [value, setValue] = useState("");
- const [prompts, setPrompts] = useState(initialPrompts);
- const isMenuOpen = getSetting("isAiMenuOpen");
- const setIsMenuOpen = (value: boolean) => {
- setSetting("isAiMenuOpen", value);
- };
-
- const [isAudioTranscribing, setIsAudioTranscribing] = useState(false);
- const [isAiRequesting, setIsAiRequesting] = useState(false);
- const abortController = useRef(undefined);
- const recordButtonRef = useRef(null);
- const guardIdRef = useRef(0);
- const { enableCanvasPointerEvents, disableCanvasPointerEvents } =
- useDisableCanvasPointerEvents();
-
- const getValue = useEffectEvent(() => {
- return value;
- });
- const [textAreaRef, selectPrompt] = useSelectText();
-
- const {
- start,
- stop,
- cancel,
- state: mediaRecorderState,
- } = useMediaRecorder({
- onError: (error) => {
- if (error instanceof DOMException && error.name === "NotAllowedError") {
- toast.info("Please enable your microphone.");
- return;
- }
- if (error instanceof Error) {
- toast.error(error.message);
- return;
- }
- toast.error(`Unknown Error: ${error}`);
- },
- onComplete: async (file) => {
- try {
- setIsAudioTranscribing(true);
- guardIdRef.current++;
- const guardId = guardIdRef.current;
- const text = await fetchTranscription(file);
- if (guardId !== guardIdRef.current) {
- return;
- }
-
- const currentValue = getValue();
- const newValue = [currentValue, text].filter(Boolean).join(" ");
-
- setValue(newValue);
- handleAiRequest(newValue);
- } catch (error) {
- if (error instanceof RateLimitException) {
- toast.info(
- <>
- Temporary AI rate limit reached. Please wait{" "}
- and try again.
- >
- );
- return;
- }
-
- // Above are known errors; we're not interested in logging them.
- console.error(error);
-
- if (error instanceof AiApiException) {
- toast.error(`API Internal Error: ${error.message}`);
- return;
- }
-
- if (error instanceof Error) {
- // Unknown error, show toast
- toast.error(`Unknown Error: ${error.message}`);
- }
- } finally {
- setIsAudioTranscribing(false);
- }
- },
- onReportSoundAmplitude: (amplitude) => {
- recordButtonRef.current?.style.setProperty(
- "--ws-ai-command-bar-amplitude",
- amplitude.toString()
- );
- },
- });
-
- const longPressToggleProps = useLongPressToggle({
- onStart: () => {
- start();
- disableCanvasPointerEvents();
- },
- onEnd: () => {
- stop();
- enableCanvasPointerEvents();
- },
- onCancel: () => {
- cancel();
- enableCanvasPointerEvents();
- },
- });
-
- const handleAiRequest = async (prompt: string) => {
- if (abortController.current) {
- if (abortController.current.signal.aborted === false) {
- console.warn(`For some reason previous operation is not aborted.`);
- }
-
- abortController.current.abort();
- }
-
- const localAbortController = new AbortController();
- abortController.current = localAbortController;
-
- setIsAiRequesting(true);
-
- // Skip Abort Logic for now
- try {
- const page = $selectedPage.get();
- const rootInstanceSelector = page?.rootInstanceId
- ? [page.rootInstanceId]
- : [];
- const instanceSelector =
- $selectedInstanceSelector.get() ?? rootInstanceSelector;
-
- const [instanceId] = instanceSelector;
-
- if (instanceId === undefined) {
- // Must not happen, we always have root instance
- throw new Error("No element selected");
- }
-
- $collaborativeInstanceSelector.set(instanceSelector);
- await fetchResult(prompt, instanceId, abortController.current.signal);
-
- if (localAbortController !== abortController.current) {
- // skip
- return;
- }
-
- setPrompts((previousPrompts) => [prompt, ...previousPrompts]);
-
- setValue("");
- } catch (error) {
- if (
- (error instanceof Error && error.message.startsWith("AbortError:")) ||
- (error instanceof DOMException && error.name === "AbortError")
- ) {
- // Aborted by user request
- return;
- }
-
- if (error instanceof RateLimitException) {
- toast.info(
- <>
- Temporary AI rate limit reached. Please wait{" "}
- and try again.
- >
- );
- return;
- }
-
- // Above is known errors, we are not interesting in
- console.error(error);
-
- if (error instanceof AiApiException) {
- toast.error(`API Internal Error: ${error.message}`);
- return;
- }
-
- if (error instanceof Error) {
- // Unknown error, show toast
- toast.error(`Unknown Error: ${error.message}`);
- }
- } finally {
- abortController.current = undefined;
- $collaborativeInstanceSelector.set(undefined);
- setIsAiRequesting(false);
- }
- };
-
- const handleAiButtonClick = () => {
- if (value.trim().length === 0) {
- return;
- }
- handleAiRequest(value);
- };
-
- const handlePropmptClick = (prompt: string) => {
- if (textAreaRef.current?.disabled) {
- return;
- }
- // We can't select text right away because value will be set using setState.
- flushSync(() => {
- setValue(prompt);
- });
- selectPrompt();
- };
-
- if (getSetting("isAiCommandBarVisible") === false) {
- return;
- }
-
- let textAreaPlaceholder = "Welcome to Webstudio AI alpha!";
- let textAreaValue = value;
- let textAreaDisabled = false;
-
- let recordButtonTooltipContent = undefined;
- let recordButtonColor: ComponentPropsWithoutRef["color"] =
- "dark-ghost";
- let recordButtonProps: PartialButtonProps = longPressToggleProps;
- let recordButtonIcon = ;
- let recordButtonDisabled = false;
-
- let aiButtonDisabled = false;
- let aiButtonTooltip: string | undefined =
- value.length === 0 ? undefined : "Generate AI results";
- let aiButtonPending = false;
- let aiIcon = ;
-
- if (isAudioTranscribing) {
- textAreaPlaceholder = "Transcribing voice...";
- // Show placeholder instead
- textAreaValue = "";
- textAreaDisabled = true;
-
- recordButtonDisabled = true;
-
- aiButtonTooltip = undefined;
- aiButtonDisabled = true;
- }
-
- if (mediaRecorderState === "recording") {
- textAreaPlaceholder = "Recording voice...";
- // Show placeholder instead
- textAreaValue = "";
- textAreaDisabled = true;
- aiButtonDisabled = true;
-
- recordButtonColor = "destructive";
- recordButtonIcon = ;
-
- aiButtonTooltip = undefined;
- }
-
- if (isAiRequesting) {
- textAreaDisabled = true;
-
- recordButtonTooltipContent = "Cancel";
- recordButtonProps = {
- onClick: () => {
- // Cancel AI request
- abortController.current?.abort();
- },
- };
- recordButtonIcon = ;
-
- aiButtonTooltip = "Generating ...";
- aiButtonDisabled = true;
- aiButtonPending = true;
- aiIcon = ;
- }
-
- return (
-
-
- }
- >
-
-
-
-
-
-
-
-
- {
- if (event.key === "Enter" && event.shiftKey === false) {
- event.preventDefault();
- handleAiRequest(value);
- }
- }}
- />
-
-
-
-
-
- {recordButtonIcon}
-
-
-
-
-
- {aiIcon}
-
-
-
-
- );
-};
-
-const CommandBarContent = (props: {
- prompts: string[];
- onPromptClick: (value: string) => void;
-}) => {
- // @todo enable when we will have shortcut
- // const shortcutText = "⌘⇧Q";
-
- return (
- <>
-
-
- Welcome to Webstudio AI alpha!
-
-
-
-
-
-
-
- {props.prompts.length > 0 && (
- <>
-
-
-
- {/* negative then positive margin is used to preserve focus outline on command prompts */}
-
-
- {props.prompts.map((prompt, index) => (
-
- props.onPromptClick(prompt)}
- >
- {prompt}
-
-
- ))}
-
-
-
- >
- )}
- >
- );
-};
diff --git a/apps/builder/app/builder/features/ai/ai-fetch-result.ts b/apps/builder/app/builder/features/ai/ai-fetch-result.ts
deleted file mode 100644
index faa8772efaef..000000000000
--- a/apps/builder/app/builder/features/ai/ai-fetch-result.ts
+++ /dev/null
@@ -1,335 +0,0 @@
-import { z } from "zod";
-import {
- copywriter,
- operations,
- handleAiRequest,
- commandDetect,
- WsEmbedTemplate,
-} from "@webstudio-is/ai";
-import { createRegularStyleSheet } from "@webstudio-is/css-engine";
-import {
- generateJsxElement,
- generateJsxChildren,
- idAttribute,
- componentAttribute,
-} from "@webstudio-is/react-sdk";
-import {
- Instance,
- createScope,
- findTreeInstanceIds,
- getIndexesWithinAncestors,
-} from "@webstudio-is/sdk";
-import { computed } from "nanostores";
-import {
- $dataSources,
- $instances,
- $project,
- $props,
- $registeredComponentMetas,
- $styleSourceSelections,
- $styles,
-} from "~/shared/nano-states";
-import { applyOperations, patchTextInstance } from "./apply-operations";
-import { restAi } from "~/shared/router-utils";
-import untruncateJson from "untruncate-json";
-import { RequestParams } from "~/routes/rest.ai._index";
-import {
- AiApiException,
- RateLimitException,
- textToRateLimitMeta,
-} from "./api-exceptions";
-import { isFeatureEnabled } from "@webstudio-is/feature-flags";
-import { fetch } from "~/shared/fetch.client";
-import { $selectedInstance } from "~/shared/awareness";
-
-const unknownArray = z.array(z.unknown());
-
-const onResponseReceived = async (response: Response) => {
- if (response.ok === false) {
- const text = await response.text();
-
- if (response.status === 429) {
- const meta = textToRateLimitMeta(text);
- throw new RateLimitException(text, meta);
- }
-
- throw new Error(
- `Fetch error status="${response.status}" text="${text.slice(0, 1000)}"`
- );
- }
-};
-
-export const fetchResult = async (
- prompt: string,
- instanceId: Instance["id"],
- abortSignal: AbortSignal
-): Promise => {
- const commandsResponse = await handleAiRequest(
- fetch(restAi("detect"), {
- method: "POST",
- body: JSON.stringify({ prompt }),
- signal: abortSignal,
- }),
- {
- onResponseReceived,
- }
- );
-
- if (commandsResponse.type === "stream") {
- throw new Error(
- "Commands detection is not using streaming. Something went wrong."
- );
- }
-
- if (commandsResponse.success === false) {
- // Server error response
- throw new Error(commandsResponse.data.message);
- }
-
- const project = $project.get();
-
- const availableComponentsNames = $availableComponentsNames.get();
- const [styles, jsx] = $jsx.get() || ["", ""];
-
- const requestParams = {
- jsx: `${styles}${jsx}`,
- components: availableComponentsNames,
- projectId: project?.id,
- instanceId,
- prompt,
- };
-
- // @todo Future commands might not require all the requestParams above.
- // When that will be the case, we should revisit the validatin below.
- if (requestParams.instanceId === undefined) {
- throw new Error("Please select an instance on the canvas.");
- }
-
- // @todo can be covered by ts
- if (
- RequestParams.omit({ command: true }).safeParse(requestParams).success ===
- false
- ) {
- throw new Error("Invalid prompt data");
- }
-
- const appliedOperations = new Set();
-
- const promises = await Promise.allSettled(
- commandsResponse.data.map((command) =>
- handleAiRequest(
- fetch(restAi(), {
- method: "POST",
- body: JSON.stringify({
- ...requestParams,
- command,
- jsx:
- // Delete instances don't need CSS.
- command === operations.deleteInstanceName
- ? jsx
- : requestParams.jsx,
- // @todo This helps the operations chain disambiguating operation detection.
- // Ideally though the operations chain can be executed just for one
- // specific kind of operation i.e. `command`.
- prompt: `${command}:\n\n${requestParams.prompt}`,
- }),
- signal: abortSignal,
- }),
- {
- onResponseReceived,
- onChunk: (operationId, { completion }) => {
- if (operationId === "copywriter") {
- try {
- const unparsedDataArray = unknownArray.parse(
- JSON.parse(untruncateJson(completion))
- );
-
- const parsedDataArray = unparsedDataArray
- .map((item) => {
- const safeResult = copywriter.TextInstance.safeParse(item);
- if (safeResult.success) {
- return safeResult.data;
- }
- })
- .filter(
- (value: T): value is NonNullable =>
- value !== undefined
- );
-
- const operationsToApply = parsedDataArray.filter(
- (item) =>
- appliedOperations.has(JSON.stringify(item)) === false
- );
-
- for (const operation of operationsToApply) {
- patchTextInstance(operation);
- appliedOperations.add(JSON.stringify(operation));
- }
- } catch (error) {
- console.error(error);
- }
- }
- },
- }
- )
- )
- );
-
- for (const promise of promises) {
- if (promise.status === "fulfilled") {
- const result = promise.value;
-
- if (result.success === false) {
- throw new AiApiException(result.data.message);
- }
-
- if (result.type !== "json") {
- continue;
- }
-
- if (result.id === "operations") {
- restoreComponentsNamespace(result.data);
- applyOperations(result.data);
- continue;
- }
- } else if (promise.status === "rejected") {
- if (promise.reason instanceof Error) {
- throw promise.reason;
- }
-
- throw new Error(promise.reason.message);
- }
- }
-};
-
-const $availableComponentsNames = computed(
- [$registeredComponentMetas],
- (metas) => {
- const exclude = [
- "Body",
- "Slot",
- // @todo Remove Radix exclusion when the model has been fine-tuned to understand them.
- isFeatureEnabled("aiRadixComponents")
- ? "@webstudio-is/sdk-components-react-radix:"
- : undefined,
- ].filter(function (value: T): value is NonNullable {
- return value !== undefined;
- });
-
- return [...metas.keys()]
- .filter((name) => !exclude.some((excluded) => name.startsWith(excluded)))
- .map(parseComponentName);
- }
-);
-
-const traverseTemplate = (
- template: WsEmbedTemplate,
- fn: (node: WsEmbedTemplate[number]) => void
-) => {
- for (const node of template) {
- fn(node);
- if (node.type === "instance") {
- traverseTemplate(node.children, fn);
- }
- }
-};
-
-// The LLM gets a list of available component names
-// therefore we need to replace the component namespace with a LLM-friendly one
-// preserving context eg. Radix.Dialog instead of just Dialog
-const parseComponentName = (name: string) =>
- name.replace("@webstudio-is/sdk-components-react-radix:", "Radix.");
-
-// When AI generation is done we need to restore components namespaces.
-const restoreComponentsNamespace = (operations: operations.WsOperations) => {
- for (const operation of operations) {
- if (operation.operation === "insertTemplate") {
- traverseTemplate(operation.template, (node) => {
- if (node.type === "instance" && node.component.startsWith("Radix.")) {
- node.component =
- "@webstudio-is/sdk-components-react-radix:" +
- node.component.slice("Radix.".length);
- }
- });
- }
- }
-};
-
-const $jsx = computed(
- [
- $selectedInstance,
- $instances,
- $props,
- $dataSources,
- $registeredComponentMetas,
- $styles,
- $styleSourceSelections,
- ],
- (
- instance,
- instances,
- props,
- dataSources,
- metas,
- styles,
- styleSourceSelections
- ) => {
- if (instance === undefined) {
- return;
- }
-
- const indexesWithinAncestors = getIndexesWithinAncestors(metas, instances, [
- instance.id,
- ]);
- const scope = createScope();
-
- const jsx = generateJsxElement({
- scope,
- metas,
- instance,
- props,
- dataSources,
- usedDataSources: new Map(),
- indexesWithinAncestors,
- children: generateJsxChildren({
- scope,
- metas,
- children: instance.children,
- instances,
- props,
- dataSources,
- usedDataSources: new Map(),
- indexesWithinAncestors,
- excludePlaceholders: true,
- }),
- });
-
- const treeInstanceIds = findTreeInstanceIds(instances, instance.id);
-
- const sheet = createRegularStyleSheet({ name: "ssr" });
- for (const styleDecl of styles.values()) {
- sheet.addMediaRule(styleDecl.breakpointId);
- const rule = sheet.addMixinRule(styleDecl.styleSourceId);
- rule.setDeclaration({
- breakpoint: styleDecl.breakpointId,
- selector: styleDecl.state ?? "",
- property: styleDecl.property,
- value: styleDecl.value,
- });
- }
- for (const { instanceId, values } of styleSourceSelections.values()) {
- if (treeInstanceIds.has(instanceId)) {
- const rule = sheet.addNestingRule(`[${idAttribute}="${instanceId}"]`);
- rule.applyMixins(values);
- }
- }
-
- const css = sheet.cssText.replace(/\n/gm, " ");
- return [
- css.length > 0 ? `` : "",
- jsx
- .replace(new RegExp(`${componentAttribute}="[^"]+"`, "g"), "")
- .replace(/\n(data-)/g, " $1"),
- ];
- }
-);
diff --git a/apps/builder/app/builder/features/ai/ai-fetch-transcription.ts b/apps/builder/app/builder/features/ai/ai-fetch-transcription.ts
deleted file mode 100644
index 02d69e9525bb..000000000000
--- a/apps/builder/app/builder/features/ai/ai-fetch-transcription.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { restAi } from "~/shared/router-utils";
-import type { action } from "~/routes/rest.ai.audio.transcriptions";
-import {
- AiApiException,
- RateLimitException,
- textToRateLimitMeta,
-} from "./api-exceptions";
-import { fetch } from "~/shared/fetch.client";
-
-export const fetchTranscription = async (file: File) => {
- const formData = new FormData();
-
- formData.append("file", file);
-
- const response = await fetch(restAi("audio/transcriptions"), {
- method: "POST",
- body: formData,
- });
-
- if (response.ok === false) {
- const text = await response.text();
-
- if (response.status === 429) {
- const meta = textToRateLimitMeta(text);
- throw new RateLimitException(text, meta);
- }
-
- throw new Error(
- `Fetch error status="${response.status}" text="${text.slice(0, 1000)}"`
- );
- }
-
- // @todo add response parsing
- const result: Awaited> = await response.json();
-
- if (result.success) {
- return result.data.text;
- }
-
- throw new AiApiException(result.error.message);
-};
diff --git a/apps/builder/app/builder/features/ai/api-exceptions.ts b/apps/builder/app/builder/features/ai/api-exceptions.ts
deleted file mode 100644
index 5bd764cf75ae..000000000000
--- a/apps/builder/app/builder/features/ai/api-exceptions.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { z } from "zod";
-
-/**
- * To facilitate debugging, categorize errors into few types, one is and API-specific errors.
- */
-export class AiApiException extends Error {
- constructor(message: string) {
- super(message);
- }
-}
-
-const zRateLimit = z.object({
- error: z.object({
- message: z.string(),
- code: z.number(),
- meta: z.object({
- limit: z.number(),
- reset: z.number(),
- remaining: z.number(),
- ratelimitName: z.string(),
- }),
- }),
-});
-
-type RateLimitMeta = z.infer["error"]["meta"];
-
-export const textToRateLimitMeta = (text: string): RateLimitMeta => {
- try {
- const { error } = zRateLimit.parse(JSON.parse(text));
-
- return error.meta;
- } catch {
- // If a 429 status code is received and it's not from our API, default to a 1-minute wait time from the current moment.
- return {
- limit: 0,
- remaining: 0,
- reset: Date.now(),
- ratelimitName: "unparsed",
- };
- }
-};
-
-export class RateLimitException extends Error {
- meta: RateLimitMeta;
-
- constructor(message: string, meta: RateLimitMeta) {
- super(message);
- this.meta = meta;
- }
-}
diff --git a/apps/builder/app/builder/features/ai/apply-operations.ts b/apps/builder/app/builder/features/ai/apply-operations.ts
deleted file mode 100644
index 7bcf7b8f5707..000000000000
--- a/apps/builder/app/builder/features/ai/apply-operations.ts
+++ /dev/null
@@ -1,252 +0,0 @@
-import { nanoid } from "nanoid";
-import {
- getStyleDeclKey,
- Instance,
- isComponentDetachable,
- type StyleSource,
-} from "@webstudio-is/sdk";
-import type { copywriter, operations } from "@webstudio-is/ai";
-import { serverSyncStore } from "~/shared/sync";
-import { isBaseBreakpoint } from "~/shared/breakpoints";
-import {
- deleteInstanceMutable,
- insertWebstudioFragmentAt,
- updateWebstudioData,
- type Insertable,
-} from "~/shared/instance-utils";
-import {
- $breakpoints,
- $instances,
- $props,
- $registeredComponentMetas,
- $selectedInstanceSelector,
- $styleSourceSelections,
- $styleSources,
- $styles,
-} from "~/shared/nano-states";
-import type { InstanceSelector } from "~/shared/tree-utils";
-import { $selectedInstance, getInstancePath } from "~/shared/awareness";
-import { isRichTextTree } from "~/shared/content-model";
-import { generateDataFromEmbedTemplate } from "./embed-template";
-
-export const applyOperations = (operations: operations.WsOperations) => {
- for (const operation of operations) {
- switch (operation.operation) {
- case "insertTemplate":
- insertTemplateByOp(operation);
- break;
- case "deleteInstance":
- deleteInstanceByOp(operation);
- break;
- case "applyStyles":
- applyStylesByOp(operation);
- break;
- default:
- if (process.env.NODE_ENV === "development") {
- console.warn(`Not supported operation: ${operation}`);
- }
- }
- }
-};
-
-const insertTemplateByOp = (
- operation: operations.generateInsertTemplateWsOperation
-) => {
- const metas = $registeredComponentMetas.get();
- const fragment = generateDataFromEmbedTemplate(operation.template, metas);
-
- // @todo Find a way to avoid the workaround below, peharps improving the prompt.
- // Occasionally the LLM picks a component name or the entire data-ws-id attribute as the insertion point.
- // Instead of throwing the otherwise correct operation we try to fix this here.
- if (
- [...metas.keys()].some((componentName) =>
- componentName.includes(operation.addTo)
- )
- ) {
- const selectedInstance = $selectedInstance.get();
- if (selectedInstance) {
- operation.addTo = selectedInstance.id;
- }
- }
-
- const rootInstanceIds = fragment.children
- .filter((child) => child.type === "id")
- .map((child) => child.value);
-
- const instanceSelector = computeSelectorForInstanceId(operation.addTo);
- if (instanceSelector) {
- const insertable: Insertable = {
- parentSelector: instanceSelector,
- position: operation.addAtIndex + 1,
- };
- insertWebstudioFragmentAt(fragment, insertable);
- return rootInstanceIds;
- }
-};
-
-const deleteInstanceByOp = (
- operation: operations.deleteInstanceWsOperation
-) => {
- const instanceSelector = computeSelectorForInstanceId(operation.wsId);
- if (instanceSelector) {
- // @todo tell user they can't delete root
- if (instanceSelector.length === 1) {
- return;
- }
- updateWebstudioData((data) => {
- const [instanceId] = instanceSelector;
- const instance = data.instances.get(instanceId);
- if (instance && !isComponentDetachable(instance.component)) {
- return;
- }
- deleteInstanceMutable(
- data,
- getInstancePath(instanceSelector, data.instances)
- );
- });
- }
-};
-
-const applyStylesByOp = (operation: operations.editStylesWsOperation) => {
- serverSyncStore.createTransaction(
- [$styleSourceSelections, $styleSources, $styles, $breakpoints],
- (styleSourceSelections, styleSources, styles, breakpoints) => {
- const newStyles = [...operation.styles.values()];
-
- const breakpointValues = Array.from(breakpoints.values());
- const baseBreakpoint =
- breakpointValues.find(isBaseBreakpoint) ?? breakpointValues[0];
-
- for (const instanceId of operation.instanceIds) {
- const styleSourceSelection = styleSourceSelections.get(instanceId);
- let styleSource: StyleSource | undefined;
- let styleSourceId: string = "";
-
- if (styleSourceSelection) {
- for (const id of styleSourceSelection.values) {
- const candidateStyleSource = styleSources.get(id);
- if (candidateStyleSource && candidateStyleSource.type === "local") {
- styleSource = candidateStyleSource;
- styleSourceId = candidateStyleSource.id;
- break;
- }
- }
- }
-
- if (styleSourceId === "") {
- styleSourceId = nanoid();
- }
-
- if (styleSource === undefined) {
- styleSources.set(styleSourceId, { type: "local", id: styleSourceId });
- }
-
- if (styleSourceSelection === undefined) {
- styleSourceSelections.set(instanceId, {
- instanceId,
- values: [styleSourceId],
- });
- }
-
- for (const embedStyleDecl of newStyles) {
- const styleDecl = {
- ...embedStyleDecl,
- breakpointId: baseBreakpoint?.id,
- styleSourceId,
- };
- styles.set(getStyleDeclKey(styleDecl), styleDecl);
- }
- }
- }
- );
-};
-
-const computeSelectorForInstanceId = (instanceId: Instance["id"]) => {
- const selectedInstanceSelector = $selectedInstanceSelector.get();
- if (selectedInstanceSelector === undefined) {
- return;
- }
-
- // When the instance is the selected instance return selectedInstanceSelector right away.
- if (instanceId === selectedInstanceSelector[0]) {
- return selectedInstanceSelector;
- }
-
- // For a given instance to delete we compute the subtree selector between
- // that instance and the selected instance (a parent).
- let subtreeSelector: InstanceSelector = [];
- const parentInstancesById = new Map();
- for (const instance of $instances.get().values()) {
- for (const child of instance.children) {
- if (child.type === "id") {
- parentInstancesById.set(child.value, instance.id);
- }
- }
- }
- const selector: InstanceSelector = [];
- let currentInstanceId: undefined | Instance["id"] = instanceId;
- while (currentInstanceId) {
- selector.push(currentInstanceId);
- currentInstanceId = parentInstancesById.get(currentInstanceId);
- if (currentInstanceId === selectedInstanceSelector[0]) {
- subtreeSelector = [...selector, ...selectedInstanceSelector];
- break;
- }
- }
-
- if (subtreeSelector.length === 0) {
- return;
- }
-
- const parentSelector = selectedInstanceSelector.slice(1);
- // Combine the subtree selector with the selected instance one
- // to get the full and final selector.
- const combinedSelector = [...subtreeSelector, ...parentSelector];
- return combinedSelector;
-};
-
-export const patchTextInstance = (textInstance: copywriter.TextInstance) => {
- serverSyncStore.createTransaction(
- [$instances, $props],
- (instances, props) => {
- const currentInstance = instances.get(textInstance.instanceId);
-
- if (currentInstance === undefined) {
- return;
- }
-
- const canBeEdited = isRichTextTree({
- instanceId: textInstance.instanceId,
- instances,
- props,
- metas: $registeredComponentMetas.get(),
- });
- if (!canBeEdited) {
- return;
- }
-
- if (currentInstance.children.length === 0) {
- currentInstance.children = [{ type: "text", value: textInstance.text }];
- return;
- }
-
- // Instances can have a number of text child nodes without interleaving components.
- // When this is the case we treat the child nodes as a single text node,
- // otherwise the AI would generate children.length chunks of separate text.
- // We can identify this case of "joint" text instances when the index is -1.
- const replaceAll = textInstance.index === -1;
- if (replaceAll) {
- if (currentInstance.children.every((child) => child.type === "text")) {
- currentInstance.children = [
- { type: "text", value: textInstance.text },
- ];
- }
- return;
- }
-
- if (currentInstance.children[textInstance.index].type === "text") {
- currentInstance.children[textInstance.index].value = textInstance.text;
- }
- }
- );
-};
diff --git a/apps/builder/app/builder/features/ai/embed-template.test.tsx b/apps/builder/app/builder/features/ai/embed-template.test.tsx
deleted file mode 100644
index 01e70259ad44..000000000000
--- a/apps/builder/app/builder/features/ai/embed-template.test.tsx
+++ /dev/null
@@ -1,666 +0,0 @@
-import { expect, test } from "vitest";
-import { showAttribute } from "@webstudio-is/react-sdk";
-import { generateDataFromEmbedTemplate } from "./embed-template";
-
-const expectString = expect.any(String);
-
-test("generate data for embedding from instances and text", () => {
- expect(
- generateDataFromEmbedTemplate(
- [
- { type: "text", value: "hello" },
- {
- type: "instance",
- component: "Box1",
- children: [
- { type: "instance", component: "Box2", children: [] },
- { type: "text", value: "world" },
- ],
- },
- ],
- new Map()
- )
- ).toEqual({
- children: [
- { type: "text", value: "hello" },
- { type: "id", value: expectString },
- ],
- instances: [
- {
- type: "instance",
- id: expectString,
- component: "Box1",
- children: [
- { type: "id", value: expectString },
- { type: "text", value: "world" },
- ],
- },
- {
- type: "instance",
- id: expectString,
- component: "Box2",
- children: [],
- },
- ],
- props: [],
- dataSources: [],
- styleSourceSelections: [],
- styleSources: [],
- styles: [],
- assets: [],
- breakpoints: [],
- resources: [],
- });
-});
-
-test("generate data for embedding from props", () => {
- expect(
- generateDataFromEmbedTemplate(
- [
- {
- type: "instance",
- component: "Box1",
- props: [
- { type: "string", name: "data-prop1", value: "value1" },
- { type: "string", name: "data-prop2", value: "value2" },
- ],
- children: [
- {
- type: "instance",
- component: "Box2",
- props: [{ type: "string", name: "data-prop3", value: "value3" }],
- children: [],
- },
- ],
- },
- ],
- new Map()
- )
- ).toEqual({
- children: [{ type: "id", value: expectString }],
- instances: [
- {
- type: "instance",
- id: expectString,
- component: "Box1",
- children: [{ type: "id", value: expectString }],
- },
- {
- type: "instance",
- id: expectString,
- component: "Box2",
- children: [],
- },
- ],
- props: [
- {
- type: "string",
- id: expectString,
- instanceId: expectString,
- name: "data-prop1",
- value: "value1",
- },
- {
- type: "string",
- id: expectString,
- instanceId: expectString,
- name: "data-prop2",
- value: "value2",
- },
- {
- type: "string",
- id: expectString,
- instanceId: expectString,
- name: "data-prop3",
- value: "value3",
- },
- ],
- dataSources: [],
- styleSourceSelections: [],
- styleSources: [],
- styles: [],
- assets: [],
- breakpoints: [],
- resources: [],
- });
-});
-
-test("generate data for embedding from styles", () => {
- const fragment = generateDataFromEmbedTemplate(
- [
- {
- type: "instance",
- component: "Box1",
- styles: [
- { property: "width", value: { type: "keyword", value: "auto" } },
- { property: "height", value: { type: "keyword", value: "auto" } },
- ],
- children: [
- {
- type: "instance",
- component: "Box2",
- styles: [
- {
- property: "color",
- value: { type: "keyword", value: "black" },
- },
- ],
- children: [],
- },
- ],
- },
- ],
- new Map()
- );
- const baseBreakpointId = fragment.breakpoints[0].id;
- expect(fragment).toEqual({
- children: [{ type: "id", value: expectString }],
- instances: [
- {
- type: "instance",
- id: expectString,
- component: "Box1",
- children: [{ type: "id", value: expectString }],
- },
- {
- type: "instance",
- id: expectString,
- component: "Box2",
- children: [],
- },
- ],
- props: [],
- dataSources: [],
- styleSourceSelections: [
- {
- instanceId: expectString,
- values: [expectString],
- },
- {
- instanceId: expectString,
- values: [expectString],
- },
- ],
- styleSources: [
- {
- type: "local",
- id: expectString,
- },
- {
- type: "local",
- id: expectString,
- },
- ],
- styles: [
- {
- breakpointId: baseBreakpointId,
- styleSourceId: expectString,
- state: undefined,
- property: "width",
- value: { type: "keyword", value: "auto" },
- },
- {
- breakpointId: baseBreakpointId,
- styleSourceId: expectString,
- state: undefined,
- property: "height",
- value: { type: "keyword", value: "auto" },
- },
- {
- breakpointId: baseBreakpointId,
- styleSourceId: expectString,
- state: undefined,
- property: "color",
- value: { type: "keyword", value: "black" },
- },
- ],
- assets: [],
- breakpoints: [
- {
- id: baseBreakpointId,
- label: "",
- },
- ],
- resources: [],
- });
-});
-
-test("generate data for embedding from props bound to data source variables", () => {
- expect(
- generateDataFromEmbedTemplate(
- [
- {
- type: "instance",
- component: "Box1",
- variables: {
- showOtherBoxDataSource: { initialValue: false },
- },
- props: [
- {
- type: "expression",
- name: "showOtherBox",
- code: "showOtherBoxDataSource",
- },
- ],
- children: [],
- },
- {
- type: "instance",
- component: "Box2",
- props: [
- {
- type: "expression",
- name: showAttribute,
- code: "showOtherBoxDataSource",
- },
- ],
- children: [],
- },
- ],
- new Map()
- )
- ).toEqual({
- children: [
- { type: "id", value: expectString },
- { type: "id", value: expectString },
- ],
- instances: [
- { type: "instance", id: expectString, component: "Box1", children: [] },
- { type: "instance", id: expectString, component: "Box2", children: [] },
- ],
- props: [
- {
- id: expectString,
- instanceId: expectString,
- type: "expression",
- name: "showOtherBox",
- value: expect.stringMatching(/\$ws\$dataSource\$\w+/),
- },
- {
- id: expectString,
- instanceId: expectString,
- type: "expression",
- name: showAttribute,
- value: expect.stringMatching(/\$ws\$dataSource\$\w+/),
- },
- ],
- dataSources: [
- {
- type: "variable",
- id: expectString,
- scopeInstanceId: expectString,
- name: "showOtherBoxDataSource",
- value: {
- type: "boolean",
- value: false,
- },
- },
- ],
- styleSourceSelections: [],
- styleSources: [],
- styles: [],
- assets: [],
- breakpoints: [],
- resources: [],
- });
-});
-
-test("generate variables with aliases instead of reference name", () => {
- expect(
- generateDataFromEmbedTemplate(
- [
- {
- type: "instance",
- component: "Box",
- variables: {
- myVar: { alias: "My Variable", initialValue: false },
- },
- children: [],
- },
- ],
- new Map()
- )
- ).toEqual({
- children: [{ type: "id", value: expectString }],
- instances: [
- { type: "instance", id: expectString, component: "Box", children: [] },
- ],
- props: [],
- dataSources: [
- {
- type: "variable",
- id: expectString,
- scopeInstanceId: expectString,
- name: "My Variable",
- value: {
- type: "boolean",
- value: false,
- },
- },
- ],
- styleSourceSelections: [],
- styleSources: [],
- styles: [],
- assets: [],
- breakpoints: [],
- resources: [],
- });
-});
-
-test("generate data for embedding from props with complex expressions", () => {
- expect(
- generateDataFromEmbedTemplate(
- [
- {
- type: "instance",
- component: "Box1",
- variables: {
- boxState: { initialValue: "initial" },
- },
- props: [
- {
- type: "expression",
- name: "state",
- code: "boxState",
- },
- ],
- children: [],
- },
- {
- type: "instance",
- component: "Box2",
- props: [
- {
- type: "expression",
- name: showAttribute,
- code: "boxState === 'success'",
- },
- ],
- children: [],
- },
- ],
- new Map()
- )
- ).toEqual({
- children: [
- { type: "id", value: expectString },
- { type: "id", value: expectString },
- ],
- instances: [
- { type: "instance", id: expectString, component: "Box1", children: [] },
- { type: "instance", id: expectString, component: "Box2", children: [] },
- ],
- props: [
- {
- id: expectString,
- instanceId: expectString,
- type: "expression",
- name: "state",
- value: expect.stringMatching(/\$ws\$dataSource\$\w+/),
- },
- {
- id: expectString,
- instanceId: expectString,
- type: "expression",
- name: showAttribute,
- value: expect.stringMatching(/\$ws\$dataSource\$\w+ === 'success'/),
- },
- ],
- dataSources: [
- {
- type: "variable",
- id: expectString,
- scopeInstanceId: expectString,
- name: "boxState",
- value: {
- type: "string",
- value: "initial",
- },
- },
- ],
- styleSourceSelections: [],
- styleSources: [],
- styles: [],
- assets: [],
- breakpoints: [],
- resources: [],
- });
-});
-
-test("generate data for embedding from action props", () => {
- expect(
- generateDataFromEmbedTemplate(
- [
- {
- type: "instance",
- component: "Box1",
- variables: {
- boxState: { initialValue: "initial" },
- },
- props: [
- {
- type: "expression",
- name: "state",
- code: "boxState",
- },
- ],
- children: [
- {
- type: "instance",
- component: "Box2",
- props: [
- {
- type: "action",
- name: "onClick",
- value: [{ type: "execute", code: `boxState = 'success'` }],
- },
- {
- type: "action",
- name: "onChange",
- value: [
- {
- type: "execute",
- args: ["value"],
- code: `boxState = value`,
- },
- ],
- },
- ],
- children: [],
- },
- ],
- },
- ],
- new Map()
- )
- ).toEqual({
- children: [{ type: "id", value: expectString }],
- instances: [
- {
- type: "instance",
- id: expectString,
- component: "Box1",
- children: [{ type: "id", value: expectString }],
- },
- { type: "instance", id: expectString, component: "Box2", children: [] },
- ],
- props: [
- {
- id: expectString,
- instanceId: expectString,
- type: "expression",
- name: "state",
- value: expect.stringMatching(/\$ws\$dataSource\$\w+/),
- },
- {
- id: expectString,
- instanceId: expectString,
- type: "action",
- name: "onClick",
- value: [
- {
- type: "execute",
- args: [],
- code: expect.stringMatching(/\$ws\$dataSource\$\w+ = 'success'/),
- },
- ],
- },
- {
- id: expectString,
- instanceId: expectString,
- type: "action",
- name: "onChange",
- value: [
- {
- type: "execute",
- args: ["value"],
- code: expect.stringMatching(/\$ws\$dataSource\$\w+ = value/),
- },
- ],
- },
- ],
- dataSources: [
- {
- type: "variable",
- id: expectString,
- scopeInstanceId: expectString,
- name: "boxState",
- value: {
- type: "string",
- value: "initial",
- },
- },
- ],
- styleSourceSelections: [],
- styleSources: [],
- styles: [],
- assets: [],
- breakpoints: [],
- resources: [],
- });
-});
-
-test("generate data for embedding from parameter props", () => {
- const data = generateDataFromEmbedTemplate(
- [
- {
- type: "instance",
- component: "Box",
- props: [
- {
- type: "parameter",
- name: "myParameter",
- variableName: "parameterName",
- },
- {
- type: "parameter",
- name: "anotherParameter",
- variableName: "anotherParameterName",
- variableAlias: "Another Parameter",
- },
- ],
- children: [],
- },
- ],
- new Map()
- );
- const instanceId = data.instances[0].id;
- const variableId = data.dataSources[0].id;
- const anotherVariableId = data.dataSources[1].id;
- expect(data).toEqual({
- children: [{ type: "id", value: instanceId }],
- instances: [
- {
- type: "instance",
- id: instanceId,
- component: "Box",
- children: [],
- },
- ],
- props: [
- {
- id: expectString,
- instanceId,
- name: "myParameter",
- type: "parameter",
- value: variableId,
- },
- {
- id: expectString,
- instanceId,
- name: "anotherParameter",
- type: "parameter",
- value: anotherVariableId,
- },
- ],
- dataSources: [
- {
- type: "parameter",
- id: variableId,
- scopeInstanceId: instanceId,
- name: "parameterName",
- },
- {
- type: "parameter",
- id: anotherVariableId,
- scopeInstanceId: instanceId,
- name: "Another Parameter",
- },
- ],
- styleSourceSelections: [],
- styleSources: [],
- styles: [],
- assets: [],
- breakpoints: [],
- resources: [],
- });
-});
-
-test("generate data for embedding from instance child bound to variables", () => {
- expect(
- generateDataFromEmbedTemplate(
- [
- {
- type: "instance",
- component: "Box",
- variables: {
- myValue: { initialValue: "text" },
- },
- children: [{ type: "expression", value: "myValue" }],
- },
- ],
- new Map()
- )
- ).toEqual({
- children: [{ type: "id", value: expectString }],
- instances: [
- {
- type: "instance",
- id: expectString,
- component: "Box",
- children: [
- {
- type: "expression",
- value: expect.stringMatching(/\$ws\$dataSource\$\w+/),
- },
- ],
- },
- ],
- dataSources: [
- {
- type: "variable",
- id: expectString,
- scopeInstanceId: expectString,
- name: "myValue",
- value: { type: "string", value: "text" },
- },
- ],
- props: [],
- styleSourceSelections: [],
- styleSources: [],
- styles: [],
- assets: [],
- breakpoints: [],
- resources: [],
- });
-});
diff --git a/apps/builder/app/builder/features/ai/embed-template.ts b/apps/builder/app/builder/features/ai/embed-template.ts
deleted file mode 100644
index 711689e8bb14..000000000000
--- a/apps/builder/app/builder/features/ai/embed-template.ts
+++ /dev/null
@@ -1,274 +0,0 @@
-import { nanoid } from "nanoid";
-import type {
- Instance,
- Prop,
- StyleSourceSelection,
- StyleSource,
- StyleDecl,
- Breakpoint,
- DataSource,
- WebstudioFragment,
- WsComponentMeta,
-} from "@webstudio-is/sdk";
-import {
- encodeDataSourceVariable,
- transpileExpression,
-} from "@webstudio-is/sdk";
-import type { EmbedTemplateVariable, WsEmbedTemplate } from "@webstudio-is/ai";
-
-const getVariablValue = (
- value: EmbedTemplateVariable["initialValue"]
-): Extract["value"] => {
- if (typeof value === "string") {
- return { type: "string", value };
- }
- if (typeof value === "number") {
- return { type: "number", value };
- }
- if (typeof value === "boolean") {
- return { type: "boolean", value };
- }
- if (Array.isArray(value)) {
- return { type: "string[]", value };
- }
- return { type: "json", value };
-};
-
-const createInstancesFromTemplate = (
- treeTemplate: WsEmbedTemplate,
- instances: Instance[],
- props: Prop[],
- dataSourceByRef: Map,
- styleSourceSelections: StyleSourceSelection[],
- styleSources: StyleSource[],
- styles: StyleDecl[],
- metas: Map,
- defaultBreakpointId: Breakpoint["id"],
- generateId: () => string
-) => {
- const parentChildren: Instance["children"] = [];
- for (const item of treeTemplate) {
- if (item.type === "instance") {
- const instanceId = generateId();
-
- if (item.variables) {
- for (const [name, variable] of Object.entries(item.variables)) {
- if (dataSourceByRef.has(name)) {
- throw Error(`${name} data source already defined`);
- }
- dataSourceByRef.set(name, {
- type: "variable",
- id: generateId(),
- scopeInstanceId: instanceId,
- name: variable.alias ?? name,
- value: getVariablValue(variable.initialValue),
- });
- }
- }
-
- // populate props
- if (item.props) {
- for (const prop of item.props) {
- const propId = generateId();
-
- if (prop.type === "expression") {
- props.push({
- id: propId,
- instanceId,
- name: prop.name,
- type: "expression",
- // replace all references with variable names
- value: transpileExpression({
- expression: prop.code,
- replaceVariable: (ref) => {
- const id = dataSourceByRef.get(ref)?.id ?? ref;
- return encodeDataSourceVariable(id);
- },
- }),
- });
- continue;
- }
-
- // action cannot be bound to data source
- if (prop.type === "action") {
- props.push({
- id: propId,
- instanceId,
- type: "action",
- name: prop.name,
- value: prop.value.map((value) => {
- const args = value.args ?? [];
- return {
- type: "execute",
- args,
- // replace all references with variable names
- code: transpileExpression({
- expression: value.code,
- replaceVariable: (ref) => {
- // bypass arguments without changes
- if (args.includes(ref)) {
- return;
- }
- const id = dataSourceByRef.get(ref)?.id ?? ref;
- return encodeDataSourceVariable(id);
- },
- }),
- };
- }),
- });
- continue;
- }
-
- if (prop.type === "parameter") {
- const dataSourceId = generateId();
- // generate data sources implicitly
- dataSourceByRef.set(prop.variableName, {
- type: "parameter",
- id: dataSourceId,
- scopeInstanceId: instanceId,
- name: prop.variableAlias ?? prop.variableName,
- });
- props.push({
- id: propId,
- instanceId,
- name: prop.name,
- type: "parameter",
- // replace variable reference with variable id
- value: dataSourceId,
- });
- continue;
- }
-
- props.push({ id: propId, instanceId, ...prop });
- }
- }
-
- const styleSourceIds: string[] = [];
-
- // populate styles
- if (item.styles) {
- const styleSourceId = generateId();
- styleSources.push({
- type: "local",
- id: styleSourceId,
- });
- // always put local style source last
- styleSourceIds.push(styleSourceId);
- for (const styleDecl of item.styles) {
- styles.push({
- breakpointId: defaultBreakpointId,
- styleSourceId,
- state: styleDecl.state,
- property: styleDecl.property,
- value: styleDecl.value,
- });
- }
- }
-
- if (styleSourceIds.length > 0) {
- styleSourceSelections.push({
- instanceId,
- values: styleSourceIds,
- });
- }
-
- // populate instances
- const instance: Instance = {
- type: "instance",
- id: instanceId,
- label: item.label,
- component: item.component,
- children: [],
- };
- instances.push(instance);
- // traverse children after to preserve top down order
- instance.children = createInstancesFromTemplate(
- item.children,
- instances,
- props,
- dataSourceByRef,
- styleSourceSelections,
- styleSources,
- styles,
- metas,
- defaultBreakpointId,
- generateId
- );
- parentChildren.push({
- type: "id",
- value: instanceId,
- });
- }
-
- if (item.type === "text") {
- parentChildren.push({
- type: "text",
- value: item.value,
- placeholder: item.placeholder,
- });
- }
-
- if (item.type === "expression") {
- parentChildren.push({
- type: "expression",
- // replace all references with variable names
- value: transpileExpression({
- expression: item.value,
- replaceVariable: (ref) => {
- const id = dataSourceByRef.get(ref)?.id ?? ref;
- return encodeDataSourceVariable(id);
- },
- }),
- });
- }
- }
- return parentChildren;
-};
-
-export const generateDataFromEmbedTemplate = (
- treeTemplate: WsEmbedTemplate,
- metas: Map,
- generateId: () => string = nanoid
-): WebstudioFragment => {
- const instances: Instance[] = [];
- const props: Prop[] = [];
- const dataSourceByRef = new Map();
- const styleSourceSelections: StyleSourceSelection[] = [];
- const styleSources: StyleSource[] = [];
- const styles: StyleDecl[] = [];
- const baseBreakpointId = generateId();
-
- const children = createInstancesFromTemplate(
- treeTemplate,
- instances,
- props,
- dataSourceByRef,
- styleSourceSelections,
- styleSources,
- styles,
- metas,
- baseBreakpointId,
- generateId
- );
- const breakpoints: Breakpoint[] = [];
- // will be merged into project base breakpoint
- if (styles.length > 0) {
- breakpoints.push({
- id: baseBreakpointId,
- label: "",
- });
- }
-
- return {
- children,
- instances,
- props,
- dataSources: Array.from(dataSourceByRef.values()),
- styleSourceSelections,
- styleSources,
- styles,
- breakpoints,
- assets: [],
- resources: [],
- };
-};
diff --git a/apps/builder/app/builder/features/ai/hooks/long-press-toggle.ts b/apps/builder/app/builder/features/ai/hooks/long-press-toggle.ts
deleted file mode 100644
index a4d42788de67..000000000000
--- a/apps/builder/app/builder/features/ai/hooks/long-press-toggle.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-import { type SyntheticEvent, type KeyboardEvent, useRef } from "react";
-import { useEffectEvent } from "~/shared/hook-utils/effect-event";
-
-type UseClickAndHoldProps = {
- onStart: () => void;
- onEnd: () => void;
- onCancel?: () => void;
- longPressDuration?: number;
-};
-
-/**
- * Toggle hook with long-press handling.
- * - `onStart`: First click.
- * - `onEnd`: Second click or long-press release.
- * - `onCancel`: Pointer up outside target during long press.
- */
-export const useLongPressToggle = (props: UseClickAndHoldProps) => {
- const currentTarget = useRef(undefined);
- const pointerDownTimeRef = useRef(0);
- const stateRef = useRef<"idle" | "active">("idle");
- const keyMapRef = useRef(new Set());
-
- const { longPressDuration = 1000 } = props;
-
- // Wrap to be sure that latest callback is used in event handlers.
- const onStart = useEffectEvent(props.onStart);
- const onEnd = useEffectEvent(props.onEnd);
- const onCancel = useEffectEvent(props.onCancel);
-
- const handlePointerUp = useEffectEvent((event: Event) => {
- if (stateRef.current === "idle") {
- return;
- }
- const { target } = event;
-
- if (!(target instanceof Element)) {
- return;
- }
-
- const time = Date.now() - pointerDownTimeRef.current;
- const isLongPress = time >= longPressDuration;
-
- if (isLongPress === false) {
- return;
- }
-
- if (currentTarget.current?.contains(target)) {
- const time = Date.now() - pointerDownTimeRef.current;
- if (time >= longPressDuration) {
- onEnd();
- stateRef.current = "idle";
- }
- return;
- }
-
- onCancel?.();
- stateRef.current = "idle";
- });
-
- const onPointerDown = useEffectEvent((event: SyntheticEvent) => {
- if (stateRef.current === "active") {
- onEnd();
- stateRef.current = "idle";
- return;
- }
-
- stateRef.current = "active";
- currentTarget.current = event.currentTarget;
- pointerDownTimeRef.current = Date.now();
- document.addEventListener("pointerup", handlePointerUp, { once: true });
- onStart();
- });
-
- const onKeyDown = useEffectEvent((event: KeyboardEvent) => {
- if (keyMapRef.current.has(event.code)) {
- return;
- }
-
- if (event.code === "Enter" || event.code === "Space") {
- keyMapRef.current.add(event.code);
- onPointerDown(event);
- }
- });
-
- const onKeyUp = useEffectEvent((event: KeyboardEvent) => {
- if (event.code === "Enter" || event.code === "Space") {
- keyMapRef.current.delete(event.code);
-
- handlePointerUp(event.nativeEvent);
- }
- });
-
- return { onPointerDown, onKeyDown, onKeyUp };
-};
diff --git a/apps/builder/app/builder/features/ai/hooks/media-recorder.stories.tsx b/apps/builder/app/builder/features/ai/hooks/media-recorder.stories.tsx
deleted file mode 100644
index 67c38ab5ed2e..000000000000
--- a/apps/builder/app/builder/features/ai/hooks/media-recorder.stories.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-import { useRef, useState } from "react";
-import { useLongPressToggle } from "./long-press-toggle";
-import { useMediaRecorder } from "./media-recorder";
-import { Button, Flex, Grid, css } from "@webstudio-is/design-system";
-
-export default {
- title: "Library/Media Recorder",
- argTypes: {
- audioBitsPerSecond: {
- options: [4000, 8000, 16000, 32000, 64000, 128000, 256000],
- control: { type: "radio" },
- },
- },
- args: {
- audioBitsPerSecond: 16000,
- },
-};
-
-const playAudio = (file: File) => {
- const blobUrl = URL.createObjectURL(file);
- const audioElement = new Audio(blobUrl);
-
- audioElement.addEventListener("ended", () => {
- URL.revokeObjectURL(blobUrl);
- });
-
- audioElement.play();
-};
-
-export const MediaRecorder = (options: { audioBitsPerSecond: number }) => {
- const soundRef = useRef(null);
-
- const [file, setFile] = useState();
-
- const { start, stop, cancel, state } = useMediaRecorder(
- {
- onError: (error) => {
- console.error(error);
- },
- onComplete: (file) => {
- setFile(file);
- },
- onReportSoundAmplitude: (amplitude) => {
- soundRef.current?.style.setProperty("--volume", amplitude.toString());
- },
- },
- options
- );
-
- const clickAndHoldProps = useLongPressToggle({
- onStart: start,
- onEnd: stop,
- onCancel: cancel,
- });
-
- return (
-
-
-
-
-
- {file && (
-
- )}
-
- );
-};
-MediaRecorder.storyName = "Media Recorder";
-
-const soundCss = css({
- transition: "transform 0.15s ease-in-out",
- borderRadius: "9999px",
- backgroundColor: "#F56565",
- width: "3rem",
- height: "3rem",
- transformOrigin: "center",
- transform: "scale(calc(1 + var(--volume, 0)))",
-});
diff --git a/apps/builder/app/builder/features/ai/hooks/media-recorder.ts b/apps/builder/app/builder/features/ai/hooks/media-recorder.ts
deleted file mode 100644
index e2cc23fc5ff0..000000000000
--- a/apps/builder/app/builder/features/ai/hooks/media-recorder.ts
+++ /dev/null
@@ -1,160 +0,0 @@
-import { useRef, useState } from "react";
-import { useEffectEvent } from "~/shared/hook-utils/effect-event";
-
-// https://github.com/openai/whisper/discussions/870
-const DEFAULT_OPTIONS: MediaRecorderOptions = {
- audioBitsPerSecond: 16000,
-};
-
-export const useMediaRecorder = (
- props: {
- onComplete: (file: File) => void;
- onError: (error: unknown) => void;
- onReportSoundAmplitude?: (amplitude: number) => void;
- },
- options = DEFAULT_OPTIONS
-) => {
- const disposeRef = useRef void)>(undefined);
-
- const cancelRef = useRef(false);
- const isActiveRef = useRef(false);
- const idRef = useRef(0);
- const [state, setState] = useState<"inactive" | "recording">("inactive");
-
- const onComplete = useEffectEvent(props.onComplete);
- const onReportSoundAmplitude = useEffectEvent(props.onReportSoundAmplitude);
-
- const start = useEffectEvent(async () => {
- isActiveRef.current = true;
- const chunks: Blob[] = [];
- idRef.current++;
- const id = idRef.current;
-
- cancelRef.current = false;
-
- let stream: MediaStream;
-
- try {
- stream = await navigator.mediaDevices.getUserMedia({
- audio: true,
- video: false,
- });
- } catch (error) {
- // Not allowed, do not start new recording
- isActiveRef.current = false;
- props.onError(error);
- return;
- }
-
- // Recording was stopped, do not start new recording
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore wrong ts error based on isActiveRef.current = true above it think that isActiveRef.current is always true
- if (isActiveRef.current === false) {
- stream.getAudioTracks().forEach((track) => track.stop());
- return;
- }
-
- // New recording started, do cleanup and return
- if (id !== idRef.current) {
- stream.getAudioTracks().forEach((track) => track.stop());
- return;
- }
-
- setState("recording");
-
- const subtype = MediaRecorder.isTypeSupported("audio/webm; codecs=opus")
- ? "webm"
- : "mp4";
- const mimeType =
- subtype === "webm" ? `audio/${subtype}; codecs=opus` : `audio/${subtype}`;
-
- const recorder = new MediaRecorder(stream, {
- mimeType,
- ...options,
- });
-
- const audioContext = new AudioContext();
- const source = audioContext.createMediaStreamSource(stream);
- const analyser = audioContext.createAnalyser();
- source.connect(analyser);
-
- analyser.fftSize = 2048;
- const bufferLength = analyser.fftSize;
- const dataArray = new Float32Array(bufferLength);
-
- disposeRef.current = () => {
- source.disconnect();
- analyser.disconnect();
- audioContext.close();
- // Safari bug: Calling `stop` on tracks delays next `getUserMedia` by 3-5s.
- // Chrome: `stop` needed to remove recording tab indicator.
- // @todo: Probably don't stop tracks in Safari, as subsequent getUserMedia blocks the main thread, and cause long-press logic to fail
- stream.getAudioTracks().forEach((track) => track.stop());
- recorder.stop();
- };
-
- const latestSamples = Array.from({ length: 10 }, () => 1);
- let latestSamplesIndex = 0;
-
- recorder.ondataavailable = (event) => {
- analyser.getFloatTimeDomainData(dataArray);
- const sampleMaxAmplitude = Math.max(...dataArray);
- latestSamples[latestSamplesIndex] = sampleMaxAmplitude;
- latestSamplesIndex = (latestSamplesIndex + 1) % latestSamples.length;
-
- // To not normalize amplitude around near zero values
- const normalizeThreshold = 0.1;
-
- // Normalize amplitude to be between 0 and 1, and against lastest samples.
- // The idea to use latest samples for normalization
- onReportSoundAmplitude?.(
- sampleMaxAmplitude /
- Math.max(normalizeThreshold, Math.max(...latestSamples))
- );
-
- // New recording started, do cleanup and return
- if (id !== idRef.current) {
- chunks.length = 0;
- return;
- }
-
- // Cancelled, do cleanup and return
- if (cancelRef.current) {
- setState("inactive");
- chunks.length = 0;
- return;
- }
-
- chunks.push(event.data);
-
- if (recorder.state === "inactive") {
- const audioFile = new File(chunks, "recording." + subtype, {
- // Add type to be able to play in audio element
- type: "audio/" + subtype,
- });
-
- if (audioFile.size > 0) {
- onComplete(audioFile);
- onReportSoundAmplitude?.(0);
- }
- chunks.length = 0;
- setState("inactive");
- }
- };
-
- recorder.start(50);
- });
-
- const stop = useEffectEvent(() => {
- isActiveRef.current = false;
- disposeRef.current?.();
- disposeRef.current = undefined;
- });
-
- const cancel = useEffectEvent(() => {
- cancelRef.current = true;
- stop();
- });
-
- return { start, stop, cancel, state };
-};
diff --git a/apps/builder/app/builder/shared/client-settings/settings.ts b/apps/builder/app/builder/shared/client-settings/settings.ts
index 54c8befc1fc9..cf09f9928b35 100644
--- a/apps/builder/app/builder/shared/client-settings/settings.ts
+++ b/apps/builder/app/builder/shared/client-settings/settings.ts
@@ -3,8 +3,6 @@ import { z } from "zod";
const Settings = z.object({
navigatorLayout: z.enum(["docked", "undocked"]).default("undocked"),
- isAiMenuOpen: z.boolean().default(true),
- isAiCommandBarVisible: z.boolean().default(false),
stylePanelMode: z.enum(["default", "focus", "advanced"]).default("default"),
});
diff --git a/apps/builder/app/builder/sidebar-left/sidebar-left.tsx b/apps/builder/app/builder/sidebar-left/sidebar-left.tsx
index 7a91aeb3855a..f6ea93abd5f9 100644
--- a/apps/builder/app/builder/sidebar-left/sidebar-left.tsx
+++ b/apps/builder/app/builder/sidebar-left/sidebar-left.tsx
@@ -4,13 +4,11 @@ import { useSubscribe, type Publish } from "~/shared/pubsub";
import {
$dragAndDropState,
$isContentMode,
- $isDesignMode,
$isPreviewMode,
} from "~/shared/nano-states";
import { Flex } from "@webstudio-is/design-system";
import { theme } from "@webstudio-is/design-system";
import {
- AiIcon,
ExtensionIcon,
HelpIcon,
ImageIcon,
@@ -41,7 +39,7 @@ import {
useOnDropEffect,
useExternalDragStateEffect,
} from "~/builder/shared/assets/drag-monitor";
-import { getSetting, setSetting } from "~/builder/shared/client-settings";
+import { getSetting } from "~/builder/shared/client-settings";
import { ComponentsPanel } from "~/builder/features/components";
import { PagesPanel } from "~/builder/features/pages";
import { NavigatorPanel } from "~/builder/features/navigator";
@@ -50,23 +48,6 @@ import { MarketplacePanel } from "~/builder/features/marketplace";
const none = { Panel: () => null };
-const AiTabTrigger = () => {
- return (
- {
- setSetting(
- "isAiCommandBarVisible",
- getSetting("isAiCommandBarVisible") ? false : true
- );
- }}
- >
-
-
- );
-};
-
const HelpTabTrigger = () => {
const [helpIsOpen, setHelpIsOpen] = useState(false);
return (
@@ -174,7 +155,6 @@ type SidebarLeftProps = {
};
export const SidebarLeft = ({ publish }: SidebarLeftProps) => {
- const isDesignMode = useStore($isDesignMode);
const activePanel = useStore($activeSidebarPanel);
const dragAndDropState = useStore($dragAndDropState);
const { Panel } = panels.find((item) => item.name === activePanel) ?? none;
@@ -264,7 +244,6 @@ export const SidebarLeft = ({ publish }: SidebarLeftProps) => {
})}
- {isDesignMode && }
diff --git a/apps/builder/app/env/env.server.ts b/apps/builder/app/env/env.server.ts
index ce3c2517ca4b..5beff53a952b 100644
--- a/apps/builder/app/env/env.server.ts
+++ b/apps/builder/app/env/env.server.ts
@@ -51,19 +51,6 @@ const env = {
projectId.trim()
) ?? [],
- /**
- * OpenAI secrets for AI features
- *
- * OPENAI_KEY is a personal API key that you should generate here https://platform.openai.com/account/api-keys
- * OPENAI_ORG can be found at https://platform.openai.com/account/org-settings
- *
- * Both are mandatory as OpenAI will bill OPENAI_ORG
- */
- OPENAI_KEY: process.env.OPENAI_KEY,
- OPENAI_ORG: process.env.OPENAI_ORG,
-
- PEXELS_API_KEY: process.env.PEXELS_API_KEY,
-
N8N_WEBHOOK_URL: process.env.N8N_WEBHOOK_URL,
N8N_WEBHOOK_TOKEN: process.env.N8N_WEBHOOK_TOKEN,
diff --git a/apps/builder/app/routes/_canvas.canvas.tsx b/apps/builder/app/routes/_canvas.canvas.tsx
index b6f0cf3d7b49..c5cf560b12cd 100644
--- a/apps/builder/app/routes/_canvas.canvas.tsx
+++ b/apps/builder/app/routes/_canvas.canvas.tsx
@@ -38,8 +38,3 @@ const CanvasRoute = () => {
};
export default CanvasRoute;
-
-// Reduces Vercel function size from 29MB to 9MB for unknown reasons; effective when used in limited files.
-export const config = {
- maxDuration: 30,
-};
diff --git a/apps/builder/app/routes/_ui.(builder).tsx b/apps/builder/app/routes/_ui.(builder).tsx
index af4074ab397a..cfcd28ed6af0 100644
--- a/apps/builder/app/routes/_ui.(builder).tsx
+++ b/apps/builder/app/routes/_ui.(builder).tsx
@@ -293,8 +293,3 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({
};
export default BuilderRoute;
-
-// Reduces Vercel function size from 29MB to 9MB for unknown reasons; effective when used in limited files.
-export const config = {
- maxDuration: 30,
-};
diff --git a/apps/builder/app/routes/_ui.login._index.tsx b/apps/builder/app/routes/_ui.login._index.tsx
index 246df700f718..2ba9aa9b1d83 100644
--- a/apps/builder/app/routes/_ui.login._index.tsx
+++ b/apps/builder/app/routes/_ui.login._index.tsx
@@ -111,8 +111,3 @@ const LoginRoute = () => {
};
export default LoginRoute;
-
-// Reduces Vercel function size from 29MB to 9MB for unknown reasons; effective when used in limited files.
-export const config = {
- maxDuration: 30,
-};
diff --git a/apps/builder/app/routes/rest.ai._index.ts b/apps/builder/app/routes/rest.ai._index.ts
deleted file mode 100644
index f005197a3643..000000000000
--- a/apps/builder/app/routes/rest.ai._index.ts
+++ /dev/null
@@ -1,298 +0,0 @@
-import { z } from "zod";
-import type { ActionFunctionArgs } from "@remix-run/server-runtime";
-import {
- copywriter,
- operations,
- templateGenerator,
- createGptModel,
- type GptModelMessageFormat,
- createErrorResponse,
- type ModelMessage,
-} from "@webstudio-is/ai/index.server";
-import {
- copywriter as clientCopywriter,
- operations as clientOperations,
- queryImagesAndMutateTemplate,
-} from "@webstudio-is/ai";
-import env from "~/env/env.server";
-import { createContext } from "~/shared/context.server";
-import { authorizeProject } from "@webstudio-is/trpc-interface/index.server";
-import { loadDevBuildByProjectId } from "@webstudio-is/project-build/index.server";
-import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
-import { checkCsrf } from "~/services/csrf-session.server";
-
-export const RequestParams = z.object({
- projectId: z.string().min(1, "nonempty"),
- instanceId: z.string().min(1, "nonempty"),
- prompt: z.string().min(1, "nonempty").max(1200),
- components: z.array(z.string()),
- jsx: z.string().min(1, "nonempty"),
- command: z.union([
- // Using client* friendly imports because RequestParams
- // is used to parse the form data on the client too.
- z.literal(clientCopywriter.name),
- z.literal(clientOperations.editStylesName),
- z.literal(clientOperations.generateTemplatePromptName),
- z.literal(clientOperations.deleteInstanceName),
- ]),
-});
-
-// Override Vercel's default serverless functions timeout.
-export const config = {
- maxDuration: 180, // seconds
-};
-
-export const action = async ({ request }: ActionFunctionArgs) => {
- preventCrossOriginCookie(request);
- await checkCsrf(request);
-
- const context = await createContext(request);
- // @todo Reinstate isFeatureEnabled('ai')
-
- if (env.OPENAI_KEY === undefined) {
- return {
- id: "ai",
- ...createErrorResponse({
- error: "ai.invalidApiKey",
- status: 401,
- message: "Invalid OpenAI API key",
- debug: "Invalid OpenAI API key",
- }),
- llmMessages: [],
- };
- }
-
- if (
- env.OPENAI_ORG === undefined ||
- env.OPENAI_ORG.startsWith("org-") === false
- ) {
- return {
- id: "ai",
- ...createErrorResponse({
- error: "ai.invalidOrg",
- status: 401,
- message: "Invalid OpenAI API organization",
- debug: "Invalid OpenAI API organization",
- }),
- llmMessages: [],
- };
- }
-
- if (env.PEXELS_API_KEY === undefined) {
- return {
- id: "ai",
- ...createErrorResponse({
- error: "ai.invalidApiKey",
- status: 401,
- message: "Invalid Pexels API key",
- debug: "Invalid Pexels API key",
- }),
- llmMessages: [],
- };
- }
- const PEXELS_API_KEY = env.PEXELS_API_KEY;
-
- const parsed = RequestParams.safeParse(await request.json());
-
- if (parsed.success === false) {
- return {
- id: "ai",
- ...createErrorResponse({
- error: "ai.invalidRequest",
- status: 401,
- message: "Invalid request data",
- debug: "Invalid request data",
- }),
- llmMessages: [],
- };
- }
-
- const requestContext = await createContext(request);
-
- if (requestContext.authorization.type !== "user") {
- return {
- id: "ai",
- ...createErrorResponse({
- error: "unauthorized",
- status: 401,
- message: "You don't have edit access to this project",
- debug: "Unauthorized access attempt",
- }),
- llmMessages: [],
- };
- }
-
- if (requestContext.authorization.userId === undefined) {
- return {
- id: "ai",
- ...createErrorResponse({
- error: "unauthorized",
- status: 401,
- message: "You don't have edit access to this project",
- debug: "Unauthorized access attempt",
- }),
- llmMessages: [],
- };
- }
-
- const { prompt, components, jsx, projectId, instanceId, command } =
- parsed.data;
-
- if (command === copywriter.name) {
- const canEdit = await authorizeProject.hasProjectPermit(
- { projectId: projectId, permit: "edit" },
- requestContext
- );
-
- if (canEdit === false) {
- return {
- id: copywriter.name,
- ...createErrorResponse({
- error: "unauthorized",
- status: 401,
- message: "You don't have edit access to this project",
- debug: "Unauthorized access attempt",
- }),
- llmMessages: [],
- };
- }
-
- const { instances } = await loadDevBuildByProjectId(context, projectId);
-
- const model = createGptModel({
- apiKey: env.OPENAI_KEY,
- organization: env.OPENAI_ORG,
- temperature: 0,
- model: "gpt-3.5-turbo",
- });
-
- const copywriterChain = copywriter.createChain();
- const copywriterResponse = await copywriterChain({
- model,
- context: {
- prompt,
- textInstances: copywriter.collectTextInstances({
- instances: new Map(
- instances.map((instance) => [instance.id, instance])
- ),
- rootInstanceId: instanceId,
- }),
- },
- });
-
- if (copywriterResponse.success === false) {
- return copywriterResponse;
- }
-
- // Return the copywriter generation stream.
- return copywriterResponse.data;
- }
-
- // If the request requires context about the instances tree use the Operations chain.
-
- const llmMessages: ModelMessage[] = [];
-
- const model = createGptModel({
- apiKey: env.OPENAI_KEY,
- organization: env.OPENAI_ORG,
- temperature: 0,
- model: "gpt-3.5-turbo",
- });
-
- const chain = operations.createChain();
-
- const response = await chain({
- model,
- context: {
- prompt,
- components,
- jsx,
- },
- });
-
- if (response.success === false) {
- return response;
- }
-
- llmMessages.push(...response.llmMessages);
-
- // The operations chain can detect a user interface generation request.
- // In such cases we let this chain select the insertion point
- // and then handle the generation request with a standalone chain called template-generator
- // that has a dedicate and comprehensive prompt.
-
- const generateTemplatePrompts: {
- dataIndex: number;
- operation: operations.generateTemplatePrompt.wsOperation;
- }[] = [];
- response.data.forEach((operation, dataIndex) => {
- if (operation.operation === "generateTemplatePrompt") {
- // preserve the index in response.data to update it after executing operations
- generateTemplatePrompts.push({ dataIndex, operation });
- }
- });
-
- if (generateTemplatePrompts.length > 0) {
- const generationModel = createGptModel({
- apiKey: env.OPENAI_KEY,
- organization: env.OPENAI_ORG,
- temperature: 0,
- model: "gpt-4",
- });
-
- const generationChain =
- templateGenerator.createChain();
-
- const results = await Promise.all(
- generateTemplatePrompts.map(async ({ dataIndex, operation }) => {
- const result = await generationChain({
- model: generationModel,
- context: {
- prompt:
- operation.llmPrompt +
- (operation.classNames && operation.classNames.length > 0
- ? `.\nSuggested Tailwind classes: ${operation.classNames}`
- : ""),
-
- components,
- },
- });
- if (result.success) {
- await queryImagesAndMutateTemplate({
- template: result.data,
- apiKey: PEXELS_API_KEY,
- });
- }
- return {
- dataIndex,
- operation,
- result,
- };
- })
- );
-
- for (const { dataIndex, operation, result } of results) {
- llmMessages.push(...result.llmMessages);
-
- if (result.success === false) {
- return {
- ...result,
- llmMessages,
- };
- }
-
- // Replace generateTemplatePrompt.wsOperation with the AI-generated Webstudio template.
- response.data[dataIndex] = {
- operation: "insertTemplate",
- addTo: operation.addTo,
- addAtIndex: operation.addAtIndex,
- template: result.data,
- };
- }
- }
-
- return {
- ...response,
- llmMessages,
- };
-};
diff --git a/apps/builder/app/routes/rest.ai.audio.transcriptions.ts b/apps/builder/app/routes/rest.ai.audio.transcriptions.ts
deleted file mode 100644
index c8eaf08608e0..000000000000
--- a/apps/builder/app/routes/rest.ai.audio.transcriptions.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import type { ActionFunctionArgs } from "@remix-run/server-runtime";
-import { z } from "zod";
-import { checkCsrf } from "~/services/csrf-session.server";
-import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
-
-const zTranscription = z.object({
- text: z.string().transform((value) => value.trim()),
-});
-
-// @todo: move to AI package
-export const action = async ({ request }: ActionFunctionArgs) => {
- preventCrossOriginCookie(request);
- await checkCsrf(request);
-
- // @todo: validate request
- const formData = await request.formData();
- formData.append("model", "whisper-1");
-
- const response = await fetch(
- "https://api.openai.com/v1/audio/transcriptions",
- {
- method: "POST",
- headers: {
- Authorization: `Bearer ${process.env.OPENAI_KEY}`,
- },
- body: formData,
- }
- );
-
- if (response.ok === false) {
- const message = await response.text();
-
- console.error("ERROR", response.status, message);
-
- return {
- success: false,
- error: {
- status: response.status,
- message,
- },
- } as const;
- }
-
- // @todo untyped
- const data = zTranscription.safeParse(await response.json());
-
- if (data.success === false) {
- console.error("ERROR openai transcriptions", data.error);
- }
-
- return data;
-};
diff --git a/apps/builder/app/routes/rest.ai.detect.ts b/apps/builder/app/routes/rest.ai.detect.ts
deleted file mode 100644
index 91c4cfee814b..000000000000
--- a/apps/builder/app/routes/rest.ai.detect.ts
+++ /dev/null
@@ -1,126 +0,0 @@
-import { z } from "zod";
-import type { ActionFunctionArgs } from "@remix-run/server-runtime";
-import {
- commandDetect,
- createGptModel,
- type GptModelMessageFormat,
- createErrorResponse,
- copywriter,
- operations,
-} from "@webstudio-is/ai/index.server";
-
-import env from "~/env/env.server";
-import { createContext } from "~/shared/context.server";
-import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
-import { checkCsrf } from "~/services/csrf-session.server";
-
-export const RequestParams = z.object({
- prompt: z.string().max(1200),
-});
-
-export const action = async ({ request }: ActionFunctionArgs) => {
- preventCrossOriginCookie(request);
- await checkCsrf(request);
-
- // @todo Reinstate isFeatureEnabled('ai')
-
- if (env.OPENAI_KEY === undefined) {
- return {
- id: "ai",
- ...createErrorResponse({
- error: "ai.invalidApiKey",
- status: 401,
- message: "Invalid OpenAI API key",
- debug: "Invalid OpenAI API key",
- }),
- llmMessages: [],
- };
- }
-
- if (
- env.OPENAI_ORG === undefined ||
- env.OPENAI_ORG.startsWith("org-") === false
- ) {
- return {
- id: "ai",
- ...createErrorResponse({
- error: "ai.invalidOrg",
- status: 401,
- message: "Invalid OpenAI API organization",
- debug: "Invalid OpenAI API organization",
- }),
- llmMessages: [],
- };
- }
-
- const requestJson = await request.json();
- const parsed = RequestParams.safeParse(requestJson);
-
- if (parsed.success === false) {
- return {
- id: "ai",
- ...createErrorResponse({
- error: "ai.invalidRequest",
- status: 401,
- message: `RequestParams.safeParse failed on ${JSON.stringify(
- requestJson
- )}`,
- debug: "Invalid request data",
- }),
- llmMessages: [],
- };
- }
-
- const requestContext = await createContext(request);
-
- if (requestContext.authorization.type !== "user") {
- return {
- id: "ai",
- ...createErrorResponse({
- error: "unauthorized",
- status: 401,
- message: "You don't have edit access to this project",
- debug: "Unauthorized access attempt",
- }),
- llmMessages: [],
- };
- }
-
- if (requestContext.authorization.userId === undefined) {
- return {
- id: "ai",
- ...createErrorResponse({
- error: "unauthorized",
- status: 401,
- message: "You don't have edit access to this project",
- debug: "Unauthorized access attempt",
- }),
- llmMessages: [],
- };
- }
-
- const { prompt } = parsed.data;
-
- const model = createGptModel({
- apiKey: env.OPENAI_KEY,
- organization: env.OPENAI_ORG,
- temperature: 0,
- model: "gpt-3.5-turbo",
- });
-
- const commandDetectChain = commandDetect.createChain();
- return commandDetectChain({
- model,
- context: {
- prompt,
- commands: {
- [copywriter.name]:
- "rewrites, rephrases, shortens, increases length or translates text",
- [operations.editStyles.name]: "edits styles",
- [operations.generateTemplatePrompt.name]:
- "handles a user interface generation request",
- [operations.deleteInstance.name]: "deletes elements",
- },
- },
- });
-};
diff --git a/apps/builder/app/routes/rest.patch.ts b/apps/builder/app/routes/rest.patch.ts
index f1acebe5076d..920c07647b1f 100644
--- a/apps/builder/app/routes/rest.patch.ts
+++ b/apps/builder/app/routes/rest.patch.ts
@@ -453,8 +453,3 @@ export const action = async ({
};
}
};
-
-// Reduces Vercel function size from 29MB to 9MB for unknown reasons; effective when used in limited files.
-export const config = {
- maxDuration: 30, // seconds
-};
diff --git a/apps/builder/app/shared/router-utils/path-utils.ts b/apps/builder/app/shared/router-utils/path-utils.ts
index 7488177fb57b..a7a190350003 100644
--- a/apps/builder/app/shared/router-utils/path-utils.ts
+++ b/apps/builder/app/shared/router-utils/path-utils.ts
@@ -163,9 +163,6 @@ export const getCanvasUrl = () => {
return `/canvas`;
};
-export const restAi = (subEndpoint?: "detect" | "audio/transcriptions") =>
- typeof subEndpoint === "string" ? `/rest/ai/${subEndpoint}` : "/rest/ai";
-
export const restResourcesLoader = () => `/rest/resources-loader`;
export const marketplacePath = (method: string) =>
diff --git a/apps/builder/package.json b/apps/builder/package.json
index 471bf9273a83..64df11041e65 100644
--- a/apps/builder/package.json
+++ b/apps/builder/package.json
@@ -59,7 +59,6 @@
"@unocss/preset-legacy-compat": "66.1.2",
"@unocss/preset-wind3": "66.1.2",
"@vercel/remix": "2.15.3",
- "@webstudio-is/ai": "workspace:*",
"@webstudio-is/asset-uploader": "workspace:*",
"@webstudio-is/authorization-token": "workspace:*",
"@webstudio-is/css-data": "workspace:*",
@@ -120,7 +119,6 @@
"strip-indent": "^4.0.0",
"tiny-invariant": "^1.3.3",
"title-case": "^4.3.2",
- "untruncate-json": "^0.0.1",
"urlpattern-polyfill": "^10.0.0",
"use-debounce": "^10.0.4",
"warn-once": "^0.1.1",
diff --git a/packages/feature-flags/src/flags.ts b/packages/feature-flags/src/flags.ts
index 47326d843d87..0a952b87c8df 100644
--- a/packages/feature-flags/src/flags.ts
+++ b/packages/feature-flags/src/flags.ts
@@ -1,6 +1,5 @@
// Only for development, is not supposed to be enabled at all.
export const internalComponents = false;
export const unsupportedBrowsers = false;
-export const aiRadixComponents = false;
export const resourceProp = false;
export const tailwind = false;
diff --git a/packages/sdk-components-animation/private-src b/packages/sdk-components-animation/private-src
index 3cb7601c98c7..527f1e607473 160000
--- a/packages/sdk-components-animation/private-src
+++ b/packages/sdk-components-animation/private-src
@@ -1 +1 @@
-Subproject commit 3cb7601c98c79bee062c68af377ea2430363c84f
+Subproject commit 527f1e6074734eb44025d0d7a946d1eba28dda14
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4cd97a1c721d..949191e8e74d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -238,9 +238,6 @@ importers:
'@vercel/remix':
specifier: 2.15.3
version: 2.15.3(@remix-run/dev@2.16.5(patch_hash=yortwzoeu3uj2blmdikhhw5byy)(@remix-run/react@2.16.5(react-dom@18.3.0-canary-14898b6a9-20240318(react@18.3.0-canary-14898b6a9-20240318))(react@18.3.0-canary-14898b6a9-20240318)(typescript@5.8.2))(@remix-run/serve@2.16.5(typescript@5.8.2))(@types/node@22.13.10)(jiti@2.4.2)(tsx@4.19.3)(typescript@5.8.2)(vite@6.3.4(@types/node@22.13.10)(jiti@2.4.2)(tsx@4.19.3))(wrangler@3.63.2(@cloudflare/workers-types@4.20240701.0)))(@remix-run/node@2.16.5(typescript@5.8.2))(@remix-run/server-runtime@2.16.5(typescript@5.8.2))(react-dom@18.3.0-canary-14898b6a9-20240318(react@18.3.0-canary-14898b6a9-20240318))(react@18.3.0-canary-14898b6a9-20240318)
- '@webstudio-is/ai':
- specifier: workspace:*
- version: link:../../packages/ai
'@webstudio-is/asset-uploader':
specifier: workspace:*
version: link:../../packages/asset-uploader
@@ -421,9 +418,6 @@ importers:
title-case:
specifier: ^4.3.2
version: 4.3.2
- untruncate-json:
- specifier: ^0.0.1
- version: 0.0.1
urlpattern-polyfill:
specifier: ^10.0.0
version: 10.0.0
@@ -9314,9 +9308,6 @@ packages:
uploadthing:
optional: true
- untruncate-json@0.0.1:
- resolution: {integrity: sha512-4W9enDK4X1y1s2S/Rz7ysw6kDuMS3VmRjMFg7GZrNO+98OSe+x5Lh7PKYoVjy3lW/1wmhs6HW0lusnQRHgMarA==}
-
untun@0.1.3:
resolution: {integrity: sha512-4luGP9LMYszMRZwsvyUd9MrxgEGZdZuZgpVQHEEX0lCYFESasVRvZd0EYpCkOIbJKHMuv0LskpXc/8Un+MJzEQ==}
hasBin: true
@@ -17504,8 +17495,6 @@ snapshots:
ofetch: 1.4.1
ufo: 1.5.4
- untruncate-json@0.0.1: {}
-
untun@0.1.3:
dependencies:
citty: 0.1.6