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>
Before Width: | Height: | Size: 249 KiB After Width: | Height: | Size: 52 KiB |
Before Width: | Height: | Size: 249 KiB After Width: | Height: | Size: 52 KiB |
Before Width: | Height: | Size: 249 KiB After Width: | Height: | Size: 52 KiB |
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 49 KiB |
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 49 KiB |
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 49 KiB |
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 49 KiB |
Before Width: | Height: | Size: 249 KiB After Width: | Height: | Size: 52 KiB |
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 49 KiB |
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 49 KiB |
@ -12,7 +12,7 @@ import pixelMatch from 'pixelmatch'
|
||||
import { PNG } from 'pngjs'
|
||||
import { Protocol } from 'playwright-core/types/protocol'
|
||||
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 { secrets } from './secrets'
|
||||
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 }),
|
||||
}
|
||||
)
|
||||
|
||||
await context.addCookies([
|
||||
{
|
||||
name: COOKIE_NAME,
|
||||
value: secrets.token,
|
||||
path: '/',
|
||||
domain: 'localhost',
|
||||
secure: true,
|
||||
},
|
||||
])
|
||||
// kill animations, speeds up tests and reduced flakiness
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
}
|
||||
|
358
e2e/playwright/text-to-cad-tests.spec.ts
Normal 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')
|
||||
}
|
@ -17,7 +17,7 @@
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@headlessui/react": "^1.7.19",
|
||||
"@headlessui/tailwindcss": "^0.2.0",
|
||||
"@kittycad/lib": "^0.0.70",
|
||||
"@kittycad/lib": "^0.0.76",
|
||||
"@lezer/highlight": "^1.2.0",
|
||||
"@lezer/lr": "^1.4.1",
|
||||
"@react-hook/resize-observer": "^2.0.1",
|
||||
|
@ -5,6 +5,7 @@ import { CommandArgument } from 'lib/commandTypes'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
import CommandBarHeader from './CommandBarHeader'
|
||||
import CommandBarKclInput from './CommandBarKclInput'
|
||||
import CommandBarTextareaInput from './CommandBarTextareaInput'
|
||||
|
||||
function CommandBarArgument({ stepBack }: { stepBack: () => void }) {
|
||||
const { commandBarState, commandBarSend } = useCommandsContext()
|
||||
@ -87,6 +88,14 @@ function ArgumentInput({
|
||||
return (
|
||||
<CommandBarKclInput arg={arg} stepBack={stepBack} onSubmit={onSubmit} />
|
||||
)
|
||||
case 'text':
|
||||
return (
|
||||
<CommandBarTextareaInput
|
||||
arg={arg}
|
||||
stepBack={stepBack}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<CommandBarBasicInput
|
||||
|
109
src/components/CommandBar/CommandBarTextareaInput.tsx
Normal 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
|
@ -121,6 +121,16 @@ const CustomIconMap = {
|
||||
/>
|
||||
</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: (
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
@ -211,6 +221,16 @@ const CustomIconMap = {
|
||||
/>
|
||||
</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: (
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
@ -590,13 +610,7 @@ const CustomIconMap = {
|
||||
</svg>
|
||||
),
|
||||
revolve: (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
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
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
@ -645,6 +659,16 @@ const CustomIconMap = {
|
||||
/>
|
||||
</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: (
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
@ -656,13 +680,7 @@ const CustomIconMap = {
|
||||
</svg>
|
||||
),
|
||||
sweep: (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
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
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
|
@ -15,7 +15,13 @@ import {
|
||||
} from 'xstate'
|
||||
import { useCommandsContext } from 'hooks/useCommandsContext'
|
||||
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 { join, sep } from '@tauri-apps/api/path'
|
||||
import { DEFAULT_FILE_NAME, FILE_EXT } from 'lib/constants'
|
||||
@ -94,6 +100,30 @@ export const FileMachineProvider = ({
|
||||
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) => {
|
||||
let createdName = event.data.name.trim() || DEFAULT_FILE_NAME
|
||||
let createdPath: string
|
||||
@ -108,10 +138,12 @@ export const FileMachineProvider = ({
|
||||
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,
|
||||
}
|
||||
},
|
||||
@ -182,6 +214,7 @@ export const FileMachineProvider = ({
|
||||
if (event.type !== 'done.invoke.read-files') return false
|
||||
return !!event?.data?.children && event.data.children.length > 0
|
||||
},
|
||||
'Is not silent': (_, event) => !event.data?.silent,
|
||||
},
|
||||
})
|
||||
|
||||
|
@ -71,7 +71,7 @@ import { exportFromEngine } from 'lib/exportFromEngine'
|
||||
import { Models } from '@kittycad/lib/dist/types/src'
|
||||
import toast from 'react-hot-toast'
|
||||
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 { getVarNameModal } from 'hooks/useToolbarGuards'
|
||||
import { err, trap } from 'lib/trap'
|
||||
@ -83,6 +83,8 @@ import {
|
||||
EngineConnectionStateType,
|
||||
EngineConnectionEvents,
|
||||
} from 'lang/std/engineConnection'
|
||||
import { submitAndAwaitTextToKcl } from 'lib/textToCad'
|
||||
import { useFileContext } from 'hooks/useFileContext'
|
||||
|
||||
type MachineContext<T extends AnyStateMachine> = {
|
||||
state: StateFrom<T>
|
||||
@ -108,6 +110,8 @@ export const ModelingMachineProvider = ({
|
||||
},
|
||||
},
|
||||
} = useSettingsAuthContext()
|
||||
const navigate = useNavigate()
|
||||
const { context, send: fileMachineSend } = useFileContext()
|
||||
const token = auth?.context?.token
|
||||
const streamRef = useRef<HTMLDivElement>(null)
|
||||
const persistedContext = useMemo(() => getPersistedContext(), [])
|
||||
@ -115,7 +119,7 @@ export const ModelingMachineProvider = ({
|
||||
let [searchParams] = useSearchParams()
|
||||
const pool = searchParams.get('pool')
|
||||
|
||||
const { commandBarState } = useCommandsContext()
|
||||
const { commandBarState, commandBarSend } = useCommandsContext()
|
||||
|
||||
// Settings machine setup
|
||||
// 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: {
|
||||
'has valid extrude selection': ({ selectionRanges }) => {
|
||||
@ -522,6 +549,8 @@ export const ModelingMachineProvider = ({
|
||||
return false
|
||||
}
|
||||
},
|
||||
'Has no pending text-to-cad submissions': ({ pendingTextToCad }) =>
|
||||
!pendingTextToCad,
|
||||
},
|
||||
services: {
|
||||
'AST-undo-startSketchOn': async ({ sketchDetails }) => {
|
||||
|
364
src/components/ToastTextToCad.tsx
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
@ -34,6 +34,7 @@ root.render(
|
||||
toastOptions={{
|
||||
style: {
|
||||
borderRadius: '3px',
|
||||
maxInlineSize: 'min(480px, 100%)',
|
||||
},
|
||||
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',
|
||||
|
@ -17,6 +17,7 @@ import init, {
|
||||
parse_project_settings,
|
||||
default_project_settings,
|
||||
parse_project_route,
|
||||
base64_decode,
|
||||
} from '../wasm-lib/pkg/wasm_lib'
|
||||
import { KCLError } from './errors'
|
||||
import { KclError as RustKclError } from '../wasm-lib/kcl/bindings/KclError'
|
||||
@ -593,3 +594,13 @@ export function parseProjectRoute(
|
||||
): ProjectRoute | Error {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
@ -40,6 +40,9 @@ export type ModelingCommandSchema = {
|
||||
'change tool': {
|
||||
tool: SketchTool
|
||||
}
|
||||
'Text-to-CAD': {
|
||||
prompt: string
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -15,6 +15,7 @@ const PLATFORMS = ['both', 'web', 'desktop'] as const
|
||||
const INPUT_TYPES = [
|
||||
'options',
|
||||
'string',
|
||||
'text',
|
||||
'kcl',
|
||||
'selection',
|
||||
'boolean',
|
||||
@ -151,6 +152,16 @@ export type CommandArgumentConfig<
|
||||
) => OutputType)
|
||||
defaultValueFromContext?: (context: C) => OutputType
|
||||
}
|
||||
| {
|
||||
inputType: 'text'
|
||||
defaultValue?:
|
||||
| OutputType
|
||||
| ((
|
||||
commandBarContext: ContextFrom<typeof commandBarMachine>,
|
||||
machineContext?: C
|
||||
) => OutputType)
|
||||
defaultValueFromContext?: (context: C) => OutputType
|
||||
}
|
||||
| {
|
||||
inputType: 'boolean'
|
||||
defaultValue?:
|
||||
@ -213,6 +224,15 @@ export type CommandArgument<
|
||||
machineContext?: ContextFrom<T>
|
||||
) => OutputType)
|
||||
}
|
||||
| {
|
||||
inputType: 'text'
|
||||
defaultValue?:
|
||||
| OutputType
|
||||
| ((
|
||||
commandBarContext: ContextFrom<typeof commandBarMachine>,
|
||||
machineContext?: ContextFrom<T>
|
||||
) => OutputType)
|
||||
}
|
||||
| {
|
||||
inputType: 'boolean'
|
||||
defaultValue?:
|
||||
|
@ -55,3 +55,4 @@ export const KCL_DEFAULT_CONSTANT_PREFIXES = {
|
||||
} as const
|
||||
/** The default KCL length expression */
|
||||
export const KCL_DEFAULT_LENGTH = `5`
|
||||
export const COOKIE_NAME = '__Secure-next-auth.session-token'
|
||||
|
56
src/lib/crossPlatformFetch.ts
Normal 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
@ -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
|
||||
)
|
||||
}
|
@ -4,6 +4,7 @@ import withBaseURL from '../lib/withBaseURL'
|
||||
import { isTauri } from 'lib/isTauri'
|
||||
import { VITE_KC_API_BASE_URL, VITE_KC_DEV_TOKEN } from 'env'
|
||||
import { getUser as getUserTauri } from 'lib/tauri'
|
||||
import { COOKIE_NAME } from 'lib/constants'
|
||||
|
||||
const SKIP_AUTH =
|
||||
import.meta.env.VITE_KC_SKIP_AUTH === 'true' && import.meta.env.DEV
|
||||
@ -38,7 +39,6 @@ export type Events =
|
||||
token?: string
|
||||
}
|
||||
|
||||
const COOKIE_NAME = '__Secure-next-auth.session-token'
|
||||
export const TOKEN_PERSIST_KEY = 'TOKEN_PERSIST_KEY'
|
||||
const persistedToken =
|
||||
getCookie(COOKIE_NAME) || localStorage?.getItem(TOKEN_PERSIST_KEY) || ''
|
||||
|
@ -22,7 +22,7 @@ export type CommandBarMachineEvent =
|
||||
| { type: 'Clear' }
|
||||
| {
|
||||
type: 'Select command'
|
||||
data: { command: Command }
|
||||
data: { command: Command; argDefaultValues?: { [x: string]: unknown } }
|
||||
}
|
||||
| { type: 'Deselect command' }
|
||||
| { type: 'Submit command'; data: { [x: string]: unknown } }
|
||||
@ -57,7 +57,11 @@ export type CommandBarMachineEvent =
|
||||
}
|
||||
| {
|
||||
type: 'Find and select command'
|
||||
data: { name: string; groupId: string }
|
||||
data: {
|
||||
name: string
|
||||
groupId: string
|
||||
argDefaultValues?: { [x: string]: unknown }
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'Change current argument'
|
||||
@ -328,7 +332,12 @@ export const commandBarMachine = createMachine(
|
||||
context.argumentsToSubmit[argName] === undefined ||
|
||||
(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 {
|
||||
...selectedCommand.args[argName],
|
||||
name: argName,
|
||||
@ -393,7 +402,11 @@ export const commandBarMachine = createMachine(
|
||||
const args: { [x: string]: unknown } = {}
|
||||
for (const [argName, arg] of Object.entries(command.args)) {
|
||||
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
|
||||
},
|
||||
|
@ -4,7 +4,7 @@ import { Project } from 'wasm-lib/kcl/bindings/Project'
|
||||
|
||||
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',
|
||||
|
||||
initial: 'Reading files',
|
||||
@ -29,7 +29,7 @@ export const fileMachine = createMachine(
|
||||
'Has no files': {
|
||||
on: {
|
||||
'Create file': {
|
||||
target: 'Creating file',
|
||||
target: 'Creating and opening file',
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -40,9 +40,13 @@ export const fileMachine = createMachine(
|
||||
target: 'Renaming file',
|
||||
},
|
||||
|
||||
'Create file': {
|
||||
target: 'Creating file',
|
||||
'Create file': [
|
||||
{
|
||||
target: 'Creating and opening file',
|
||||
cond: 'Is not silent',
|
||||
},
|
||||
'Creating file',
|
||||
],
|
||||
|
||||
'Delete file': {
|
||||
target: 'Deleting file',
|
||||
@ -59,10 +63,10 @@ export const fileMachine = createMachine(
|
||||
},
|
||||
},
|
||||
|
||||
'Creating file': {
|
||||
'Creating and opening file': {
|
||||
invoke: {
|
||||
id: 'create-file',
|
||||
src: 'createFile',
|
||||
id: 'create-and-open-file',
|
||||
src: 'createAndOpenFile',
|
||||
onDone: [
|
||||
{
|
||||
target: 'Reading files',
|
||||
@ -147,6 +151,15 @@ export const fileMachine = createMachine(
|
||||
'Opening file': {
|
||||
entry: ['navigateToFile'],
|
||||
},
|
||||
|
||||
'Creating file': {
|
||||
invoke: {
|
||||
src: 'createFile',
|
||||
id: 'create-file',
|
||||
onDone: 'Reading files',
|
||||
onError: 'Reading files',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
schema: {
|
||||
@ -156,7 +169,16 @@ export const fileMachine = createMachine(
|
||||
type: 'Rename file'
|
||||
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: 'Set selected directory'; data: FileEntry }
|
||||
| { 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: {
|
||||
message: string
|
||||
path: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'done.invoke.create-file'
|
||||
data: {
|
||||
path: string
|
||||
}
|
||||
}
|
||||
| { type: 'assign'; data: { [key: string]: any } }
|
||||
| { type: 'Refresh' },
|
||||
},
|
||||
|
5
src/wasm-lib/Cargo.lock
generated
@ -686,9 +686,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.5.0"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5"
|
||||
checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2"
|
||||
|
||||
[[package]]
|
||||
name = "databake"
|
||||
@ -3617,6 +3617,7 @@ dependencies = [
|
||||
"bson",
|
||||
"clap",
|
||||
"console_error_panic_hook",
|
||||
"data-encoding",
|
||||
"futures",
|
||||
"gloo-utils",
|
||||
"hyper",
|
||||
|
@ -12,6 +12,7 @@ crate-type = ["cdylib"]
|
||||
[dependencies]
|
||||
bson = { version = "2.11.0", features = ["uuid-1", "chrono"] }
|
||||
clap = "4.5.15"
|
||||
data-encoding = "2.6.0"
|
||||
gloo-utils = "0.2.0"
|
||||
kcl-lib = { path = "kcl" }
|
||||
kittycad.workspace = true
|
||||
|
@ -564,3 +564,26 @@ pub fn parse_project_route(configuration: &str, route: &str) -> Result<JsValue,
|
||||
// gloo-serialize crate instead.
|
||||
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"))
|
||||
}
|
||||
|
39
yarn.lock
@ -1748,10 +1748,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60"
|
||||
integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==
|
||||
|
||||
"@kittycad/lib@^0.0.70":
|
||||
version "0.0.70"
|
||||
resolved "https://registry.yarnpkg.com/@kittycad/lib/-/lib-0.0.70.tgz#398ec3b2385cef055bbd7c2681040f08b3751ac6"
|
||||
integrity sha512-P6IyfUIiCZ5Cc7EDx/apXBsmHAUmO/yhMw5E6fviMCRt0sNJnUfed6iTmfTpq2m44k7k8Vrn0WwrkQMsReLUhA==
|
||||
"@kittycad/lib@^0.0.76":
|
||||
version "0.0.76"
|
||||
resolved "https://registry.yarnpkg.com/@kittycad/lib/-/lib-0.0.76.tgz#d544a50c54547139d6dfa15730354313a51d0e49"
|
||||
integrity sha512-14zzP7JS7J8xiwKltJqiszOCF9LdQeJK2nN58Xjiep+LOEVWtiLSGuILTameU2ryjA3aeQzPtNc1WJZ2JYRg2A==
|
||||
dependencies:
|
||||
node-fetch "3.3.2"
|
||||
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"
|
||||
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"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
@ -7790,7 +7799,14 @@ string_decoder@~1.1.1:
|
||||
dependencies:
|
||||
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"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
@ -8666,7 +8682,7 @@ workerpool@6.2.1:
|
||||
resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343"
|
||||
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"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
@ -8684,6 +8700,15 @@ wrap-ansi@^6.2.0:
|
||||
string-width "^4.1.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:
|
||||
version "8.1.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
|
||||
|