import { useState, useRef, useCallback, useEffect, useMemo, memo } from 'react'; import { Copy, Download, FileJson, RefreshCw, Sparkles, Check, Loader2, AlertTriangle, MinusCircle, SkipForward, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { useCanvasStore } from '@/stores/canvas-store'; import { useDocumentStore, getActivePageChildren } from '@/stores/document-store'; import { useAIStore } from '@/stores/ai-store'; import { generateCode } from '@/services/ai/code-generation-pipeline'; import { buildCodegenBundleManifest, type CodegenAssetFile } from '@/services/ai/codegen-assets'; import { buildAIStructureBundle, encodeAIStructureBundleZip } from '@/services/ai/structure-bundle'; import { highlightCode } from '@/utils/syntax-highlight'; import type { Framework, CodeGenProgress, ChunkStatus } from '@zseven-w/pen-types'; import { FRAMEWORKS } from '@zseven-w/pen-types'; import type { PenNode } from '@/types/pen'; import type { SyntaxLanguage } from '@/utils/syntax-highlight'; import { encode as encodeZip } from 'uzip'; type PanelState = 'empty' | 'generating' | 'complete'; interface ChunkProgress { chunkId: string; name: string; status: ChunkStatus; error?: string; } interface GeneratedCodeBundle { code: string; degraded: boolean; assets: CodegenAssetFile[]; } const TAB_LABELS: Record = { react: 'React', vue: 'Vue', svelte: 'Svelte', html: 'HTML', flutter: 'Flutter', swiftui: 'SwiftUI', compose: 'Compose', 'react-native': 'RN', }; const HIGHLIGHT_LANG: Record = { react: 'jsx', vue: 'html', svelte: 'html', html: 'html', flutter: 'dart', swiftui: 'swift', compose: 'kotlin', 'react-native': 'jsx', }; function triggerDownload(blob: Blob, fileName: string) { const url = URL.createObjectURL(blob); const link = globalThis.document.createElement('a'); link.href = url; link.download = fileName; globalThis.document.body.appendChild(link); link.click(); globalThis.document.body.removeChild(link); setTimeout(() => URL.revokeObjectURL(url), 0); } function CodePanelInner() { const [activeTab, setActiveTab] = useState('react'); const [codeCache, setCodeCache] = useState>>({}); const [isDegraded, setIsDegraded] = useState(false); const [isGenerating, setIsGenerating] = useState(false); const [copied, setCopied] = useState(false); const [planningStatus, setPlanningStatus] = useState<'idle' | 'running' | 'done' | 'failed'>( 'idle', ); const [planningError, setPlanningError] = useState(); const [assemblyStatus, setAssemblyStatus] = useState<'idle' | 'running' | 'done' | 'failed'>( 'idle', ); const [chunks, setChunks] = useState([]); const [selectionChanged, setSelectionChanged] = useState(false); const [generateError, setGenerateError] = useState(); const cached = codeCache[activeTab]; const generatedCode = cached?.code ?? ''; const exportedAssets = cached?.assets ?? []; const hasExportedAssets = exportedAssets.length > 0; const panelState: PanelState = isGenerating ? 'generating' : cached ? 'complete' : 'empty'; const abortRef = useRef(null); const lastSelectionRef = useRef(''); const copyTimeoutRef = useRef>(null); const selectedIds = useCanvasStore((s) => s.selection.selectedIds); const activePageId = useCanvasStore((s) => s.activePageId); const getNodeById = useDocumentStore((s) => s.getNodeById); const children = useDocumentStore((s) => getActivePageChildren(s.document, activePageId)); const variables = useDocumentStore((s) => s.document?.variables); const model = useAIStore((s) => s.model); // For builtin models, force provider to 'builtin' — modelGroups may report // 'anthropic'/'openai' based on the upstream API type, but streamChat needs // 'builtin' to route through streamViaBuiltin on the server. Mirrors // ai-chat-handlers.ts behavior so the code panel uses the same model/provider // as the chat panel. const provider = useAIStore((s) => { if (s.model.startsWith('builtin:')) return 'builtin'; return s.modelGroups.find((g) => g.models.some((m) => m.value === s.model))?.provider; }); const selectionKey = selectedIds.join(','); // Detect selection changes when code is already generated useEffect(() => { if (panelState === 'complete' && selectionKey !== lastSelectionRef.current) { setSelectionChanged(true); } }, [panelState, selectionKey]); // Cleanup on unmount useEffect(() => { return () => { if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current); abortRef.current?.abort(); }; }, []); const getTargetNodes = useCallback((): PenNode[] => { if (selectedIds.length > 0) { return selectedIds.map((id) => getNodeById(id)).filter((n): n is PenNode => n !== undefined); } return children; }, [selectedIds, getNodeById, children]); const handleGenerate = useCallback(async () => { const nodes = getTargetNodes(); if (nodes.length === 0) return; abortRef.current = new AbortController(); setIsGenerating(true); setPlanningStatus('idle'); setPlanningError(undefined); setAssemblyStatus('idle'); setChunks([]); setIsDegraded(false); setSelectionChanged(false); setGenerateError(undefined); lastSelectionRef.current = selectionKey; const handleProgress = (event: CodeGenProgress) => { switch (event.step) { case 'planning': setPlanningStatus(event.status); if (event.error) setPlanningError(event.error); break; case 'chunk': setChunks((prev) => { const existing = prev.findIndex((c) => c.chunkId === event.chunkId); const entry: ChunkProgress = { chunkId: event.chunkId, name: event.name, status: event.status, error: event.error, }; if (existing >= 0) { const next = [...prev]; next[existing] = entry; return next; } return [...prev, entry]; }); break; case 'assembly': setAssemblyStatus(event.status); break; case 'complete': setIsDegraded(event.degraded); setIsGenerating(false); break; case 'error': setGenerateError(event.message); setIsGenerating(false); break; } }; try { const result = await generateCode( nodes, activeTab, variables, handleProgress, model, provider, abortRef.current.signal, ); setCodeCache((prev) => ({ ...prev, [activeTab]: { code: result.code, degraded: result.degraded, assets: result.assets, }, })); setIsDegraded(result.degraded); } catch (err) { if (!abortRef.current?.signal.aborted) { const msg = err instanceof Error ? err.message : 'Code generation failed'; setGenerateError(msg); } setIsGenerating(false); } }, [getTargetNodes, activeTab, variables, selectionKey, model, provider]); const handleCancel = useCallback(() => { abortRef.current?.abort(); setIsGenerating(false); }, []); const handleRetryChunk = useCallback( (_chunkId: string) => { // Re-run the full pipeline (planning is fast, only failed/skipped chunks re-run) void handleGenerate(); }, [handleGenerate], ); const handleCopy = useCallback(async () => { await navigator.clipboard.writeText(generatedCode); setCopied(true); if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current); copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000); }, [generatedCode]); const handleDownload = useCallback(() => { const extensions: Record = { react: '.tsx', vue: '.vue', svelte: '.svelte', html: '.html', flutter: '.dart', swiftui: '.swift', compose: '.kt', 'react-native': '.tsx', }; const codeFileName = `design${extensions[activeTab]}`; const assets = exportedAssets; const codeBytes = new TextEncoder().encode(generatedCode); void (async () => { const blob = assets.length > 0 ? new Blob( [ encodeZip({ [codeFileName]: codeBytes, 'manifest.json': new TextEncoder().encode( JSON.stringify( await buildCodegenBundleManifest({ framework: activeTab, codeFile: codeFileName, codeBytes, assets, }), null, 2, ), ), ...Object.fromEntries(assets.map((asset) => [asset.zipPath, asset.bytes])), }), ], { type: 'application/zip' }, ) : new Blob([generatedCode], { type: 'text/plain;charset=utf-8' }); triggerDownload(blob, assets.length > 0 ? `design-${activeTab}.zip` : codeFileName); })(); }, [generatedCode, activeTab, exportedAssets]); const handleDownloadStructureBundle = useCallback(() => { const nodes = getTargetNodes(); if (nodes.length === 0) return; void (async () => { const bundle = await buildAIStructureBundle({ nodes, activePageId, selectedIds, }); const blob = new Blob([encodeAIStructureBundleZip(bundle.zipEntries)], { type: 'application/zip', }); triggerDownload(blob, bundle.fileName); })(); }, [getTargetNodes, activePageId, selectedIds]); const handleTabChange = useCallback( (tab: Framework) => { setActiveTab(tab); setGenerateError(undefined); // isDegraded follows the cached tab's value const tabCache = codeCache[tab]; setIsDegraded(tabCache?.degraded ?? false); }, [codeCache], ); const nodeCount = selectedIds.length > 0 ? selectedIds.length : children.length; const highlightedHTML = useMemo(() => { if (!generatedCode) return ''; const lang = HIGHLIGHT_LANG[activeTab]; // HTML / Vue / Svelte: split at '); if (closingIdx !== -1) { const cssContent = styleBody.slice(0, closingIdx); const closingTag = styleBody.slice(closingIdx); return ( highlightCode(templatePart, 'html') + highlightCode(styleTag, 'html') + '\n' + highlightCode(cssContent, 'css') + highlightCode(closingTag, 'html') ); } } return highlightCode(templatePart, 'html') + highlightCode(stylePart, 'css'); } } return highlightCode(generatedCode, lang); }, [activeTab, generatedCode]); const totalSteps = 1 + chunks.length + (assemblyStatus !== 'idle' ? 1 : 0); const completedSteps = (planningStatus === 'done' ? 1 : 0) + chunks.filter((c) => c.status === 'done' || c.status === 'degraded').length + (assemblyStatus === 'done' ? 1 : 0); const progressPct = totalSteps > 0 ? (completedSteps / totalSteps) * 100 : 0; return (
{/* Tab Bar */}
{FRAMEWORKS.map((fw) => ( ))}
{/* Content */}
{/* Empty State */} {panelState === 'empty' && (
{nodeCount > 0 ? `${nodeCount} node${nodeCount > 1 ? 's' : ''} selected` : 'No nodes on page'}
Generate production-ready code
{generateError && (
Generation failed
{generateError}
)} {selectionChanged && (
Selection changed since last generation
)}
)} {/* Generating State */} {panelState === 'generating' && (
{/* Progress bar */}
{/* Planning */} {/* Chunks */} {chunks.map((chunk) => ( handleRetryChunk(chunk.chunkId) : undefined } /> ))} {/* Assembly */} {assemblyStatus !== 'idle' && ( )}
)} {/* Complete State */} {panelState === 'complete' && ( <> {isDegraded && (
Some chunks failed. Output may not compile.
)} {selectionChanged && (
Selection changed
)} {hasExportedAssets && (
This generation includes {exportedAssets.length} image asset {exportedAssets.length > 1 ? 's' : ''}. Download will export a ZIP bundle.
The ZIP contains the code file, exported assets, and a manifest.json index.
)}
                
              
)}
); } export default memo(CodePanelInner); // ── Progress Item Sub-Component ── function ProgressItem({ label, status, error, onRetry, }: { label: string; status: ChunkStatus | 'running' | 'done' | 'failed' | 'pending'; error?: string; onRetry?: () => void; }) { const icons: Record = { pending:
, running: , done: , degraded: , failed: , skipped: , }; const sublabels: Record = { degraded: 'generated without contract', skipped: 'skipped (dependency failed)', }; return (
{icons[status]}
{label}
{sublabels[status] && (
{sublabels[status]}
)} {error &&
{error}
}
{onRetry && ( )}
); }