diff --git a/json-components-registry.json b/json-components-registry.json index 9a23f7b..575f4c0 100644 --- a/json-components-registry.json +++ b/json-components-registry.json @@ -80,7 +80,10 @@ "status": "json-compatible", "source": "wrappers", "jsonCompatible": true, - "wrapperFor": "ComponentBindingDialog" + "wrapperFor": "ComponentBindingDialog", + "load": { + "export": "ComponentBindingDialogWrapper" + } }, { "type": "Container", @@ -122,7 +125,10 @@ "status": "json-compatible", "source": "wrappers", "jsonCompatible": true, - "wrapperFor": "DataSourceEditorDialog" + "wrapperFor": "DataSourceEditorDialog", + "load": { + "export": "DataSourceEditorDialogWrapper" + } }, { "type": "Dialog", @@ -724,7 +730,10 @@ "canHaveChildren": false, "description": "ArrowLeft icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "ArrowLeft" + } }, { "type": "ArrowRight", @@ -733,7 +742,10 @@ "canHaveChildren": false, "description": "ArrowRight icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "ArrowRight" + } }, { "type": "Check", @@ -742,7 +754,10 @@ "canHaveChildren": false, "description": "Check icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "Check" + } }, { "type": "X", @@ -751,7 +766,10 @@ "canHaveChildren": false, "description": "X icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "X" + } }, { "type": "Plus", @@ -760,7 +778,10 @@ "canHaveChildren": false, "description": "Plus icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "Plus" + } }, { "type": "Minus", @@ -769,7 +790,10 @@ "canHaveChildren": false, "description": "Minus icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "Minus" + } }, { "type": "Search", @@ -778,7 +802,10 @@ "canHaveChildren": false, "description": "Search icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "MagnifyingGlass" + } }, { "type": "Filter", @@ -787,7 +814,10 @@ "canHaveChildren": false, "description": "Filter icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "Funnel" + } }, { "type": "Download", @@ -796,7 +826,10 @@ "canHaveChildren": false, "description": "Download icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "Download" + } }, { "type": "Upload", @@ -805,7 +838,10 @@ "canHaveChildren": false, "description": "Upload icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "Upload" + } }, { "type": "Edit", @@ -814,7 +850,10 @@ "canHaveChildren": false, "description": "Edit icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "PencilSimple" + } }, { "type": "Trash", @@ -823,7 +862,10 @@ "canHaveChildren": false, "description": "Trash icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "Trash" + } }, { "type": "Eye", @@ -832,7 +874,10 @@ "canHaveChildren": false, "description": "Eye icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "Eye" + } }, { "type": "EyeOff", @@ -841,7 +886,10 @@ "canHaveChildren": false, "description": "EyeOff icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "EyeClosed" + } }, { "type": "ChevronUp", @@ -850,7 +898,10 @@ "canHaveChildren": false, "description": "ChevronUp icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "CaretUp" + } }, { "type": "ChevronDown", @@ -859,7 +910,10 @@ "canHaveChildren": false, "description": "ChevronDown icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "CaretDown" + } }, { "type": "ChevronLeft", @@ -868,7 +922,10 @@ "canHaveChildren": false, "description": "ChevronLeft icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "CaretLeft" + } }, { "type": "ChevronRight", @@ -877,7 +934,10 @@ "canHaveChildren": false, "description": "ChevronRight icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "CaretRight" + } }, { "type": "Settings", @@ -886,7 +946,10 @@ "canHaveChildren": false, "description": "Settings icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "Gear" + } }, { "type": "User", @@ -895,7 +958,10 @@ "canHaveChildren": false, "description": "User icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "User" + } }, { "type": "Bell", @@ -904,7 +970,10 @@ "canHaveChildren": false, "description": "Bell icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "Bell" + } }, { "type": "Mail", @@ -913,7 +982,10 @@ "canHaveChildren": false, "description": "Mail icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "Envelope" + } }, { "type": "Calendar", @@ -922,7 +994,10 @@ "canHaveChildren": false, "description": "Calendar icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "Calendar" + } }, { "type": "Clock", @@ -931,7 +1006,10 @@ "canHaveChildren": false, "description": "Clock icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "Clock" + } }, { "type": "Star", @@ -940,7 +1018,10 @@ "canHaveChildren": false, "description": "Star icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "Star" + } }, { "type": "Heart", @@ -949,7 +1030,10 @@ "canHaveChildren": false, "description": "Heart icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "Heart" + } }, { "type": "Share", @@ -958,7 +1042,10 @@ "canHaveChildren": false, "description": "Share icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "ShareNetwork" + } }, { "type": "Link", @@ -967,7 +1054,10 @@ "canHaveChildren": false, "description": "Link icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "LinkSimple" + } }, { "type": "Copy", @@ -976,7 +1066,10 @@ "canHaveChildren": false, "description": "Copy icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "Copy" + } }, { "type": "Save", @@ -985,7 +1078,10 @@ "canHaveChildren": false, "description": "Save icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "FloppyDisk" + } }, { "type": "RefreshCw", @@ -994,7 +1090,10 @@ "canHaveChildren": false, "description": "RefreshCw icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "ArrowClockwise" + } }, { "type": "AlertCircle", @@ -1003,7 +1102,10 @@ "canHaveChildren": false, "description": "AlertCircle icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "WarningCircle" + } }, { "type": "Info", @@ -1012,7 +1114,10 @@ "canHaveChildren": false, "description": "Info icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "Info" + } }, { "type": "HelpCircle", @@ -1021,7 +1126,10 @@ "canHaveChildren": false, "description": "HelpCircle icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "Question" + } }, { "type": "Home", @@ -1030,7 +1138,10 @@ "canHaveChildren": false, "description": "Home icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "House" + } }, { "type": "Menu", @@ -1039,7 +1150,10 @@ "canHaveChildren": false, "description": "Menu icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "List" + } }, { "type": "MoreVertical", @@ -1048,7 +1162,10 @@ "canHaveChildren": false, "description": "MoreVertical icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "DotsThreeVertical" + } }, { "type": "MoreHorizontal", @@ -1057,7 +1174,10 @@ "canHaveChildren": false, "description": "MoreHorizontal icon", "status": "supported", - "source": "icons" + "source": "icons", + "load": { + "export": "DotsThree" + } }, { "type": "Breadcrumb", @@ -1275,7 +1395,10 @@ "status": "json-compatible", "source": "wrappers", "jsonCompatible": true, - "wrapperFor": "GitHubBuildStatus" + "wrapperFor": "GitHubBuildStatus", + "load": { + "export": "GitHubBuildStatusWrapper" + } }, { "type": "InfoBox", @@ -1437,7 +1560,10 @@ "status": "json-compatible", "source": "wrappers", "jsonCompatible": true, - "wrapperFor": "LazyBarChart" + "wrapperFor": "LazyBarChart", + "load": { + "export": "LazyBarChartWrapper" + } }, { "type": "LazyD3BarChart", @@ -1460,7 +1586,10 @@ "status": "json-compatible", "source": "wrappers", "jsonCompatible": true, - "wrapperFor": "LazyD3BarChart" + "wrapperFor": "LazyD3BarChart", + "load": { + "export": "LazyD3BarChartWrapper" + } }, { "type": "LazyLineChart", @@ -1483,7 +1612,10 @@ "status": "json-compatible", "source": "wrappers", "jsonCompatible": true, - "wrapperFor": "LazyLineChart" + "wrapperFor": "LazyLineChart", + "load": { + "export": "LazyLineChartWrapper" + } }, { "type": "List", @@ -1542,7 +1674,10 @@ "status": "json-compatible", "source": "wrappers", "jsonCompatible": true, - "wrapperFor": "SeedDataManager" + "wrapperFor": "SeedDataManager", + "load": { + "export": "SeedDataManagerWrapper" + } }, { "type": "StatCard", @@ -1829,7 +1964,10 @@ "status": "json-compatible", "source": "wrappers", "jsonCompatible": true, - "wrapperFor": "ComponentTree" + "wrapperFor": "ComponentTree", + "load": { + "export": "ComponentTreeWrapper" + } }, { "type": "ComponentTreeNode", @@ -2072,7 +2210,10 @@ "status": "json-compatible", "source": "wrappers", "jsonCompatible": true, - "wrapperFor": "SaveIndicator" + "wrapperFor": "SaveIndicator", + "load": { + "export": "SaveIndicatorWrapper" + } }, { "type": "SchemaEditorCanvas", @@ -2236,7 +2377,10 @@ "status": "json-compatible", "source": "wrappers", "jsonCompatible": true, - "wrapperFor": "StorageSettings" + "wrapperFor": "StorageSettings", + "load": { + "export": "StorageSettingsWrapper" + } }, { "type": "Timestamp", diff --git a/schemas/json-components-registry-schema.json b/schemas/json-components-registry-schema.json index e2160a4..9b9d424 100644 --- a/schemas/json-components-registry-schema.json +++ b/schemas/json-components-registry-schema.json @@ -73,6 +73,16 @@ "wrapperFor": { "type": "string" }, + "load": { + "type": "object", + "properties": { + "export": { + "type": "string" + } + }, + "required": ["export"], + "additionalProperties": false + }, "deprecated": { "type": "object", "properties": { diff --git a/src/lib/ai-service.ts b/src/lib/ai-service.ts index ab667d3..d87219b 100644 --- a/src/lib/ai-service.ts +++ b/src/lib/ai-service.ts @@ -2,6 +2,101 @@ import { PrismaModel, ComponentNode, ThemeConfig, ProjectFile } from '@/types/project' import { ProtectedLLMService } from './protected-llm-service' +import { toast } from 'sonner' +import { z } from 'zod' + +const componentNodeSchema: z.ZodType = z.lazy(() => z.object({ + id: z.string(), + type: z.string(), + name: z.string(), + props: z.record(z.any()), + children: z.array(componentNodeSchema) +})) + +const prismaFieldSchema = z.object({ + id: z.string(), + name: z.string(), + type: z.string(), + isRequired: z.boolean(), + isUnique: z.boolean(), + isArray: z.boolean(), + defaultValue: z.string().optional(), + relation: z.string().optional() +}) + +const prismaModelSchema = z.object({ + id: z.string(), + name: z.string(), + fields: z.array(prismaFieldSchema) +}) + +const themeSchema = z.object({ + primaryColor: z.string(), + secondaryColor: z.string(), + errorColor: z.string(), + warningColor: z.string(), + successColor: z.string(), + fontFamily: z.string(), + fontSize: z.object({ + small: z.number(), + medium: z.number(), + large: z.number() + }), + spacing: z.number(), + borderRadius: z.number() +}) + +const projectFileSchema = z.object({ + id: z.string(), + name: z.string(), + path: z.string(), + content: z.string(), + language: z.string() +}) + +const componentResponseSchema = z.object({ component: componentNodeSchema }) +const prismaModelResponseSchema = z.object({ model: prismaModelSchema }) +const themeResponseSchema = z.object({ theme: themeSchema }) +const suggestFieldsResponseSchema = z.object({ fields: z.array(z.string()) }) +const completeAppResponseSchema = z.object({ + files: z.array(projectFileSchema), + models: z.array(prismaModelSchema), + theme: themeSchema +}) + +const parseAndValidateJson = ( + result: string, + schema: z.ZodType, + context: string, + toastMessage: string +): T | null => { + let parsed: unknown + + try { + parsed = JSON.parse(result) + } catch (error) { + console.error('AI response JSON parse failed', { + context, + error: error instanceof Error ? error.message : String(error), + rawResponse: result + }) + toast.error(toastMessage) + return null + } + + const validation = schema.safeParse(parsed) + if (!validation.success) { + console.error('AI response validation failed', { + context, + issues: validation.error.issues, + rawResponse: parsed + }) + toast.error(toastMessage) + return null + } + + return validation.data +} export class AIService { static async generateComponent(description: string): Promise { @@ -29,8 +124,13 @@ Make sure to use appropriate Material UI components and props. Keep the structur ) if (result) { - const parsed = JSON.parse(result) - return parsed.component + const parsed = parseAndValidateJson( + result, + componentResponseSchema, + 'generate-component', + 'AI component response was invalid. Please retry or clarify your description.' + ) + return parsed ? parsed.component : null } return null } catch (error) { @@ -80,8 +180,13 @@ Return a valid JSON object with a single property "model" containing the model s ) if (result) { - const parsed = JSON.parse(result) - return parsed.model + const parsed = parseAndValidateJson( + result, + prismaModelResponseSchema, + 'generate-model', + 'AI model response was invalid. Please retry or describe the model differently.' + ) + return parsed ? parsed.model : null } return null } catch (error) { @@ -172,8 +277,13 @@ Return a valid JSON object with a single property "theme" containing: ) if (result) { - const parsed = JSON.parse(result) - return parsed.theme + const parsed = parseAndValidateJson( + result, + themeResponseSchema, + 'generate-theme', + 'AI theme response was invalid. Please retry or specify the theme requirements.' + ) + return parsed ? parsed.theme : null } return null } catch (error) { @@ -202,8 +312,13 @@ Suggest 3-5 common fields that would be useful for this model type. Use camelCas ) if (result) { - const parsed = JSON.parse(result) - return parsed.fields + const parsed = parseAndValidateJson( + result, + suggestFieldsResponseSchema, + 'suggest-fields', + 'AI field suggestions were invalid. Please retry with a clearer model name.' + ) + return parsed ? parsed.fields : null } return null } catch (error) { @@ -284,7 +399,12 @@ Create 2-4 essential files for the app structure. Include appropriate Prisma mod ) if (result) { - return JSON.parse(result) + return parseAndValidateJson( + result, + completeAppResponseSchema, + 'generate-app', + 'AI app generation response was invalid. Please retry with more detail.' + ) } return null } catch (error) { diff --git a/src/lib/json-ui/component-registry.ts b/src/lib/json-ui/component-registry.ts index 736e039..d18b342 100644 --- a/src/lib/json-ui/component-registry.ts +++ b/src/lib/json-ui/component-registry.ts @@ -1,63 +1,7 @@ import { ComponentType } from 'react' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { InputOtp } from '@/components/ui/input-otp' -import { Textarea } from '@/components/ui/textarea' -import { Label } from '@/components/ui/label' -import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card' -import { Badge } from '@/components/ui/badge' -import { Separator } from '@/components/ui/separator' -import { Alert as ShadcnAlert, AlertDescription, AlertTitle } from '@/components/ui/alert' -import { AlertDialog } from '@/components/ui/alert-dialog' -import { AspectRatio } from '@/components/ui/aspect-ratio' -import { Carousel } from '@/components/ui/carousel' -import { ChartContainer as Chart } from '@/components/ui/chart' -import { Collapsible } from '@/components/ui/collapsible' -import { Command } from '@/components/ui/command' -import { Switch } from '@/components/ui/switch' -import { Checkbox } from '@/components/ui/checkbox' -import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { DropdownMenu } from '@/components/ui/dropdown-menu' -import { Menubar } from '@/components/ui/menubar' -import { NavigationMenu } from '@/components/ui/navigation-menu' -import { Table as ShadcnTable, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { Skeleton as ShadcnSkeleton } from '@/components/ui/skeleton' -import { Progress } from '@/components/ui/progress' -import { Pagination } from '@/components/ui/pagination' -import { ResizablePanelGroup as Resizable } from '@/components/ui/resizable' -import { Sheet } from '@/components/ui/sheet' -import { Sidebar } from '@/components/ui/sidebar' -import { Toaster as Sonner } from '@/components/ui/sonner' -import { ToggleGroup } from '@/components/ui/toggle-group' -import { Avatar as ShadcnAvatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' -import { CircularProgress, Divider, ProgressBar } from '@/components/atoms' -import * as AtomComponents from '@/components/atoms' -import * as MoleculeComponents from '@/components/molecules' -import * as OrganismComponents from '@/components/organisms' -import { - ComponentBindingDialogWrapper, - ComponentTreeWrapper, - DataSourceEditorDialogWrapper, - GitHubBuildStatusWrapper, - LazyBarChartWrapper, - LazyD3BarChartWrapper, - LazyLineChartWrapper, - SaveIndicatorWrapper, - SeedDataManagerWrapper, - StorageSettingsWrapper, -} from '@/lib/json-ui/wrappers' +import * as PhosphorIcons from '@phosphor-icons/react' import jsonComponentsRegistry from '../../../json-components-registry.json' -import { - ArrowLeft, ArrowRight, Check, X, Plus, Minus, MagnifyingGlass, - Funnel, Download, Upload, PencilSimple, Trash, Eye, EyeClosed, - CaretUp, CaretDown, CaretLeft, CaretRight, - Gear, User, Bell, Envelope, Calendar, Clock, Star, - Heart, ShareNetwork, LinkSimple, Copy, FloppyDisk, ArrowClockwise, WarningCircle, - Info, Question, House, List as ListIcon, DotsThreeVertical, DotsThree -} from '@phosphor-icons/react' +import { JSONUIShowcase } from '@/components/JSONUIShowcase' export interface UIComponentRegistry { [key: string]: ComponentType @@ -72,6 +16,9 @@ interface JsonRegistryEntry { wrapperRequired?: boolean wrapperComponent?: string wrapperFor?: string + load?: { + export?: string + } deprecated?: DeprecatedComponentInfo } @@ -86,69 +33,117 @@ export interface DeprecatedComponentInfo { const jsonRegistry = jsonComponentsRegistry as JsonComponentRegistry -const getRegistryEntryName = (entry: JsonRegistryEntry): string | undefined => - entry.export ?? entry.name ?? entry.type +const getRegistryEntryKey = (entry: JsonRegistryEntry): string | undefined => + entry.name ?? entry.type -const buildRegistryFromNames = ( - names: string[], - components: Record> -): UIComponentRegistry => { - return names.reduce((registry, name) => { - const component = components[name] - if (component) { - registry[name] = component - } - return registry - }, {}) -} +const getRegistryEntryExportName = (entry: JsonRegistryEntry): string | undefined => + entry.load?.export ?? entry.export ?? getRegistryEntryKey(entry) const jsonRegistryEntries = jsonRegistry.components ?? [] const registryEntryByType = new Map( jsonRegistryEntries .map((entry) => { - const entryName = getRegistryEntryName(entry) - return entryName ? [entryName, entry] : null + const entryKey = getRegistryEntryKey(entry) + return entryKey ? [entryKey, entry] : null }) .filter((entry): entry is [string, JsonRegistryEntry] => Boolean(entry)) ) -const atomComponentMap = AtomComponents as Record> const deprecatedComponentInfo = jsonRegistryEntries.reduce>( (acc, entry) => { - const entryName = getRegistryEntryName(entry) - if (!entryName) { + const entryKey = getRegistryEntryKey(entry) + if (!entryKey) { return acc } if (entry.status === 'deprecated' || entry.deprecated) { - acc[entryName] = entry.deprecated ?? {} + acc[entryKey] = entry.deprecated ?? {} } return acc }, {} ) -const atomRegistryNames = jsonRegistryEntries - .filter((entry) => entry.source === 'atoms') - .map((entry) => getRegistryEntryName(entry)) - .filter((name): name is string => Boolean(name)) -const moleculeRegistryNames = jsonRegistryEntries - .filter((entry) => entry.source === 'molecules') - .map((entry) => getRegistryEntryName(entry)) - .filter((name): name is string => Boolean(name)) -const organismRegistryNames = jsonRegistryEntries - .filter((entry) => entry.source === 'organisms') - .map((entry) => getRegistryEntryName(entry)) - .filter((name): name is string => Boolean(name)) -const shadcnRegistryNames = jsonRegistryEntries - .filter((entry) => entry.source === 'ui') - .map((entry) => getRegistryEntryName(entry)) - .filter((name): name is string => Boolean(name)) -const wrapperRegistryNames = jsonRegistryEntries - .filter((entry) => entry.source === 'wrappers') - .map((entry) => getRegistryEntryName(entry)) - .filter((name): name is string => Boolean(name)) -const iconRegistryNames = jsonRegistryEntries - .filter((entry) => entry.source === 'icons') - .map((entry) => getRegistryEntryName(entry)) - .filter((name): name is string => Boolean(name)) + +const buildComponentMapFromExports = ( + exports: Record +): Record> => { + return Object.entries(exports).reduce>>((acc, [key, value]) => { + if (value && (typeof value === 'function' || typeof value === 'object')) { + acc[key] = value as ComponentType + } + return acc + }, {}) +} + +const buildComponentMapFromModules = ( + modules: Record +): Record> => { + return Object.values(modules).reduce>>((acc, moduleExports) => { + if (!moduleExports || typeof moduleExports !== 'object') { + return acc + } + Object.entries(buildComponentMapFromExports(moduleExports as Record)).forEach( + ([key, component]) => { + acc[key] = component + } + ) + return acc + }, {}) +} + +const atomModules = import.meta.glob('@/components/atoms/*.tsx', { eager: true }) +const moleculeModules = import.meta.glob('@/components/molecules/*.tsx', { eager: true }) +const organismModules = import.meta.glob('@/components/organisms/*.tsx', { eager: true }) +const uiModules = import.meta.glob('@/components/ui/**/*.{ts,tsx}', { eager: true }) +const wrapperModules = import.meta.glob('@/lib/json-ui/wrappers/*.tsx', { eager: true }) + +const atomComponentMap = buildComponentMapFromModules(atomModules) +const moleculeComponentMap = buildComponentMapFromModules(moleculeModules) +const organismComponentMap = buildComponentMapFromModules(organismModules) +const uiComponentMap = buildComponentMapFromModules(uiModules) +const wrapperComponentMap = buildComponentMapFromModules(wrapperModules) +const iconComponentMap = buildComponentMapFromExports(PhosphorIcons) + +const sourceAliases: Record> = { + atoms: { + PageHeader: 'BasicPageHeader', + SearchInput: 'BasicSearchInput', + }, + molecules: {}, + organisms: {}, + ui: { + Chart: 'ChartContainer', + Resizable: 'ResizablePanelGroup', + }, + wrappers: {}, +} + +const explicitComponentAllowlist: Record> = { + JSONUIShowcase, +} + +const buildRegistryFromEntries = ( + source: string, + componentMap: Record>, + aliases: Record = {} +): UIComponentRegistry => { + return jsonRegistryEntries + .filter((entry) => entry.source === source) + .reduce((registry, entry) => { + const entryKey = getRegistryEntryKey(entry) + const entryExportName = getRegistryEntryExportName(entry) + if (!entryKey || !entryExportName) { + return registry + } + const aliasName = aliases[entryKey] + const component = + componentMap[entryExportName] ?? + (aliasName ? componentMap[aliasName] : undefined) ?? + explicitComponentAllowlist[entryKey] + if (component) { + registry[entryKey] = component + } + return registry + }, {}) +} export const primitiveComponents: UIComponentRegistry = { div: 'div' as any, @@ -169,173 +164,38 @@ export const primitiveComponents: UIComponentRegistry = { nav: 'nav' as any, } -const shadcnComponentMap: Record> = { - AlertDialog, - AspectRatio, - Button, - Carousel, - Chart, - Collapsible, - Command, - DropdownMenu, - Input, - InputOtp, - Textarea, - Label, - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, - CardFooter, - Badge, - Separator, - Alert: ShadcnAlert, - AlertDescription, - AlertTitle, - Switch, - Checkbox, - RadioGroup, - RadioGroupItem, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Table: ShadcnTable, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, - Tabs, - TabsContent, - TabsList, - TabsTrigger, - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - Menubar, - NavigationMenu, - Skeleton: ShadcnSkeleton, - Pagination, - Progress, - Resizable, - Sheet, - Sidebar, - Sonner, - ToggleGroup, - Avatar: ShadcnAvatar, - AvatarFallback, - AvatarImage, -} - -export const shadcnComponents: UIComponentRegistry = buildRegistryFromNames( - shadcnRegistryNames, - shadcnComponentMap +export const shadcnComponents: UIComponentRegistry = buildRegistryFromEntries( + 'ui', + uiComponentMap, + sourceAliases.ui ) -export const atomComponents: UIComponentRegistry = { - ...buildRegistryFromNames( - atomRegistryNames, - atomComponentMap - ), - DatePicker: atomComponentMap.DatePicker, - FileUpload: atomComponentMap.FileUpload, - CircularProgress, - Divider, - ProgressBar, - DataList: (AtomComponents as Record>).DataList, - DataTable: (AtomComponents as Record>).DataTable, - ListItem: (AtomComponents as Record>).ListItem, - MetricCard: (AtomComponents as Record>).MetricCard, - Timeline: (AtomComponents as Record>).Timeline, -} - -const breadcrumbComponent = AtomComponents.Breadcrumb ?? AtomComponents.BreadcrumbNav -if (breadcrumbComponent) { - atomComponents.Breadcrumb = breadcrumbComponent as ComponentType -} - -export const moleculeComponents: UIComponentRegistry = { - ...buildRegistryFromNames( - moleculeRegistryNames, - MoleculeComponents as Record> - ), - AppBranding: (MoleculeComponents as Record>).AppBranding, - LabelWithBadge: (MoleculeComponents as Record>).LabelWithBadge, - NavigationGroupHeader: (MoleculeComponents as Record>).NavigationGroupHeader, -} - -export const organismComponents: UIComponentRegistry = buildRegistryFromNames( - organismRegistryNames, - OrganismComponents as Record> +export const atomComponents: UIComponentRegistry = buildRegistryFromEntries( + 'atoms', + atomComponentMap, + sourceAliases.atoms ) -const wrapperComponentMap: Record> = { - ComponentBindingDialogWrapper, - ComponentTreeWrapper, - DataSourceEditorDialogWrapper, - GitHubBuildStatusWrapper, - SaveIndicatorWrapper, - LazyBarChartWrapper, - LazyLineChartWrapper, - LazyD3BarChartWrapper, - SeedDataManagerWrapper, - StorageSettingsWrapper, -} - -export const jsonWrapperComponents: UIComponentRegistry = buildRegistryFromNames( - wrapperRegistryNames, - wrapperComponentMap +export const moleculeComponents: UIComponentRegistry = buildRegistryFromEntries( + 'molecules', + moleculeComponentMap, + sourceAliases.molecules ) -const iconComponentMap: Record> = { - ArrowLeft, - ArrowRight, - Check, - X, - Plus, - Minus, - Search: MagnifyingGlass, - Filter: Funnel, - Download, - Upload, - Edit: PencilSimple, - Trash, - Eye, - EyeOff: EyeClosed, - ChevronUp: CaretUp, - ChevronDown: CaretDown, - ChevronLeft: CaretLeft, - ChevronRight: CaretRight, - Settings: Gear, - User, - Bell, - Mail: Envelope, - Calendar, - Clock, - Star, - Heart, - Share: ShareNetwork, - Link: LinkSimple, - Copy, - Save: FloppyDisk, - RefreshCw: ArrowClockwise, - AlertCircle: WarningCircle, - Info, - HelpCircle: Question, - Home: House, - Menu: ListIcon, - MoreVertical: DotsThreeVertical, - MoreHorizontal: DotsThree, -} +export const organismComponents: UIComponentRegistry = buildRegistryFromEntries( + 'organisms', + organismComponentMap, + sourceAliases.organisms +) -export const iconComponents: UIComponentRegistry = buildRegistryFromNames( - iconRegistryNames, +export const jsonWrapperComponents: UIComponentRegistry = buildRegistryFromEntries( + 'wrappers', + wrapperComponentMap, + sourceAliases.wrappers +) + +export const iconComponents: UIComponentRegistry = buildRegistryFromEntries( + 'icons', iconComponentMap ) diff --git a/src/lib/rate-limiter.test.ts b/src/lib/rate-limiter.test.ts new file mode 100644 index 0000000..14a0246 --- /dev/null +++ b/src/lib/rate-limiter.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { RateLimiter } from './rate-limiter' + +describe('RateLimiter.throttle', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date(0)) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('returns null when the window is saturated for medium priority', async () => { + const limiter = new RateLimiter({ + maxRequests: 1, + windowMs: 1000, + retryDelay: 10, + maxRetries: 2 + }) + const fn = vi.fn(async () => 'ok') + + await limiter.throttle('key', fn, 'medium') + const result = await limiter.throttle('key', fn, 'medium') + + expect(result).toBeNull() + expect(fn).toHaveBeenCalledTimes(1) + }) + + it('bounds high-priority retries without recursion when the window is saturated', async () => { + const limiter = new RateLimiter({ + maxRequests: 1, + windowMs: 1000, + retryDelay: 10, + maxRetries: 3 + }) + const fn = vi.fn(async () => 'ok') + + await limiter.throttle('key', fn, 'high') + + const spy = vi.spyOn(limiter, 'throttle') + let resolved: unknown = 'pending' + const pending = limiter.throttle('key', fn, 'high').then(result => { + resolved = result + return result + }) + + await vi.advanceTimersByTimeAsync(30) + await pending + + expect(resolved).toBeNull() + expect(fn).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/lib/rate-limiter.ts b/src/lib/rate-limiter.ts index cd54531..e761def 100644 --- a/src/lib/rate-limiter.ts +++ b/src/lib/rate-limiter.ts @@ -2,6 +2,7 @@ interface RateLimitConfig { maxRequests: number windowMs: number retryDelay: number + maxRetries?: number } interface RequestRecord { @@ -9,14 +10,15 @@ interface RequestRecord { count: number } -class RateLimiter { +export class RateLimiter { private requests: Map = new Map() private config: RateLimitConfig constructor(config: RateLimitConfig = { maxRequests: 5, windowMs: 60000, - retryDelay: 2000 + retryDelay: 2000, + maxRetries: 3 }) { this.config = config } @@ -26,49 +28,60 @@ class RateLimiter { fn: () => Promise, priority: 'low' | 'medium' | 'high' = 'medium' ): Promise { - const now = Date.now() - const record = this.requests.get(key) + const maxRetries = this.config.maxRetries ?? 3 + let attempts = 0 - if (record) { - const timeElapsed = now - record.timestamp + while (true) { + const now = Date.now() + const record = this.requests.get(key) + let isLimited = false - if (timeElapsed < this.config.windowMs) { - if (record.count >= this.config.maxRequests) { - console.warn(`Rate limit exceeded for ${key}. Try again in ${Math.ceil((this.config.windowMs - timeElapsed) / 1000)}s`) - - if (priority === 'high') { - await new Promise(resolve => setTimeout(resolve, this.config.retryDelay)) - return this.throttle(key, fn, priority) + if (record) { + const timeElapsed = now - record.timestamp + + if (timeElapsed < this.config.windowMs) { + if (record.count >= this.config.maxRequests) { + console.warn(`Rate limit exceeded for ${key}. Try again in ${Math.ceil((this.config.windowMs - timeElapsed) / 1000)}s`) + isLimited = true + } else { + record.count++ } - - return null + } else { + this.requests.set(key, { timestamp: now, count: 1 }) } - - record.count++ } else { this.requests.set(key, { timestamp: now, count: 1 }) } - } else { - this.requests.set(key, { timestamp: now, count: 1 }) - } - this.cleanup() + this.cleanup() - try { - return await fn() - } catch (error) { - if (error instanceof Error && ( - error.message.includes('502') || - error.message.includes('Bad Gateway') || - error.message.includes('429') || - error.message.includes('rate limit') - )) { - console.error(`Gateway error for ${key}:`, error.message) - if (record) { - record.count = this.config.maxRequests + if (isLimited) { + if (priority === 'high' && attempts < maxRetries) { + attempts += 1 + await new Promise(resolve => setTimeout(resolve, this.config.retryDelay)) + continue } + + return null + } + + try { + return await fn() + } catch (error) { + if (error instanceof Error && ( + error.message.includes('502') || + error.message.includes('Bad Gateway') || + error.message.includes('429') || + error.message.includes('rate limit') + )) { + console.error(`Gateway error for ${key}:`, error.message) + const updatedRecord = this.requests.get(key) + if (updatedRecord) { + updatedRecord.count = this.config.maxRequests + } + } + throw error } - throw error } } diff --git a/src/store/middleware/autoSyncMiddleware.ts b/src/store/middleware/autoSyncMiddleware.ts index 277769c..b8cc1f4 100644 --- a/src/store/middleware/autoSyncMiddleware.ts +++ b/src/store/middleware/autoSyncMiddleware.ts @@ -20,6 +20,8 @@ class AutoSyncManager { private timer: ReturnType | null = null private lastSyncTime = 0 private changeCounter = 0 + private inFlight = false + private pendingSync = false private dispatch: any = null configure(config: Partial) { @@ -68,18 +70,33 @@ class AutoSyncManager { private async performSync() { if (!this.dispatch) return + if (this.inFlight) { + this.pendingSync = true + return + } + this.inFlight = true try { await this.dispatch(syncToFlaskBulk()) this.lastSyncTime = Date.now() this.changeCounter = 0 } catch (error) { console.error('[AutoSync] Sync failed:', error) + } finally { + this.inFlight = false + } + + if (this.pendingSync) { + this.pendingSync = false + await this.performSync() } } trackChange() { this.changeCounter++ + if (this.inFlight) { + this.pendingSync = true + } if (this.changeCounter >= this.config.maxQueueSize && this.config.syncOnChange) { this.performSync() diff --git a/src/store/middleware/flaskSync.ts b/src/store/middleware/flaskSync.ts index 99a705d..c7c7cbc 100644 --- a/src/store/middleware/flaskSync.ts +++ b/src/store/middleware/flaskSync.ts @@ -37,6 +37,7 @@ export async function syncToFlask( } } catch (error) { console.error('[FlaskSync] Error syncing to Flask:', error) + throw error } } diff --git a/src/store/middleware/persistenceMiddleware.test.ts b/src/store/middleware/persistenceMiddleware.test.ts new file mode 100644 index 0000000..3e91cd6 --- /dev/null +++ b/src/store/middleware/persistenceMiddleware.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { PersistenceQueue } from './persistenceMiddleware' + +const { putMock, deleteMock, syncMock } = vi.hoisted(() => ({ + putMock: vi.fn<[string, unknown], Promise>(), + deleteMock: vi.fn<[string, string], Promise>(), + syncMock: vi.fn<[string, string, unknown, string], Promise>() +})) + +vi.mock('@/lib/db', () => ({ + db: { + put: putMock, + delete: deleteMock + } +})) + +vi.mock('./flaskSync', () => ({ + syncToFlask: syncMock +})) + +const nextTick = () => new Promise(resolve => setTimeout(resolve, 0)) + +const waitFor = async (assertion: () => void, attempts = 5) => { + let lastError: unknown + + for (let i = 0; i < attempts; i += 1) { + await nextTick() + + try { + assertion() + return + } catch (error) { + lastError = error + } + } + + throw lastError +} + +const createControlledPromise = () => { + let resolve: () => void + + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + + return { + promise, + resolve: resolve! + } +} + +describe('PersistenceQueue', () => { + beforeEach(() => { + putMock.mockReset() + deleteMock.mockReset() + syncMock.mockReset() + syncMock.mockResolvedValue(undefined) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('flushes new operations enqueued while processing after the first batch finishes', async () => { + const queue = new PersistenceQueue() + const controlled = createControlledPromise() + + putMock + .mockReturnValueOnce(controlled.promise) + .mockResolvedValueOnce(undefined) + + queue.enqueue({ + type: 'put', + storeName: 'files', + key: 'file-1', + value: { id: 'file-1' }, + timestamp: Date.now(), + }, 0) + + await waitFor(() => { + expect(putMock).toHaveBeenCalledTimes(1) + }) + + queue.enqueue({ + type: 'put', + storeName: 'files', + key: 'file-2', + value: { id: 'file-2' }, + timestamp: Date.now(), + }, 0) + + await nextTick() + expect(putMock).toHaveBeenCalledTimes(1) + + controlled.resolve() + + await waitFor(() => { + expect(putMock).toHaveBeenCalledTimes(2) + }) + }) +}) diff --git a/src/store/middleware/persistenceMiddleware.ts b/src/store/middleware/persistenceMiddleware.ts index 7d20c2e..b98373d 100644 --- a/src/store/middleware/persistenceMiddleware.ts +++ b/src/store/middleware/persistenceMiddleware.ts @@ -38,10 +38,23 @@ type PendingOperation = { timestamp: number } +type FailedSyncOperation = PendingOperation & { + attempt: number + lastError: string + nextRetryAt: number +} + +const MAX_SYNC_RETRIES = 5 +const BASE_SYNC_RETRY_DELAY_MS = 1000 +const MAX_SYNC_RETRY_DELAY_MS = 30000 + class PersistenceQueue { private queue: Map = new Map() private processing = false + private pendingFlush = false private debounceTimers: Map> = new Map() + private failedSyncs: Map = new Map() + private retryTimers: Map> = new Map() enqueue(operation: PendingOperation, debounceMs: number) { const opKey = `${operation.storeName}:${operation.key}` @@ -62,7 +75,12 @@ class PersistenceQueue { } async processQueue() { - if (this.processing || this.queue.size === 0) return + if (this.processing) { + this.pendingFlush = true + return + } + + if (this.queue.size === 0) return this.processing = true @@ -75,14 +93,10 @@ class PersistenceQueue { try { if (op.type === 'put') { await db.put(op.storeName as any, op.value) - if (sliceToPersistenceMap[op.storeName]?.syncToFlask) { - await syncToFlask(op.storeName, op.key, op.value, 'put') - } + await this.syncToFlaskWithRetry(op, op.value) } else if (op.type === 'delete') { await db.delete(op.storeName as any, op.key) - if (sliceToPersistenceMap[op.storeName]?.syncToFlask) { - await syncToFlask(op.storeName, op.key, null, 'delete') - } + await this.syncToFlaskWithRetry(op, null) } } catch (error) { console.error(`[PersistenceMiddleware] Failed to persist ${op.type} for ${op.storeName}:${op.key}`, error) @@ -97,6 +111,23 @@ class PersistenceQueue { } } finally { this.processing = false + const needsFlush = this.pendingFlush || this.queue.size > 0 + this.pendingFlush = false + if (needsFlush) { + await this.processQueue() + } + } + } + + getFailedSyncs() { + return Array.from(this.failedSyncs.values()).sort((a, b) => a.nextRetryAt - b.nextRetryAt) + } + + async retryFailedSyncs() { + for (const [opKey, failure] of this.failedSyncs.entries()) { + if (failure.nextRetryAt <= Date.now()) { + await this.retryFailedSync(opKey) + } } } @@ -107,6 +138,89 @@ class PersistenceQueue { this.debounceTimers.clear() await this.processQueue() } + + private async syncToFlaskWithRetry(op: PendingOperation, value: any) { + if (!sliceToPersistenceMap[op.storeName]?.syncToFlask) return + + try { + await syncToFlask(op.storeName, op.key, value, op.type) + this.clearSyncFailure(op) + } catch (error) { + this.recordSyncFailure(op, error) + console.warn( + `[PersistenceMiddleware] Flask sync failed for ${op.storeName}:${op.key} (${op.type}); queued for retry.`, + error + ) + } + } + + private recordSyncFailure(op: PendingOperation, error: unknown) { + const opKey = this.getFailureKey(op) + const previous = this.failedSyncs.get(opKey) + const attempt = previous ? previous.attempt + 1 : 1 + const delayMs = this.getRetryDelayMs(attempt) + const nextRetryAt = Date.now() + delayMs + const lastError = error instanceof Error ? error.message : String(error) + + this.failedSyncs.set(opKey, { + ...op, + attempt, + lastError, + nextRetryAt, + }) + + const existingTimer = this.retryTimers.get(opKey) + if (existingTimer) { + clearTimeout(existingTimer) + } + + if (attempt <= MAX_SYNC_RETRIES) { + const timer = setTimeout(() => { + this.retryTimers.delete(opKey) + void this.retryFailedSync(opKey) + }, delayMs) + this.retryTimers.set(opKey, timer) + } + } + + private clearSyncFailure(op: PendingOperation) { + const opKey = this.getFailureKey(op) + const timer = this.retryTimers.get(opKey) + if (timer) { + clearTimeout(timer) + this.retryTimers.delete(opKey) + } + this.failedSyncs.delete(opKey) + } + + private async retryFailedSync(opKey: string) { + const failure = this.failedSyncs.get(opKey) + if (!failure) return + + if (failure.attempt > MAX_SYNC_RETRIES) { + return + } + + this.enqueue( + { + type: failure.type, + storeName: failure.storeName, + key: failure.key, + value: failure.value, + timestamp: Date.now(), + }, + 0 + ) + } + + private getRetryDelayMs(attempt: number) { + const delay = BASE_SYNC_RETRY_DELAY_MS * Math.pow(2, attempt - 1) + return Math.min(delay, MAX_SYNC_RETRY_DELAY_MS) + } + + private getFailureKey(op: PendingOperation) { + return `${op.storeName}:${op.key}:${op.type}` + } } const persistenceQueue = new PersistenceQueue() @@ -208,6 +322,8 @@ export const createPersistenceMiddleware = (): Middleware => { } export const flushPersistence = () => persistenceQueue.flush() +export const getFailedSyncOperations = () => persistenceQueue.getFailedSyncs() +export const retryFailedSyncOperations = () => persistenceQueue.retryFailedSyncs() export const configurePersistence = (sliceName: string, config: Partial) => { if (sliceToPersistenceMap[sliceName]) { diff --git a/src/store/middleware/syncMonitorMiddleware.ts b/src/store/middleware/syncMonitorMiddleware.ts index 2ddfa92..df97aa0 100644 --- a/src/store/middleware/syncMonitorMiddleware.ts +++ b/src/store/middleware/syncMonitorMiddleware.ts @@ -107,21 +107,18 @@ export const createSyncMonitorMiddleware = (): Middleware => { const isFulfilledAction = asyncThunkActions.some((prefix) => action.type === `${prefix}/fulfilled`) const isRejectedAction = asyncThunkActions.some((prefix) => action.type === `${prefix}/rejected`) - if (isPendingAction) { - const operationId = action.meta?.requestId || `${action.type}-${Date.now()}` - syncMonitor.startOperation(operationId) + if (isPendingAction && action.meta?.requestId) { + syncMonitor.startOperation(action.meta.requestId) } const result = next(action) - if (isFulfilledAction) { - const operationId = action.meta?.requestId || `${action.type}-${Date.now()}` - syncMonitor.endOperation(operationId, true) + if (isFulfilledAction && action.meta?.requestId) { + syncMonitor.endOperation(action.meta.requestId, true) } - if (isRejectedAction) { - const operationId = action.meta?.requestId || `${action.type}-${Date.now()}` - syncMonitor.endOperation(operationId, false) + if (isRejectedAction && action.meta?.requestId) { + syncMonitor.endOperation(action.meta.requestId, false) } return result diff --git a/src/store/slices/syncSlice.ts b/src/store/slices/syncSlice.ts index 4038ffe..6cad873 100644 --- a/src/store/slices/syncSlice.ts +++ b/src/store/slices/syncSlice.ts @@ -70,12 +70,51 @@ export const syncFromFlaskBulk = createAsyncThunk( async (_, { rejectWithValue }) => { try { const data = await fetchAllFromFlask() - + const allowedStoreNames = new Set(['files', 'models', 'components', 'workflows']) + const serverIdsByStore = { + files: new Set(), + models: new Set(), + components: new Set(), + workflows: new Set(), + } + for (const [key, value] of Object.entries(data)) { const [storeName, id] = key.split(':') if (SYNCABLE_STORES.has(storeName)) { await db.put(storeName as any, value) + if (typeof key !== 'string') { + continue + } + + const parts = key.split(':') + if (parts.length !== 2) { + continue + } + + const [storeName, id] = parts + if (!storeName || !id) { + continue + } + + if (!allowedStoreNames.has(storeName)) { + continue + } + + serverIdsByStore[storeName as keyof typeof serverIdsByStore].add(id) + await db.put(storeName as any, value) + } + + // Explicit merge strategy: server is source of truth; delete local records missing from server response. + const storeNames = Array.from(allowedStoreNames) + for (const storeName of storeNames) { + const localRecords = await db.getAll(storeName as any) + for (const record of localRecords) { + const recordId = record?.id + const recordIdString = recordId == null ? '' : String(recordId) + if (!serverIdsByStore[storeName as keyof typeof serverIdsByStore].has(recordIdString)) { + await db.delete(storeName as any, recordId) + } } }