643 lines
20 KiB
TypeScript
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
|