Files
cad-Integration/CADDesigner-Code-main/frontend/src/App.tsx
T
2026-07-22 13:48:46 +08:00

643 lines
20 KiB
TypeScript

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