Files
cadSet/CADDesigner-Code/frontend/src/components/WorkbenchPane.tsx
T
2026-07-22 19:38:36 +08:00

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>
)
}