mirror of
https://github.com/johndoe6345789/low-code-react-app-b.git
synced 2026-04-24 13:44:54 +00:00
Merge branch 'main' into codex/add-tests-for-persistencequeue-enqueuing
This commit is contained in:
@@ -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<ComponentNode> = 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 = <T,>(
|
||||
result: string,
|
||||
schema: z.ZodType<T>,
|
||||
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<ComponentNode | null> {
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<any>
|
||||
@@ -89,19 +33,6 @@ const jsonRegistry = jsonComponentsRegistry as JsonComponentRegistry
|
||||
const getRegistryEntryName = (entry: JsonRegistryEntry): string | undefined =>
|
||||
entry.export ?? entry.name ?? entry.type
|
||||
|
||||
const buildRegistryFromNames = (
|
||||
names: string[],
|
||||
components: Record<string, ComponentType<any>>
|
||||
): UIComponentRegistry => {
|
||||
return names.reduce<UIComponentRegistry>((registry, name) => {
|
||||
const component = components[name]
|
||||
if (component) {
|
||||
registry[name] = component
|
||||
}
|
||||
return registry
|
||||
}, {})
|
||||
}
|
||||
|
||||
const jsonRegistryEntries = jsonRegistry.components ?? []
|
||||
const registryEntryByType = new Map(
|
||||
jsonRegistryEntries
|
||||
@@ -111,7 +42,6 @@ const registryEntryByType = new Map(
|
||||
})
|
||||
.filter((entry): entry is [string, JsonRegistryEntry] => Boolean(entry))
|
||||
)
|
||||
const atomComponentMap = AtomComponents as Record<string, ComponentType<any>>
|
||||
const deprecatedComponentInfo = jsonRegistryEntries.reduce<Record<string, DeprecatedComponentInfo>>(
|
||||
(acc, entry) => {
|
||||
const entryName = getRegistryEntryName(entry)
|
||||
@@ -125,30 +55,130 @@ const deprecatedComponentInfo = jsonRegistryEntries.reduce<Record<string, Deprec
|
||||
},
|
||||
{}
|
||||
)
|
||||
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<string, unknown>
|
||||
): Record<string, ComponentType<any>> => {
|
||||
return Object.entries(exports).reduce<Record<string, ComponentType<any>>>((acc, [key, value]) => {
|
||||
if (value && (typeof value === 'function' || typeof value === 'object')) {
|
||||
acc[key] = value as ComponentType<any>
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
const buildComponentMapFromModules = (
|
||||
modules: Record<string, unknown>
|
||||
): Record<string, ComponentType<any>> => {
|
||||
return Object.values(modules).reduce<Record<string, ComponentType<any>>>((acc, moduleExports) => {
|
||||
if (!moduleExports || typeof moduleExports !== 'object') {
|
||||
return acc
|
||||
}
|
||||
Object.entries(buildComponentMapFromExports(moduleExports as Record<string, unknown>)).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<string, Record<string, string>> = {
|
||||
atoms: {
|
||||
PageHeader: 'BasicPageHeader',
|
||||
SearchInput: 'BasicSearchInput',
|
||||
},
|
||||
molecules: {},
|
||||
organisms: {},
|
||||
ui: {
|
||||
Chart: 'ChartContainer',
|
||||
Resizable: 'ResizablePanelGroup',
|
||||
},
|
||||
wrappers: {},
|
||||
}
|
||||
|
||||
const iconAliases: Record<string, string> = {
|
||||
Search: 'MagnifyingGlass',
|
||||
Filter: 'Funnel',
|
||||
Edit: 'PencilSimple',
|
||||
EyeOff: 'EyeClosed',
|
||||
ChevronUp: 'CaretUp',
|
||||
ChevronDown: 'CaretDown',
|
||||
ChevronLeft: 'CaretLeft',
|
||||
ChevronRight: 'CaretRight',
|
||||
Settings: 'Gear',
|
||||
Mail: 'Envelope',
|
||||
Share: 'ShareNetwork',
|
||||
Link: 'LinkSimple',
|
||||
Save: 'FloppyDisk',
|
||||
RefreshCw: 'ArrowClockwise',
|
||||
AlertCircle: 'WarningCircle',
|
||||
HelpCircle: 'Question',
|
||||
Home: 'House',
|
||||
Menu: 'List',
|
||||
MoreVertical: 'DotsThreeVertical',
|
||||
MoreHorizontal: 'DotsThree',
|
||||
}
|
||||
|
||||
const explicitComponentAllowlist: Record<string, ComponentType<any>> = {
|
||||
JSONUIShowcase,
|
||||
}
|
||||
|
||||
const buildRegistryFromEntries = (
|
||||
source: string,
|
||||
componentMap: Record<string, ComponentType<any>>,
|
||||
aliases: Record<string, string> = {}
|
||||
): UIComponentRegistry => {
|
||||
return jsonRegistryEntries
|
||||
.filter((entry) => entry.source === source)
|
||||
.reduce<UIComponentRegistry>((registry, entry) => {
|
||||
const entryName = getRegistryEntryName(entry)
|
||||
if (!entryName) {
|
||||
return registry
|
||||
}
|
||||
const aliasName = aliases[entryName]
|
||||
const component =
|
||||
componentMap[entryName] ??
|
||||
(aliasName ? componentMap[aliasName] : undefined) ??
|
||||
explicitComponentAllowlist[entryName]
|
||||
if (component) {
|
||||
registry[entryName] = component
|
||||
}
|
||||
return registry
|
||||
}, {})
|
||||
}
|
||||
|
||||
const buildIconRegistry = (): UIComponentRegistry => {
|
||||
return jsonRegistryEntries
|
||||
.filter((entry) => entry.source === 'icons')
|
||||
.reduce<UIComponentRegistry>((registry, entry) => {
|
||||
const entryName = getRegistryEntryName(entry)
|
||||
if (!entryName) {
|
||||
return registry
|
||||
}
|
||||
const aliasName = iconAliases[entryName]
|
||||
const component =
|
||||
iconComponentMap[entryName] ??
|
||||
(aliasName ? iconComponentMap[aliasName] : undefined)
|
||||
if (component) {
|
||||
registry[entryName] = component
|
||||
}
|
||||
return registry
|
||||
}, {})
|
||||
}
|
||||
|
||||
export const primitiveComponents: UIComponentRegistry = {
|
||||
div: 'div' as any,
|
||||
@@ -169,176 +199,38 @@ export const primitiveComponents: UIComponentRegistry = {
|
||||
nav: 'nav' as any,
|
||||
}
|
||||
|
||||
const shadcnComponentMap: Record<string, ComponentType<any>> = {
|
||||
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<string, ComponentType<any>>).DataList,
|
||||
DataTable: (AtomComponents as Record<string, ComponentType<any>>).DataTable,
|
||||
ListItem: (AtomComponents as Record<string, ComponentType<any>>).ListItem,
|
||||
MetricCard: (AtomComponents as Record<string, ComponentType<any>>).MetricCard,
|
||||
Timeline: (AtomComponents as Record<string, ComponentType<any>>).Timeline,
|
||||
}
|
||||
|
||||
const breadcrumbComponent = AtomComponents.Breadcrumb ?? AtomComponents.BreadcrumbNav
|
||||
if (breadcrumbComponent) {
|
||||
atomComponents.Breadcrumb = breadcrumbComponent as ComponentType<any>
|
||||
}
|
||||
|
||||
export const moleculeComponents: UIComponentRegistry = {
|
||||
...buildRegistryFromNames(
|
||||
moleculeRegistryNames,
|
||||
MoleculeComponents as Record<string, ComponentType<any>>
|
||||
),
|
||||
AppBranding: (MoleculeComponents as Record<string, ComponentType<any>>).AppBranding,
|
||||
LabelWithBadge: (MoleculeComponents as Record<string, ComponentType<any>>).LabelWithBadge,
|
||||
NavigationGroupHeader: (MoleculeComponents as Record<string, ComponentType<any>>).NavigationGroupHeader,
|
||||
}
|
||||
|
||||
export const organismComponents: UIComponentRegistry = buildRegistryFromNames(
|
||||
organismRegistryNames,
|
||||
OrganismComponents as Record<string, ComponentType<any>>
|
||||
export const atomComponents: UIComponentRegistry = buildRegistryFromEntries(
|
||||
'atoms',
|
||||
atomComponentMap,
|
||||
sourceAliases.atoms
|
||||
)
|
||||
|
||||
const wrapperComponentMap: Record<string, ComponentType<any>> = {
|
||||
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<string, ComponentType<any>> = {
|
||||
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 iconComponents: UIComponentRegistry = buildRegistryFromNames(
|
||||
iconRegistryNames,
|
||||
iconComponentMap
|
||||
export const organismComponents: UIComponentRegistry = buildRegistryFromEntries(
|
||||
'organisms',
|
||||
organismComponentMap,
|
||||
sourceAliases.organisms
|
||||
)
|
||||
|
||||
export const jsonWrapperComponents: UIComponentRegistry = buildRegistryFromEntries(
|
||||
'wrappers',
|
||||
wrapperComponentMap,
|
||||
sourceAliases.wrappers
|
||||
)
|
||||
|
||||
export const iconComponents: UIComponentRegistry = buildIconRegistry()
|
||||
|
||||
export const uiComponentRegistry: UIComponentRegistry = {
|
||||
...primitiveComponents,
|
||||
...shadcnComponents,
|
||||
|
||||
@@ -26,30 +26,39 @@ class RateLimiter {
|
||||
fn: () => Promise<T>,
|
||||
priority: 'low' | 'medium' | 'high' = 'medium'
|
||||
): Promise<T | null> {
|
||||
const now = Date.now()
|
||||
const record = this.requests.get(key)
|
||||
const maxHighPriorityRetries = 5
|
||||
let retryCount = 0
|
||||
let record: RequestRecord | undefined
|
||||
|
||||
if (record) {
|
||||
const timeElapsed = now - record.timestamp
|
||||
while (true) {
|
||||
const now = Date.now()
|
||||
record = this.requests.get(key)
|
||||
|
||||
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`)
|
||||
|
||||
if (priority === 'high' && retryCount < maxHighPriorityRetries) {
|
||||
retryCount++
|
||||
await new Promise(resolve => setTimeout(resolve, this.config.retryDelay))
|
||||
continue
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
|
||||
record.count++
|
||||
} 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 })
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
this.cleanup()
|
||||
|
||||
@@ -20,6 +20,8 @@ class AutoSyncManager {
|
||||
private timer: ReturnType<typeof setTimeout> | null = null
|
||||
private lastSyncTime = 0
|
||||
private changeCounter = 0
|
||||
private inFlight = false
|
||||
private pendingSync = false
|
||||
private dispatch: any = null
|
||||
|
||||
configure(config: Partial<AutoSyncConfig>) {
|
||||
@@ -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()
|
||||
|
||||
@@ -37,6 +37,7 @@ export async function syncToFlask(
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[FlaskSync] Error syncing to Flask:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,10 +38,23 @@ type PendingOperation = {
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export class PersistenceQueue {
|
||||
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<string, PendingOperation> = new Map()
|
||||
private processing = false
|
||||
private pendingFlush = false
|
||||
private debounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
||||
private failedSyncs: Map<string, FailedSyncOperation> = new Map()
|
||||
private retryTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
||||
|
||||
enqueue(operation: PendingOperation, debounceMs: number) {
|
||||
const opKey = `${operation.storeName}:${operation.key}`
|
||||
@@ -62,7 +75,12 @@ export 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 @@ export 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,12 +111,26 @@ export class PersistenceQueue {
|
||||
}
|
||||
} finally {
|
||||
this.processing = false
|
||||
if (this.queue.size > 0) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async flush() {
|
||||
for (const timer of this.debounceTimers.values()) {
|
||||
clearTimeout(timer)
|
||||
@@ -110,6 +138,89 @@ export 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()
|
||||
@@ -211,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<PersistenceConfig>) => {
|
||||
if (sliceToPersistenceMap[sliceName]) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -68,15 +68,47 @@ export const syncFromFlaskBulk = createAsyncThunk(
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const data = await fetchAllFromFlask()
|
||||
|
||||
const allowedStoreNames = new Set(['files', 'models', 'components', 'workflows'])
|
||||
const serverIdsByStore = {
|
||||
files: new Set<string>(),
|
||||
models: new Set<string>(),
|
||||
components: new Set<string>(),
|
||||
workflows: new Set<string>(),
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
const [storeName, id] = key.split(':')
|
||||
|
||||
if (storeName === 'files' ||
|
||||
storeName === 'models' ||
|
||||
storeName === 'components' ||
|
||||
storeName === 'workflows') {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user