first commit
This commit is contained in:
@@ -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?
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+3377
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
@@ -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>,
|
||||
)
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user