From e0e4d4012dff58161413dda9ef406979a6d47fc4 Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Sun, 3 Aug 2025 22:03:17 +0200 Subject: [PATCH 1/3] Trigger rebuild From 12516b0e89098856a48a73eaea9108c9355815da Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Sun, 3 Aug 2025 02:04:26 +0200 Subject: [PATCH 2/3] chore: drop AI from UI (#5344) Our first AI experiment is barely used. And we don't want our users to confuse it with upcoming Inception. The next integrated with builder solution will be based on Inception. --- apps/builder/app/builder/builder.tsx | 2 - .../app/builder/features/ai/ai-button.tsx | 34 - .../builder/features/ai/ai-command-bar.tsx | 497 ------------- .../builder/features/ai/ai-fetch-result.ts | 335 --------- .../features/ai/ai-fetch-transcription.ts | 41 -- .../app/builder/features/ai/api-exceptions.ts | 50 -- .../builder/features/ai/apply-operations.ts | 252 ------- .../features/ai/embed-template.test.tsx | 666 ------------------ .../app/builder/features/ai/embed-template.ts | 274 ------- .../features/ai/hooks/long-press-toggle.ts | 94 --- .../ai/hooks/media-recorder.stories.tsx | 82 --- .../features/ai/hooks/media-recorder.ts | 160 ----- .../shared/client-settings/settings.ts | 2 - .../app/builder/sidebar-left/sidebar-left.tsx | 23 +- apps/builder/app/env/env.server.ts | 13 - apps/builder/app/routes/rest.ai._index.ts | 298 -------- .../routes/rest.ai.audio.transcriptions.ts | 52 -- apps/builder/app/routes/rest.ai.detect.ts | 126 ---- .../app/shared/router-utils/path-utils.ts | 3 - apps/builder/package.json | 2 - packages/feature-flags/src/flags.ts | 1 - pnpm-lock.yaml | 11 - 22 files changed, 1 insertion(+), 3017 deletions(-) delete mode 100644 apps/builder/app/builder/features/ai/ai-button.tsx delete mode 100644 apps/builder/app/builder/features/ai/ai-command-bar.tsx delete mode 100644 apps/builder/app/builder/features/ai/ai-fetch-result.ts delete mode 100644 apps/builder/app/builder/features/ai/ai-fetch-transcription.ts delete mode 100644 apps/builder/app/builder/features/ai/api-exceptions.ts delete mode 100644 apps/builder/app/builder/features/ai/apply-operations.ts delete mode 100644 apps/builder/app/builder/features/ai/embed-template.test.tsx delete mode 100644 apps/builder/app/builder/features/ai/embed-template.ts delete mode 100644 apps/builder/app/builder/features/ai/hooks/long-press-toggle.ts delete mode 100644 apps/builder/app/builder/features/ai/hooks/media-recorder.stories.tsx delete mode 100644 apps/builder/app/builder/features/ai/hooks/media-recorder.ts delete mode 100644 apps/builder/app/routes/rest.ai._index.ts delete mode 100644 apps/builder/app/routes/rest.ai.audio.transcriptions.ts delete mode 100644 apps/builder/app/routes/rest.ai.detect.ts 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/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/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/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 From 1eebf2d3888596c94dc9e283783ad3fec5d5da9c Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Sun, 3 Aug 2025 22:23:57 +0200 Subject: [PATCH 3/3] Remove vercel route configs --- apps/builder/app/routes/_canvas.canvas.tsx | 5 ----- apps/builder/app/routes/_ui.(builder).tsx | 5 ----- apps/builder/app/routes/_ui.login._index.tsx | 5 ----- apps/builder/app/routes/rest.patch.ts | 5 ----- packages/sdk-components-animation/private-src | 2 +- 5 files changed, 1 insertion(+), 21 deletions(-) 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.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/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