444 lines
14 KiB
TypeScript
444 lines
14 KiB
TypeScript
import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { Group, Panel, Separator } from 'react-resizable-panels'
|
|
import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter'
|
|
import python from 'react-syntax-highlighter/dist/esm/languages/prism/python'
|
|
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'
|
|
|
|
import { readConversationArtifactText } from '../lib/api'
|
|
import type { ArtifactFileInfo, LatestArtifactsResponse } from '../types'
|
|
|
|
SyntaxHighlighter.registerLanguage('python', python)
|
|
|
|
const ModelViewport = lazy(async () => {
|
|
const module = await import('./ModelViewport')
|
|
return { default: module.ModelViewport }
|
|
})
|
|
|
|
interface WorkbenchPaneProps {
|
|
artifacts: LatestArtifactsResponse | null
|
|
loading: boolean
|
|
}
|
|
|
|
function dedupeArtifacts(items: ArtifactFileInfo[]): ArtifactFileInfo[] {
|
|
const seen = new Set<string>()
|
|
return items.filter((item) => {
|
|
if (!item.path || seen.has(item.path)) {
|
|
return false
|
|
}
|
|
|
|
seen.add(item.path)
|
|
return true
|
|
})
|
|
}
|
|
|
|
function formatArtifactLabel(path: string): string {
|
|
const segments = path.replace(/\\/g, '/').split('/').filter(Boolean)
|
|
if (!segments.length) {
|
|
return path
|
|
}
|
|
|
|
return segments.slice(-3).join('/')
|
|
}
|
|
|
|
function deriveSiblingModelScriptPath(modelPath: string): string | null {
|
|
const normalizedPath = modelPath.replace(/\\/g, '/').trim()
|
|
if (!normalizedPath || normalizedPath.startsWith('Local /')) {
|
|
return null
|
|
}
|
|
|
|
if (!normalizedPath.toLowerCase().endsWith('.stl')) {
|
|
return null
|
|
}
|
|
|
|
const lastSlashIndex = normalizedPath.lastIndexOf('/')
|
|
if (lastSlashIndex < 0) {
|
|
return 'model.py'
|
|
}
|
|
|
|
return `${normalizedPath.slice(0, lastSlashIndex)}/model.py`
|
|
}
|
|
|
|
export function WorkbenchPane({ artifacts, loading }: WorkbenchPaneProps) {
|
|
const modelFileInputId = 'local-model-file-input'
|
|
const codeFileInputId = 'local-code-file-input'
|
|
|
|
const baseCodeOptions = useMemo(() => {
|
|
if (artifacts?.code_files?.length) {
|
|
return dedupeArtifacts(artifacts.code_files)
|
|
}
|
|
|
|
return artifacts?.code_file ? [artifacts.code_file] : []
|
|
}, [artifacts])
|
|
|
|
const modelOptions = useMemo(() => {
|
|
if (artifacts?.model_files?.length) {
|
|
return dedupeArtifacts(artifacts.model_files)
|
|
}
|
|
|
|
return artifacts?.model_file ? [artifacts.model_file] : []
|
|
}, [artifacts])
|
|
|
|
const [selectedCodePath, setSelectedCodePath] = useState('')
|
|
const [selectedModelPath, setSelectedModelPath] = useState('')
|
|
const [localCodeFile, setLocalCodeFile] = useState<{ path: string; content: string } | null>(
|
|
null,
|
|
)
|
|
const [localModelFile, setLocalModelFile] = useState<{ path: string; url: string } | null>(
|
|
null,
|
|
)
|
|
const [autoCodeFile, setAutoCodeFile] = useState<ArtifactFileInfo | null>(null)
|
|
const autoResolveRequestRef = useRef(0)
|
|
|
|
const codeOptions = useMemo(() => {
|
|
if (!autoCodeFile) {
|
|
return baseCodeOptions
|
|
}
|
|
|
|
return dedupeArtifacts([autoCodeFile, ...baseCodeOptions])
|
|
}, [autoCodeFile, baseCodeOptions])
|
|
|
|
const latestCodePath = autoCodeFile?.path ?? artifacts?.code_file?.path ?? codeOptions[0]?.path ?? ''
|
|
const latestModelPath = artifacts?.model_file?.path ?? modelOptions[0]?.path ?? ''
|
|
|
|
const effectiveSelectedCodePath = codeOptions.some((item) => item.path === selectedCodePath)
|
|
? selectedCodePath
|
|
: latestCodePath
|
|
|
|
const effectiveSelectedModelPath = modelOptions.some((item) => item.path === selectedModelPath)
|
|
? selectedModelPath
|
|
: latestModelPath
|
|
|
|
const selectedCodeFile = useMemo(
|
|
() => codeOptions.find((item) => item.path === effectiveSelectedCodePath) ?? codeOptions[0] ?? null,
|
|
[codeOptions, effectiveSelectedCodePath],
|
|
)
|
|
|
|
const selectedModelFile = useMemo(
|
|
() => modelOptions.find((item) => item.path === effectiveSelectedModelPath) ?? modelOptions[0] ?? null,
|
|
[effectiveSelectedModelPath, modelOptions],
|
|
)
|
|
|
|
const codePath = localCodeFile?.path ?? selectedCodeFile?.path
|
|
const codeValue = localCodeFile?.content ?? selectedCodeFile?.content ?? ''
|
|
const codeReadFailed = !localCodeFile && Boolean(codePath && selectedCodeFile?.content == null)
|
|
const modelPath = localModelFile?.path ?? selectedModelFile?.path
|
|
const modelUrl = localModelFile?.url ?? selectedModelFile?.url
|
|
const showModelArtifactPicker = !localModelFile && modelOptions.length > 1
|
|
const showCodeArtifactPicker = !localCodeFile && codeOptions.length > 1
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (localModelFile?.url) {
|
|
URL.revokeObjectURL(localModelFile.url)
|
|
}
|
|
}
|
|
}, [localModelFile])
|
|
|
|
useEffect(() => {
|
|
autoResolveRequestRef.current += 1
|
|
setAutoCodeFile(null)
|
|
}, [artifacts?.conversation_id])
|
|
|
|
const handleLocalModelSelection = (file: File | null) => {
|
|
if (!file) {
|
|
return
|
|
}
|
|
|
|
const nextUrl = URL.createObjectURL(file)
|
|
setLocalModelFile((currentFile) => {
|
|
if (currentFile?.url) {
|
|
URL.revokeObjectURL(currentFile.url)
|
|
}
|
|
|
|
return {
|
|
path: `Local / ${file.name}`,
|
|
url: nextUrl,
|
|
}
|
|
})
|
|
setAutoCodeFile(null)
|
|
setSelectedModelPath('')
|
|
}
|
|
|
|
const handleLocalCodeSelection = async (file: File | null) => {
|
|
if (!file) {
|
|
return
|
|
}
|
|
|
|
const nextContent = await file.text()
|
|
setLocalCodeFile({
|
|
path: `Local / ${file.name}`,
|
|
content: nextContent,
|
|
})
|
|
setSelectedCodePath('')
|
|
}
|
|
|
|
const handleModelReady = useCallback(
|
|
async (readyModelPath: string) => {
|
|
if (localModelFile || !artifacts?.conversation_id) {
|
|
setAutoCodeFile(null)
|
|
return
|
|
}
|
|
|
|
const preferredCodePath = deriveSiblingModelScriptPath(readyModelPath)
|
|
if (!preferredCodePath) {
|
|
setAutoCodeFile(null)
|
|
return
|
|
}
|
|
|
|
const existingCodeFile = baseCodeOptions.find((item) => item.path === preferredCodePath)
|
|
if (existingCodeFile?.content != null) {
|
|
setAutoCodeFile(existingCodeFile)
|
|
setLocalCodeFile(null)
|
|
setSelectedCodePath('')
|
|
return
|
|
}
|
|
|
|
const requestId = autoResolveRequestRef.current + 1
|
|
autoResolveRequestRef.current = requestId
|
|
|
|
const content = await readConversationArtifactText(artifacts.conversation_id, preferredCodePath)
|
|
if (autoResolveRequestRef.current !== requestId) {
|
|
return
|
|
}
|
|
|
|
if (content == null) {
|
|
setAutoCodeFile(null)
|
|
return
|
|
}
|
|
|
|
setAutoCodeFile({
|
|
path: preferredCodePath,
|
|
content,
|
|
content_type: 'text/plain; charset=utf-8',
|
|
})
|
|
setLocalCodeFile(null)
|
|
setSelectedCodePath('')
|
|
},
|
|
[artifacts?.conversation_id, baseCodeOptions, localModelFile],
|
|
)
|
|
|
|
const renderEmptyStateButton = (
|
|
className: string,
|
|
heading: string,
|
|
body: string,
|
|
fileInputId: string,
|
|
) => (
|
|
<label htmlFor={fileInputId} className={`empty-state ${className} is-clickable`}>
|
|
<h3>{heading}</h3>
|
|
<p>{body}</p>
|
|
</label>
|
|
)
|
|
|
|
useEffect(() => {
|
|
console.info('[workbench] artifact selection state', {
|
|
loading,
|
|
latestCodePath: artifacts?.code_file?.path ?? null,
|
|
latestModelPath: artifacts?.model_file?.path ?? null,
|
|
codeOptions: codeOptions.map((item) => item.path),
|
|
modelOptions: modelOptions.map((item) => item.path),
|
|
outputFiles: artifacts?.output_files ?? [],
|
|
selectedCodePath: codePath ?? null,
|
|
selectedModelPath: modelPath ?? null,
|
|
codeReadFailed,
|
|
hasModelUrl: Boolean(modelUrl),
|
|
autoCodePath: autoCodeFile?.path ?? null,
|
|
})
|
|
}, [
|
|
autoCodeFile,
|
|
artifacts,
|
|
codeOptions,
|
|
codePath,
|
|
codeReadFailed,
|
|
loading,
|
|
modelOptions,
|
|
modelPath,
|
|
modelUrl,
|
|
])
|
|
|
|
return (
|
|
<Group orientation="vertical" className="workbench-group">
|
|
<input
|
|
id={modelFileInputId}
|
|
type="file"
|
|
accept=".stl"
|
|
className="hidden-file-input"
|
|
onChange={(event) => {
|
|
handleLocalModelSelection(event.target.files?.[0] ?? null)
|
|
event.currentTarget.value = ''
|
|
}}
|
|
/>
|
|
<input
|
|
id={codeFileInputId}
|
|
type="file"
|
|
accept=".py,.txt,.md,.json,.toml,.yaml,.yml,.ts,.tsx,.js,.jsx"
|
|
className="hidden-file-input"
|
|
onChange={(event) => {
|
|
void handleLocalCodeSelection(event.target.files?.[0] ?? null)
|
|
event.currentTarget.value = ''
|
|
}}
|
|
/>
|
|
|
|
<Panel defaultSize="56%" minSize="28%">
|
|
<section className="panel-shell workbench-panel">
|
|
<header className="panel-header">
|
|
<div className="panel-title-group">
|
|
<h2>Preview</h2>
|
|
<p className="panel-caption" title={modelPath ?? undefined}>
|
|
{modelPath ? formatArtifactLabel(modelPath) : 'No model yet'}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="panel-header-actions">
|
|
{showModelArtifactPicker ? (
|
|
<label className="model-select artifact-picker">
|
|
<span>Model</span>
|
|
<select
|
|
value={effectiveSelectedModelPath}
|
|
onChange={(event) => {
|
|
setLocalModelFile((currentFile) => {
|
|
if (currentFile?.url) {
|
|
URL.revokeObjectURL(currentFile.url)
|
|
}
|
|
return null
|
|
})
|
|
setAutoCodeFile(null)
|
|
setSelectedModelPath(event.target.value)
|
|
}}
|
|
>
|
|
{modelOptions.map((item) => (
|
|
<option key={item.path} value={item.path}>
|
|
{formatArtifactLabel(item.path)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
) : null}
|
|
|
|
{modelUrl ? (
|
|
<a className="download-link" href={modelUrl} target="_blank" rel="noreferrer">
|
|
Download
|
|
</a>
|
|
) : null}
|
|
</div>
|
|
</header>
|
|
|
|
<div className="workbench-content viewport-panel-content">
|
|
{loading ? (
|
|
renderEmptyStateButton(
|
|
'viewport-empty-state',
|
|
'Loading preview',
|
|
'Fetching the latest model files. Click to open a local STL instead.',
|
|
modelFileInputId,
|
|
)
|
|
) : (
|
|
<Suspense
|
|
fallback={
|
|
renderEmptyStateButton(
|
|
'viewport-empty-state',
|
|
'Preparing viewport',
|
|
'Loading the 3D renderer. Click to open a local STL instead.',
|
|
modelFileInputId,
|
|
)
|
|
}
|
|
>
|
|
<ModelViewport
|
|
key={modelUrl ?? modelPath ?? 'empty-model'}
|
|
modelPath={modelPath}
|
|
modelUrl={modelUrl}
|
|
localFileInputId={modelFileInputId}
|
|
onModelReady={handleModelReady}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</Panel>
|
|
|
|
<Separator className="resize-handle resize-handle-horizontal" />
|
|
|
|
<Panel defaultSize="44%" minSize="24%">
|
|
<section className="panel-shell workbench-panel">
|
|
<header className="panel-header">
|
|
<div className="panel-title-group">
|
|
<h2>Code</h2>
|
|
<p className="panel-caption" title={codePath ?? undefined}>
|
|
{codePath ? formatArtifactLabel(codePath) : 'No code yet'}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="panel-header-actions">
|
|
{showCodeArtifactPicker ? (
|
|
<label className="model-select artifact-picker">
|
|
<span>Code</span>
|
|
<select
|
|
value={effectiveSelectedCodePath}
|
|
onChange={(event) => {
|
|
setLocalCodeFile(null)
|
|
setSelectedCodePath(event.target.value)
|
|
}}
|
|
>
|
|
{codeOptions.map((item) => (
|
|
<option key={item.path} value={item.path}>
|
|
{formatArtifactLabel(item.path)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
) : null}
|
|
</div>
|
|
</header>
|
|
|
|
<div className="workbench-content code-panel-content">
|
|
{codePath && !codeReadFailed ? (
|
|
<div className="code-scroll-shell">
|
|
<SyntaxHighlighter
|
|
language="python"
|
|
style={oneDark}
|
|
showLineNumbers
|
|
wrapLongLines
|
|
className="code-scroll-view code-syntax-view"
|
|
customStyle={{
|
|
margin: 0,
|
|
minHeight: '100%',
|
|
padding: '0.85rem 0',
|
|
background: 'transparent',
|
|
fontSize: '0.82rem',
|
|
}}
|
|
codeTagProps={{
|
|
style: {
|
|
fontFamily: 'IBM Plex Mono, SFMono-Regular, Consolas, monospace',
|
|
},
|
|
}}
|
|
lineNumberStyle={{
|
|
color: '#6f7883',
|
|
minWidth: '2.4rem',
|
|
paddingRight: '0.9rem',
|
|
textAlign: 'right',
|
|
userSelect: 'none',
|
|
}}
|
|
>
|
|
{codeValue}
|
|
</SyntaxHighlighter>
|
|
</div>
|
|
) : codeReadFailed ? (
|
|
renderEmptyStateButton(
|
|
'code-empty-state',
|
|
'Code preview unavailable',
|
|
'The selected file exists, but its text content could not be read. Click to open a local file instead.',
|
|
codeFileInputId,
|
|
)
|
|
) : (
|
|
renderEmptyStateButton(
|
|
'code-empty-state',
|
|
'No code yet',
|
|
'The latest generated CAD script will show up here. Click to open a local code file now.',
|
|
codeFileInputId,
|
|
)
|
|
)}
|
|
</div>
|
|
</section>
|
|
</Panel>
|
|
</Group>
|
|
)
|
|
}
|