Text-to-CAD with alpha model (might have issues) integration (#3299)

* Add close dismiss button to Infinite duration non-loading toasts

* Add text-to-cad icon candidates

* Add a way to silently create files

* Add text-to-cad command with mock backend

* add the actual endpoint

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fix the response

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fixups

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* Add `credentials: include`

* add headers

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* Mostly working? Just getting CORS on desktop

* Merge goof

* fixups

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* create cross platform fetch;

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* send the token;

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* send the token;

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* better names for files

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* Commit broken THREEjs success toast

* base64 decode

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* send telemetry on reject / accept

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* start of tests

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* more tests

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* basic tests;

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* lego

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fixes

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* updates

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fmt

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* fixes

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* Get model stylized based on settings

* Don't need automatic dismiss button for Infinity-duration toasts anymore

* Stylize loaded model, add OrbitControls, polish button behavior

* Allow user to retry prompt if one fails

* Add an auto-grow textarea input type to the command bar, set text-to-cad to use it

* Delete the created file in desktop if user rejects it

* Submit with meta+Enter

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)

* add more tests and various fixes

Signed-off-by: Jess Frazelle <github@jessfraz.com>

* Set `prompt` arg defaultValue to failed prompt value on retry

* Add missing `awaits` to playwright tests to get them passing

* A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu)

* empty

---------

Signed-off-by: Jess Frazelle <github@jessfraz.com>
Co-authored-by: Jess Frazelle <github@jessfraz.com>
Co-authored-by: Jess Frazelle <jessfraz@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Frank Noirot
2024-08-14 14:26:44 -04:00
committed by GitHub
parent 669cab8737
commit 5bdd090119
34 changed files with 1420 additions and 46 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -12,7 +12,7 @@ import pixelMatch from 'pixelmatch'
import { PNG } from 'pngjs' import { PNG } from 'pngjs'
import { Protocol } from 'playwright-core/types/protocol' import { Protocol } from 'playwright-core/types/protocol'
import type { Models } from '@kittycad/lib' import type { Models } from '@kittycad/lib'
import { APP_NAME } from 'lib/constants' import { APP_NAME, COOKIE_NAME } from 'lib/constants'
import waitOn from 'wait-on' import waitOn from 'wait-on'
import { secrets } from './secrets' import { secrets } from './secrets'
import { TEST_SETTINGS_KEY, TEST_SETTINGS } from './storageStates' import { TEST_SETTINGS_KEY, TEST_SETTINGS } from './storageStates'
@ -643,6 +643,16 @@ export async function setup(context: BrowserContext, page: Page) {
settings: TOML.stringify({ settings: TEST_SETTINGS }), settings: TOML.stringify({ settings: TEST_SETTINGS }),
} }
) )
await context.addCookies([
{
name: COOKIE_NAME,
value: secrets.token,
path: '/',
domain: 'localhost',
secure: true,
},
])
// kill animations, speeds up tests and reduced flakiness // kill animations, speeds up tests and reduced flakiness
await page.emulateMedia({ reducedMotion: 'reduce' }) await page.emulateMedia({ reducedMotion: 'reduce' })
} }

View File

@ -0,0 +1,358 @@
import { test, expect, Page } from '@playwright/test'
import { getUtils, setup, tearDown } from './test-utils'
test.beforeEach(async ({ context, page }) => {
await setup(context, page)
})
test.afterEach(async ({ page }, testInfo) => {
await tearDown(page, testInfo)
})
const CtrlKey = process.platform === 'darwin' ? 'Meta' : 'Control'
test.describe('Text-to-CAD tests', () => {
test('basic lego happy case', async ({ page }) => {
const u = await getUtils(page)
await page.setViewportSize({ width: 1000, height: 500 })
await u.waitForAuthSkipAppStart()
await sendPromptFromCommandBar(page, 'a 2x4 lego')
// Find the toast.
// Look out for the toast message
const submittingToastMessage = page.getByText(
`Submitting to Text-to-CAD API...`
)
await expect(submittingToastMessage).toBeVisible()
await page.waitForTimeout(5000)
const generatingToastMessage = page.getByText(
`Generating parametric model...`
)
await expect(generatingToastMessage).toBeVisible()
const successToastMessage = page.getByText(`Text-to-CAD successful`)
await expect(successToastMessage).toBeVisible()
await expect(page.getByText('Copied')).not.toBeVisible()
// Hit copy to clipboard.
const copyToClipboardButton = page.getByRole('button', {
name: 'Copy to clipboard',
})
await expect(copyToClipboardButton).toBeVisible()
await copyToClipboardButton.click()
// Expect the code to be copied.
await expect(page.getByText('Copied')).toBeVisible()
// Click in the code editor.
await page.locator('.cm-content').click()
// Paste the code.
await page.keyboard.down(CtrlKey)
await page.keyboard.press('KeyV')
await page.keyboard.up(CtrlKey)
// Expect the code to be pasted.
await expect(page.locator('.cm-content')).toContainText(`const`)
// make sure a model renders.
// wait for execution done
await u.openDebugPanel()
await u.expectCmdLog('[data-message-type="execution-done"]')
await u.closeDebugPanel()
// Find the toast close button.
const closeButton = page.getByRole('button', { name: 'Close' })
await expect(closeButton).toBeVisible()
await closeButton.click()
// The toast should disappear.
await expect(successToastMessage).not.toBeVisible()
})
test('you can reject text-to-cad output and it does nothing', async ({
page,
}) => {
const u = await getUtils(page)
await page.setViewportSize({ width: 1000, height: 500 })
await u.waitForAuthSkipAppStart()
await sendPromptFromCommandBar(page, 'a 2x4 lego')
// Find the toast.
// Look out for the toast message
const submittingToastMessage = page.getByText(
`Submitting to Text-to-CAD API...`
)
await expect(submittingToastMessage).toBeVisible()
await page.waitForTimeout(5000)
const generatingToastMessage = page.getByText(
`Generating parametric model...`
)
await expect(generatingToastMessage).toBeVisible()
const successToastMessage = page.getByText(`Text-to-CAD successful`)
await expect(successToastMessage).toBeVisible()
// Hit copy to clipboard.
const rejectButton = page.getByRole('button', { name: 'Reject' })
await expect(rejectButton).toBeVisible()
await rejectButton.click()
// The toast should disappear.
await expect(successToastMessage).not.toBeVisible()
// Expect no code.
await expect(page.locator('.cm-content')).toContainText(``)
})
test('sending a bad prompt fails, can dismiss', async ({ page }) => {
const u = await getUtils(page)
await page.setViewportSize({ width: 1000, height: 500 })
await u.waitForAuthSkipAppStart()
const commandBarButton = page.getByRole('button', { name: 'Commands' })
await expect(commandBarButton).toBeVisible()
// Click the command bar button
await commandBarButton.click()
// Wait for the command bar to appear
const cmdSearchBar = page.getByPlaceholder('Search commands')
await expect(cmdSearchBar).toBeVisible()
const textToCadCommand = page.getByText('Text-to-CAD')
await expect(textToCadCommand.first()).toBeVisible()
// Click the Text-to-CAD command
await textToCadCommand.first().click()
// Enter the prompt.
const prompt = page.getByText('Prompt')
await expect(prompt.first()).toBeVisible()
// Type the prompt.
await page.keyboard.type(
'akjsndladf lajbhflauweyfa;wieufjn;wieJNUF;.wjdfn weh Fwhefb'
)
await page.waitForTimeout(1000)
await page.keyboard.press('Enter')
// Find the toast.
// Look out for the toast message
const submittingToastMessage = page.getByText(
`Submitting to Text-to-CAD API...`
)
await expect(submittingToastMessage).toBeVisible()
const generatingToastMessage = page.getByText(
`Generating parametric model...`
)
await expect(generatingToastMessage).toBeVisible()
const failureToastMessage = page.getByText(
`The prompt must clearly describe a CAD model`
)
await expect(failureToastMessage).toBeVisible()
await page.waitForTimeout(1000)
// Make sure the toast did not say it was successful.
const successToastMessage = page.getByText(`Text-to-CAD successful`)
await expect(successToastMessage).not.toBeVisible()
await expect(page.getByText(`Text-to-CAD failed`)).toBeVisible()
// Find the toast dismiss button.
const dismissButton = page.getByRole('button', { name: 'Dismiss' })
await expect(dismissButton).toBeVisible()
await dismissButton.click()
// The toast should disappear.
await expect(failureToastMessage).not.toBeVisible()
})
test('sending a bad prompt fails, can start over', async ({ page }) => {
const u = await getUtils(page)
await page.setViewportSize({ width: 1000, height: 500 })
await u.waitForAuthSkipAppStart()
const commandBarButton = page.getByRole('button', { name: 'Commands' })
await expect(commandBarButton).toBeVisible()
// Click the command bar button
await commandBarButton.click()
// Wait for the command bar to appear
const cmdSearchBar = page.getByPlaceholder('Search commands')
await expect(cmdSearchBar).toBeVisible()
const textToCadCommand = page.getByText('Text-to-CAD')
await expect(textToCadCommand.first()).toBeVisible()
// Click the Text-to-CAD command
await textToCadCommand.first().click()
// Enter the prompt.
const prompt = page.getByText('Prompt')
await expect(prompt.first()).toBeVisible()
const badPrompt =
'akjsndladf lajbhflauweyfa;wieufjn;wieJNUF;.wjdfn weh Fwhefb'
// Type the prompt.
await page.keyboard.type(badPrompt)
await page.waitForTimeout(1000)
await page.keyboard.press('Enter')
// Find the toast.
// Look out for the toast message
const submittingToastMessage = page.getByText(
`Submitting to Text-to-CAD API...`
)
await expect(submittingToastMessage).toBeVisible()
const generatingToastMessage = page.getByText(
`Generating parametric model...`
)
await expect(generatingToastMessage).toBeVisible()
const failureToastMessage = page.getByText(
`The prompt must clearly describe a CAD model`
)
await expect(failureToastMessage).toBeVisible()
await page.waitForTimeout(1000)
// Make sure the toast did not say it was successful.
const successToastMessage = page.getByText(`Text-to-CAD successful`)
await expect(successToastMessage).not.toBeVisible()
await expect(page.getByText(`Text-to-CAD failed`)).toBeVisible()
// Click the edit prompt button to try again.
const editPromptButton = page.getByRole('button', { name: 'Edit prompt' })
await expect(editPromptButton).toBeVisible()
await editPromptButton.click()
// The toast should disappear.
await expect(failureToastMessage).not.toBeVisible()
// Make sure the old prompt is still there and can be edited.
await expect(page.locator('textarea')).toContainText(badPrompt)
// Select all and start a new prompt.
await page.keyboard.down(CtrlKey)
await page.keyboard.press('KeyA')
await page.keyboard.up(CtrlKey)
await page.keyboard.type('a 2x4 lego')
// Submit the new prompt.
await page.keyboard.press('Enter')
// Make sure the new prompt works.
// Find the toast.
// Look out for the toast message
await expect(submittingToastMessage).toBeVisible()
await page.waitForTimeout(5000)
await expect(generatingToastMessage).toBeVisible()
await expect(successToastMessage).toBeVisible()
})
test('ensure you can shift+enter in the prompt box', async ({ page }) => {
const u = await getUtils(page)
await page.setViewportSize({ width: 1000, height: 500 })
await u.waitForAuthSkipAppStart()
const promptWithNewline = `a 2x4\nlego`
const commandBarButton = page.getByRole('button', { name: 'Commands' })
await expect(commandBarButton).toBeVisible()
// Click the command bar button
await commandBarButton.click()
// Wait for the command bar to appear
const cmdSearchBar = page.getByPlaceholder('Search commands')
await expect(cmdSearchBar).toBeVisible()
const textToCadCommand = page.getByText('Text-to-CAD')
await expect(textToCadCommand.first()).toBeVisible()
// Click the Text-to-CAD command
await textToCadCommand.first().click()
// Enter the prompt.
const prompt = page.getByText('Prompt')
await expect(prompt.first()).toBeVisible()
// Type the prompt.
await page.keyboard.type('a 2x4')
await page.waitForTimeout(1000)
await page.keyboard.down('Shift')
await page.keyboard.press('Enter')
await page.keyboard.up('Shift')
await page.keyboard.type('lego')
await page.waitForTimeout(1000)
await page.keyboard.press('Enter')
// Find the toast.
// Look out for the toast message
const submittingToastMessage = page.getByText(
`Submitting to Text-to-CAD API...`
)
await expect(submittingToastMessage).toBeVisible()
await page.waitForTimeout(1000)
const generatingToastMessage = page.getByText(
`Generating parametric model...`
)
await expect(generatingToastMessage).toBeVisible()
await page.waitForTimeout(5000)
const successToastMessage = page.getByText(`Text-to-CAD successful`)
await expect(successToastMessage).toBeVisible()
await expect(page.getByText(promptWithNewline)).toBeVisible()
})
})
async function sendPromptFromCommandBar(page: Page, promptStr: string) {
const commandBarButton = page.getByRole('button', { name: 'Commands' })
await expect(commandBarButton).toBeVisible()
// Click the command bar button
await commandBarButton.click()
// Wait for the command bar to appear
const cmdSearchBar = page.getByPlaceholder('Search commands')
await expect(cmdSearchBar).toBeVisible()
const textToCadCommand = page.getByText('Text-to-CAD')
await expect(textToCadCommand.first()).toBeVisible()
// Click the Text-to-CAD command
await textToCadCommand.first().click()
// Enter the prompt.
const prompt = page.getByText('Prompt')
await expect(prompt.first()).toBeVisible()
// Type the prompt.
await page.keyboard.type(promptStr)
await page.waitForTimeout(1000)
await page.keyboard.press('Enter')
}

View File

@ -17,7 +17,7 @@
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"@headlessui/react": "^1.7.19", "@headlessui/react": "^1.7.19",
"@headlessui/tailwindcss": "^0.2.0", "@headlessui/tailwindcss": "^0.2.0",
"@kittycad/lib": "^0.0.70", "@kittycad/lib": "^0.0.76",
"@lezer/highlight": "^1.2.0", "@lezer/highlight": "^1.2.0",
"@lezer/lr": "^1.4.1", "@lezer/lr": "^1.4.1",
"@react-hook/resize-observer": "^2.0.1", "@react-hook/resize-observer": "^2.0.1",

View File

@ -5,6 +5,7 @@ import { CommandArgument } from 'lib/commandTypes'
import { useCommandsContext } from 'hooks/useCommandsContext' import { useCommandsContext } from 'hooks/useCommandsContext'
import CommandBarHeader from './CommandBarHeader' import CommandBarHeader from './CommandBarHeader'
import CommandBarKclInput from './CommandBarKclInput' import CommandBarKclInput from './CommandBarKclInput'
import CommandBarTextareaInput from './CommandBarTextareaInput'
function CommandBarArgument({ stepBack }: { stepBack: () => void }) { function CommandBarArgument({ stepBack }: { stepBack: () => void }) {
const { commandBarState, commandBarSend } = useCommandsContext() const { commandBarState, commandBarSend } = useCommandsContext()
@ -87,6 +88,14 @@ function ArgumentInput({
return ( return (
<CommandBarKclInput arg={arg} stepBack={stepBack} onSubmit={onSubmit} /> <CommandBarKclInput arg={arg} stepBack={stepBack} onSubmit={onSubmit} />
) )
case 'text':
return (
<CommandBarTextareaInput
arg={arg}
stepBack={stepBack}
onSubmit={onSubmit}
/>
)
default: default:
return ( return (
<CommandBarBasicInput <CommandBarBasicInput

View File

@ -0,0 +1,109 @@
import { useCommandsContext } from 'hooks/useCommandsContext'
import { CommandArgument } from 'lib/commandTypes'
import { RefObject, useEffect, useRef } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
function CommandBarTextareaInput({
arg,
stepBack,
onSubmit,
}: {
arg: CommandArgument<unknown> & {
inputType: 'text'
name: string
}
stepBack: () => void
onSubmit: (event: unknown) => void
}) {
const { commandBarSend, commandBarState } = useCommandsContext()
useHotkeys('mod + k, mod + /', () => commandBarSend({ type: 'Close' }))
const formRef = useRef<HTMLFormElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
useTextareaAutoGrow(inputRef)
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [arg, inputRef])
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
onSubmit(inputRef.current?.value)
}
return (
<form id="arg-form" onSubmit={handleSubmit} ref={formRef}>
<label className="flex items-start rounded mx-4 my-4 border border-chalkboard-100 dark:border-chalkboard-80">
<span className="capitalize px-2 py-1 rounded-br bg-chalkboard-100 dark:bg-chalkboard-80 text-chalkboard-10 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80">
{arg.name}
</span>
<textarea
id="arg-form"
name={arg.inputType}
ref={inputRef}
required
className="flex-grow mx-2 my-1 !bg-transparent focus:outline-none min-h-12"
placeholder="Enter a value"
defaultValue={
(commandBarState.context.argumentsToSubmit[arg.name] as
| string
| undefined) || (arg.defaultValue as string)
}
onKeyDown={(event) => {
if (event.key === 'Backspace' && !event.currentTarget.value) {
stepBack()
} else if (
event.key === 'Enter' &&
(event.metaKey || event.shiftKey)
) {
// Insert a newline
event.preventDefault()
const target = event.currentTarget
const value = target.value
const selectionStart = target.selectionStart
const selectionEnd = target.selectionEnd
target.value =
value.substring(0, selectionStart) +
'\n' +
value.substring(selectionEnd)
target.selectionStart = selectionStart + 1
target.selectionEnd = selectionStart + 1
} else if (event.key === 'Enter') {
formRef.current?.dispatchEvent(
new Event('submit', { bubbles: true })
)
}
}}
autoFocus
/>
</label>
</form>
)
}
/**
* Modified from https://www.reddit.com/r/reactjs/comments/twmild/comment/i3jf330/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button
* Thank you to @sidkh for the original code
*/
const useTextareaAutoGrow = (ref: RefObject<HTMLTextAreaElement>) => {
useEffect(() => {
const listener = () => {
if (ref.current === null) return
ref.current.style.padding = '0px'
ref.current.style.height = ref.current.scrollHeight + 'px'
ref.current.style.removeProperty('padding')
}
if (ref.current === null) return
ref.current.addEventListener('input', listener)
return () => {
if (ref.current === null) return
ref.current.removeEventListener('input', listener)
}
}, [ref.current])
}
export default CommandBarTextareaInput

View File

@ -121,6 +121,16 @@ const CustomIconMap = {
/> />
</svg> </svg>
), ),
chat: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M17 14.5L15.2929 15.2071L14.0858 14H5C3.89543 14 3 13.1046 3 12V6C3 4.89543 3.89543 4 5 4H14C15.6569 4 17 5.34315 17 7V11.5V13V14.5ZM5 13H14.5L15 13.5L16 14.5V13.0858V13V11.5V7C16 5.89543 15.1046 5 14 5H5C4.44771 5 4 5.44772 4 6V12C4 12.5523 4.44772 13 5 13ZM7 10C7.55228 10 8 9.55228 8 9C8 8.44772 7.55228 8 7 8C6.44772 8 6 8.44772 6 9C6 9.55228 6.44772 10 7 10ZM11 9C11 9.55228 10.5523 10 10 10C9.44772 10 9 9.55228 9 9C9 8.44772 9.44772 8 10 8C10.5523 8 11 8.44772 11 9ZM13 10C13.5523 10 14 9.55228 14 9C14 8.44772 13.5523 8 13 8C12.4477 8 12 8.44772 12 9C12 9.55228 12.4477 10 13 10Z"
fill="currentColor"
/>
</svg>
),
checkmark: ( checkmark: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path <path
@ -211,6 +221,16 @@ const CustomIconMap = {
/> />
</svg> </svg>
), ),
elephant: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.5 4C4.11929 4 3 5.11929 3 6.5V7C3 10.0376 5.46243 12.5 8.5 12.5H8.96482C9.46635 12.5 9.93469 12.2493 10.2129 11.8321L10.5173 11.3755C11.1396 12.0849 12.0423 12.5 13 12.5H13.75H15V14C15 14.2626 14.9483 14.5227 14.8478 14.7654C14.7472 15.008 14.5999 15.2285 14.4142 15.4142C14.2285 15.5999 14.008 15.7472 13.7654 15.8478C13.5227 15.9483 13.2626 16 13 16C12.7374 16 12.4773 15.9483 12.2346 15.8478C11.992 15.7472 11.7715 15.5999 11.5858 15.4142C11.4001 15.2285 11.2528 15.008 11.1522 14.7654C11.1164 14.6789 11.0868 14.5902 11.0635 14.5H11.8544C11.9168 14.6431 12.0056 14.7734 12.1161 14.8839C12.2322 15 12.37 15.092 12.5216 15.1548C12.6733 15.2177 12.8358 15.25 13 15.25C13.1642 15.25 13.3267 15.2177 13.4784 15.1548C13.63 15.092 13.7678 15 13.8839 14.8839C14 14.7678 14.092 14.63 14.1548 14.4784C14.2177 14.3267 14.25 14.1642 14.25 14V13H13.25V14C13.25 14.0328 13.2435 14.0653 13.231 14.0957C13.2184 14.126 13.2 14.1536 13.1768 14.1768C13.1536 14.2 13.126 14.2184 13.0957 14.231C13.0653 14.2435 13.0328 14.25 13 14.25C12.9672 14.25 12.9347 14.2435 12.9043 14.231C12.874 14.2184 12.8464 14.2 12.8232 14.1768C12.8 14.1536 12.7816 14.126 12.769 14.0957C12.7565 14.0653 12.75 14.0328 12.75 14V13.5H12.25H10.5H10V14C10 14.394 10.0776 14.7841 10.2284 15.1481C10.3791 15.512 10.6001 15.8427 10.8787 16.1213C11.1573 16.3999 11.488 16.6209 11.8519 16.7716C12.2159 16.9224 12.606 17 13 17C13.394 17 13.7841 16.9224 14.1481 16.7716C14.512 16.6209 14.8427 16.3999 15.1213 16.1213C15.3999 15.8427 15.6209 15.512 15.7716 15.1481C15.9224 14.7841 16 14.394 16 14V12.5H17V11.5H16V8.5C16 6.01472 13.9853 4 11.5 4H5.5ZM11.084 10.4746L10.9226 10.2326L9.42875 7.74275L8.57125 8.25725L9.90846 10.4859L9.38084 11.2773C9.28811 11.4164 9.13199 11.5 8.96482 11.5H8.5C6.01472 11.5 4 9.48528 4 7V6.5C4 5.67157 4.67157 5 5.5 5H11.5C13.433 5 15 6.567 15 8.5V11.5H13.75H13C12.2301 11.5 11.5111 11.1152 11.084 10.4746ZM13.5 8.5C13.5 9.05228 13.0523 9.5 12.5 9.5C11.9477 9.5 11.5 9.05228 11.5 8.5C11.5 7.94772 11.9477 7.5 12.5 7.5C13.0523 7.5 13.5 7.94772 13.5 8.5Z"
fill="black"
/>
</svg>
),
equal: ( equal: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path <path
@ -590,13 +610,7 @@ const CustomIconMap = {
</svg> </svg>
), ),
revolve: ( revolve: (
<svg <svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path <path
fillRule="evenodd" fillRule="evenodd"
clipRule="evenodd" clipRule="evenodd"
@ -645,6 +659,16 @@ const CustomIconMap = {
/> />
</svg> </svg>
), ),
sparkles: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.92704 6.63163L7.67352 4.61429L8.42 6.63163L8.71541 6.92704L10.7327 7.67352L8.71541 8.42L8.42 8.71541L7.67352 10.7327L6.92704 8.71541L6.63163 8.42L4.61429 7.67352L6.63163 6.92704L6.92704 6.63163ZM7.20459 3L6.06897 6.06897L3 7.20459V8.14244L6.06897 9.27807L7.20459 12.347H8.14244L9.27807 9.27807L12.347 8.14244V7.20459L9.27807 6.06897L8.14244 3H7.20459ZM13.4822 13.1868L13.8235 12.2643L14.1649 13.1868L14.4603 13.4822L15.3827 13.8235L14.4603 14.1649L14.1649 14.4603L13.8235 15.3827L13.4822 14.4603L13.1868 14.1649L12.2643 13.8235L13.1868 13.4822L13.4822 13.1868ZM13.3546 10.65L12.6241 12.6241L10.65 13.3546V14.2924L12.6241 15.0229L13.3546 16.997H14.2924L15.0229 15.0229L16.997 14.2924V13.3546L15.0229 12.6241L14.2924 10.65H13.3546Z"
fill="currentColor"
/>
</svg>
),
spline: ( spline: (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path <path
@ -656,13 +680,7 @@ const CustomIconMap = {
</svg> </svg>
), ),
sweep: ( sweep: (
<svg <svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path <path
fillRule="evenodd" fillRule="evenodd"
clipRule="evenodd" clipRule="evenodd"

View File

@ -15,7 +15,13 @@ import {
} from 'xstate' } from 'xstate'
import { useCommandsContext } from 'hooks/useCommandsContext' import { useCommandsContext } from 'hooks/useCommandsContext'
import { fileMachine } from 'machines/fileMachine' import { fileMachine } from 'machines/fileMachine'
import { mkdir, remove, rename, create } from '@tauri-apps/plugin-fs' import {
mkdir,
remove,
rename,
create,
writeTextFile,
} from '@tauri-apps/plugin-fs'
import { isTauri } from 'lib/isTauri' import { isTauri } from 'lib/isTauri'
import { join, sep } from '@tauri-apps/api/path' import { join, sep } from '@tauri-apps/api/path'
import { DEFAULT_FILE_NAME, FILE_EXT } from 'lib/constants' import { DEFAULT_FILE_NAME, FILE_EXT } from 'lib/constants'
@ -94,6 +100,30 @@ export const FileMachineProvider = ({
children: newFiles, children: newFiles,
} }
}, },
createAndOpenFile: async (context, event) => {
let createdName = event.data.name.trim() || DEFAULT_FILE_NAME
let createdPath: string
if (event.data.makeDir) {
createdPath = await join(context.selectedDirectory.path, createdName)
await mkdir(createdPath)
} else {
createdPath =
context.selectedDirectory.path +
sep() +
createdName +
(createdName.endsWith(FILE_EXT) ? '' : FILE_EXT)
await create(createdPath)
if (event.data.content) {
await writeTextFile(createdPath, event.data.content)
}
}
return {
message: `Successfully created "${createdName}"`,
path: createdPath,
}
},
createFile: async (context, event) => { createFile: async (context, event) => {
let createdName = event.data.name.trim() || DEFAULT_FILE_NAME let createdName = event.data.name.trim() || DEFAULT_FILE_NAME
let createdPath: string let createdPath: string
@ -108,10 +138,12 @@ export const FileMachineProvider = ({
createdName + createdName +
(createdName.endsWith(FILE_EXT) ? '' : FILE_EXT) (createdName.endsWith(FILE_EXT) ? '' : FILE_EXT)
await create(createdPath) await create(createdPath)
if (event.data.content) {
await writeTextFile(createdPath, event.data.content)
}
} }
return { return {
message: `Successfully created "${createdName}"`,
path: createdPath, path: createdPath,
} }
}, },
@ -182,6 +214,7 @@ export const FileMachineProvider = ({
if (event.type !== 'done.invoke.read-files') return false if (event.type !== 'done.invoke.read-files') return false
return !!event?.data?.children && event.data.children.length > 0 return !!event?.data?.children && event.data.children.length > 0
}, },
'Is not silent': (_, event) => !event.data?.silent,
}, },
}) })

View File

@ -71,7 +71,7 @@ import { exportFromEngine } from 'lib/exportFromEngine'
import { Models } from '@kittycad/lib/dist/types/src' import { Models } from '@kittycad/lib/dist/types/src'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { EditorSelection, Transaction } from '@codemirror/state' import { EditorSelection, Transaction } from '@codemirror/state'
import { useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { letEngineAnimateAndSyncCamAfter } from 'clientSideScene/CameraControls' import { letEngineAnimateAndSyncCamAfter } from 'clientSideScene/CameraControls'
import { getVarNameModal } from 'hooks/useToolbarGuards' import { getVarNameModal } from 'hooks/useToolbarGuards'
import { err, trap } from 'lib/trap' import { err, trap } from 'lib/trap'
@ -83,6 +83,8 @@ import {
EngineConnectionStateType, EngineConnectionStateType,
EngineConnectionEvents, EngineConnectionEvents,
} from 'lang/std/engineConnection' } from 'lang/std/engineConnection'
import { submitAndAwaitTextToKcl } from 'lib/textToCad'
import { useFileContext } from 'hooks/useFileContext'
type MachineContext<T extends AnyStateMachine> = { type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T> state: StateFrom<T>
@ -108,6 +110,8 @@ export const ModelingMachineProvider = ({
}, },
}, },
} = useSettingsAuthContext() } = useSettingsAuthContext()
const navigate = useNavigate()
const { context, send: fileMachineSend } = useFileContext()
const token = auth?.context?.token const token = auth?.context?.token
const streamRef = useRef<HTMLDivElement>(null) const streamRef = useRef<HTMLDivElement>(null)
const persistedContext = useMemo(() => getPersistedContext(), []) const persistedContext = useMemo(() => getPersistedContext(), [])
@ -115,7 +119,7 @@ export const ModelingMachineProvider = ({
let [searchParams] = useSearchParams() let [searchParams] = useSearchParams()
const pool = searchParams.get('pool') const pool = searchParams.get('pool')
const { commandBarState } = useCommandsContext() const { commandBarState, commandBarSend } = useCommandsContext()
// Settings machine setup // Settings machine setup
// const retrievedSettings = useRef( // const retrievedSettings = useRef(
@ -463,6 +467,29 @@ export const ModelingMachineProvider = ({
} }
) )
}, },
'Add pending text-to-cad prompt': assign({
pendingTextToCad: (_, event) => event.data.prompt.trim(),
}),
'Remove pending text-to-cad prompt': assign({
pendingTextToCad: undefined,
}),
'Submit to Text-to-CAD API': async (_, { data }) => {
const trimmedPrompt = data.prompt.trim()
if (!trimmedPrompt) return
void submitAndAwaitTextToKcl({
trimmedPrompt,
fileMachineSend,
navigate,
commandBarSend,
context,
token,
settings: {
theme: theme.current,
highlightEdges: highlightEdges.current,
},
})
},
}, },
guards: { guards: {
'has valid extrude selection': ({ selectionRanges }) => { 'has valid extrude selection': ({ selectionRanges }) => {
@ -522,6 +549,8 @@ export const ModelingMachineProvider = ({
return false return false
} }
}, },
'Has no pending text-to-cad submissions': ({ pendingTextToCad }) =>
!pendingTextToCad,
}, },
services: { services: {
'AST-undo-startSketchOn': async ({ sketchDetails }) => { 'AST-undo-startSketchOn': async ({ sketchDetails }) => {

View File

@ -0,0 +1,364 @@
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { useFileContext } from 'hooks/useFileContext'
import { isTauri } from 'lib/isTauri'
import { PATHS } from 'lib/paths'
import toast from 'react-hot-toast'
import { sep } from '@tauri-apps/api/path'
import { TextToCad_type } from '@kittycad/lib/dist/types/src/models'
import { useEffect, useRef, useState } from 'react'
import {
Box3,
Color,
DirectionalLight,
EdgesGeometry,
LineBasicMaterial,
LineSegments,
Mesh,
MeshBasicMaterial,
OrthographicCamera,
Scene,
Vector3,
WebGLRenderer,
} from 'three'
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader'
import { base64Decode } from 'lang/wasm'
import { sendTelemetry } from 'lib/textToCad'
import { Themes } from 'lib/theme'
import { ActionButton } from './ActionButton'
import { commandBarMachine } from 'machines/commandBarMachine'
import { EventData, EventFrom } from 'xstate'
import { fileMachine } from 'machines/fileMachine'
const CANVAS_SIZE = 128
const PROMPT_TRUNCATE_LENGTH = 128
const FRUSTUM_SIZE = 0.5
const OUTPUT_KEY = 'source.glb'
export function ToastTextToCadError({
message,
prompt,
commandBarSend,
}: {
message: string
prompt: string
commandBarSend: (
event: EventFrom<typeof commandBarMachine>,
data?: EventData
) => void
}) {
return (
<div className="flex flex-col justify-between gap-6">
<section>
<h2>Text-to-CAD failed</h2>
<p className="text-sm text-chalkboard-70 dark:text-chalkboard-30">
{message}
</p>
</section>
<div className="flex justify-between gap-8">
<ActionButton
Element="button"
iconStart={{
icon: 'close',
}}
name="Dismiss"
onClick={() => {
toast.dismiss()
}}
>
Dismiss
</ActionButton>
<ActionButton
Element="button"
iconStart={{
icon: 'refresh',
}}
name="Edit prompt"
onClick={() => {
commandBarSend({
type: 'Find and select command',
data: {
groupId: 'modeling',
name: 'Text-to-CAD',
argDefaultValues: {
prompt,
},
},
})
toast.dismiss()
}}
>
Edit prompt
</ActionButton>
</div>
</div>
)
}
export function ToastTextToCadSuccess({
data,
navigate,
context,
token,
fileMachineSend,
settings,
}: {
// TODO: update this type to match the actual data when API is done
data: TextToCad_type & { fileName: string }
navigate: (to: string) => void
context: ReturnType<typeof useFileContext>['context']
token?: string
fileMachineSend: (
event: EventFrom<typeof fileMachine>,
data?: EventData
) => void
settings: {
theme: Themes
highlightEdges: boolean
}
}) {
const wrapperRef = useRef<HTMLDivElement | null>(null)
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const [hasCopied, setHasCopied] = useState(false)
const [showCopiedUi, setShowCopiedUi] = useState(false)
const modelId = data.id
useEffect(() => {
if (!canvasRef.current) return
const canvas = canvasRef.current
const renderer = new WebGLRenderer({ canvas, antialias: true, alpha: true })
renderer.setSize(CANVAS_SIZE, CANVAS_SIZE)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.setAnimationLoop(animate)
const scene = new Scene()
const ambientLight = new DirectionalLight(new Color('white'), 8.0)
scene.add(ambientLight)
const camera = createCamera()
const controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
const loader = new GLTFLoader()
const dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath('/examples/jsm/libs/draco/')
loader.setDRACOLoader(dracoLoader)
scene.add(camera)
// Get the base64 encoded GLB file
const buffer = base64Decode(data.outputs[OUTPUT_KEY])
if (buffer instanceof Error) {
toast.error('Error loading GLB file: ' + buffer.message)
console.error('decoding buffer from base64 failed', buffer)
return
}
function animate() {
requestAnimationFrame(animate)
// required if controls.enableDamping or controls.autoRotate are set to true
controls.update()
renderer.render(scene, camera)
}
loader.parse(
buffer,
'',
// called when the resource is loaded
function (gltf) {
scene.add(gltf.scene)
// Style the objects in the scene
traverseSceneToStyleObjects({
scene,
...settings,
})
// Then we'll calculate the max distance of the bounding box
// and set that as the camera's position
const size = new Vector3()
const boundingBox = new Box3()
boundingBox.setFromObject(gltf.scene)
boundingBox.getSize(size)
const maxDistance = Math.max(size.x, size.y, size.z)
camera.position.set(maxDistance * 2, maxDistance * 2, maxDistance * 2)
camera.lookAt(0, 0, 0)
camera.left = -maxDistance
camera.right = maxDistance
camera.top = maxDistance
camera.bottom = -maxDistance
camera.near = 0
camera.far = maxDistance * 10
// Create and attach the lights,
// since their position depends on the bounding box
const cameraLight1 = new DirectionalLight(new Color('white'), 1)
cameraLight1.position.set(maxDistance * -5, -maxDistance, maxDistance)
camera.add(cameraLight1)
const cameraLight2 = new DirectionalLight(new Color('white'), 1.4)
cameraLight2.position.set(0, 0, 2 * maxDistance)
camera.add(cameraLight2)
const sceneLight = new DirectionalLight(new Color('white'), 1)
sceneLight.position.set(
-2 * maxDistance,
-2 * maxDistance,
2 * maxDistance
)
scene.add(sceneLight)
camera.updateProjectionMatrix()
controls.update()
},
// called when loading has errors
function (error) {
toast.error('Error loading GLB file: ' + error.message)
console.error('Error loading GLB file', error)
return
}
)
return () => {
renderer.dispose()
}
}, [])
return (
<div className="flex gap-4 min-w-80" ref={wrapperRef}>
<div
className="flex-none overflow-hidden"
style={{ width: CANVAS_SIZE + 'px', height: CANVAS_SIZE + 'px' }}
>
<canvas ref={canvasRef} width={CANVAS_SIZE} height={CANVAS_SIZE} />
</div>
<div className="flex flex-col justify-between gap-6">
<section>
<h2>Text-to-CAD successful</h2>
<p className="text-sm text-chalkboard-70 dark:text-chalkboard-30">
Prompt: "
{data.prompt.length > PROMPT_TRUNCATE_LENGTH
? data.prompt.slice(0, PROMPT_TRUNCATE_LENGTH) + '...'
: data.prompt}
"
</p>
</section>
<div className="flex justify-between gap-8">
<ActionButton
Element="button"
iconStart={{
icon: 'close',
}}
name={hasCopied ? 'Close' : 'Reject'}
onClick={() => {
if (!hasCopied) {
sendTelemetry(modelId, 'rejected', token)
}
if (isTauri()) {
// Delete the file from the project
fileMachineSend({
type: 'Delete file',
data: {
name: data.fileName,
path: `${context.project.path}${sep()}${data.fileName}`,
children: null,
},
})
}
toast.dismiss()
}}
>
{hasCopied ? 'Close' : 'Reject'}
</ActionButton>
{isTauri() ? (
<ActionButton
Element="button"
iconStart={{
icon: 'checkmark',
}}
name="Accept"
onClick={() => {
sendTelemetry(modelId, 'accepted', token)
navigate(
`${PATHS.FILE}/${encodeURIComponent(
`${context.project.path}${sep()}${data.fileName}`
)}`
)
toast.dismiss()
}}
>
Accept
</ActionButton>
) : (
<ActionButton
Element="button"
iconStart={{
icon: showCopiedUi ? 'clipboardCheckmark' : 'clipboardPlus',
}}
name="Copy to clipboard"
onClick={() => {
sendTelemetry(modelId, 'accepted', token)
navigator.clipboard.writeText(data.code || '// no code found')
setShowCopiedUi(true)
setHasCopied(true)
// Reset the button text after 5 seconds
setTimeout(() => {
setShowCopiedUi(false)
}, 5000)
}}
>
{showCopiedUi ? 'Copied' : 'Copy to clipboard'}
</ActionButton>
)}
</div>
</div>
</div>
)
}
const createCamera = (): OrthographicCamera => {
return new OrthographicCamera(
-FRUSTUM_SIZE,
FRUSTUM_SIZE,
FRUSTUM_SIZE,
-FRUSTUM_SIZE,
0.5,
3
)
}
function traverseSceneToStyleObjects({
scene,
color = 0x29ffa4,
highlightEdges = false,
}: {
scene: Scene
color?: number
theme: Themes
highlightEdges?: boolean
}) {
scene.traverse((child) => {
if ('isMesh' in child && child.isMesh) {
// Replace the material with our flat color one
;(child as Mesh).material = new MeshBasicMaterial({
color,
})
// Add edges to the mesh if the option is enabled
if (!highlightEdges) return
const edges = new EdgesGeometry((child as Mesh).geometry, 30)
const lines = new LineSegments(
edges,
new LineBasicMaterial({
// We don't respect the theme here on purpose,
// because I found the dark edges to work better
// in light and dark themes,
// but we can change that if needed, it is wired up
color: 0x1f2020,
})
)
scene.add(lines)
}
})
}

View File

@ -34,6 +34,7 @@ root.render(
toastOptions={{ toastOptions={{
style: { style: {
borderRadius: '3px', borderRadius: '3px',
maxInlineSize: 'min(480px, 100%)',
}, },
className: className:
'bg-chalkboard-10 dark:bg-chalkboard-90 text-chalkboard-110 dark:text-chalkboard-10 rounded-sm border-chalkboard-20/50 dark:border-chalkboard-80/50', 'bg-chalkboard-10 dark:bg-chalkboard-90 text-chalkboard-110 dark:text-chalkboard-10 rounded-sm border-chalkboard-20/50 dark:border-chalkboard-80/50',

View File

@ -17,6 +17,7 @@ import init, {
parse_project_settings, parse_project_settings,
default_project_settings, default_project_settings,
parse_project_route, parse_project_route,
base64_decode,
} from '../wasm-lib/pkg/wasm_lib' } from '../wasm-lib/pkg/wasm_lib'
import { KCLError } from './errors' import { KCLError } from './errors'
import { KclError as RustKclError } from '../wasm-lib/kcl/bindings/KclError' import { KclError as RustKclError } from '../wasm-lib/kcl/bindings/KclError'
@ -593,3 +594,13 @@ export function parseProjectRoute(
): ProjectRoute | Error { ): ProjectRoute | Error {
return parse_project_route(JSON.stringify(configuration), route_str) return parse_project_route(JSON.stringify(configuration), route_str)
} }
export function base64Decode(base64: string): ArrayBuffer | Error {
try {
const decoded = base64_decode(base64)
return new Uint8Array(decoded).buffer
} catch (e) {
console.error('Caught error decoding base64 string: ' + e)
return new Error('Caught error decoding base64 string: ' + e)
}
}

View File

@ -40,6 +40,9 @@ export type ModelingCommandSchema = {
'change tool': { 'change tool': {
tool: SketchTool tool: SketchTool
} }
'Text-to-CAD': {
prompt: string
}
} }
export const modelingMachineCommandConfig: StateMachineCommandSetConfig< export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
@ -258,4 +261,14 @@ export const modelingMachineCommandConfig: StateMachineCommandSetConfig<
}, },
}, },
}, },
'Text-to-CAD': {
description: 'Use the Zoo Text-to-CAD API to generate part starters.',
icon: 'chat',
args: {
prompt: {
inputType: 'text',
required: true,
},
},
},
} }

View File

@ -15,6 +15,7 @@ const PLATFORMS = ['both', 'web', 'desktop'] as const
const INPUT_TYPES = [ const INPUT_TYPES = [
'options', 'options',
'string', 'string',
'text',
'kcl', 'kcl',
'selection', 'selection',
'boolean', 'boolean',
@ -151,6 +152,16 @@ export type CommandArgumentConfig<
) => OutputType) ) => OutputType)
defaultValueFromContext?: (context: C) => OutputType defaultValueFromContext?: (context: C) => OutputType
} }
| {
inputType: 'text'
defaultValue?:
| OutputType
| ((
commandBarContext: ContextFrom<typeof commandBarMachine>,
machineContext?: C
) => OutputType)
defaultValueFromContext?: (context: C) => OutputType
}
| { | {
inputType: 'boolean' inputType: 'boolean'
defaultValue?: defaultValue?:
@ -213,6 +224,15 @@ export type CommandArgument<
machineContext?: ContextFrom<T> machineContext?: ContextFrom<T>
) => OutputType) ) => OutputType)
} }
| {
inputType: 'text'
defaultValue?:
| OutputType
| ((
commandBarContext: ContextFrom<typeof commandBarMachine>,
machineContext?: ContextFrom<T>
) => OutputType)
}
| { | {
inputType: 'boolean' inputType: 'boolean'
defaultValue?: defaultValue?:

View File

@ -55,3 +55,4 @@ export const KCL_DEFAULT_CONSTANT_PREFIXES = {
} as const } as const
/** The default KCL length expression */ /** The default KCL length expression */
export const KCL_DEFAULT_LENGTH = `5` export const KCL_DEFAULT_LENGTH = `5`
export const COOKIE_NAME = '__Secure-next-auth.session-token'

View File

@ -0,0 +1,56 @@
import { DEV } from 'env'
import { isTauri } from './isTauri'
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
const headers = (token?: string): HeadersInit => ({
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
})
export default async function crossPlatformFetch<T>(
url: string,
options?: RequestInit,
token?: string
): Promise<T | Error> {
let response = null
let opts = options || {}
if (isTauri()) {
if (!token) {
return new Error('No token provided')
}
opts.headers = headers(token)
response = await tauriFetch(url, opts)
} else {
// Add credentials: 'include' to options
// We send the token with the headers only in development mode, DO NOT
// DO THIS IN PRODUCTION, as it is a security risk.
opts.headers = headers(DEV ? token : undefined)
opts.credentials = 'include'
response = await fetch(url, opts)
}
if (!response) {
return new Error('Failed to request endpoint: ' + url)
}
if (!response.ok) {
console.error(
'Failed to request endpoint: ' + url,
JSON.stringify(response)
)
return new Error(
'Failed to request endpoint: ' +
url +
' with status: ' +
response.status +
' ' +
response.statusText
)
}
const data = (await response.json()) as T | Error
return data
}

238
src/lib/textToCad.ts Normal file
View File

@ -0,0 +1,238 @@
import { Models } from '@kittycad/lib'
import {
ToastTextToCadError,
ToastTextToCadSuccess,
} from 'components/ToastTextToCad'
import { VITE_KC_API_BASE_URL } from 'env'
import toast from 'react-hot-toast'
import { FILE_EXT } from './constants'
import { ContextFrom, EventData, EventFrom } from 'xstate'
import { fileMachine } from 'machines/fileMachine'
import { NavigateFunction } from 'react-router-dom'
import crossPlatformFetch from './crossPlatformFetch'
import { isTauri } from './isTauri'
import { Themes } from './theme'
import { commandBarMachine } from 'machines/commandBarMachine'
export async function submitTextToCadPrompt(
prompt: string,
token?: string
): Promise<Models['TextToCad_type'] | Error> {
const body: Models['TextToCadCreateBody_type'] = { prompt }
// Glb has a smaller footprint than gltf, should we want to render it.
const url = VITE_KC_API_BASE_URL + '/ai/text-to-cad/glb?kcl=true'
const data: Models['TextToCad_type'] | Error = await crossPlatformFetch(
url,
{
method: 'POST',
body: JSON.stringify(body),
},
token
)
return data
}
export async function getTextToCadResult(
id: string,
token?: string
): Promise<Models['TextToCad_type'] | Error> {
const url = VITE_KC_API_BASE_URL + '/user/text-to-cad/' + id
const data: Models['TextToCad_type'] | Error = await crossPlatformFetch(
url,
{
method: 'GET',
},
token
)
return data
}
interface TextToKclProps {
trimmedPrompt: string
fileMachineSend: (
type: EventFrom<typeof fileMachine>,
data?: EventData
) => unknown
navigate: NavigateFunction
commandBarSend: (
type: EventFrom<typeof commandBarMachine>,
data?: EventData
) => unknown
context: ContextFrom<typeof fileMachine>
token?: string
settings: {
theme: Themes
highlightEdges: boolean
}
}
export async function submitAndAwaitTextToKcl({
trimmedPrompt,
fileMachineSend,
navigate,
commandBarSend,
context,
token,
settings,
}: TextToKclProps) {
const toastId = toast.loading('Submitting to Text-to-CAD API...')
const showFailureToast = (message: string) => {
toast.error(
() =>
ToastTextToCadError({
message,
commandBarSend,
prompt: trimmedPrompt,
}),
{
id: toastId,
duration: Infinity,
}
)
}
const textToCadQueued = await submitTextToCadPrompt(trimmedPrompt, token)
.then((value) => {
if (value instanceof Error) {
return Promise.reject(value)
}
return value
})
.catch((error) => {
showFailureToast('Failed to submit to Text-to-CAD API')
return error
})
if (textToCadQueued instanceof Error) {
showFailureToast('Failed to submit to Text-to-CAD API')
return
}
toast.loading('Generating parametric model...', {
id: toastId,
})
// Check the status of the text-to-cad API job
// until it is completed
const textToCadComplete = new Promise<Models['TextToCad_type']>(
async (resolve, reject) => {
const value = await textToCadQueued
if (value instanceof Error) {
reject(value)
}
const MAX_CHECK_TIMEOUT = 3 * 60_000
const CHECK_INTERVAL = 3000
let timeElapsed = 0
const interval = setInterval(async () => {
timeElapsed += CHECK_INTERVAL
if (timeElapsed >= MAX_CHECK_TIMEOUT) {
clearInterval(interval)
reject(new Error('Text-to-CAD API timed out'))
}
const check = await getTextToCadResult(value.id, token)
if (check instanceof Error) {
clearInterval(interval)
reject(check)
}
if (check instanceof Error || check.status === 'failed') {
clearInterval(interval)
reject(check)
} else if (check.status === 'completed') {
clearInterval(interval)
resolve(check)
}
}, CHECK_INTERVAL)
}
)
const textToCadOutputCreated = await textToCadComplete
.catch((e) => {
showFailureToast('Failed to generate parametric model')
return e
})
.then((value) => {
if (value.code === undefined || !value.code || value.code.length === 0) {
// We want to show the real error message to the user.
if (value.error && value.error.length > 0) {
const error = value.error.replace('Text-to-CAD server:', '').trim()
showFailureToast(error)
return Promise.reject(new Error(error))
} else {
showFailureToast('No KCL code returned')
return Promise.reject(new Error('No KCL code returned'))
}
}
const TRUNCATED_PROMPT_LENGTH = 24
const newFileName = `${value.prompt
.slice(0, TRUNCATED_PROMPT_LENGTH)
.replace(/\s/gi, '-')
.replace(/\W/gi, '-')
.toLowerCase()}`
if (isTauri()) {
fileMachineSend({
type: 'Create file',
data: {
name: newFileName,
makeDir: false,
content: value.code,
silent: true,
makeUnique: true,
},
})
}
return {
...value,
fileName: newFileName + FILE_EXT,
}
})
if (textToCadOutputCreated instanceof Error) {
showFailureToast('Failed to generate parametric model')
return
}
// Show a custom toast with the .glb model preview
// and options to reject or accept the model
toast.success(
() =>
ToastTextToCadSuccess({
data: textToCadOutputCreated,
token,
navigate,
context,
fileMachineSend,
settings,
}),
{
id: toastId,
duration: Infinity,
icon: null,
}
)
return textToCadOutputCreated
}
export async function sendTelemetry(
id: string,
feedback: Models['AiFeedback_type'],
token?: string
): Promise<void> {
const url =
VITE_KC_API_BASE_URL + '/user/text-to-cad/' + id + '?feedback=' + feedback
await crossPlatformFetch(
url,
{
method: 'POST',
},
token
)
}

View File

@ -4,6 +4,7 @@ import withBaseURL from '../lib/withBaseURL'
import { isTauri } from 'lib/isTauri' import { isTauri } from 'lib/isTauri'
import { VITE_KC_API_BASE_URL, VITE_KC_DEV_TOKEN } from 'env' import { VITE_KC_API_BASE_URL, VITE_KC_DEV_TOKEN } from 'env'
import { getUser as getUserTauri } from 'lib/tauri' import { getUser as getUserTauri } from 'lib/tauri'
import { COOKIE_NAME } from 'lib/constants'
const SKIP_AUTH = const SKIP_AUTH =
import.meta.env.VITE_KC_SKIP_AUTH === 'true' && import.meta.env.DEV import.meta.env.VITE_KC_SKIP_AUTH === 'true' && import.meta.env.DEV
@ -38,7 +39,6 @@ export type Events =
token?: string token?: string
} }
const COOKIE_NAME = '__Secure-next-auth.session-token'
export const TOKEN_PERSIST_KEY = 'TOKEN_PERSIST_KEY' export const TOKEN_PERSIST_KEY = 'TOKEN_PERSIST_KEY'
const persistedToken = const persistedToken =
getCookie(COOKIE_NAME) || localStorage?.getItem(TOKEN_PERSIST_KEY) || '' getCookie(COOKIE_NAME) || localStorage?.getItem(TOKEN_PERSIST_KEY) || ''

View File

@ -22,7 +22,7 @@ export type CommandBarMachineEvent =
| { type: 'Clear' } | { type: 'Clear' }
| { | {
type: 'Select command' type: 'Select command'
data: { command: Command } data: { command: Command; argDefaultValues?: { [x: string]: unknown } }
} }
| { type: 'Deselect command' } | { type: 'Deselect command' }
| { type: 'Submit command'; data: { [x: string]: unknown } } | { type: 'Submit command'; data: { [x: string]: unknown } }
@ -57,7 +57,11 @@ export type CommandBarMachineEvent =
} }
| { | {
type: 'Find and select command' type: 'Find and select command'
data: { name: string; groupId: string } data: {
name: string
groupId: string
argDefaultValues?: { [x: string]: unknown }
}
} }
| { | {
type: 'Change current argument' type: 'Change current argument'
@ -328,7 +332,12 @@ export const commandBarMachine = createMachine(
context.argumentsToSubmit[argName] === undefined || context.argumentsToSubmit[argName] === undefined ||
(rejectedArg && rejectedArg.name === argName)) (rejectedArg && rejectedArg.name === argName))
if (mustNotSkipArg === true) { if (
mustNotSkipArg === true ||
argIndex + 1 === Object.keys(selectedCommand.args).length
) {
// If we have reached the end of the arguments and none are skippable,
// return the last argument.
return { return {
...selectedCommand.args[argName], ...selectedCommand.args[argName],
name: argName, name: argName,
@ -393,7 +402,11 @@ export const commandBarMachine = createMachine(
const args: { [x: string]: unknown } = {} const args: { [x: string]: unknown } = {}
for (const [argName, arg] of Object.entries(command.args)) { for (const [argName, arg] of Object.entries(command.args)) {
args[argName] = args[argName] =
arg.skip && 'defaultValue' in arg ? arg.defaultValue : undefined e.data.argDefaultValues && argName in e.data.argDefaultValues
? e.data.argDefaultValues[argName]
: arg.skip && 'defaultValue' in arg
? arg.defaultValue
: undefined
} }
return args return args
}, },

View File

@ -4,7 +4,7 @@ import { Project } from 'wasm-lib/kcl/bindings/Project'
export const fileMachine = createMachine( export const fileMachine = createMachine(
{ {
/** @xstate-layout N4IgpgJg5mDOIC5QDECWAbMACAtgQwGMALVAOzAGI9ZZUpSBtABgF1FQAHAe1oBdUupdiAAeiAKwBGcQDoALACYAHAE4AzGoUA2JXK1yANCACeiabOmSlGpkqsqAvg6NpMuQiXIUASmABmAE5wRMxsSCDcfAJC4WIIWgpMMtpqkgrKalJa0kamCJIA7AUySgX6mkwK4gXVckpOLhjY+MRkYDIAEtRYpFxYfk2wFADCQXi82AOYocKRqPyCwnGSmbJa4ulKquJqcnriuYiaKsm2BSpVTAVyTOJyDSCuzR5tnd1TcD5gpHg4k00zcJzBYxUBxFRKWRKLS3XRqFSSHTVQ4IXRJAppRJWSr6erOR5NdytchvWD9QYjMYTcnTVizHjzaJLMyrGTrTbbXb7FF3E6SOSaNRMJhaLQFeEKB5PImedpdMkfIYAETAmGpH0BnAZIOZ+VZ7OUnL26xRui0MnE2WkpQxq0l+OlLVlpJpnwA8hxvq7NRFtUzYizxGsNoaVDtjQcTIh1JISsLMiLUlIrlLCU7XvLXUMAMpgXhYWCqsAECYQLAQVBBEtcALGH3A-1gsxpYoIla6BQqcUqbIo65JGF3LRCjSSJj3B1pl4k0ZgcZkKCuigQQTtMgANy4AGt2gQqWAALQaulAv2LAP5ZQnaoQuR3K5KJg5KMIeFJJSJfQFeM7NSQ1NuOmM5UguS5gAEAQ1jIHDoOMfg1jgMh7nOExHgCJ5alE55NnqloyBCWjqIo0iVJGeR2DImidgociIukOiEQBzzEu0vg-DgoEfMuq4yBu27tEE7GHseYSYYy2GiEcTBqPIVQ1FcEL8toKIJOaaQXPY2TWOIeKNIB06sd8vycU0FDgZBATQbBvDwQEiGCb8wnoaJvpYaCkmvp28gqD5kJ-lc-LQiif7mqKFwXNk8aVExMqvCqaomZg3EknxO4yBARaoSJ9JubqKx4SsNyWmkGgfkoPIQvhOg1AoRQ+TpkgxUB7TxXmiWUOZUEwXBCHpZlTm0i5DYScsmTFHopRPn+cg9oifZ3MkNp-to4iJloTUGTIvh4BWpCLoqyVrqQm5pWMEBoZgsD1me7lxHYMljpUNGJOKahin2dTyH+BR2J2w6WmoG0sVtc67ftFIrilx38TIZ0XXADCSENN26vdMiPekihXBo70vkmyQ2D5Qrik+BRA8621g1mZkQV11m2fZoPw1dGGueJt2IGjGPPdjb0FCi6QKPh4g9gK1G-qKTj4r0GXwOEjoGTl7O6gomgWtcVR3kVH6GC+B5QuUlphtJKhaxOenMc6ma9FmSs6hekiFLGyjfnUdwzQokjBV5ahlGUqSVPCYbmwS+nA5mip242HmOz9bKFEU7t3rVaimjcJSPoU1jSAUnviOTryzvOe2ulHI1mHcraclsOyiyoPLCnGthpDcGLrGTk5hxTRkcSXHxlxzCDKEktSa-eOk0aajslLstyu9J1j2hbsUkq1-B900A95ZX+HV35demtJBMTRCwqdpoBckpT7Vy2J9s4RsSgzyLiQRqTKJ7CcOtYtcqSFetndLavA9N8dqW8HY7whGGP8+99D1xfCoWwFodiijGrcT2eInBAA */ /** @xstate-layout N4IgpgJg5mDOIC5QDECWAbMACAtgQwGMALVAOzAGI9ZZUpSBtABgF1FQAHAe1oBdUupdiAAeiACwAmADQgAnogAcANgCsAOnHiAjOICcAZh3K9TRQHYAvpdlpMuQiXIUASmABmAJzhFmbJCDcfAJCAWIIUrIKCHpq6nraipJJKorahqrWthjY+MRkYOoAEtRYpFxY7jmwFADC3ni82FWYfsJBqPyCwuG6hurKTAYG5mlSyeJRiHqS2prKg6Mj2pKz4lkgdrmOBcWlLXCuYKR4OM05bQEdXaGgvdpD6qPiioqqieJM2gaqUxELT3MQz0eleBlMhmUGy2Dny5D2sEq1TqDSaSNarHaPE6IR6iD6BgGQxGY1Wikm8gkQM0n2UknERm0qmUw2hOVhTkKJURBxqABEwJg0QdLpxsTc8QhtNLlOozKNlNpzOZDEwTH80upEuZkg9zKoDJIDGo2fY8pyEejDgB5DjHK2iwLi3FhfH6QmDYajXRkinRHSKLXgph6VSSVQqh4GU3bOFc-bIgDKYF4WFggrABCaECwEFQ3izXE8ckd1xdd3xkhZ6neP0UQ1mZL+xvEcul4PSiiMOkkMY5u3qYEaZCgWDwpBzXDtpBHVooEEEhTIADcuABrQoEVFgAC044gO6nxx3IsxV2d3VdUtr6iSPwDH0+fvxTCrNdUTHEzKYP8NPz75oDqis77lgR4zqQo4HBQYCeJ4RbqBw6CNO4RY4OoW5Dk0e4Toe04nhcZ5isEl4VteTJaiy+riKMqhJF+fwRkw6jGskigJOSaTfABOzwm4Jw4LO0ELvCK7roU3gCbup7+MROKkaIiAjOY6j0uYyg6iyBqKuYfx0hoqixECIaGZITCqJkNibOygF8ccpxCTkMFwQhSEoWh6iSac0mEbJTokbcikIMpqk0RpVY-MaSp-Dqki3mCuhmGoRp6DxcbqAKQqOZg86LuoYkbuoEAZthMlYgFkohWp4VaVFumUsFP5apIoYWO26nKmlFqZSm2WULB8GeIhyG8KhnjocVQo+Rifllgp4RVWFmmRTpfxmXFXb1l+OlMl8XW7G4eB5pBVo1CJS6kKuhUNAevKlhegXhDMzE-OYwxqMoryhtoMUrHKhraIqtWzIo+12UdfVnXlBUSUOt3VAw2izQ9krPSxEbvcyX3vH8piyoqBoWF9rWpVZMK2YUh3HVByIDa5I1jehN0EZgsD3RVV5o69mOfexOMNfWegsUayphVWT5g-GPLIoOjTnK0SPlfJj1uv0nokj6EyMWZmhfMqiq0iGvZkzZvGFLL-AncJ0OXeJGHbizYDs8rkpMiqAyqDRryfcaJgGH8KzpKp9IvF+UhfJZ2Rmmb6gW31zmDcN7njfbWHTU7RH+S7V5MvW8R6C8hoGqYTDmD9DXSjqqlaeInpSAYTLWFZ5TFfAATk2bSsSleO7KH8O4aCCQ-D8PP6R9Z0fpdyZQVLyXflkF3whgMtcQq8+gWc+MSzAMm2GTMZkGEkkuWnP54c2R3xKpoEzfEfEYBn8q+aK8iS6EYmkn3HJ2geBfXz-NfEow4o+gsKsT6LVIgV1fISdS7EdQpAeBZE+-EHJWxyAAlWEQZANUWKpYYDYhiJGUF+E+PVLY00wJgyU7EBiA09uYHQ3YLL1WiJ2VSOofySEYR-AmKC4aQ2oVeOkcx7xh3JHoZUkjcbL09gXX84UuIn1tMcf+59s6X2AVqKQYCqxJALjg6I4JCQRi2jpZUnsv7AXQVQ9R3dNFJG0ckWKECDEByVHFUuFkwzGjeHRJulggA */
id: 'File machine', id: 'File machine',
initial: 'Reading files', initial: 'Reading files',
@ -29,7 +29,7 @@ export const fileMachine = createMachine(
'Has no files': { 'Has no files': {
on: { on: {
'Create file': { 'Create file': {
target: 'Creating file', target: 'Creating and opening file',
}, },
}, },
}, },
@ -40,9 +40,13 @@ export const fileMachine = createMachine(
target: 'Renaming file', target: 'Renaming file',
}, },
'Create file': { 'Create file': [
target: 'Creating file', {
}, target: 'Creating and opening file',
cond: 'Is not silent',
},
'Creating file',
],
'Delete file': { 'Delete file': {
target: 'Deleting file', target: 'Deleting file',
@ -59,10 +63,10 @@ export const fileMachine = createMachine(
}, },
}, },
'Creating file': { 'Creating and opening file': {
invoke: { invoke: {
id: 'create-file', id: 'create-and-open-file',
src: 'createFile', src: 'createAndOpenFile',
onDone: [ onDone: [
{ {
target: 'Reading files', target: 'Reading files',
@ -147,6 +151,15 @@ export const fileMachine = createMachine(
'Opening file': { 'Opening file': {
entry: ['navigateToFile'], entry: ['navigateToFile'],
}, },
'Creating file': {
invoke: {
src: 'createFile',
id: 'create-file',
onDone: 'Reading files',
onError: 'Reading files',
},
},
}, },
schema: { schema: {
@ -156,7 +169,16 @@ export const fileMachine = createMachine(
type: 'Rename file' type: 'Rename file'
data: { oldName: string; newName: string; isDir: boolean } data: { oldName: string; newName: string; isDir: boolean }
} }
| { type: 'Create file'; data: { name: string; makeDir: boolean } } | {
type: 'Create file'
data: {
name: string
makeDir: boolean
content?: string
silent?: boolean
makeUnique?: boolean
}
}
| { type: 'Delete file'; data: FileEntry } | { type: 'Delete file'; data: FileEntry }
| { type: 'Set selected directory'; data: FileEntry } | { type: 'Set selected directory'; data: FileEntry }
| { type: 'navigate'; data: { name: string } } | { type: 'navigate'; data: { name: string } }
@ -173,12 +195,18 @@ export const fileMachine = createMachine(
} }
} }
| { | {
type: 'done.invoke.create-file' type: 'done.invoke.create-and-open-file'
data: { data: {
message: string message: string
path: string path: string
} }
} }
| {
type: 'done.invoke.create-file'
data: {
path: string
}
}
| { type: 'assign'; data: { [key: string]: any } } | { type: 'assign'; data: { [key: string]: any } }
| { type: 'Refresh' }, | { type: 'Refresh' },
}, },

File diff suppressed because one or more lines are too long

View File

@ -686,9 +686,9 @@ dependencies = [
[[package]] [[package]]
name = "data-encoding" name = "data-encoding"
version = "2.5.0" version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5" checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2"
[[package]] [[package]]
name = "databake" name = "databake"
@ -3617,6 +3617,7 @@ dependencies = [
"bson", "bson",
"clap", "clap",
"console_error_panic_hook", "console_error_panic_hook",
"data-encoding",
"futures", "futures",
"gloo-utils", "gloo-utils",
"hyper", "hyper",

View File

@ -12,6 +12,7 @@ crate-type = ["cdylib"]
[dependencies] [dependencies]
bson = { version = "2.11.0", features = ["uuid-1", "chrono"] } bson = { version = "2.11.0", features = ["uuid-1", "chrono"] }
clap = "4.5.15" clap = "4.5.15"
data-encoding = "2.6.0"
gloo-utils = "0.2.0" gloo-utils = "0.2.0"
kcl-lib = { path = "kcl" } kcl-lib = { path = "kcl" }
kittycad.workspace = true kittycad.workspace = true

View File

@ -564,3 +564,26 @@ pub fn parse_project_route(configuration: &str, route: &str) -> Result<JsValue,
// gloo-serialize crate instead. // gloo-serialize crate instead.
JsValue::from_serde(&route).map_err(|e| e.to_string()) JsValue::from_serde(&route).map_err(|e| e.to_string())
} }
static ALLOWED_DECODING_FORMATS: &[data_encoding::Encoding] = &[
data_encoding::BASE64,
data_encoding::BASE64URL,
data_encoding::BASE64URL_NOPAD,
data_encoding::BASE64_MIME,
data_encoding::BASE64_NOPAD,
];
/// Base64 decode a string.
#[wasm_bindgen]
pub fn base64_decode(input: &str) -> Result<Vec<u8>, JsValue> {
console_error_panic_hook::set_once();
// Forgive alt base64 decoding formats
for config in ALLOWED_DECODING_FORMATS {
if let Ok(data) = config.decode(input.as_bytes()) {
return Ok(data);
}
}
Err(JsValue::from_str("Invalid base64 encoding"))
}

View File

@ -1748,10 +1748,10 @@
resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60" resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60"
integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA== integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==
"@kittycad/lib@^0.0.70": "@kittycad/lib@^0.0.76":
version "0.0.70" version "0.0.76"
resolved "https://registry.yarnpkg.com/@kittycad/lib/-/lib-0.0.70.tgz#398ec3b2385cef055bbd7c2681040f08b3751ac6" resolved "https://registry.yarnpkg.com/@kittycad/lib/-/lib-0.0.76.tgz#d544a50c54547139d6dfa15730354313a51d0e49"
integrity sha512-P6IyfUIiCZ5Cc7EDx/apXBsmHAUmO/yhMw5E6fviMCRt0sNJnUfed6iTmfTpq2m44k7k8Vrn0WwrkQMsReLUhA== integrity sha512-14zzP7JS7J8xiwKltJqiszOCF9LdQeJK2nN58Xjiep+LOEVWtiLSGuILTameU2ryjA3aeQzPtNc1WJZ2JYRg2A==
dependencies: dependencies:
node-fetch "3.3.2" node-fetch "3.3.2"
openapi-types "^12.0.0" openapi-types "^12.0.0"
@ -7712,7 +7712,16 @@ string-natural-compare@^3.0.1:
resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4"
integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: "string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3" version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -7790,7 +7799,14 @@ string_decoder@~1.1.1:
dependencies: dependencies:
safe-buffer "~5.1.0" safe-buffer "~5.1.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: "strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1" version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@ -8666,7 +8682,7 @@ workerpool@6.2.1:
resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343" resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343"
integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw== integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0" version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@ -8684,6 +8700,15 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0" string-width "^4.1.0"
strip-ansi "^6.0.0" strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.1.0: wrap-ansi@^8.1.0:
version "8.1.0" version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"