// Workspace → Animate module: "Animate with AI". The user describes a motion; // an AI agent (Claude Code / Codex on the user's subscription, or the Claude / // OpenAI APIs / a local OpenAI-compatible model with a key) reads the rig, // previews, fixes and saves the clip with the animation tools. Every save is a // new VERSION of the mesh: it is logged to the history, becomes the panel's // mesh, and plays in the viewer. import { useEffect, useMemo, useRef, useState } from 'react' import AssetSelectorModal from '../AssetSelectorModal' import WorkspaceMeshSlot from './WorkspaceMeshSlot' import { ANIMATE_SUGGESTIONS, BILLING_LABEL, cancelAgentRun, fetchAgentProviders, runAgentMessage } from '../../utils/workspaceAnimate' const STORAGE_PREFIX = 'workspace.animate.v1.' const TOOL_LABELS = { describe_rig: 'Reading the rig', inspect_rig: 'Checking the rig', annotate_rig: 'Correcting the rig labels', preview_animation: 'Previewing', save_animation: 'Saving the clip', list_animations: 'Listing clips', delete_animation: 'Removing a clip', list_motion_library: 'Browsing the motion library', publish_animation: 'Saving to the animation library', rebind_weights: 'Fixing skin weights', rename_bones: 'Renaming bones', add_bone: 'Adding a bone', split_bone: 'Splitting a bone', rig_rigid_parts: 'Rigging the parts' } // Per-viewer convenience: the conversation survives a reload (without the // preview images, which would overflow storage). function readSaved(projectId) { try { const parsed = JSON.parse(window.localStorage.getItem(STORAGE_PREFIX + projectId) || 'null') return parsed && typeof parsed === 'object' ? parsed : null } catch { return null } } function writeSaved(projectId, value) { try { const items = value.items.slice(-200).map(item => (item.kind === 'image' ? { kind: 'image', expired: true, tool: item.tool } : item)) window.localStorage.setItem(STORAGE_PREFIX + projectId, JSON.stringify({ ...value, items })) } catch { // storage unavailable or full - the conversation just won't survive a reload } } function formatUsage(u) { if (!u) return '' const parts = [] const input = (u.inputTokens || 0) + (u.cacheReadTokens || 0) + (u.cacheWriteTokens || 0) if (input) parts.push(`${input.toLocaleString()} in`) if (u.outputTokens) parts.push(`${u.outputTokens.toLocaleString()} out`) if (u.subscription) parts.push('subscription') else if (u.costUsd) parts.push(`$${u.costUsd.toFixed(3)}`) return parts.join(' · ') } export default function WorkspaceAnimatePanel({ projectId, target, targetBusy, importers, onTargetChange, selectedMeshEntry, onResult, onOpenInEditor, onOpenSettings, settingsVersion = 0 }) { const saved = useMemo(() => readSaved(projectId), [projectId]) const [providers, setProviders] = useState([]) const [provider, setProvider] = useState(saved?.provider || '') const [providersError, setProvidersError] = useState('') const [items, setItems] = useState(saved?.items || []) const [conversationId, setConversationId] = useState(saved?.conversationId || null) const [message, setMessage] = useState('') const [running, setRunning] = useState(false) const [runUsage, setRunUsage] = useState(null) const [assetPicker, setAssetPicker] = useState(false) const [slotBusy, setSlotBusy] = useState(false) const [lightbox, setLightbox] = useState(null) const runRef = useRef({ runId: null, controller: null }) const scrollRef = useRef(null) useEffect(() => { let cancelled = false fetchAgentProviders() .then((data) => { if (cancelled) return setProviders(data.providers || []) setProvider(prev => prev || data.selected || 'claude_code') }) .catch(err => { if (!cancelled) setProvidersError(err.message) }) return () => { cancelled = true } }, [settingsVersion]) useEffect(() => { writeSaved(projectId, { provider, conversationId, items }) }, [projectId, provider, conversationId, items]) useEffect(() => { const el = scrollRef.current if (el) el.scrollTop = el.scrollHeight }, [items]) // Leaving the Workspace stops a run in flight. useEffect(() => () => runRef.current.controller?.abort(), []) const current = providers.find(p => p.id === provider) || null const busy = running || targetBusy || slotBusy const push = (item) => setItems(prev => [...prev, item]) const patchLast = (kind, fn) => setItems((prev) => { for (let i = prev.length - 1; i >= 0; i -= 1) { if (prev[i].kind === kind) { const next = prev.slice() next[i] = fn(prev[i]) return next } if (prev[i].kind === 'user') break } return prev }) const fillTarget = async (load) => { setSlotBusy(true) try { onTargetChange(await load()) } catch (err) { push({ kind: 'error', text: err.message || 'Could not use that mesh.' }) } finally { setSlotBusy(false) } } const send = async (text) => { const prompt = String(text ?? message).trim() if (!prompt || !target || busy || !current?.available) return setMessage('') push({ kind: 'user', text: prompt }) setRunning(true) setRunUsage(null) const controller = new AbortController() runRef.current = { runId: null, controller } let streaming = false try { await runAgentMessage({ projectId, assetId: target.assetId, assetName: target.name, message: prompt, conversationId, provider }, (ev) => { if (ev.type === 'start') { runRef.current.runId = ev.runId setConversationId(ev.conversationId) } else if (ev.type === 'text') { if (!streaming) { push({ kind: 'assistant', text: ev.delta }); streaming = true } else patchLast('assistant', item => ({ ...item, text: item.text + ev.delta })) } else if (ev.type === 'turn_end') { streaming = false } else if (ev.type === 'note') { streaming = false push({ kind: 'note', text: ev.text }) } else if (ev.type === 'tool') { streaming = false push({ kind: 'tool', id: ev.id, name: ev.name, status: 'running' }) } else if (ev.type === 'tool_result') { setItems(prev => prev.map(item => (item.kind === 'tool' && item.id === ev.id ? { ...item, status: ev.isError ? 'error' : 'done', summary: ev.summary } : item))) } else if (ev.type === 'image') { push({ kind: 'image', data: ev.data, mimeType: ev.mimeType, tool: ev.tool }) } else if (ev.type === 'asset') { push({ kind: 'asset', assetId: ev.assetId, clip: ev.clip, tool: ev.tool }) onResult?.(ev) } else if (ev.type === 'usage') { setRunUsage(prev => ({ inputTokens: (prev?.inputTokens || 0) + (ev.inputTokens || 0), cacheReadTokens: (prev?.cacheReadTokens || 0) + (ev.cacheReadTokens || 0), cacheWriteTokens: (prev?.cacheWriteTokens || 0) + (ev.cacheWriteTokens || 0), outputTokens: (prev?.outputTokens || 0) + (ev.outputTokens || 0), costUsd: ev.costUsd ?? prev?.costUsd, subscription: Boolean(ev.subscription) })) } else if (ev.type === 'error') { push({ kind: 'error', text: ev.message }) } else if (ev.type === 'done') { setConversationId(ev.conversationId) } }, controller.signal) } catch (err) { if (!controller.signal.aborted) push({ kind: 'error', text: err.message || 'The AI run failed.' }) } finally { setRunning(false) runRef.current = { runId: null, controller: null } } } const stop = () => { cancelAgentRun(runRef.current.runId) runRef.current.controller?.abort() push({ kind: 'note', text: 'Stopped.' }) } const newConversation = () => { if (running) return setItems([]) setConversationId(null) setRunUsage(null) } const onKeyDown = (event) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault() send() } } return (