first commit

This commit is contained in:
2026-07-22 13:48:46 +08:00
commit c87751c3dc
2820 changed files with 726976 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+46
View File
@@ -0,0 +1,46 @@
# CADDesigner React UI
This directory contains the standalone TypeScript + React frontend for the CADDesigner project.
## What It Includes
- A collapsible session sidebar with conversation stats and history
- A left-side chat timeline that interleaves assistant text and tool calls in event order
- A right-side workbench with a resizable 3D preview pane and code viewer
- Native streaming from the backend `POST /v1/chat/events` SSE endpoint
- A Vite dev proxy for `/health` and `/v1/*`
## Run It
For the standard local workflow from the repo root, use the startup scripts:
```bash
uv run python start_caddesigner_full.py
```
If you are working on the frontend itself, start the FastAPI backend first on
`http://127.0.0.1:8000`, then run:
```bash
pnpm install
pnpm dev
```
The direct Vite dev server runs on `http://127.0.0.1:4173` by default.
The wrapper script `start_caddesigner_ui.py` starts the same frontend on
`http://127.0.0.1:7860` and auto-installs dependencies when `node_modules/`
is missing.
## Build It
```bash
pnpm build
```
## Notes
- `frontend/.gitignore` ignores local frontend build artifacts like `node_modules/` and `dist/`.
- The startup scripts prefer `pnpm`; they fall back to `npm` only if `pnpm` is unavailable.
- The current 3D viewport focuses on STL previews, which matches the agent's main output flow.
- Model preview assets are loaded through the backend artifact endpoints added under `/v1/conversations/{conversation_id}/artifacts/*`.
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,41 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"packageManager": "pnpm@10.29.3",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"lucide-react": "^0.577.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.7.3",
"react-syntax-highlighter": "^16.1.1",
"remark-gfm": "^4.0.1",
"three": "^0.183.2"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/three": "^0.183.1",
"@vitejs/plugin-react": "^6.0.0",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^8.0.0"
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

File diff suppressed because it is too large Load Diff
+642
View File
@@ -0,0 +1,642 @@
import { Suspense, lazy, useCallback, useEffect, useState } from 'react'
import { Group, Panel, Separator } from 'react-resizable-panels'
import './App.css'
import {
appendAssistantTextBlock,
applyStreamPacketToTurn,
buildChatTurns,
createTurn,
extractArtifactTagPaths,
} from './lib/chat'
import {
createConversation,
deleteConversation,
getConversationArtifactUrl,
getConversationHistory,
getHealth,
getLatestArtifacts,
listConversations,
listModels,
probeConversationArtifact,
readConversationArtifactText,
streamChatEvents,
} from './lib/api'
import { ConversationView } from './components/ConversationView'
import { Sidebar } from './components/Sidebar'
import type {
ArtifactFileInfo,
ChatTurn,
ComposerImageAttachment,
ConversationSummary,
LatestArtifactsResponse,
ModelInfo,
RawChatMessage,
RawMessageContentPart,
} from './types'
const WorkbenchPane = lazy(async () => {
const module = await import('./components/WorkbenchPane')
return { default: module.WorkbenchPane }
})
function sortConversations(items: ConversationSummary[]): ConversationSummary[] {
return [...items].sort((left, right) => {
const rightTime = new Date(
right.context_last_activity ?? right.context_start_time ?? 0,
).getTime()
const leftTime = new Date(
left.context_last_activity ?? left.context_start_time ?? 0,
).getTime()
return rightTime - leftTime
})
}
function buildErrorTurn(turns: ChatTurn[], message: string): ChatTurn[] {
if (!turns.length) {
return [appendAssistantTextBlock(createTurn(''), `Error: ${message}`)]
}
const nextTurns = [...turns]
nextTurns[nextTurns.length - 1] = appendAssistantTextBlock(
nextTurns[nextTurns.length - 1],
`Error: ${message}`,
)
return nextTurns
}
function makeAttachmentId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
async function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
if (typeof reader.result === 'string') {
resolve(reader.result)
return
}
reject(new Error('Failed to encode image attachment'))
}
reader.onerror = () => reject(reader.error ?? new Error('Failed to read image file'))
reader.readAsDataURL(file)
})
}
function dedupeArtifactFiles(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 candidateArtifactPaths(path: string): string[] {
const trimmedPath = path.trim()
if (!trimmedPath) {
return []
}
const normalizedPath = trimmedPath.replace(/\\/g, '/').replace(/^\.\//, '')
const nextPaths = [trimmedPath]
if (normalizedPath && normalizedPath !== trimmedPath) {
nextPaths.push(normalizedPath)
}
if (normalizedPath && !normalizedPath.startsWith('workspace/')) {
nextPaths.push(`workspace/${normalizedPath}`)
}
return Array.from(new Set(nextPaths))
}
async function resolveCodeArtifact(
conversationId: string,
taggedPath: string,
): Promise<ArtifactFileInfo | null> {
for (const candidatePath of candidateArtifactPaths(taggedPath)) {
const content = await readConversationArtifactText(conversationId, candidatePath)
if (content == null) {
continue
}
return {
path: candidatePath,
content,
url: getConversationArtifactUrl(conversationId, candidatePath),
content_type: 'text/plain; charset=utf-8',
}
}
return null
}
async function resolveModelArtifact(
conversationId: string,
taggedPath: string,
): Promise<ArtifactFileInfo | null> {
for (const candidatePath of candidateArtifactPaths(taggedPath)) {
const exists = await probeConversationArtifact(conversationId, candidatePath)
if (!exists) {
continue
}
return {
path: candidatePath,
url: getConversationArtifactUrl(conversationId, candidatePath),
}
}
return null
}
async function buildArtifactsFromHistory(
conversationId: string,
messages: RawChatMessage[],
): Promise<LatestArtifactsResponse> {
const taggedArtifacts = extractArtifactTagPaths(messages)
const [codeFiles, modelFiles] = await Promise.all([
Promise.all(taggedArtifacts.codePaths.map((path) => resolveCodeArtifact(conversationId, path))),
Promise.all(taggedArtifacts.modelPaths.map((path) => resolveModelArtifact(conversationId, path))),
])
const resolvedCodeFiles = dedupeArtifactFiles(codeFiles.filter((item): item is ArtifactFileInfo => item !== null))
const resolvedModelFiles = dedupeArtifactFiles(
modelFiles.filter((item): item is ArtifactFileInfo => item !== null),
)
return {
conversation_id: conversationId,
code_file: resolvedCodeFiles[0] ?? null,
code_files: resolvedCodeFiles,
model_file: resolvedModelFiles[0] ?? null,
model_files: resolvedModelFiles,
output_files: resolvedModelFiles.map((item) => item.path),
}
}
function mergeArtifacts(
backendArtifacts: LatestArtifactsResponse | null,
historyArtifacts: LatestArtifactsResponse | null,
): LatestArtifactsResponse | null {
if (!backendArtifacts && !historyArtifacts) {
return null
}
const baseArtifacts = backendArtifacts ?? historyArtifacts
if (!baseArtifacts) {
return null
}
const codeFiles = dedupeArtifactFiles([
...(backendArtifacts?.code_files ?? []),
...(backendArtifacts?.code_file ? [backendArtifacts.code_file] : []),
...(historyArtifacts?.code_files ?? []),
...(historyArtifacts?.code_file ? [historyArtifacts.code_file] : []),
])
const modelFiles = dedupeArtifactFiles([
...(backendArtifacts?.model_files ?? []),
...(backendArtifacts?.model_file ? [backendArtifacts.model_file] : []),
...(historyArtifacts?.model_files ?? []),
...(historyArtifacts?.model_file ? [historyArtifacts.model_file] : []),
])
const outputFiles = Array.from(
new Set([...(backendArtifacts?.output_files ?? []), ...(historyArtifacts?.output_files ?? [])]),
)
return {
conversation_id: baseArtifacts.conversation_id,
code_file: backendArtifacts?.code_file ?? historyArtifacts?.code_file ?? codeFiles[0] ?? null,
code_files: codeFiles,
model_file: backendArtifacts?.model_file ?? historyArtifacts?.model_file ?? modelFiles[0] ?? null,
model_files: modelFiles,
output_files: outputFiles,
}
}
function App() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [conversations, setConversations] = useState<ConversationSummary[]>([])
const [activeConversationId, setActiveConversationId] = useState('')
const [turns, setTurns] = useState<ChatTurn[]>([])
const [artifacts, setArtifacts] = useState<LatestArtifactsResponse | null>(null)
const [models, setModels] = useState<ModelInfo[]>([])
const [selectedModel, setSelectedModel] = useState('cadagent')
const [input, setInput] = useState('')
const [attachments, setAttachments] = useState<ComposerImageAttachment[]>([])
const [bootstrapping, setBootstrapping] = useState(true)
const [artifactLoading, setArtifactLoading] = useState(false)
const [streaming, setStreaming] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const clearConversationState = useCallback(() => {
setActiveConversationId('')
setTurns([])
setArtifacts(null)
}, [])
const handleAttachImages = useCallback(async (files: File[]) => {
if (!files.length) {
return
}
try {
const nextAttachments = await Promise.all(
Array.from(files).map(async (file) => ({
id: makeAttachmentId(),
name: file.name,
dataUrl: await fileToDataUrl(file),
})),
)
setAttachments((current) => [...current, ...nextAttachments])
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Failed to load image attachment')
}
}, [])
const handleRemoveImage = useCallback((attachmentId: string) => {
setAttachments((current) => current.filter((attachment) => attachment.id !== attachmentId))
}, [])
const refreshConversationList = useCallback(async (): Promise<ConversationSummary[]> => {
const nextConversations = sortConversations(await listConversations())
setConversations(nextConversations)
return nextConversations
}, [])
const loadConversationState = useCallback(async (conversationId: string) => {
setArtifactLoading(true)
const [historyResult, artifactsResult] = await Promise.allSettled([
getConversationHistory(conversationId),
getLatestArtifacts(conversationId),
])
const historyArtifacts =
historyResult.status === 'fulfilled'
? await buildArtifactsFromHistory(conversationId, historyResult.value)
: null
if (historyResult.status === 'fulfilled') {
setTurns(buildChatTurns(historyResult.value))
} else {
setTurns([])
}
if (artifactsResult.status === 'fulfilled') {
console.info('[artifacts] loaded latest artifacts', {
conversationId,
historyMessageCount:
historyResult.status === 'fulfilled' ? historyResult.value.length : undefined,
codeFile: artifactsResult.value.code_file?.path ?? null,
codeFiles: (artifactsResult.value.code_files ?? []).map((item) => item.path),
modelFile: artifactsResult.value.model_file?.path ?? null,
modelFiles: (artifactsResult.value.model_files ?? []).map((item) => item.path),
outputFiles: artifactsResult.value.output_files ?? [],
})
} else {
console.warn('[artifacts] failed to load latest artifacts', {
conversationId,
historyStatus: historyResult.status,
reason:
artifactsResult.reason instanceof Error
? artifactsResult.reason.message
: artifactsResult.reason,
})
}
if (historyArtifacts) {
console.info('[artifacts] history tag fallback', {
conversationId,
codeFile: historyArtifacts.code_file?.path ?? null,
codeFiles: historyArtifacts.code_files.map((item) => item.path),
modelFile: historyArtifacts.model_file?.path ?? null,
modelFiles: historyArtifacts.model_files.map((item) => item.path),
outputFiles: historyArtifacts.output_files,
})
}
const mergedArtifacts = mergeArtifacts(
artifactsResult.status === 'fulfilled' ? artifactsResult.value : null,
historyArtifacts,
)
console.info('[artifacts] merged artifact state', {
conversationId,
codeFile: mergedArtifacts?.code_file?.path ?? null,
codeFiles: mergedArtifacts?.code_files.map((item) => item.path) ?? [],
modelFile: mergedArtifacts?.model_file?.path ?? null,
modelFiles: mergedArtifacts?.model_files.map((item) => item.path) ?? [],
outputFiles: mergedArtifacts?.output_files ?? [],
})
setArtifacts(mergedArtifacts)
if (historyResult.status === 'rejected') {
console.warn('[history] failed to load conversation history', {
conversationId,
reason:
historyResult.reason instanceof Error ? historyResult.reason.message : historyResult.reason,
})
}
setArtifactLoading(false)
}, [])
const ensureConversation = useCallback(async (): Promise<string> => {
if (activeConversationId) {
return activeConversationId
}
const created = await createConversation()
setActiveConversationId(created.conversation_id)
return created.conversation_id
}, [activeConversationId])
const bootstrap = useCallback(async () => {
setBootstrapping(true)
setErrorMessage(null)
try {
await getHealth()
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Backend unavailable')
}
try {
const [availableModels, knownConversations] = await Promise.all([
listModels(),
refreshConversationList(),
])
setModels(availableModels)
if (availableModels.length) {
setSelectedModel((currentModel) =>
availableModels.some((model) => model.id === currentModel)
? currentModel
: availableModels[0].id,
)
}
const nextConversationId =
knownConversations[0]?.conversation_id ?? (await createConversation()).conversation_id
setActiveConversationId(nextConversationId)
await loadConversationState(nextConversationId)
await refreshConversationList()
} catch (error) {
setErrorMessage(
error instanceof Error ? error.message : 'Failed to initialize the React UI',
)
} finally {
setBootstrapping(false)
}
}, [loadConversationState, refreshConversationList])
useEffect(() => {
void bootstrap()
}, [bootstrap])
const handleCreateConversation = useCallback(async () => {
setErrorMessage(null)
try {
const created = await createConversation()
setActiveConversationId(created.conversation_id)
setTurns([])
setArtifacts(null)
await refreshConversationList()
await loadConversationState(created.conversation_id)
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Failed to create session')
}
}, [loadConversationState, refreshConversationList])
const handleSelectConversation = useCallback(
async (conversationId: string) => {
setActiveConversationId(conversationId)
setErrorMessage(null)
await loadConversationState(conversationId)
},
[loadConversationState],
)
const handleDeleteConversation = useCallback(
async (conversationId: string) => {
if (streaming || bootstrapping) {
return
}
const shortId = conversationId.slice(0, 8)
if (!window.confirm(`Delete conversation ${shortId}? This cannot be undone.`)) {
return
}
setErrorMessage(null)
try {
await deleteConversation(conversationId)
const refreshed = await refreshConversationList()
if (conversationId !== activeConversationId) {
return
}
const nextConversation = refreshed.find(
(conversation) => conversation.conversation_id !== conversationId,
)
if (!nextConversation) {
clearConversationState()
return
}
setActiveConversationId(nextConversation.conversation_id)
await loadConversationState(nextConversation.conversation_id)
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Failed to delete conversation')
}
},
[
activeConversationId,
bootstrapping,
clearConversationState,
loadConversationState,
refreshConversationList,
streaming,
],
)
useEffect(() => {
const intervalId = window.setInterval(() => {
void refreshConversationList()
}, 10000)
return () => {
window.clearInterval(intervalId)
}
}, [refreshConversationList])
const handleSendMessage = useCallback(async () => {
const nextMessage = input.trim()
if ((!nextMessage && !attachments.length) || streaming) {
return
}
const pendingAttachments = attachments
setStreaming(true)
setErrorMessage(null)
setInput('')
setAttachments([])
const messageContent: string | RawMessageContentPart[] = pendingAttachments.length
? [
...(nextMessage ? [{ type: 'text', text: nextMessage } as const] : []),
...pendingAttachments.map(
(attachment) =>
({
type: 'image_url',
image_url: { url: attachment.dataUrl },
}) as const,
),
]
: nextMessage
try {
const conversationId = await ensureConversation()
setActiveConversationId(conversationId)
const optimisticTurn = createTurn(
nextMessage,
pendingAttachments.map((attachment) => attachment.dataUrl),
)
setTurns((currentTurns) => [...currentTurns, optimisticTurn])
const result = await streamChatEvents({
conversationId,
model: selectedModel,
messageContent,
onPacket: (packet) => {
if (packet.event === 'done') {
return
}
if (packet.event === 'error') {
const packetMessage =
typeof packet.data === 'object' &&
packet.data !== null &&
'message' in packet.data &&
typeof packet.data.message === 'string'
? packet.data.message
: 'The stream reported an unknown error.'
setErrorMessage(packetMessage)
setTurns((currentTurns) => buildErrorTurn(currentTurns, packetMessage))
return
}
setTurns((currentTurns) => {
if (!currentTurns.length) {
return currentTurns
}
const nextTurns = [...currentTurns]
nextTurns[nextTurns.length - 1] = applyStreamPacketToTurn(
nextTurns[nextTurns.length - 1],
packet,
)
return nextTurns
})
},
})
const finalConversationId = result.conversationId || conversationId
setActiveConversationId(finalConversationId)
await Promise.all([refreshConversationList(), loadConversationState(finalConversationId)])
} catch (error) {
const message = error instanceof Error ? error.message : 'Streaming request failed'
setErrorMessage(message)
setAttachments(pendingAttachments)
setTurns((currentTurns) => buildErrorTurn(currentTurns, message))
} finally {
setStreaming(false)
}
}, [
attachments,
ensureConversation,
input,
loadConversationState,
refreshConversationList,
selectedModel,
streaming,
])
return (
<div className="app-shell">
<Sidebar
collapsed={sidebarCollapsed}
conversations={conversations}
activeConversationId={activeConversationId}
busy={streaming || bootstrapping}
onToggle={() => setSidebarCollapsed((value) => !value)}
onCreateConversation={() => void handleCreateConversation()}
onSelectConversation={(conversationId) => void handleSelectConversation(conversationId)}
onDeleteConversation={(conversationId) => void handleDeleteConversation(conversationId)}
/>
<main className="workspace-shell">
<div className="workspace-panels">
<Group orientation="horizontal" className="main-panel-group">
<Panel defaultSize="54%" minSize="34%">
<ConversationView
turns={turns}
activeConversationId={activeConversationId}
isStreaming={streaming}
input={input}
attachments={attachments}
models={models}
selectedModel={selectedModel}
errorMessage={errorMessage}
onInputChange={setInput}
onAttachImages={(files) => void handleAttachImages(files)}
onRemoveImage={handleRemoveImage}
onModelChange={setSelectedModel}
onSubmit={() => void handleSendMessage()}
/>
</Panel>
<Separator className="resize-handle resize-handle-vertical" />
<Panel defaultSize="46%" minSize="30%">
<Suspense
fallback={
<section className="panel-shell workbench-loading-shell">
<div className="empty-state viewport-empty-state">
<h3>Loading workbench</h3>
<p>Preparing the preview and code panels.</p>
</div>
</section>
}
>
<WorkbenchPane artifacts={artifacts} loading={artifactLoading || bootstrapping} />
</Suspense>
</Panel>
</Group>
</div>
</main>
</div>
)
}
export default App
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,72 @@
import { type ReactNode, useId, useLayoutEffect, useRef, useState } from 'react'
interface AnimatedDisclosureProps {
className: string
summaryClassName: string
bodyWrapClassName: string
bodyClassName: string
open: boolean
onToggle: (nextOpen: boolean) => void
summary: ReactNode
children: ReactNode
}
export function AnimatedDisclosure({
className,
summaryClassName,
bodyWrapClassName,
bodyClassName,
open,
onToggle,
summary,
children,
}: AnimatedDisclosureProps) {
const bodyId = useId()
const bodyRef = useRef<HTMLDivElement | null>(null)
const [contentHeight, setContentHeight] = useState(0)
useLayoutEffect(() => {
const node = bodyRef.current
if (!node) {
return
}
const measure = () => {
setContentHeight(node.scrollHeight)
}
measure()
if (typeof ResizeObserver === 'undefined') {
return
}
const observer = new ResizeObserver(() => {
measure()
})
observer.observe(node)
return () => {
observer.disconnect()
}
}, [children])
return (
<section className={`${className} ${open ? 'is-open' : ''}`.trim()}>
<button
aria-controls={bodyId}
aria-expanded={open}
className={summaryClassName}
onClick={() => onToggle(!open)}
type="button"
>
{summary}
</button>
<div className={bodyWrapClassName} id={bodyId} style={{ height: open ? `${contentHeight}px` : '0px' }}>
<div className={bodyClassName} ref={bodyRef}>
{children}
</div>
</div>
</section>
)
}
@@ -0,0 +1,437 @@
import { type ClipboardEvent, useEffect, useMemo, useRef, useState } from 'react'
import { Brain, ImagePlus, Send, X } from 'lucide-react'
import { sanitizeAssistantText } from '../lib/chat'
import type { ChatTurn, ComposerImageAttachment, ModelInfo } from '../types'
import { AnimatedDisclosure } from './AnimatedDisclosure'
import { StreamingMarkdown } from './StreamingMarkdown'
import { ToolCallCard } from './ToolCallCard'
interface ConversationViewProps {
turns: ChatTurn[]
activeConversationId: string
isStreaming: boolean
input: string
attachments: ComposerImageAttachment[]
models: ModelInfo[]
selectedModel: string
errorMessage: string | null
onInputChange: (value: string) => void
onAttachImages: (files: File[]) => void
onRemoveImage: (attachmentId: string) => void
onModelChange: (value: string) => void
onSubmit: () => void
}
interface ImagePreviewState {
src: string
label: string
}
const AUTO_SCROLL_THRESHOLD = 120
const AUTO_SCROLL_UP_EPSILON = 6
interface ReasoningCardProps {
title: string
text: string
autoOpen: boolean
}
function ReasoningCard({ title, text, autoOpen }: ReasoningCardProps) {
const [isOpen, setIsOpen] = useState(autoOpen)
useEffect(() => {
if (autoOpen) {
setIsOpen(true)
}
}, [autoOpen])
return (
<AnimatedDisclosure
bodyClassName="reasoning-card-body"
bodyWrapClassName="reasoning-card-body-wrap"
className="reasoning-card"
onToggle={setIsOpen}
open={isOpen}
summary={
<div className="reasoning-card-title">
<Brain size={14} />
<strong>{title}</strong>
</div>
}
summaryClassName="reasoning-card-summary"
>
<StreamingMarkdown pulseOnMount={autoOpen} text={text} />
</AnimatedDisclosure>
)
}
function collectImageFiles(files: Iterable<File>): File[] {
return Array.from(files).filter((file) => file.type.startsWith('image/'))
}
function collectClipboardImages(event: ClipboardEvent<HTMLTextAreaElement>): File[] {
return Array.from(event.clipboardData.items)
.filter((item) => item.kind === 'file' && item.type.startsWith('image/'))
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null)
}
function isNearTimelineBottom(node: HTMLDivElement): boolean {
return node.scrollHeight - node.scrollTop - node.clientHeight <= AUTO_SCROLL_THRESHOLD
}
export function ConversationView({
turns,
activeConversationId,
isStreaming,
input,
attachments,
models,
selectedModel,
errorMessage,
onInputChange,
onAttachImages,
onRemoveImage,
onModelChange,
onSubmit,
}: ConversationViewProps) {
const timelineRef = useRef<HTMLDivElement | null>(null)
const shouldAutoScrollRef = useRef(true)
const pendingConversationScrollRef = useRef(false)
const lastScrollTopRef = useRef(0)
const [isDraggingFiles, setIsDraggingFiles] = useState(false)
const [previewImage, setPreviewImage] = useState<ImagePreviewState | null>(null)
useEffect(() => {
const node = timelineRef.current
if (!node) {
return
}
pendingConversationScrollRef.current = true
shouldAutoScrollRef.current = true
node.scrollTop = node.scrollHeight
lastScrollTopRef.current = node.scrollTop
}, [activeConversationId])
useEffect(() => {
const node = timelineRef.current
if (!node) {
return
}
const shouldSnapToBottom = pendingConversationScrollRef.current || (isStreaming && shouldAutoScrollRef.current)
if (!shouldSnapToBottom) {
return
}
node.scrollTop = node.scrollHeight
lastScrollTopRef.current = node.scrollTop
if (pendingConversationScrollRef.current && turns.length > 0) {
pendingConversationScrollRef.current = false
}
}, [turns, isStreaming])
useEffect(() => {
const node = timelineRef.current
if (!node) {
return
}
const handleScroll = () => {
const currentScrollTop = node.scrollTop
const nearBottom = isNearTimelineBottom(node)
const scrolledUp = currentScrollTop < lastScrollTopRef.current - AUTO_SCROLL_UP_EPSILON
if (nearBottom) {
shouldAutoScrollRef.current = true
} else if (scrolledUp) {
shouldAutoScrollRef.current = false
}
lastScrollTopRef.current = currentScrollTop
}
handleScroll()
node.addEventListener('scroll', handleScroll)
return () => {
node.removeEventListener('scroll', handleScroll)
}
}, [])
const streamingTurnId = useMemo(
() => (isStreaming ? turns.at(-1)?.id : undefined),
[isStreaming, turns],
)
useEffect(() => {
if (!previewImage) {
return
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setPreviewImage(null)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [previewImage])
return (
<section className="panel-shell chat-panel-shell">
<header className="panel-header">
<div className="panel-title-group">
<h2>Chat</h2>
<p className="panel-caption">
{activeConversationId
? `Conversation ${activeConversationId.slice(0, 8)}`
: 'New conversation'}
</p>
</div>
<label className="model-select">
<span>Model</span>
<select value={selectedModel} onChange={(event) => onModelChange(event.target.value)}>
{models.map((model) => (
<option key={model.id} value={model.id}>
{model.id}
</option>
))}
</select>
</label>
</header>
<div className="chat-timeline" ref={timelineRef}>
{turns.length === 0 ? (
<div className="empty-state chat-empty-state">
<h3>Start a conversation</h3>
<p>Describe the part you want, or paste/drag reference images, and the agent will stream text and tool calls in order.</p>
</div>
) : null}
{turns.map((turn) => {
const isActiveStreamingTurn = turn.id === streamingTurnId
return (
<article className="turn" key={turn.id}>
<div className="bubble bubble-user">
<span className="bubble-role">You</span>
{turn.userImages.length ? (
<div className="user-image-grid">
{turn.userImages.map((imageUrl, index) => (
<img
key={`${turn.id}-${index}`}
className="user-image-preview"
src={imageUrl}
alt={`User attachment ${index + 1}`}
onClick={() =>
setPreviewImage({
src: imageUrl,
label: `Conversation image ${index + 1}`,
})
}
/>
))}
</div>
) : null}
{turn.userText ? <p>{turn.userText}</p> : null}
</div>
<div className="assistant-sequence">
{turn.segments.map((segment) => {
if (segment.kind === 'tool') {
return <ToolCallCard key={`${segment.id}-${segment.status}`} activity={segment} />
}
if (segment.kind === 'reasoning') {
const visibleReasoning = segment.rawText.trim()
if (!visibleReasoning) {
return null
}
return (
<ReasoningCard
autoOpen={isActiveStreamingTurn}
key={segment.id}
text={visibleReasoning}
title={segment.title}
/>
)
}
const visibleText = sanitizeAssistantText(segment.rawText)
if (!visibleText) {
return null
}
return (
<div className="bubble bubble-assistant" key={segment.id}>
<span className="bubble-role">Agent</span>
<StreamingMarkdown text={visibleText} pulseOnMount={isActiveStreamingTurn} />
</div>
)
})}
{isActiveStreamingTurn && !turn.segments.length ? (
<div aria-live="polite" className="bubble bubble-assistant bubble-thinking" role="status">
<span className="bubble-role">Agent</span>
<div className="thinking-indicator">
<div className="thinking-indicator-line">
<span className="thinking-indicator-text">Thinking</span>
<span aria-hidden="true" className="thinking-indicator-dots">
<span className="thinking-indicator-dot" />
<span className="thinking-indicator-dot" />
<span className="thinking-indicator-dot" />
</span>
</div>
<p>Planning the next step, selecting tools, and preparing the first response.</p>
</div>
</div>
) : null}
</div>
</article>
)
})}
</div>
<footer
className={`chat-composer-shell ${isDraggingFiles ? 'is-dragging-files' : ''}`}
onDragEnter={(event) => {
event.preventDefault()
setIsDraggingFiles(true)
}}
onDragOver={(event) => {
event.preventDefault()
setIsDraggingFiles(true)
}}
onDragLeave={(event) => {
event.preventDefault()
const nextTarget = event.relatedTarget
if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) {
setIsDraggingFiles(false)
}
}}
onDrop={(event) => {
event.preventDefault()
setIsDraggingFiles(false)
const files = collectImageFiles(event.dataTransfer.files)
if (files.length) {
onAttachImages(files)
}
}}
>
{errorMessage ? <div className="error-banner">{errorMessage}</div> : null}
{attachments.length ? (
<div className="composer-image-list">
{attachments.map((attachment) => (
<div className="composer-image-item" key={attachment.id}>
<img
src={attachment.dataUrl}
alt={attachment.name}
onClick={() =>
setPreviewImage({
src: attachment.dataUrl,
label: attachment.name,
})
}
/>
<div className="composer-image-meta">
<span>{attachment.name}</span>
<button
type="button"
className="composer-image-remove"
onClick={() => onRemoveImage(attachment.id)}
disabled={isStreaming}
aria-label={`Remove ${attachment.name}`}
>
<X size={14} />
</button>
</div>
</div>
))}
</div>
) : null}
<div className="chat-composer-row">
<label className="composer-attach-button" aria-label="Attach image">
<input
type="file"
accept="image/*"
multiple
onChange={(event) => {
onAttachImages(collectImageFiles(event.target.files ?? []))
event.currentTarget.value = ''
}}
disabled={isStreaming}
/>
<ImagePlus size={16} />
</label>
<textarea
className="chat-composer"
value={input}
onChange={(event) => onInputChange(event.target.value)}
onPaste={(event) => {
const imageFiles = collectClipboardImages(event)
if (!imageFiles.length) {
return
}
const plainText = event.clipboardData.getData('text/plain')
if (!plainText) {
event.preventDefault()
}
onAttachImages(imageFiles)
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
onSubmit()
}
}}
placeholder="Describe dimensions, constraints, or changes. You can also paste or drag images here..."
rows={3}
disabled={isStreaming}
/>
<button
type="button"
className="send-button"
onClick={onSubmit}
disabled={isStreaming || (!input.trim() && !attachments.length)}
>
<Send size={16} />
</button>
</div>
</footer>
{previewImage ? (
<div
className="image-lightbox"
role="dialog"
aria-modal="true"
aria-label={previewImage.label}
onClick={() => setPreviewImage(null)}
>
<div className="image-lightbox-content" onClick={(event) => event.stopPropagation()}>
<button
type="button"
className="image-lightbox-close"
onClick={() => setPreviewImage(null)}
aria-label="Close image preview"
>
<X size={18} />
</button>
<img src={previewImage.src} alt={previewImage.label} className="image-lightbox-image" />
<p className="image-lightbox-caption">{previewImage.label}</p>
</div>
</div>
) : null}
</section>
)
}
@@ -0,0 +1,159 @@
import { useEffect, useMemo, useState } from 'react'
import type { BufferGeometry } from 'three'
import { Canvas } from '@react-three/fiber'
import { Bounds, OrbitControls } from '@react-three/drei'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'
interface ModelViewportProps {
modelUrl?: string
modelPath?: string
localFileInputId?: string
onModelReady?: (modelPath: string) => void
}
function ModelMesh({ geometry }: { geometry: BufferGeometry }) {
return (
<mesh geometry={geometry} castShadow receiveShadow>
<meshStandardMaterial color="#73cab9" metalness={0.16} roughness={0.44} />
</mesh>
)
}
function renderEmptyViewportState(
title: string,
body: string,
localFileInputId?: string,
) {
if (!localFileInputId) {
return (
<div className="empty-state viewport-empty-state">
<h3>{title}</h3>
<p>{body}</p>
</div>
)
}
return (
<label
htmlFor={localFileInputId}
className="empty-state viewport-empty-state is-clickable"
>
<h3>{title}</h3>
<p>{body}</p>
</label>
)
}
export function ModelViewport({
modelUrl,
modelPath,
localFileInputId,
onModelReady,
}: ModelViewportProps) {
const [geometry, setGeometry] = useState<BufferGeometry | null>(null)
const [loading, setLoading] = useState(
Boolean(modelUrl && modelPath && modelPath.toLowerCase().endsWith('.stl')),
)
const [error, setError] = useState<string | null>(null)
const isSupported = useMemo(
() => Boolean(modelPath && modelPath.toLowerCase().endsWith('.stl')),
[modelPath],
)
useEffect(() => {
let cancelled = false
let nextGeometry: BufferGeometry | null = null
if (!modelUrl || !modelPath) {
return undefined
}
if (!isSupported) {
return undefined
}
const loader = new STLLoader()
setLoading(true)
setError(null)
loader.load(
modelUrl,
(loadedGeometry) => {
if (cancelled) {
loadedGeometry.dispose()
return
}
loadedGeometry.computeVertexNormals()
loadedGeometry.center()
nextGeometry = loadedGeometry
setGeometry(loadedGeometry)
setLoading(false)
onModelReady?.(modelPath)
},
undefined,
(loadError) => {
if (cancelled) {
return
}
setError(loadError instanceof Error ? loadError.message : 'Failed to load STL')
setLoading(false)
},
)
return () => {
cancelled = true
nextGeometry?.dispose()
}
}, [isSupported, modelPath, modelUrl, onModelReady])
if (!modelUrl || !modelPath) {
return renderEmptyViewportState(
'3D preview will appear here',
'Run a modeling turn and the newest generated STL will load into this viewport. Click to open a local STL now.',
localFileInputId,
)
}
if (!isSupported) {
return renderEmptyViewportState(
'Preview unavailable',
'Only STL preview is enabled right now. Click to choose a local STL file.',
localFileInputId,
)
}
if (loading) {
return (
<div className="empty-state viewport-empty-state">
<h3>Loading model</h3>
<p>Fetching `{modelPath.split('/').pop()}` for interactive preview.</p>
</div>
)
}
if (error || !geometry) {
return renderEmptyViewportState(
'Preview unavailable',
error ?? 'The geometry could not be prepared. Click to choose another local STL file.',
localFileInputId,
)
}
return (
<div className="model-canvas-shell">
<Canvas camera={{ position: [170, 120, 170], fov: 28 }}>
<color attach="background" args={['#0d141b']} />
<ambientLight intensity={0.82} />
<directionalLight position={[8, 10, 6]} intensity={1.3} />
<directionalLight position={[-8, -8, -6]} intensity={0.32} />
<gridHelper args={[240, 16, '#294553', '#17242d']} position={[0, -36, 0]} />
<Bounds fit clip observe margin={1.25}>
<ModelMesh geometry={geometry} />
</Bounds>
<OrbitControls makeDefault enableDamping />
</Canvas>
</div>
)
}
@@ -0,0 +1,159 @@
import { ChevronLeft, ChevronRight, Plus, Trash2 } from 'lucide-react'
import type { ConversationSummary } from '../types'
interface SidebarProps {
collapsed: boolean
conversations: ConversationSummary[]
activeConversationId: string
busy: boolean
onToggle: () => void
onCreateConversation: () => void
onSelectConversation: (conversationId: string) => void
onDeleteConversation: (conversationId: string) => void
}
function formatDate(value?: string): string {
if (!value) {
return 'No activity'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
return date.toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
export function Sidebar({
collapsed,
conversations,
activeConversationId,
busy,
onToggle,
onCreateConversation,
onSelectConversation,
onDeleteConversation,
}: SidebarProps) {
return (
<aside className={`sidebar ${collapsed ? 'is-collapsed' : ''}`}>
{collapsed ? (
<>
<div className="sidebar-header-actions">
<button
type="button"
className="toolbar-button"
onClick={onCreateConversation}
disabled={busy}
aria-label="Create conversation"
>
<Plus size={16} />
</button>
<button
type="button"
className="toolbar-button"
onClick={onToggle}
aria-label="Expand sidebar"
>
<ChevronRight size={16} />
</button>
</div>
<div className="rail-list">
{conversations.map((conversation) => {
const isActive = conversation.conversation_id === activeConversationId
return (
<button
type="button"
key={conversation.conversation_id}
className={`rail-item ${isActive ? 'is-active' : ''}`}
onClick={() => onSelectConversation(conversation.conversation_id)}
disabled={busy}
title={conversation.conversation_id}
>
{conversation.conversation_id.slice(0, 2)}
</button>
)
})}
</div>
</>
) : (
<>
<header className="sidebar-header">
<div className="sidebar-title-block">
<h1 className="sidebar-title">Conversations</h1>
<p className="sidebar-caption">{conversations.length} shared sessions</p>
</div>
<div className="sidebar-header-actions">
<button
type="button"
className="toolbar-button"
onClick={onCreateConversation}
disabled={busy}
aria-label="Create conversation"
>
<Plus size={16} />
</button>
<button
type="button"
className="toolbar-button"
onClick={onToggle}
aria-label="Collapse sidebar"
>
<ChevronLeft size={16} />
</button>
</div>
</header>
<div className="conversation-list">
{conversations.map((conversation) => {
const isActive = conversation.conversation_id === activeConversationId
return (
<div
key={conversation.conversation_id}
className={`conversation-row ${isActive ? 'is-active' : ''}`}
>
<button
type="button"
className={`conversation-item ${isActive ? 'is-active' : ''}`}
onClick={() => onSelectConversation(conversation.conversation_id)}
disabled={busy}
>
<div className="conversation-item-primary">
<strong>{conversation.conversation_id.slice(0, 8)}</strong>
<span>{formatDate(conversation.context_last_activity)}</span>
</div>
<div className="conversation-item-secondary">
{conversation.context_total_messages ?? 0} messages
</div>
</button>
<button
type="button"
className="conversation-delete-button"
onClick={() => onDeleteConversation(conversation.conversation_id)}
disabled={busy}
aria-label={`Delete conversation ${conversation.conversation_id}`}
title="Delete conversation"
>
<Trash2 size={14} />
</button>
</div>
)
})}
{!conversations.length ? (
<div className="empty-card">No conversations yet.</div>
) : null}
</div>
</>
)}
</aside>
)
}
@@ -0,0 +1,141 @@
import { useEffect, useMemo, useRef } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
interface StreamingMarkdownProps {
text: string
className?: string
pulseOnMount?: boolean
}
interface PositionPoint {
offset?: number
}
interface HastNode {
type: string
value?: string
tagName?: string
properties?: Record<string, unknown>
children?: HastNode[]
position?: {
start?: PositionPoint
end?: PositionPoint
}
}
function createDeltaSpanNode(text: string): HastNode {
return {
type: 'element',
tagName: 'span',
properties: { className: ['stream-delta'] },
children: [{ type: 'text', value: text }],
}
}
function splitTextNodeByOffset(node: HastNode, boundary: number): HastNode[] {
const value = typeof node.value === 'string' ? node.value : ''
const start = node.position?.start?.offset
const end = node.position?.end?.offset
if (!value || typeof start !== 'number' || typeof end !== 'number') {
return [node]
}
if (boundary <= start) {
return [createDeltaSpanNode(value)]
}
if (boundary >= end) {
return [node]
}
const splitIndex = Math.max(0, Math.min(value.length, boundary - start))
const stableText = value.slice(0, splitIndex)
const deltaText = value.slice(splitIndex)
const nextNodes: HastNode[] = []
if (stableText) {
nextNodes.push({ ...node, value: stableText })
}
if (deltaText) {
nextNodes.push(createDeltaSpanNode(deltaText))
}
return nextNodes.length ? nextNodes : [node]
}
function applyStreamingDelta(node: HastNode, boundary: number): void {
const children = Array.isArray(node.children) ? node.children : null
if (!children?.length) {
return
}
const nextChildren: HastNode[] = []
for (const child of children) {
if (child.type === 'text') {
nextChildren.push(...splitTextNodeByOffset(child, boundary))
continue
}
applyStreamingDelta(child, boundary)
nextChildren.push(child)
}
node.children = nextChildren
}
function createStreamingDeltaPlugin(boundary: number | null) {
return () => (tree: HastNode) => {
if (boundary === null) {
return
}
applyStreamingDelta(tree, boundary)
}
}
export function StreamingMarkdown({
text,
className = 'markdown-body',
pulseOnMount = false,
}: StreamingMarkdownProps) {
const previousTextRef = useRef('')
const hasCommittedRef = useRef(false)
const deltaBoundary = useMemo(() => {
const previousText = previousTextRef.current
if (!hasCommittedRef.current) {
if (pulseOnMount && text) {
return 0
}
return null
}
if (!text || text === previousText || !text.startsWith(previousText)) {
return null
}
return previousText.length
}, [pulseOnMount, text])
const rehypePlugins = useMemo(
() => [createStreamingDeltaPlugin(deltaBoundary)],
[deltaBoundary],
)
useEffect(() => {
previousTextRef.current = text
hasCommittedRef.current = true
}, [text])
return (
<div className={className}>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={rehypePlugins}>
{text}
</ReactMarkdown>
</div>
)
}
@@ -0,0 +1,247 @@
import { useEffect, useRef, useState } from 'react'
import { AlertTriangle, Brain, CheckCircle2, ChevronDown, LoaderCircle, Wrench } from 'lucide-react'
import { sanitizeAssistantText } from '../lib/chat'
import type { ToolNestedEvent, ToolSegment } from '../types'
import { AnimatedDisclosure } from './AnimatedDisclosure'
import { StreamingMarkdown } from './StreamingMarkdown'
interface ToolCallCardProps {
activity: ToolSegment
}
function statusLabel(status: ToolSegment['status']): string {
if (status === 'running') {
return 'Running'
}
if (status === 'completed') {
return 'Completed'
}
if (status === 'error') {
return 'Failed'
}
return 'Queued'
}
function StatusIcon({ status }: { status: ToolSegment['status'] }) {
if (status === 'running' || status === 'pending') {
return <LoaderCircle size={14} className="spin" />
}
if (status === 'completed') {
return <CheckCircle2 size={14} />
}
return <AlertTriangle size={14} />
}
function nestedStatusLabel(status: ToolNestedEvent['status']): string {
if (status === 'running') {
return 'Running'
}
if (status === 'completed') {
return 'Done'
}
if (status === 'error') {
return 'Failed'
}
return 'Info'
}
function nestedEventVariant(event: ToolNestedEvent): string {
const label = event.detailLabel.trim().toLowerCase()
if (label === 'reasoning') {
return 'reasoning'
}
if (label === 'response') {
return 'response'
}
if (label === 'error') {
return 'error'
}
if (label === 'result') {
return 'result'
}
if (label === 'progress') {
return 'progress'
}
return 'detail'
}
function isNarrativeNestedEvent(event: ToolNestedEvent): boolean {
const variant = nestedEventVariant(event)
return variant === 'response' || variant === 'reasoning'
}
function NestedToolCard({ event }: { event: ToolNestedEvent }) {
const variant = nestedEventVariant(event)
const isRunning = event.status === 'running'
const [isOpen, setIsOpen] = useState(isRunning)
const previousStatusRef = useRef(event.status)
useEffect(() => {
if (isRunning) {
setIsOpen(true)
} else if (previousStatusRef.current === 'running' && event.status !== 'running') {
setIsOpen(false)
}
previousStatusRef.current = event.status
}, [event.status, isRunning])
return (
<AnimatedDisclosure
bodyClassName="tool-nested-body"
bodyWrapClassName="tool-nested-body-wrap"
className={`tool-nested-item is-${variant}`}
onToggle={setIsOpen}
open={isOpen}
summary={
<div className="tool-nested-item-topline">
<strong>{event.title}</strong>
<div className="tool-nested-item-actions">
<span className={`tool-nested-status is-${event.status}`}>{nestedStatusLabel(event.status)}</span>
<ChevronDown className="tool-nested-chevron" size={14} />
</div>
</div>
}
summaryClassName="tool-nested-summary"
>
{event.argumentsPreview ? (
<div className="tool-nested-block is-arguments">
<span className="tool-card-label">Arguments</span>
<pre>{event.argumentsPreview}</pre>
</div>
) : null}
{event.detailPreview ? (
<div className={`tool-nested-block is-${variant}`}>
<span className="tool-card-label">{event.detailLabel}</span>
<pre>{event.detailPreview}</pre>
</div>
) : null}
</AnimatedDisclosure>
)
}
function InlineReasoningCard({ title, text, autoOpen }: { title: string; text: string; autoOpen: boolean }) {
const [isOpen, setIsOpen] = useState(autoOpen)
useEffect(() => {
if (autoOpen) {
setIsOpen(true)
}
}, [autoOpen])
return (
<AnimatedDisclosure
bodyClassName="reasoning-card-body"
bodyWrapClassName="reasoning-card-body-wrap"
className="reasoning-card tool-inline-reasoning"
onToggle={setIsOpen}
open={isOpen}
summary={
<div className="reasoning-card-title">
<Brain size={14} />
<strong>{title}</strong>
</div>
}
summaryClassName="reasoning-card-summary"
>
<StreamingMarkdown pulseOnMount={autoOpen} text={text} />
</AnimatedDisclosure>
)
}
export function ToolCallCard({ activity }: ToolCallCardProps) {
const isRunning = activity.status === 'pending' || activity.status === 'running'
const [isOpen, setIsOpen] = useState(isRunning)
useEffect(() => {
if (isRunning) {
setIsOpen(true)
}
}, [isRunning])
return (
<AnimatedDisclosure
bodyClassName="tool-card-body"
bodyWrapClassName="tool-card-body-wrap"
className="tool-card"
onToggle={setIsOpen}
open={isOpen}
summary={
<div className="tool-card-title-row">
<div className={`tool-status-pill is-${activity.status}`}>
<StatusIcon status={activity.status} />
<span>{statusLabel(activity.status)}</span>
</div>
<div className="tool-card-title">
<Wrench size={14} />
<strong>{activity.toolName}</strong>
</div>
<ChevronDown size={16} className="tool-card-chevron" />
</div>
}
summaryClassName="tool-card-summary"
>
{activity.argumentsPreview ? (
<div className="tool-card-section">
<span className="tool-card-label">Arguments</span>
<pre>{activity.argumentsPreview}</pre>
</div>
) : null}
{activity.nestedEvents.length ? (
<div className="tool-card-section">
<span className="tool-card-label">Subagent Trace</span>
<div className="tool-card-middle-sequence">
{activity.nestedEvents.map((event) => {
const variant = nestedEventVariant(event)
if (isNarrativeNestedEvent(event)) {
const rawText =
variant === 'response'
? sanitizeAssistantText(event.detailPreview)
: event.detailPreview.trim()
if (!rawText) {
return null
}
if (variant === 'reasoning') {
return (
<InlineReasoningCard
autoOpen={isRunning}
key={event.id}
text={rawText}
title={event.title}
/>
)
}
return (
<div className="bubble bubble-assistant bubble-tool-inline" key={event.id}>
<span className="bubble-role">{event.title}</span>
<StreamingMarkdown pulseOnMount={isRunning} text={rawText} />
</div>
)
}
return <NestedToolCard event={event} key={event.id} />
})}
</div>
</div>
) : null}
{activity.detailPreview ? (
<div className="tool-card-section">
<span className="tool-card-label">{activity.status === 'error' ? 'Error' : 'Result'}</span>
<pre>{activity.detailPreview}</pre>
</div>
) : null}
</AnimatedDisclosure>
)
}
@@ -0,0 +1,443 @@
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>
)
}
@@ -0,0 +1,62 @@
:root {
font-family: 'IBM Plex Sans', 'Avenir Next', 'Segoe UI', sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: dark;
color: #e6e8eb;
background: #111317;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--bg: #111317;
--sidebar: #15181d;
--surface: #171b20;
--surface-soft: #1a1f25;
--surface-active: #1f252d;
--ink: #e6e8eb;
--muted: #959da8;
--line: #272d35;
--line-strong: #3a424d;
--accent-line: #5d7388;
--focus: rgba(147, 168, 189, 0.6);
}
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
min-height: 100%;
}
body {
margin: 0;
height: 100vh;
min-height: 100vh;
overflow: hidden;
background: var(--bg);
color: var(--ink);
}
#root {
height: 100svh;
min-height: 100svh;
overflow: hidden;
}
button,
input,
textarea,
select {
font: inherit;
}
a {
color: inherit;
text-decoration: none;
}
@@ -0,0 +1,266 @@
import type {
RawMessageContentPart,
ConversationDetail,
ConversationSummary,
LatestArtifactsResponse,
ModelInfo,
RawChatMessage,
StreamPacket,
} from '../types'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL?.replace(/\/$/, '') ?? ''
function buildUrl(path: string): string {
if (!API_BASE_URL) {
return path
}
return `${API_BASE_URL}${path}`
}
async function parseError(response: Response): Promise<string> {
try {
const payload = (await response.json()) as {
error?: { message?: string }
message?: string
}
return payload.error?.message ?? payload.message ?? response.statusText
} catch {
return response.statusText
}
}
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(buildUrl(path), {
...init,
headers: {
Accept: 'application/json',
...(init?.headers ?? {}),
},
})
if (!response.ok) {
throw new Error(await parseError(response))
}
return (await response.json()) as T
}
export async function getHealth(): Promise<{ status: string }> {
return fetchJson('/health')
}
export async function listModels(): Promise<ModelInfo[]> {
const response = await fetchJson<{ data: ModelInfo[] }>('/v1/models')
return response.data ?? []
}
export async function listConversations(): Promise<ConversationSummary[]> {
const response = await fetchJson<{ conversations: ConversationSummary[] }>('/v1/conversations')
return response.conversations ?? []
}
export async function createConversation(): Promise<{ conversation_id: string }> {
return fetchJson('/v1/conversations', {
method: 'POST',
})
}
export async function deleteConversation(
conversationId: string,
): Promise<{ deleted: boolean; conversation_id: string }> {
return fetchJson(`/v1/conversations/${conversationId}`, {
method: 'DELETE',
})
}
export async function getConversationDetail(
conversationId: string,
): Promise<ConversationDetail> {
return fetchJson(`/v1/conversations/${conversationId}`)
}
export async function getConversationHistory(
conversationId: string,
): Promise<RawChatMessage[]> {
const response = await fetchJson<{ messages: RawChatMessage[] }>(
`/v1/conversations/${conversationId}/history`,
)
return response.messages ?? []
}
export async function getLatestArtifacts(
conversationId: string,
): Promise<LatestArtifactsResponse> {
return fetchJson(`/v1/conversations/${conversationId}/artifacts/latest`)
}
export function getConversationArtifactUrl(conversationId: string, path: string): string {
return buildUrl(`/v1/conversations/${conversationId}/artifacts/raw?path=${encodeURIComponent(path)}`)
}
export async function probeConversationArtifact(
conversationId: string,
path: string,
): Promise<boolean> {
try {
const url = getConversationArtifactUrl(conversationId, path)
const headResponse = await fetch(url, { method: 'HEAD' })
if (headResponse.ok) {
return true
}
if (headResponse.status !== 405) {
return false
}
const getResponse = await fetch(url, {
headers: {
Accept: '*/*',
},
})
return getResponse.ok
} catch {
return false
}
}
export async function readConversationArtifactText(
conversationId: string,
path: string,
): Promise<string | null> {
try {
const response = await fetch(getConversationArtifactUrl(conversationId, path), {
headers: {
Accept: 'text/plain, text/x-python, */*',
},
})
if (!response.ok) {
return null
}
return await response.text()
} catch {
return null
}
}
function flushPacket(
currentEvent: string,
dataLines: string[],
): StreamPacket | null {
if (!dataLines.length) {
return null
}
const payloadText = dataLines.join('\n')
let payload: unknown = { raw: payloadText }
try {
payload = JSON.parse(payloadText)
} catch {
payload = { raw: payloadText }
}
return {
event: currentEvent || 'message',
data: payload,
}
}
async function consumeSseStream(
stream: ReadableStream<Uint8Array>,
onPacket: (packet: StreamPacket) => void,
): Promise<void> {
const reader = stream.getReader()
const decoder = new TextDecoder()
let buffer = ''
let currentEvent = 'message'
let dataLines: string[] = []
const processLines = (chunk: string): void => {
const lines = chunk.split(/\r?\n/)
buffer = lines.pop() ?? ''
for (const line of lines) {
if (line === '') {
const packet = flushPacket(currentEvent, dataLines)
currentEvent = 'message'
dataLines = []
if (packet) {
onPacket(packet)
}
continue
}
if (line.startsWith(':')) {
continue
}
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim() || 'message'
continue
}
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trimStart())
}
}
}
while (true) {
const { value, done } = await reader.read()
if (done) {
if (buffer) {
processLines(`${buffer}\n`)
}
const packet = flushPacket(currentEvent, dataLines)
if (packet) {
onPacket(packet)
}
break
}
buffer += decoder.decode(value, { stream: true })
processLines(buffer)
}
}
export async function streamChatEvents(options: {
conversationId?: string
model: string
messageContent: string | RawMessageContentPart[]
onPacket: (packet: StreamPacket) => void
}): Promise<{ conversationId: string }> {
const response = await fetch(buildUrl('/v1/chat/events'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
...(options.conversationId
? { 'X-Conversation-ID': options.conversationId }
: {}),
},
body: JSON.stringify({
model: options.model,
messages: [{ role: 'user', content: options.messageContent }],
stream: true,
}),
})
if (!response.ok) {
throw new Error(await parseError(response))
}
if (!response.body) {
throw new Error('Streaming response body is missing')
}
await consumeSseStream(response.body, options.onPacket)
return {
conversationId:
response.headers.get('X-Conversation-ID') ?? options.conversationId ?? '',
}
}
@@ -0,0 +1,632 @@
import type {
AssistantTextSegment,
ChatTurn,
ReasoningSegment,
RawContentImagePart,
RawContentTextPart,
RawChatMessage,
StreamEventEnvelope,
StreamPacket,
ToolNestedEvent,
ToolSegment,
TurnSegment,
} from '../types'
const CODE_FILE_PATTERN = /<\|code_file\|>[^<]*?<\/\|code_file(?:\|)?>/g
const OUTPUT_FILE_PATTERN = /<\|output_file\|>[^<]*?<\/\|output_file(?:\|)?>/g
const CODE_FILE_CAPTURE_PATTERN = /<\|code_file\|>([^<]*?)<\/\|code_file(?:\|)?>/g
const OUTPUT_FILE_CAPTURE_PATTERN = /<\|output_file\|>([^<]*?)<\/\|output_file(?:\|)?>/g
function makeId(prefix: string): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return `${prefix}-${crypto.randomUUID()}`
}
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
function rememberRecentString(values: string[], nextValue: string): void {
const trimmedValue = nextValue.trim()
if (!trimmedValue) {
return
}
const existingIndex = values.indexOf(trimmedValue)
if (existingIndex >= 0) {
values.splice(existingIndex, 1)
}
values.push(trimmedValue)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function isTextPart(value: unknown): value is RawContentTextPart {
return isRecord(value) && value.type === 'text' && typeof value.text === 'string'
}
function isImagePart(value: unknown): value is RawContentImagePart {
return (
isRecord(value) &&
value.type === 'image_url' &&
isRecord(value.image_url) &&
typeof value.image_url.url === 'string'
)
}
function cloneSegment(segment: TurnSegment): TurnSegment {
if (segment.kind === 'text' || segment.kind === 'reasoning') {
return { ...segment }
}
return {
...segment,
nestedEvents: segment.nestedEvents.map((event) => ({ ...event })),
}
}
function cloneTurn(turn: ChatTurn): ChatTurn {
return {
...turn,
segments: turn.segments.map(cloneSegment),
}
}
function createTextSegment(rawText = ''): AssistantTextSegment {
return {
kind: 'text',
id: makeId('text'),
rawText,
}
}
function createReasoningSegment(title: string, rawText = ''): ReasoningSegment {
return {
kind: 'reasoning',
id: makeId('reasoning'),
title,
rawText,
}
}
function createToolSegment(toolCallId: string, toolName: string): ToolSegment {
return {
kind: 'tool',
id: makeId('tool'),
toolCallId,
toolName: toolName || 'tool',
status: 'pending',
argumentsPreview: '',
detailPreview: '',
nestedEvents: [],
}
}
function upsertNestedToolEvent(
tool: ToolSegment,
nestedToolCallId: string,
title: string,
): ToolNestedEvent {
const existing = tool.nestedEvents.find(
(item) => item.nestedToolCallId === nestedToolCallId && nestedToolCallId,
)
if (existing) {
if (title) {
existing.title = title
}
return existing
}
const event: ToolNestedEvent = {
id: makeId('tool-nested'),
nestedToolCallId,
title,
status: 'info',
argumentsPreview: '',
detailLabel: 'Detail',
detailPreview: '',
}
tool.nestedEvents.push(event)
return event
}
function appendNarrativeNestedEvent(
tool: ToolSegment,
title: string,
detailLabel: 'Response' | 'Reasoning',
deltaText: string,
): ToolNestedEvent {
const lastEvent = tool.nestedEvents.at(-1)
if (
lastEvent &&
lastEvent.title === title &&
lastEvent.detailLabel === detailLabel &&
lastEvent.status === 'info'
) {
lastEvent.detailPreview += deltaText
return lastEvent
}
const event: ToolNestedEvent = {
id: makeId('tool-narrative'),
nestedToolCallId: makeId('tool-narrative-call'),
title,
status: 'info',
argumentsPreview: '',
detailLabel,
detailPreview: deltaText,
}
tool.nestedEvents.push(event)
return event
}
function upsertToolSegment(
nextTurn: ChatTurn,
toolCallId: string,
toolName: string,
): ToolSegment {
const existing = nextTurn.segments.find(
(segment): segment is ToolSegment =>
segment.kind === 'tool' && segment.toolCallId === toolCallId,
)
if (existing) {
if (toolName && !existing.toolName) {
existing.toolName = toolName
}
return existing
}
const nextSegment = createToolSegment(toolCallId, toolName)
nextTurn.segments.push(nextSegment)
return nextSegment
}
function appendText(
nextTurn: ChatTurn,
rawText: string,
mergeWithPrevious: boolean,
): ChatTurn {
if (!rawText) {
return nextTurn
}
nextTurn.responseText += rawText
const lastSegment = nextTurn.segments.at(-1)
if (mergeWithPrevious && lastSegment?.kind === 'text') {
lastSegment.rawText += rawText
return nextTurn
}
nextTurn.segments.push(createTextSegment(rawText))
return nextTurn
}
function appendReasoning(nextTurn: ChatTurn, rawText: string, title = 'Reasoning'): ChatTurn {
if (!rawText) {
return nextTurn
}
const lastSegment = nextTurn.segments.at(-1)
if (lastSegment?.kind === 'reasoning' && lastSegment.title === title) {
lastSegment.rawText += rawText
return nextTurn
}
nextTurn.segments.push(createReasoningSegment(title, rawText))
return nextTurn
}
export function sanitizeAssistantText(text: string): string {
return text
.replace(CODE_FILE_PATTERN, '')
.replace(OUTPUT_FILE_PATTERN, '')
.replace(/\n\s*\n\s*\n/g, '\n\n')
.trim()
}
export function extractArtifactTagPaths(messages: RawChatMessage[]): {
codePaths: string[]
modelPaths: string[]
} {
const codePaths: string[] = []
const modelPaths: string[] = []
for (const message of messages) {
if (message.role !== 'assistant') {
continue
}
const text = contentToText(message.content)
const codeMatches = text.matchAll(CODE_FILE_CAPTURE_PATTERN)
const modelMatches = text.matchAll(OUTPUT_FILE_CAPTURE_PATTERN)
for (const match of codeMatches) {
if (match[1]) {
rememberRecentString(codePaths, match[1])
}
}
for (const match of modelMatches) {
if (match[1]) {
rememberRecentString(modelPaths, match[1])
}
}
}
return {
codePaths: [...codePaths].reverse(),
modelPaths: [...modelPaths].reverse(),
}
}
export function contentToText(content: unknown): string {
if (content == null) {
return ''
}
if (typeof content === 'string') {
return content
}
if (Array.isArray(content)) {
return content
.map((item) => {
if (isRecord(item)) {
if (item.type === 'text') {
return typeof item.text === 'string' ? item.text : ''
}
if (item.type === 'image_url') {
return '[Image]'
}
}
return String(item)
})
.filter(Boolean)
.join('\n')
.trim()
}
return String(content)
}
export function parseUserMessageContent(content: unknown): {
text: string
imageUrls: string[]
} {
if (typeof content === 'string') {
return { text: content, imageUrls: [] }
}
if (!Array.isArray(content)) {
return { text: contentToText(content), imageUrls: [] }
}
const textParts: string[] = []
const imageUrls: string[] = []
for (const item of content) {
if (isTextPart(item)) {
textParts.push(item.text)
continue
}
if (isImagePart(item)) {
imageUrls.push(item.image_url.url)
}
}
return {
text: textParts.join('\n').trim(),
imageUrls,
}
}
export function compactPreview(value: unknown): string {
if (value == null) {
return ''
}
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) {
return ''
}
try {
const parsed = JSON.parse(trimmed) as unknown
if (typeof parsed === 'object' && parsed !== null) {
return JSON.stringify(parsed, null, 2)
}
} catch {
return value
}
return value
}
if (typeof value === 'object') {
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
return String(value)
}
export function createTurn(userText: string, userImages: string[] = []): ChatTurn {
return {
id: makeId('turn'),
userText,
userImages,
responseText: '',
segments: [],
}
}
export function appendAssistantTextBlock(turn: ChatTurn, rawText: string): ChatTurn {
const nextTurn = cloneTurn(turn)
return appendText(nextTurn, rawText, false)
}
function eventEnvelopeFromPacket(packet: StreamPacket): StreamEventEnvelope | null {
if (!isRecord(packet.data)) {
return null
}
const nestedEvent = packet.data.event
if (!isRecord(nestedEvent)) {
return null
}
return nestedEvent as StreamEventEnvelope
}
function nestedEventTitle(data: Record<string, unknown>, fallbackName: string): string {
const sourceToolName = typeof data.source_tool_name === 'string' ? data.source_tool_name : ''
const nestedToolName = typeof data.nested_tool_name === 'string' ? data.nested_tool_name : fallbackName
const subagentLabel = typeof data.subagent_label === 'string' ? data.subagent_label : ''
if (sourceToolName && sourceToolName !== nestedToolName) {
return `${sourceToolName} > ${nestedToolName}`
}
if (nestedToolName) {
return nestedToolName
}
return subagentLabel || 'step'
}
function applySubagentNestedEvent(
tool: ToolSegment,
eventName: string,
data: unknown,
): ToolSegment {
if (!isRecord(data)) {
return tool
}
const nestedToolCallId = String(data.nested_tool_call_id ?? '')
const title = nestedEventTitle(data, 'step')
if (eventName === 'subagent_tool_start') {
const nestedEvent = upsertNestedToolEvent(tool, nestedToolCallId, title)
nestedEvent.status = 'running'
nestedEvent.argumentsPreview = compactPreview(data.arguments)
nestedEvent.detailLabel = 'Progress'
nestedEvent.detailPreview = ''
return tool
}
if (eventName === 'subagent_tool_end') {
const nestedEvent = upsertNestedToolEvent(tool, nestedToolCallId, title)
nestedEvent.status = 'completed'
if (!nestedEvent.argumentsPreview) {
nestedEvent.argumentsPreview = compactPreview(data.arguments)
}
nestedEvent.detailLabel = 'Result'
nestedEvent.detailPreview = compactPreview(data.result)
return tool
}
if (eventName === 'subagent_tool_error') {
const nestedEvent = upsertNestedToolEvent(tool, nestedToolCallId, title)
nestedEvent.status = 'error'
if (!nestedEvent.argumentsPreview) {
nestedEvent.argumentsPreview = compactPreview(data.arguments)
}
nestedEvent.detailLabel = 'Error'
nestedEvent.detailPreview = compactPreview(data.error_message)
return tool
}
if (eventName === 'subagent_custom') {
const title = String(data.custom_event_name ?? 'custom event')
const nestedEvent = upsertNestedToolEvent(tool, nestedToolCallId, title)
nestedEvent.status = 'info'
nestedEvent.detailLabel = 'Detail'
nestedEvent.detailPreview = compactPreview(data.data)
return tool
}
if (eventName === 'subagent_response') {
const subagentLabel = String(data.subagent_label ?? 'subagent')
const deltaText =
typeof data.delta_text === 'string' ? data.delta_text : compactPreview(data.delta_text)
appendNarrativeNestedEvent(
tool,
`${subagentLabel} response`,
'Response',
deltaText,
)
return tool
}
if (eventName === 'subagent_reasoning') {
const subagentLabel = String(data.subagent_label ?? 'subagent')
const deltaReasoning =
typeof data.delta_reasoning === 'string'
? data.delta_reasoning
: compactPreview(data.delta_reasoning)
appendNarrativeNestedEvent(
tool,
`${subagentLabel} reasoning`,
'Reasoning',
deltaReasoning,
)
return tool
}
if (eventName === 'subagent_status') {
const title = String(data.phase ?? 'status')
const subagentLabel = String(data.subagent_label ?? 'subagent')
const nestedEvent = upsertNestedToolEvent(
tool,
`status-${subagentLabel}-${title}`,
`${subagentLabel} ${title}`,
)
nestedEvent.status = title === 'finished' ? 'completed' : 'info'
nestedEvent.detailLabel = 'Detail'
nestedEvent.detailPreview = compactPreview(data.message ?? data.target_file_path)
return tool
}
return tool
}
export function applyStreamPacketToTurn(turn: ChatTurn, packet: StreamPacket): ChatTurn {
const nextTurn = cloneTurn(turn)
if (packet.event === 'response') {
if (isRecord(packet.data)) {
if (typeof packet.data.delta_reasoning === 'string') {
appendReasoning(nextTurn, packet.data.delta_reasoning)
}
if (typeof packet.data.delta_text === 'string') {
return appendText(nextTurn, packet.data.delta_text, true)
}
}
return nextTurn
}
const event = eventEnvelopeFromPacket(packet)
if (!event) {
return nextTurn
}
const toolCallId = String(event.tool_call_id ?? '')
const toolName = String(event.tool_name ?? 'tool')
if (packet.event === 'tool_call_start') {
const tool = upsertToolSegment(nextTurn, toolCallId, toolName)
tool.status = 'running'
tool.argumentsPreview = compactPreview(event.arguments)
return nextTurn
}
if (packet.event === 'tool_call_end') {
const tool = upsertToolSegment(nextTurn, toolCallId, toolName)
tool.status = 'completed'
if (!tool.argumentsPreview) {
tool.argumentsPreview = compactPreview(event.arguments)
}
tool.detailPreview = compactPreview(event.result)
return nextTurn
}
if (packet.event === 'tool_call_error') {
const tool = upsertToolSegment(nextTurn, toolCallId, toolName)
tool.status = 'error'
if (!tool.argumentsPreview) {
tool.argumentsPreview = compactPreview(event.arguments)
}
tool.detailPreview = compactPreview(
event.error_message ?? event.error ?? 'Tool call failed',
)
return nextTurn
}
const subagentEventName =
packet.event === 'custom_event' ? String(event.event_name ?? '') : packet.event
if (subagentEventName.startsWith('subagent_')) {
if (!toolCallId) {
return nextTurn
}
const tool = upsertToolSegment(nextTurn, toolCallId, toolName)
applySubagentNestedEvent(tool, subagentEventName, event.data)
return nextTurn
}
return nextTurn
}
export function buildChatTurns(messages: RawChatMessage[]): ChatTurn[] {
const turns: ChatTurn[] = []
let currentTurn: ChatTurn | null = null
const flushTurn = (): void => {
if (currentTurn) {
turns.push(currentTurn)
currentTurn = null
}
}
for (const message of messages) {
if (message.role === 'user') {
flushTurn()
const parsedUserContent = parseUserMessageContent(message.content)
currentTurn = createTurn(parsedUserContent.text, parsedUserContent.imageUrls)
continue
}
if (!currentTurn) {
currentTurn = createTurn('')
}
if (message.role === 'assistant') {
const assistantText = contentToText(message.content)
if (assistantText) {
currentTurn = appendAssistantTextBlock(currentTurn, assistantText)
}
const toolCalls = message.tool_calls ?? []
for (const toolCall of toolCalls) {
const functionInfo = toolCall.function ?? {}
const tool = upsertToolSegment(
currentTurn,
String(toolCall.id ?? ''),
String(functionInfo.name ?? 'tool'),
)
tool.status = 'running'
tool.argumentsPreview = compactPreview(functionInfo.arguments)
}
continue
}
if (message.role === 'tool') {
const tool = upsertToolSegment(
currentTurn,
String(message.tool_call_id ?? ''),
'tool',
)
tool.status = 'completed'
tool.detailPreview = compactPreview(contentToText(message.content))
}
}
flushTurn()
return turns
}
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+150
View File
@@ -0,0 +1,150 @@
export type MessageRole = 'system' | 'user' | 'assistant' | 'tool'
export interface RawToolCall {
id?: string
type?: string
function?: {
name?: string
arguments?: unknown
}
}
export interface RawContentTextPart {
type: 'text'
text: string
}
export interface RawContentImagePart {
type: 'image_url'
image_url: {
url: string
detail?: string
}
}
export type RawMessageContentPart = RawContentTextPart | RawContentImagePart
export interface RawChatMessage {
role: MessageRole
content?: unknown
name?: string | null
tool_calls?: RawToolCall[] | null
tool_call_id?: string | null
}
export interface ModelInfo {
id: string
object: string
created: number
owned_by: string
}
export interface ConversationSummary {
conversation_id: string
marker_file?: string
is_active?: boolean
context_start_time?: string
context_last_activity?: string
context_total_messages?: number
context_has_summary?: boolean
sketch_total_items?: number
sketch_max_items?: number
sketch_memory_usage?: number
}
export interface ConversationDetail {
conversation_id: string
created_at: string
last_accessed: string
message_count: number
sketch_stats?: {
total_items?: number
total_accesses?: number
memory_usage_percent?: number
}
}
export interface ArtifactFileInfo {
path: string
content?: string | null
content_type?: string
url?: string
}
export interface LatestArtifactsResponse {
conversation_id: string
code_file: ArtifactFileInfo | null
code_files: ArtifactFileInfo[]
model_file: ArtifactFileInfo | null
model_files: ArtifactFileInfo[]
output_files: string[]
}
export type ToolStatus = 'pending' | 'running' | 'completed' | 'error'
export type ToolNestedEventStatus = 'running' | 'completed' | 'error' | 'info'
export interface AssistantTextSegment {
kind: 'text'
id: string
rawText: string
}
export interface ReasoningSegment {
kind: 'reasoning'
id: string
title: string
rawText: string
}
export interface ToolSegment {
kind: 'tool'
id: string
toolCallId: string
toolName: string
status: ToolStatus
argumentsPreview: string
detailPreview: string
nestedEvents: ToolNestedEvent[]
}
export interface ToolNestedEvent {
id: string
nestedToolCallId: string
title: string
status: ToolNestedEventStatus
argumentsPreview: string
detailLabel: string
detailPreview: string
}
export type TurnSegment = AssistantTextSegment | ReasoningSegment | ToolSegment
export interface ChatTurn {
id: string
userText: string
userImages: string[]
responseText: string
segments: TurnSegment[]
}
export interface ComposerImageAttachment {
id: string
name: string
dataUrl: string
}
export interface StreamPacket {
event: string
data: unknown
}
export interface StreamEventEnvelope {
tool_call_id?: string
tool_name?: string
arguments?: unknown
result?: unknown
error?: string
error_message?: string
event_name?: string
data?: unknown
}
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
@@ -0,0 +1,19 @@
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const proxyTarget =
env.CADDESIGNER_API_PROXY_TARGET || env.VITE_API_BASE_URL || 'http://127.0.0.1:8000'
return {
plugins: [react()],
server: {
port: 4173,
proxy: {
'/health': proxyTarget,
'/v1': proxyTarget,
},
},
}
})