2025-04-01 23:54:26 -07:00
import type { SelectionRange } from '@codemirror/state'
import { EditorSelection , Transaction } from '@codemirror/state'
import type { Models } from '@kittycad/lib'
2025-05-16 23:25:04 -04:00
import { VITE_KC_API_BASE_URL , VITE_KC_SITE_BASE_URL } from '@src/env'
2025-04-01 15:31:19 -07:00
import { diffLines } from 'diff'
2025-04-01 23:54:26 -07:00
import toast from 'react-hot-toast'
2025-05-08 03:54:40 +10:00
import type { TextToCadMultiFileIteration_type } from '@kittycad/lib/dist/types/src/models'
import { getCookie , TOKEN_PERSIST_KEY } from '@src/machines/authMachine'
2025-05-16 23:25:04 -04:00
import { APP_DOWNLOAD_PATH , COOKIE_NAME } from '@src/lib/constants'
2025-05-08 03:54:40 +10:00
import { isDesktop } from '@src/lib/isDesktop'
import { openExternalBrowserIfDesktop } from '@src/lib/openWindow'
import { ActionButton } from '@src/components/ActionButton'
import { CustomIcon } from '@src/components/CustomIcon'
2025-04-01 23:54:26 -07:00
2025-05-08 03:54:40 +10:00
import {
ToastPromptToEditCadSuccess ,
writeOverFilesAndExecute ,
} from '@src/components/ToastTextToCad'
2025-04-01 23:54:26 -07:00
import { modelingMachineEvent } from '@src/editor/manager'
import { getArtifactOfTypes } from '@src/lang/std/artifactGraph'
import { topLevelRange } from '@src/lang/util'
import type { ArtifactGraph , SourceRange } from '@src/lang/wasm'
import crossPlatformFetch from '@src/lib/crossPlatformFetch'
import type { Selections } from '@src/lib/selections'
import { codeManager , editorManager , kclManager } from '@src/lib/singletons'
import { err , reportRejection } from '@src/lib/trap'
import { uuidv4 } from '@src/lib/utils'
2025-05-08 03:54:40 +10:00
import type { File as KittyCadLibFile } from '@kittycad/lib/dist/types/src/models'
import type { FileMeta } from '@src/lib/types'
import type { RequestedKCLFile } from '@src/machines/systemIO/utils'
type KclFileMetaMap = {
[ execStateFileNamesIndex : number ] : Extract < FileMeta , { type : 'kcl' } >
}
2024-12-20 13:39:06 +11:00
function sourceIndexToLineColumn (
code : string ,
index : number
) : { line : number ; column : number } {
const codeStart = code . slice ( 0 , index )
const lines = codeStart . split ( '\n' )
const line = lines . length
const column = lines [ lines . length - 1 ] . length
return { line , column }
}
function convertAppRangeToApiRange (
range : SourceRange ,
code : string
) : Models [ 'SourceRange_type' ] {
return {
start : sourceIndexToLineColumn ( code , range [ 0 ] ) ,
end : sourceIndexToLineColumn ( code , range [ 1 ] ) ,
}
}
2025-05-08 03:54:40 +10:00
type TextToCadErrorResponse = {
error_code : string
message : string
}
async function submitTextToCadRequest (
body : {
prompt : string
source_ranges : Models [ 'SourceRangePrompt_type' ] [ ]
project_name? : string
kcl_version : string
} ,
files : KittyCadLibFile [ ] ,
token : string
) : Promise < TextToCadMultiFileIteration_type | Error > {
const formData = new FormData ( )
formData . append ( 'body' , JSON . stringify ( body ) )
files . forEach ( ( file ) = > {
formData . append ( 'files' , file . data , file . name )
} )
const response = await fetch (
` ${ VITE_KC_API_BASE_URL } /ml/text-to-cad/multi-file/iteration ` ,
{
method : 'POST' ,
headers : {
Authorization : ` Bearer ${ token } ` ,
} ,
body : formData ,
}
)
if ( ! response . ok ) {
return new Error ( ` HTTP error! status: ${ response . status } ` )
}
const data = await response . json ( )
if ( 'error_code' in data ) {
const errorData = data as TextToCadErrorResponse
return new Error ( errorData . message || 'Unknown error' )
}
return data as TextToCadMultiFileIteration_type
}
2024-12-20 13:39:06 +11:00
export async function submitPromptToEditToQueue ( {
prompt ,
selections ,
2025-05-08 03:54:40 +10:00
projectFiles ,
2024-12-20 13:39:06 +11:00
token ,
artifactGraph ,
2025-02-12 14:44:47 -08:00
projectName ,
2024-12-20 13:39:06 +11:00
} : {
prompt : string
2025-02-26 14:06:51 +11:00
selections : Selections | null
2025-05-08 03:54:40 +10:00
projectFiles : FileMeta [ ]
2025-02-12 14:44:47 -08:00
projectName : string
2024-12-20 13:39:06 +11:00
token? : string
artifactGraph : ArtifactGraph
2025-05-08 03:54:40 +10:00
} ) {
const _token =
token && token !== ''
? token
: getCookie ( COOKIE_NAME ) || localStorage ? . getItem ( TOKEN_PERSIST_KEY ) || ''
const kclFilesMap : KclFileMetaMap = { }
const endPointFiles : KittyCadLibFile [ ] = [ ]
projectFiles . forEach ( ( file ) = > {
let data : Blob
if ( file . type === 'other' ) {
data = file . data
} else {
// file.type === 'kcl'
kclFilesMap [ file . execStateFileNamesIndex ] = file
data = new Blob ( [ file . fileContents ] , { type : 'text/kcl' } )
}
endPointFiles . push ( {
name : file.relPath ,
data ,
} )
} )
2025-02-26 14:06:51 +11:00
// If no selection, use whole file
if ( selections === null ) {
2025-05-08 03:54:40 +10:00
return submitTextToCadRequest (
{
prompt ,
source_ranges : [ ] ,
project_name :
projectName !== '' && projectName !== 'browser'
? projectName
: undefined ,
kcl_version : kclManager.kclVersion ,
} ,
endPointFiles ,
_token
)
2025-02-26 14:06:51 +11:00
}
// Handle manual code selections and artifact selections differently
2025-05-08 03:54:40 +10:00
const ranges : Models [ 'SourceRangePrompt_type' ] [ ] =
2024-12-20 13:39:06 +11:00
selections . graphSelections . flatMap ( ( selection ) = > {
const artifact = selection . artifact
2025-05-08 03:54:40 +10:00
const execStateFileNamesIndex = selection ? . codeRef ? . range ? . [ 2 ]
const file = kclFilesMap ? . [ execStateFileNamesIndex ]
const code = file ? . fileContents || ''
const filePath = file ? . relPath || ''
2025-02-26 14:06:51 +11:00
// For artifact selections, add context
2025-05-08 03:54:40 +10:00
const prompts : Models [ 'SourceRangePrompt_type' ] [ ] = [ ]
2024-12-20 13:39:06 +11:00
if ( artifact ? . type === 'cap' ) {
prompts . push ( {
prompt : ` The users main selection is the end cap of a general-sweep (that is an extrusion, revolve, sweep or loft).
2025-04-25 16:01:35 -05:00
The source range most likely refers to "startProfile" simply because this is the start of the profile that was swept .
2024-12-20 13:39:06 +11:00
If you need to operate on this cap , for example for sketching on the face , you can use the special string $ {
artifact . subType === 'end' ? 'END' : 'START'
2025-04-14 05:58:19 -04:00
} i . e . \ ` startSketchOn(someSweepVariable, face = ${
2024-12-20 13:39:06 +11:00
artifact . subType === 'end' ? 'END' : 'START'
} ) \ `
When they made this selection they main have intended this surface directly or meant something more general like the sweep body .
See later source ranges for more context . ` ,
range : convertAppRangeToApiRange ( selection . codeRef . range , code ) ,
2025-05-08 03:54:40 +10:00
file : filePath ,
2024-12-20 13:39:06 +11:00
} )
let sweep = getArtifactOfTypes (
{ key : artifact.sweepId , types : [ 'sweep' ] } ,
artifactGraph
)
if ( ! err ( sweep ) ) {
prompts . push ( {
prompt : ` This is the sweep's source range from the user's main selection of the end cap. ` ,
range : convertAppRangeToApiRange ( sweep . codeRef . range , code ) ,
2025-05-08 03:54:40 +10:00
file : filePath ,
2024-12-20 13:39:06 +11:00
} )
}
}
if ( artifact ? . type === 'wall' ) {
prompts . push ( {
prompt : ` The users main selection is the wall of a general-sweep (that is an extrusion, revolve, sweep or loft).
2025-04-14 05:58:19 -04:00
The source range though is for the original segment before it was extruded , you can add a tag to that segment in order to refer to this wall , for example "startSketchOn(someSweepVariable, face = segmentTag)"
2024-12-20 13:39:06 +11:00
But it ' s also worth bearing in mind that the user may have intended to select the sweep itself , not this individual wall , see later source ranges for more context . about the sweep ` ,
range : convertAppRangeToApiRange ( selection . codeRef . range , code ) ,
2025-05-08 03:54:40 +10:00
file : filePath ,
2024-12-20 13:39:06 +11:00
} )
let sweep = getArtifactOfTypes (
{ key : artifact.sweepId , types : [ 'sweep' ] } ,
artifactGraph
)
if ( ! err ( sweep ) ) {
prompts . push ( {
prompt : ` This is the sweep's source range from the user's main selection of the end cap. ` ,
range : convertAppRangeToApiRange ( sweep . codeRef . range , code ) ,
2025-05-08 03:54:40 +10:00
file : filePath ,
2024-12-20 13:39:06 +11:00
} )
}
}
if ( artifact ? . type === 'sweepEdge' ) {
prompts . push ( {
prompt : ` The users main selection is the edge of a general-sweep (that is an extrusion, revolve, sweep or loft).
it is an $ {
artifact . subType
} edge , in order to refer to this edge you should add a tag to the segment function in this source range ,
and then use the function $ {
artifact . subType === 'adjacent'
? 'getAdjacentEdge'
: 'getOppositeEdge'
}
See later source ranges for more context . about the sweep ` ,
range : convertAppRangeToApiRange ( selection . codeRef . range , code ) ,
2025-05-08 03:54:40 +10:00
file : filePath ,
2024-12-20 13:39:06 +11:00
} )
let sweep = getArtifactOfTypes (
{ key : artifact.sweepId , types : [ 'sweep' ] } ,
artifactGraph
)
if ( ! err ( sweep ) ) {
prompts . push ( {
prompt : ` This is the sweep's source range from the user's main selection of the end cap. ` ,
range : convertAppRangeToApiRange ( sweep . codeRef . range , code ) ,
2025-05-08 03:54:40 +10:00
file : filePath ,
2024-12-20 13:39:06 +11:00
} )
}
}
if ( artifact ? . type === 'segment' ) {
if ( ! artifact . surfaceId ) {
prompts . push ( {
prompt : ` This selection is of a segment, likely an individual part of a profile. Segments are often "constrained" by the use of variables and relationships with other segments. Adding tags to segments helps refer to their length, angle or other properties ` ,
range : convertAppRangeToApiRange ( selection . codeRef . range , code ) ,
2025-05-08 03:54:40 +10:00
file : filePath ,
2024-12-20 13:39:06 +11:00
} )
} else {
prompts . push ( {
prompt : ` This selection is for a segment (line, xLine, angledLine etc) that has been swept (a general-sweep, either an extrusion, revolve, sweep or loft).
Because it now refers to an edge the way to refer to this edge is to add a tag to the segment , and then use that tag directly .
2025-02-21 14:41:25 -06:00
i . e . \ ` fillet( radius = someInteger, tags = [newTag]) \` will work in the case of filleting this edge
2024-12-20 13:39:06 +11:00
See later source ranges for more context . about the sweep ` ,
range : convertAppRangeToApiRange ( selection . codeRef . range , code ) ,
2025-05-08 03:54:40 +10:00
file : filePath ,
2024-12-20 13:39:06 +11:00
} )
let path = getArtifactOfTypes (
{ key : artifact.pathId , types : [ 'path' ] } ,
artifactGraph
)
2025-01-13 15:02:55 -05:00
if ( ! err ( path ) && path . sweepId ) {
2024-12-20 13:39:06 +11:00
const sweep = getArtifactOfTypes (
{ key : path.sweepId , types : [ 'sweep' ] } ,
artifactGraph
)
if ( ! err ( sweep ) ) {
prompts . push ( {
prompt : ` This is the sweep's source range from the user's main selection of the edge. ` ,
range : convertAppRangeToApiRange ( sweep . codeRef . range , code ) ,
2025-05-08 03:54:40 +10:00
file : filePath ,
2024-12-20 13:39:06 +11:00
} )
}
}
}
}
2025-02-26 14:06:51 +11:00
if ( ! artifact ) {
// manually selected code is more likely to not have an artifact
// an example might be highlighting the variable name only in a variable declaration
prompts . push ( {
prompt : '' ,
range : convertAppRangeToApiRange ( selection . codeRef . range , code ) ,
2025-05-08 03:54:40 +10:00
file : filePath ,
2025-02-26 14:06:51 +11:00
} )
}
2024-12-20 13:39:06 +11:00
return prompts
} )
2025-05-08 03:54:40 +10:00
return submitTextToCadRequest (
{
prompt ,
source_ranges : ranges ,
project_name :
projectName !== '' && projectName !== 'browser'
? projectName
: undefined ,
kcl_version : kclManager.kclVersion ,
} ,
endPointFiles ,
_token
)
2024-12-20 13:39:06 +11:00
}
export async function getPromptToEditResult (
id : string ,
token? : string
2025-05-08 03:54:40 +10:00
) : Promise < Models [ 'TextToCadMultiFileIteration_type' ] | Error > {
2024-12-20 13:39:06 +11:00
const url = VITE_KC_API_BASE_URL + '/async/operations/' + id
2025-05-08 03:54:40 +10:00
const data : Models [ 'TextToCadMultiFileIteration_type' ] | Error =
2024-12-20 13:39:06 +11:00
await crossPlatformFetch (
url ,
{
method : 'GET' ,
} ,
token
)
return data
}
export async function doPromptEdit ( {
prompt ,
selections ,
2025-05-08 03:54:40 +10:00
projectFiles ,
2024-12-20 13:39:06 +11:00
token ,
artifactGraph ,
2025-02-12 14:44:47 -08:00
projectName ,
2024-12-20 13:39:06 +11:00
} : {
prompt : string
selections : Selections
2025-05-08 03:54:40 +10:00
projectFiles : FileMeta [ ]
2024-12-20 13:39:06 +11:00
token? : string
2025-02-12 14:44:47 -08:00
projectName : string
2024-12-20 13:39:06 +11:00
artifactGraph : ArtifactGraph
2025-05-08 03:54:40 +10:00
} ) : Promise < Models [ 'TextToCadMultiFileIteration_type' ] | Error > {
2024-12-20 14:52:07 +11:00
const toastId = toast . loading ( 'Submitting to Text-to-CAD API...' )
2024-12-20 13:39:06 +11:00
2025-05-08 03:54:40 +10:00
let submitResult
// work around for @kittycad/lib not really being built for the browser
; ( window as any ) . process = {
env : {
ZOO_API_TOKEN : token ,
ZOO_HOST : VITE_KC_API_BASE_URL ,
} ,
}
try {
submitResult = await submitPromptToEditToQueue ( {
prompt ,
selections ,
projectFiles ,
token ,
artifactGraph ,
projectName ,
} )
} catch ( e : any ) {
toast . dismiss ( toastId )
return new Error ( e . message )
}
if ( submitResult instanceof Error ) {
toast . dismiss ( toastId )
return submitResult
}
const textToCadComplete = new Promise <
Models [ 'TextToCadMultiFileIteration_type' ]
> ( ( resolve , reject ) = > {
; ( async ( ) = > {
const MAX_CHECK_TIMEOUT = 3 * 60 _000
const CHECK_DELAY = 200
let timeElapsed = 0
while ( timeElapsed < MAX_CHECK_TIMEOUT ) {
const check = await getPromptToEditResult ( submitResult . id , token )
if (
check instanceof Error ||
check . status === 'failed' ||
check . error
) {
reject ( check )
return
} else if ( check . status === 'completed' ) {
resolve ( check )
return
2024-12-20 13:39:06 +11:00
}
2025-05-08 03:54:40 +10:00
await new Promise ( ( r ) = > setTimeout ( r , CHECK_DELAY ) )
timeElapsed += CHECK_DELAY
}
reject ( new Error ( 'Text-to-CAD API timed out' ) )
} ) ( ) . catch ( reportRejection )
} )
2024-12-20 13:39:06 +11:00
try {
const result = await textToCadComplete
toast . dismiss ( toastId )
return result
} catch ( e ) {
toast . dismiss ( toastId )
toast . error (
'Failed to edit your KCL code, please try again with a different prompt or selection'
)
console . error ( 'textToCadComplete' , e )
}
return textToCadComplete
}
/** takes care of the whole submit prompt to endpoint flow including the accept-reject toast once the result is back */
export async function promptToEditFlow ( {
prompt ,
selections ,
2025-05-08 03:54:40 +10:00
projectFiles ,
2024-12-20 13:39:06 +11:00
token ,
artifactGraph ,
2025-02-12 14:44:47 -08:00
projectName ,
2025-05-19 19:05:38 -05:00
filePath ,
2024-12-20 13:39:06 +11:00
} : {
prompt : string
selections : Selections
2025-05-08 03:54:40 +10:00
projectFiles : FileMeta [ ]
2024-12-20 13:39:06 +11:00
token? : string
artifactGraph : ArtifactGraph
2025-02-12 14:44:47 -08:00
projectName : string
2025-05-19 19:05:38 -05:00
filePath : string | undefined
2024-12-20 13:39:06 +11:00
} ) {
const result = await doPromptEdit ( {
prompt ,
selections ,
2025-05-08 03:54:40 +10:00
projectFiles ,
2024-12-20 13:39:06 +11:00
token ,
artifactGraph ,
2025-02-12 14:44:47 -08:00
projectName ,
2024-12-20 13:39:06 +11:00
} )
2025-05-08 03:54:40 +10:00
if ( err ( result ) ) {
toast . error ( 'Failed to modify.' )
return Promise . reject ( result )
}
const oldCodeWebAppOnly = codeManager . code
2025-05-16 23:25:04 -04:00
const downloadLink = ` ${ VITE_KC_SITE_BASE_URL } / ${ APP_DOWNLOAD_PATH } `
2025-05-08 03:54:40 +10:00
if ( ! isDesktop ( ) && Object . values ( result . outputs ) . length > 1 ) {
const toastId = uuidv4 ( )
toast . error (
( t ) = > (
< div className = "flex flex-col gap-2" >
< p > Multiple files were returned from Text - to - CAD . < / p >
< p > You need to use the desktop app to support this . < / p >
< div className = "flex justify-between items-center mt-2" >
< >
< a
2025-05-16 23:25:04 -04:00
href = { downloadLink }
2025-05-08 03:54:40 +10:00
target = "_blank"
rel = "noopener noreferrer"
className = "text-blue-400 hover:text-blue-300 underline flex align-middle"
2025-05-16 23:25:04 -04:00
onClick = { openExternalBrowserIfDesktop ( downloadLink ) }
2025-05-08 03:54:40 +10:00
>
< CustomIcon
name = "link"
className = "w-4 h-4 text-chalkboard-70 dark:text-chalkboard-40"
/ >
Download Desktop App
< / a >
< / >
< ActionButton
Element = "button"
iconStart = { {
icon : 'close' ,
} }
name = "Dismiss"
onClick = { ( ) = > {
toast . dismiss ( toastId )
} }
>
Dismiss
< / ActionButton >
< / div >
< / div >
) ,
{
id : toastId ,
duration : Infinity ,
icon : null ,
}
)
return
}
if ( isDesktop ( ) ) {
const requestedFiles : RequestedKCLFile [ ] = [ ]
for ( const [ relativePath , fileContents ] of Object . entries ( result . outputs ) ) {
requestedFiles . push ( {
requestedCode : fileContents ,
requestedFileName : relativePath ,
requestedProjectName : projectName ,
} )
}
await writeOverFilesAndExecute ( {
requestedFiles ,
projectName ,
2025-05-19 19:05:38 -05:00
filePath ,
2025-05-08 03:54:40 +10:00
} )
} else {
const newCode = result . outputs [ 'main.kcl' ]
codeManager . updateCodeEditor ( newCode )
const diff = reBuildNewCodeWithRanges ( oldCodeWebAppOnly , newCode )
const ranges : SelectionRange [ ] = diff . insertRanges . map ( ( range ) = >
EditorSelection . range ( range [ 0 ] , range [ 1 ] )
)
2025-06-19 13:26:51 +02:00
editorManager ? . getEditorView ( ) ? . dispatch ( {
2025-05-08 03:54:40 +10:00
selection : EditorSelection.create (
ranges ,
selections . graphSelections . length - 1
) ,
annotations : [ modelingMachineEvent , Transaction . addToHistory . of ( false ) ] ,
} )
await kclManager . executeCode ( )
}
2024-12-20 13:39:06 +11:00
const toastId = uuidv4 ( )
toast . success (
( ) = >
ToastPromptToEditCadSuccess ( {
toastId ,
data : result ,
token ,
2025-05-08 03:54:40 +10:00
oldCodeWebAppOnly ,
oldFiles : projectFiles ,
2024-12-20 13:39:06 +11:00
} ) ,
{
id : toastId ,
duration : Infinity ,
icon : null ,
}
)
}
const reBuildNewCodeWithRanges = (
oldCode : string ,
newCode : string
) : {
newCode : string
insertRanges : SourceRange [ ]
} = > {
let insertRanges : SourceRange [ ] = [ ]
const changes = diffLines ( oldCode , newCode )
let newCodeWithRanges = ''
for ( const change of changes ) {
if ( ! change . added && ! change . removed ) {
// no change add it to newCodeWithRanges
newCodeWithRanges += change . value
} else if ( change . added && ! change . removed ) {
const start = newCodeWithRanges . length
const end = start + change . value . length
2025-01-17 14:34:36 -05:00
insertRanges . push ( topLevelRange ( start , end ) )
2024-12-20 13:39:06 +11:00
newCodeWithRanges += change . value
}
}
return {
newCode : newCodeWithRanges ,
insertRanges ,
}
}