From 3d98933e24b711a4cfc5c757b094538bab2309ab Mon Sep 17 00:00:00 2001 From: Stringadmin Date: Wed, 10 Jun 2026 14:06:16 +0800 Subject: [PATCH] Initial ecommerce standalone package --- .env.example | 5 + .gitignore | 17 + AGENTS.md | 39 + index.html | 41 + package-lock.json | 2756 +++ package.json | 32 + scripts/check-governance.mjs | 80 + scripts/check-style-governance.mjs | 1 + scripts/dynamic-analysis-v2.mjs | 301 + scripts/dynamic-analysis.mjs | 305 + scripts/smoke-generation-mocked.mjs | 72 + scripts/static-analysis.mjs | 148 + src/App.tsx | 587 + src/api/adVideoPlanClient.ts | 355 + src/api/aiGenerationClient.ts | 559 + src/api/apiErrorUtils.ts | 3 + src/api/assetClient.ts | 129 + src/api/betaApplicationClient.ts | 139 + src/api/communityClient.ts | 207 + src/api/conversationClient.ts | 67 + src/api/draftClient.ts | 53 + src/api/generationConcurrency.ts | 73 + src/api/keyServerClient.ts | 1022 + src/api/modelCapabilitiesClient.ts | 104 + src/api/notificationClient.ts | 173 + src/api/projectTaskClient.ts | 154 + src/api/providerHealthClient.ts | 39 + src/api/publicConfigClient.ts | 51 + src/api/referenceUploadService.ts | 84 + src/api/reportClient.ts | 71 + src/api/scriptEvalClient.ts | 204 + src/api/serverConnection.ts | 425 + src/api/taskSubscription.ts | 128 + src/api/uploadWithProgress.ts | 57 + src/api/webGenerationGateway.ts | 110 + src/assets/platform-logos/aliexpress.webp | Bin 0 -> 822 bytes src/assets/platform-logos/amazon.webp | Bin 0 -> 932 bytes src/assets/platform-logos/douyin.webp | Bin 0 -> 1634 bytes src/assets/platform-logos/ebay.webp | Bin 0 -> 2078 bytes src/assets/platform-logos/instagram.webp | Bin 0 -> 2106 bytes src/assets/platform-logos/jd.webp | Bin 0 -> 2432 bytes src/assets/platform-logos/lazada.webp | Bin 0 -> 1998 bytes src/assets/platform-logos/pinduoduo.webp | Bin 0 -> 3442 bytes src/assets/platform-logos/shopee.webp | Bin 0 -> 1024 bytes src/assets/platform-logos/taobao.webp | Bin 0 -> 1542 bytes src/assets/platform-logos/tiktok-shop.webp | Bin 0 -> 1984 bytes src/assets/platform-logos/tmall.webp | Bin 0 -> 1236 bytes src/components/AdminMonitor.tsx | 110 + src/components/AnimatedPanel.tsx | 54 + src/components/AppShell.tsx | 541 + src/components/BeforeAfterCompare.tsx | 108 + src/components/BetaApplicationModal.tsx | 349 + src/components/CookieConsentBanner.tsx | 31 + src/components/DropZone.tsx | 99 + src/components/EmptyState.tsx | 35 + src/components/ErrorBoundary.tsx | 152 + src/components/NotFoundPage.tsx | 25 + src/components/NotificationCenter.tsx | 157 + src/components/OnboardingTour.tsx | 500 + src/components/OptimizedImage.tsx | 55 + src/components/PageTransition.tsx | 91 + .../RechargeModal/RechargeModal.tsx | 307 + .../RechargeModal/loadRechargeModal.ts | 14 + src/components/ShellIcon.tsx | 344 + src/components/Skeleton.tsx | 42 + src/components/StudioToolLayout.tsx | 40 + src/components/TaskStatusBar.tsx | 22 + src/components/WorkspacePageShell.tsx | 34 + src/components/toast/ToastContainer.tsx | 37 + src/components/toast/toastStore.ts | 44 + src/data/ossAssets.ts | 124 + src/data/workflows.ts | 182 + src/features/agent/AgentPage.tsx | 380 + src/features/assets/AssetsPage.tsx | 433 + src/features/assets/localAssetStore.ts | 58 + .../BetaApplicationsPage.tsx | 298 + src/features/canvas/CanvasMarkingPopover.tsx | 40 + src/features/canvas/CanvasPage.tsx | 5747 ++++++ .../canvas/CanvasSmoothedProgressRing.tsx | 26 + .../canvas/CanvasTextPromptComposer.tsx | 219 + src/features/canvas/canvasAssetPersistence.ts | 191 + src/features/canvas/canvasCommunityPublish.ts | 93 + src/features/canvas/canvasComponents.tsx | 312 + src/features/canvas/canvasConstants.ts | 202 + src/features/canvas/canvasToolPanels.tsx | 221 + src/features/canvas/canvasTypes.ts | 338 + src/features/canvas/canvasUtils.ts | 498 + .../canvas/canvasWorkflowDeserialize.ts | 221 + .../canvas/canvasWorkflowExecution.ts | 128 + src/features/canvas/canvasWorkflowSchema.ts | 202 + src/features/canvas/useCanvasDerivedState.ts | 74 + src/features/canvas/useCanvasGeneration.ts | 189 + src/features/canvas/useCanvasHistory.ts | 65 + src/features/canvas/useCanvasKeyboard.ts | 85 + src/features/canvas/useCanvasNodeDrag.ts | 331 + .../character-mix/CharacterMixPage.tsx | 569 + .../community-review/CommunityCaseAddPage.tsx | 444 + .../community-review/CommunityReviewPage.tsx | 392 + .../community-review/communityPermissions.ts | 15 + src/features/community/CommunityPage.tsx | 504 + src/features/community/communityCaseUtils.ts | 170 + src/features/compliance/CompliancePage.tsx | 99 + .../dialog-generator/DialogGeneratorPage.tsx | 480 + .../digital-human/AvatarConsolePage.tsx | 1442 ++ .../digital-human/DigitalHumanPage.tsx | 690 + .../digital-human/avatarEditorModel.ts | 121 + .../digital-human/avatarTrainingModel.ts | 59 + .../digital-human/voiceLibraryModel.ts | 34 + src/features/ecommerce/EcommercePage.tsx | 3701 ++++ .../ecommerce/EcommerceProgressBar.tsx | 30 + .../ecommerce/EcommerceTemplatesPage.tsx | 327 + .../ecommerce/EcommerceVideoWorkspace.tsx | 817 + src/features/ecommerce/ImageMentionMenu.tsx | 71 + .../ecommerce/ecommerceImageValidation.ts | 37 + src/features/ecommerce/ecommerceTemplates.ts | 139 + .../ecommerce/ecommerceVideoKeepalive.ts | 66 + .../ecommerce/ecommerceVideoService.ts | 521 + src/features/ecommerce/ecommerceVideoTypes.ts | 93 + .../ecommerce/panels/EcommerceClonePanel.tsx | 847 + .../ecommerce/panels/EcommerceDetailPanel.tsx | 207 + .../ecommerce/panels/EcommerceSetPanel.tsx | 171 + .../ecommerce/panels/EcommerceTryOnPanel.tsx | 258 + .../panels/EcommerceVideoHistoryPanel.tsx | 185 + src/features/home/HomePage.tsx | 718 + src/features/home/ModelGenerationShowcase.tsx | 283 + src/features/home/ScriptReviewShowcase.tsx | 255 + src/features/home/ScriptReviewVisual.tsx | 134 + src/features/home/ToolboxSection.tsx | 238 + src/features/home/WelcomeSplash.tsx | 123 + .../image-workbench/CameraViewport3D.tsx | 283 + .../image-workbench/ImageWorkbenchPage.tsx | 1433 ++ .../image-workbench/useCanvasDrawing.ts | 273 + src/features/more/MorePage.tsx | 429 + src/features/profile/ProfilePage.tsx | 1578 ++ .../provider-health/ProviderHealthPage.tsx | 168 + src/features/report/ReportPage.tsx | 196 + .../ResolutionUpscalePage.tsx | 629 + .../script-tokens/ScriptTokensPage.tsx | 927 + src/features/script-tokens/TokenUsagePage.tsx | 417 + src/features/settings/SettingsPage.tsx | 47 + .../size-template/SizeTemplatePage.tsx | 1393 ++ .../subtitle-removal/SubtitleRemovalPage.tsx | 474 + .../WatermarkRemovalPage.tsx | 436 + .../workbench/ConversationSidebar.tsx | 138 + src/features/workbench/ProjectSidebar.tsx | 151 + .../workbench/SmoothedProgressBar.tsx | 27 + src/features/workbench/WorkbenchPage.tsx | 3558 ++++ .../workbench/WorkbenchPromptPreview.tsx | 88 + .../workbench/WorkbenchSelectChips.tsx | 264 + .../components/WorkbenchChatCards.tsx | 611 + src/features/workbench/markdownRenderer.tsx | 236 + src/features/workbench/toolKeepalive.ts | 79 + src/features/workbench/toolResultActions.ts | 53 + src/features/workbench/workbenchChatTypes.ts | 51 + src/features/workbench/workbenchConstants.ts | 440 + src/features/workbench/workbenchDownload.ts | 154 + .../workbench/workbenchMentionUtils.tsx | 80 + .../workbench/workbenchReferenceUtils.ts | 211 + .../workbench/workbenchResultPersistence.ts | 234 + src/features/workbench/workbenchStorage.ts | 173 + src/hooks/useDebounce.ts | 12 + src/hooks/useGenerationStatus.ts | 41 + src/hooks/useGenerationTasks.ts | 126 + src/hooks/useScrollEntrance.ts | 31 + src/hooks/useSmoothedProgress.ts | 108 + src/main.tsx | 25 + src/services/backgroundTaskRunner.ts | 151 + src/stores/index.ts | 10 + src/stores/useAppStore.ts | 111 + src/stores/useGenerationStore.ts | 121 + src/stores/useProjectStore.ts | 55 + src/stores/useSessionStore.ts | 68 + src/stores/useTaskStore.ts | 36 + src/styles/app.css | 2 + src/styles/base/reset.css | 43 + .../components/beta-application-modal.css | 471 + src/styles/components/dropzone.css | 34 + src/styles/components/empty-state.css | 50 + src/styles/components/legacy-components.css | 1168 ++ src/styles/components/motion.css | 176 + src/styles/components/onboarding.css | 317 + src/styles/components/page-transition.css | 56 + src/styles/components/primitives.css | 105 + src/styles/components/recharge-modal.css | 501 + src/styles/components/skeleton.css | 56 + src/styles/components/toast.css | 108 + src/styles/ecommerce-standalone.css | 3903 ++++ src/styles/index.css | 12 + src/styles/loadDarkGreenTheme.ts | 6 + src/styles/pages.css | 2 + src/styles/pages/agent.css | 1 + src/styles/pages/assets.css | 245 + src/styles/pages/avatar-console.css | 4213 ++++ src/styles/pages/beta-applications.css | 421 + src/styles/pages/canvas.css | 949 + src/styles/pages/community.css | 119 + src/styles/pages/compliance.css | 895 + src/styles/pages/dialog-generator.css | 783 + src/styles/pages/ecommerce-video.css | 1464 ++ src/styles/pages/ecommerce.css | 10985 ++++++++++ src/styles/pages/home.css | 2481 +++ src/styles/pages/image-workbench.css | 1799 ++ src/styles/pages/legacy-pages.css | 17007 ++++++++++++++++ src/styles/pages/local-theme-parity.css | 373 + .../pages/model-generation-showcase.css | 1005 + src/styles/pages/more-tools.css | 1288 ++ src/styles/pages/more.css | 1346 ++ src/styles/pages/not-found.css | 56 + src/styles/pages/profile.css | 452 + src/styles/pages/provider-health.css | 166 + src/styles/pages/script-review-showcase.css | 772 + src/styles/pages/script-review-visual.css | 264 + src/styles/pages/script-tokens-v5.css | 4155 ++++ src/styles/pages/script-tokens.css | 5372 +++++ src/styles/pages/size-template.css | 476 + src/styles/pages/studio-layout.css | 696 + src/styles/pages/subtitle-removal.css | 135 + src/styles/pages/toolbox.css | 891 + src/styles/pages/welcome-splash.css | 124 + src/styles/pages/workbench.css | 2321 +++ src/styles/shell/app-shell.css | 1032 + src/styles/themes/dark-green.css | 11771 +++++++++++ src/styles/tokens.css | 86 + src/types.ts | 312 + src/utils/enterpriseVideoPolicy.ts | 99 + src/utils/errorReporting.ts | 63 + src/utils/generationNotifier.ts | 50 + src/utils/happyHorseRouting.ts | 58 + src/utils/imageModelVisibility.ts | 40 + src/utils/mentionTrigger.ts | 49 + src/utils/modelOptions.ts | 72 + src/utils/ossImageOptimize.ts | 17 + src/utils/pixverseRouting.ts | 58 + src/utils/resolveVideoModel.ts | 16 + src/utils/taskLifecycle.ts | 162 + src/utils/toolPageUtils.ts | 32 + src/utils/translateTaskError.ts | 175 + src/utils/viduRouting.ts | 56 + src/vite-env.d.ts | 1 + tsconfig.json | 20 + vite.config.ts | 45 + 241 files changed, 135283 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/check-governance.mjs create mode 100644 scripts/check-style-governance.mjs create mode 100644 scripts/dynamic-analysis-v2.mjs create mode 100644 scripts/dynamic-analysis.mjs create mode 100644 scripts/smoke-generation-mocked.mjs create mode 100644 scripts/static-analysis.mjs create mode 100644 src/App.tsx create mode 100644 src/api/adVideoPlanClient.ts create mode 100644 src/api/aiGenerationClient.ts create mode 100644 src/api/apiErrorUtils.ts create mode 100644 src/api/assetClient.ts create mode 100644 src/api/betaApplicationClient.ts create mode 100644 src/api/communityClient.ts create mode 100644 src/api/conversationClient.ts create mode 100644 src/api/draftClient.ts create mode 100644 src/api/generationConcurrency.ts create mode 100644 src/api/keyServerClient.ts create mode 100644 src/api/modelCapabilitiesClient.ts create mode 100644 src/api/notificationClient.ts create mode 100644 src/api/projectTaskClient.ts create mode 100644 src/api/providerHealthClient.ts create mode 100644 src/api/publicConfigClient.ts create mode 100644 src/api/referenceUploadService.ts create mode 100644 src/api/reportClient.ts create mode 100644 src/api/scriptEvalClient.ts create mode 100644 src/api/serverConnection.ts create mode 100644 src/api/taskSubscription.ts create mode 100644 src/api/uploadWithProgress.ts create mode 100644 src/api/webGenerationGateway.ts create mode 100644 src/assets/platform-logos/aliexpress.webp create mode 100644 src/assets/platform-logos/amazon.webp create mode 100644 src/assets/platform-logos/douyin.webp create mode 100644 src/assets/platform-logos/ebay.webp create mode 100644 src/assets/platform-logos/instagram.webp create mode 100644 src/assets/platform-logos/jd.webp create mode 100644 src/assets/platform-logos/lazada.webp create mode 100644 src/assets/platform-logos/pinduoduo.webp create mode 100644 src/assets/platform-logos/shopee.webp create mode 100644 src/assets/platform-logos/taobao.webp create mode 100644 src/assets/platform-logos/tiktok-shop.webp create mode 100644 src/assets/platform-logos/tmall.webp create mode 100644 src/components/AdminMonitor.tsx create mode 100644 src/components/AnimatedPanel.tsx create mode 100644 src/components/AppShell.tsx create mode 100644 src/components/BeforeAfterCompare.tsx create mode 100644 src/components/BetaApplicationModal.tsx create mode 100644 src/components/CookieConsentBanner.tsx create mode 100644 src/components/DropZone.tsx create mode 100644 src/components/EmptyState.tsx create mode 100644 src/components/ErrorBoundary.tsx create mode 100644 src/components/NotFoundPage.tsx create mode 100644 src/components/NotificationCenter.tsx create mode 100644 src/components/OnboardingTour.tsx create mode 100644 src/components/OptimizedImage.tsx create mode 100644 src/components/PageTransition.tsx create mode 100644 src/components/RechargeModal/RechargeModal.tsx create mode 100644 src/components/RechargeModal/loadRechargeModal.ts create mode 100644 src/components/ShellIcon.tsx create mode 100644 src/components/Skeleton.tsx create mode 100644 src/components/StudioToolLayout.tsx create mode 100644 src/components/TaskStatusBar.tsx create mode 100644 src/components/WorkspacePageShell.tsx create mode 100644 src/components/toast/ToastContainer.tsx create mode 100644 src/components/toast/toastStore.ts create mode 100644 src/data/ossAssets.ts create mode 100644 src/data/workflows.ts create mode 100644 src/features/agent/AgentPage.tsx create mode 100644 src/features/assets/AssetsPage.tsx create mode 100644 src/features/assets/localAssetStore.ts create mode 100644 src/features/beta-applications/BetaApplicationsPage.tsx create mode 100644 src/features/canvas/CanvasMarkingPopover.tsx create mode 100644 src/features/canvas/CanvasPage.tsx create mode 100644 src/features/canvas/CanvasSmoothedProgressRing.tsx create mode 100644 src/features/canvas/CanvasTextPromptComposer.tsx create mode 100644 src/features/canvas/canvasAssetPersistence.ts create mode 100644 src/features/canvas/canvasCommunityPublish.ts create mode 100644 src/features/canvas/canvasComponents.tsx create mode 100644 src/features/canvas/canvasConstants.ts create mode 100644 src/features/canvas/canvasToolPanels.tsx create mode 100644 src/features/canvas/canvasTypes.ts create mode 100644 src/features/canvas/canvasUtils.ts create mode 100644 src/features/canvas/canvasWorkflowDeserialize.ts create mode 100644 src/features/canvas/canvasWorkflowExecution.ts create mode 100644 src/features/canvas/canvasWorkflowSchema.ts create mode 100644 src/features/canvas/useCanvasDerivedState.ts create mode 100644 src/features/canvas/useCanvasGeneration.ts create mode 100644 src/features/canvas/useCanvasHistory.ts create mode 100644 src/features/canvas/useCanvasKeyboard.ts create mode 100644 src/features/canvas/useCanvasNodeDrag.ts create mode 100644 src/features/character-mix/CharacterMixPage.tsx create mode 100644 src/features/community-review/CommunityCaseAddPage.tsx create mode 100644 src/features/community-review/CommunityReviewPage.tsx create mode 100644 src/features/community-review/communityPermissions.ts create mode 100644 src/features/community/CommunityPage.tsx create mode 100644 src/features/community/communityCaseUtils.ts create mode 100644 src/features/compliance/CompliancePage.tsx create mode 100644 src/features/dialog-generator/DialogGeneratorPage.tsx create mode 100644 src/features/digital-human/AvatarConsolePage.tsx create mode 100644 src/features/digital-human/DigitalHumanPage.tsx create mode 100644 src/features/digital-human/avatarEditorModel.ts create mode 100644 src/features/digital-human/avatarTrainingModel.ts create mode 100644 src/features/digital-human/voiceLibraryModel.ts create mode 100644 src/features/ecommerce/EcommercePage.tsx create mode 100644 src/features/ecommerce/EcommerceProgressBar.tsx create mode 100644 src/features/ecommerce/EcommerceTemplatesPage.tsx create mode 100644 src/features/ecommerce/EcommerceVideoWorkspace.tsx create mode 100644 src/features/ecommerce/ImageMentionMenu.tsx create mode 100644 src/features/ecommerce/ecommerceImageValidation.ts create mode 100644 src/features/ecommerce/ecommerceTemplates.ts create mode 100644 src/features/ecommerce/ecommerceVideoKeepalive.ts create mode 100644 src/features/ecommerce/ecommerceVideoService.ts create mode 100644 src/features/ecommerce/ecommerceVideoTypes.ts create mode 100644 src/features/ecommerce/panels/EcommerceClonePanel.tsx create mode 100644 src/features/ecommerce/panels/EcommerceDetailPanel.tsx create mode 100644 src/features/ecommerce/panels/EcommerceSetPanel.tsx create mode 100644 src/features/ecommerce/panels/EcommerceTryOnPanel.tsx create mode 100644 src/features/ecommerce/panels/EcommerceVideoHistoryPanel.tsx create mode 100644 src/features/home/HomePage.tsx create mode 100644 src/features/home/ModelGenerationShowcase.tsx create mode 100644 src/features/home/ScriptReviewShowcase.tsx create mode 100644 src/features/home/ScriptReviewVisual.tsx create mode 100644 src/features/home/ToolboxSection.tsx create mode 100644 src/features/home/WelcomeSplash.tsx create mode 100644 src/features/image-workbench/CameraViewport3D.tsx create mode 100644 src/features/image-workbench/ImageWorkbenchPage.tsx create mode 100644 src/features/image-workbench/useCanvasDrawing.ts create mode 100644 src/features/more/MorePage.tsx create mode 100644 src/features/profile/ProfilePage.tsx create mode 100644 src/features/provider-health/ProviderHealthPage.tsx create mode 100644 src/features/report/ReportPage.tsx create mode 100644 src/features/resolution-upscale/ResolutionUpscalePage.tsx create mode 100644 src/features/script-tokens/ScriptTokensPage.tsx create mode 100644 src/features/script-tokens/TokenUsagePage.tsx create mode 100644 src/features/settings/SettingsPage.tsx create mode 100644 src/features/size-template/SizeTemplatePage.tsx create mode 100644 src/features/subtitle-removal/SubtitleRemovalPage.tsx create mode 100644 src/features/watermark-removal/WatermarkRemovalPage.tsx create mode 100644 src/features/workbench/ConversationSidebar.tsx create mode 100644 src/features/workbench/ProjectSidebar.tsx create mode 100644 src/features/workbench/SmoothedProgressBar.tsx create mode 100644 src/features/workbench/WorkbenchPage.tsx create mode 100644 src/features/workbench/WorkbenchPromptPreview.tsx create mode 100644 src/features/workbench/WorkbenchSelectChips.tsx create mode 100644 src/features/workbench/components/WorkbenchChatCards.tsx create mode 100644 src/features/workbench/markdownRenderer.tsx create mode 100644 src/features/workbench/toolKeepalive.ts create mode 100644 src/features/workbench/toolResultActions.ts create mode 100644 src/features/workbench/workbenchChatTypes.ts create mode 100644 src/features/workbench/workbenchConstants.ts create mode 100644 src/features/workbench/workbenchDownload.ts create mode 100644 src/features/workbench/workbenchMentionUtils.tsx create mode 100644 src/features/workbench/workbenchReferenceUtils.ts create mode 100644 src/features/workbench/workbenchResultPersistence.ts create mode 100644 src/features/workbench/workbenchStorage.ts create mode 100644 src/hooks/useDebounce.ts create mode 100644 src/hooks/useGenerationStatus.ts create mode 100644 src/hooks/useGenerationTasks.ts create mode 100644 src/hooks/useScrollEntrance.ts create mode 100644 src/hooks/useSmoothedProgress.ts create mode 100644 src/main.tsx create mode 100644 src/services/backgroundTaskRunner.ts create mode 100644 src/stores/index.ts create mode 100644 src/stores/useAppStore.ts create mode 100644 src/stores/useGenerationStore.ts create mode 100644 src/stores/useProjectStore.ts create mode 100644 src/stores/useSessionStore.ts create mode 100644 src/stores/useTaskStore.ts create mode 100644 src/styles/app.css create mode 100644 src/styles/base/reset.css create mode 100644 src/styles/components/beta-application-modal.css create mode 100644 src/styles/components/dropzone.css create mode 100644 src/styles/components/empty-state.css create mode 100644 src/styles/components/legacy-components.css create mode 100644 src/styles/components/motion.css create mode 100644 src/styles/components/onboarding.css create mode 100644 src/styles/components/page-transition.css create mode 100644 src/styles/components/primitives.css create mode 100644 src/styles/components/recharge-modal.css create mode 100644 src/styles/components/skeleton.css create mode 100644 src/styles/components/toast.css create mode 100644 src/styles/ecommerce-standalone.css create mode 100644 src/styles/index.css create mode 100644 src/styles/loadDarkGreenTheme.ts create mode 100644 src/styles/pages.css create mode 100644 src/styles/pages/agent.css create mode 100644 src/styles/pages/assets.css create mode 100644 src/styles/pages/avatar-console.css create mode 100644 src/styles/pages/beta-applications.css create mode 100644 src/styles/pages/canvas.css create mode 100644 src/styles/pages/community.css create mode 100644 src/styles/pages/compliance.css create mode 100644 src/styles/pages/dialog-generator.css create mode 100644 src/styles/pages/ecommerce-video.css create mode 100644 src/styles/pages/ecommerce.css create mode 100644 src/styles/pages/home.css create mode 100644 src/styles/pages/image-workbench.css create mode 100644 src/styles/pages/legacy-pages.css create mode 100644 src/styles/pages/local-theme-parity.css create mode 100644 src/styles/pages/model-generation-showcase.css create mode 100644 src/styles/pages/more-tools.css create mode 100644 src/styles/pages/more.css create mode 100644 src/styles/pages/not-found.css create mode 100644 src/styles/pages/profile.css create mode 100644 src/styles/pages/provider-health.css create mode 100644 src/styles/pages/script-review-showcase.css create mode 100644 src/styles/pages/script-review-visual.css create mode 100644 src/styles/pages/script-tokens-v5.css create mode 100644 src/styles/pages/script-tokens.css create mode 100644 src/styles/pages/size-template.css create mode 100644 src/styles/pages/studio-layout.css create mode 100644 src/styles/pages/subtitle-removal.css create mode 100644 src/styles/pages/toolbox.css create mode 100644 src/styles/pages/welcome-splash.css create mode 100644 src/styles/pages/workbench.css create mode 100644 src/styles/shell/app-shell.css create mode 100644 src/styles/themes/dark-green.css create mode 100644 src/styles/tokens.css create mode 100644 src/types.ts create mode 100644 src/utils/enterpriseVideoPolicy.ts create mode 100644 src/utils/errorReporting.ts create mode 100644 src/utils/generationNotifier.ts create mode 100644 src/utils/happyHorseRouting.ts create mode 100644 src/utils/imageModelVisibility.ts create mode 100644 src/utils/mentionTrigger.ts create mode 100644 src/utils/modelOptions.ts create mode 100644 src/utils/ossImageOptimize.ts create mode 100644 src/utils/pixverseRouting.ts create mode 100644 src/utils/resolveVideoModel.ts create mode 100644 src/utils/taskLifecycle.ts create mode 100644 src/utils/toolPageUtils.ts create mode 100644 src/utils/translateTaskError.ts create mode 100644 src/utils/viduRouting.ts create mode 100644 src/vite-env.d.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4ad78fa --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Frontend environment variables are intentionally unsupported. +# +# API traffic must go through same-origin /api. +# Public runtime settings must come from application APIs. +# Provider keys and OSS credentials must stay on the server. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ddc64a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +dist/ +node_modules/ +*.env +*.env.local +*.env.*.local +.env.example +*.log +*.tmp +.DS_Store +Thumbs.db +.vscode/ +.idea/ +.claude/ +tmp/ +*.swp +*.swo +coverage/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b4f0e1b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,39 @@ +# Project Rules + +## Asset, Key, And Runtime Data Governance + +These rules are mandatory for all frontend, backend, deployment, and agent-generated changes. + +1. Image and media assets must be stored in OSS. + - Do not commit product images, demo images, generated images, videos, or other large media assets into `src/assets` or other source folders. + - Code may reference media only by OSS URL or by data returned from an API. + - Local assets are limited to tiny build-critical files such as icons or placeholders, and require explicit justification. + +2. Frontend code must not contain API keys or secrets. + - Do not hard-code provider keys, access keys, tokens, private endpoints, passwords, or bearer tokens in TypeScript, CSS, HTML, Vite config, Nginx snippets, or checked-in docs. + - Browser-delivered code must treat every visible value as public. + +3. Provider keys are owned by the server key pool. + - AI provider credentials are stored and managed server-side. + - The frontend requests work through application APIs; the server leases provider keys from the concurrency/key pool and calls providers on behalf of the client. + - Do not add direct browser-to-provider calls that require provider credentials. + +4. Application data must come through APIs. + - Do not hard-code product data, pricing, model availability, provider routing, account state, usage state, or operational configuration in the frontend. + - Use typed API clients and server-provided payloads for runtime data. + - Static constants are allowed only for presentation defaults that are not business-authoritative. + +5. Do not use fixed environment configuration in application code. + - Do not bake production hostnames, provider endpoints, keys, or environment-specific behavior into source code. + - Environment-specific values belong in server deployment configuration, secret management, or runtime configuration endpoints. + - Frontend code must not add fixed `VITE_*` or equivalent environment variables for API hosts, provider hosts, business data, or secrets. + - If the browser needs runtime configuration, it must request that data from an application API. + +6. Deployment configuration must follow the same rules. + - Nginx and process manager configs must not embed provider API keys or long-lived credentials. + - Reverse proxies should route application traffic to the backend, not expose third-party credentials. + - Secrets must be rotated immediately if found in source, Git remotes, shell history, Nginx config, process manager config, or logs. + +7. Reviews must reject violations. + - Any new local media file, hard-coded key, direct provider credential path, or fixed production config is a blocking issue. + - Prefer deleting local assets and replacing them with OSS URLs returned by APIs or server-managed config. diff --git a/index.html b/index.html new file mode 100644 index 0000000..bcaa1ac --- /dev/null +++ b/index.html @@ -0,0 +1,41 @@ + + + + + + + + + OmniAI 创作中心 — AI 图像 / 视频生成 · 剧本测评 · 电商素材一站式平台 + + + + + + + + + + + + + + + + + + + +
+
+
+
加载中...
+
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..90c5b9b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2756 @@ +{ + "name": "omniai-web-preview", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "omniai-web-preview", + "version": "0.1.0", + "dependencies": { + "@ant-design/icons": "5.3.0", + "@xyflow/react": "12.10.2", + "react": "18.2.0", + "react-dom": "18.2.0", + "zustand": "5.0.13" + }, + "devDependencies": { + "@types/react": "18.2.0", + "@types/react-dom": "18.2.0", + "@vitejs/plugin-react": "4.2.1", + "playwright": "1.60.0", + "sharp": "0.34.5", + "typescript": "5.3.3", + "vite": "5.1.0", + "vite-plugin-compression2": "2.5.3" + } + }, + "node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmmirror.com/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.3.0.tgz", + "integrity": "sha512-69FgBsIkeCjw72ZU3fJpqjhmLCPrzKGEllbrAZK7MUdt1BrKsyG6A8YDCBPKea27UQ0tRXi33PcjR4tp/tEXMg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.11.2", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.4.2", + "resolved": "https://registry.npmmirror.com/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", + "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==", + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", + "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", + "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", + "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", + "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", + "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", + "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", + "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", + "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", + "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", + "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", + "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", + "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", + "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", + "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", + "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", + "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", + "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", + "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", + "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", + "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", + "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", + "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", + "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmmirror.com/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmmirror.com/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmmirror.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.0.tgz", + "integrity": "sha512-0FLj93y5USLHdnhIhABk83rm8XEGA7kH3cr+YUlvxoUGp1xNt/DINUMvqPxLyOQMzLmZe8i4RTHbvb8MC7NmrA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-8yQrvS6sMpSwIovhPOwfyNf2Wz6v/B62LFSVYQ85+Rq3tLsBIG7rP5geMxaijTUxSkrO6RzN/IRuIAADYQsleA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.2.1.tgz", + "integrity": "sha512-oojO9IDc4nCUUi8qIR11KoQm0XFFLIwsRBwHRR4d/88IWghn1y6ckz/bJ8GHDCsYEJee8mDzqtJxh15/cisJNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.5", + "@babel/plugin-transform-react-jsx-self": "^7.23.3", + "@babel/plugin-transform-react-jsx-source": "^7.23.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.14.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0" + } + }, + "node_modules/@xyflow/react": { + "version": "12.10.2", + "resolved": "https://registry.npmmirror.com/@xyflow/react/-/react-12.10.2.tgz", + "integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.76", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.76", + "resolved": "https://registry.npmmirror.com/@xyflow/system/-/system-0.0.76.tgz", + "integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.29", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmmirror.com/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.353", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", + "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", + "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.19.12", + "@esbuild/android-arm": "0.19.12", + "@esbuild/android-arm64": "0.19.12", + "@esbuild/android-x64": "0.19.12", + "@esbuild/darwin-arm64": "0.19.12", + "@esbuild/darwin-x64": "0.19.12", + "@esbuild/freebsd-arm64": "0.19.12", + "@esbuild/freebsd-x64": "0.19.12", + "@esbuild/linux-arm": "0.19.12", + "@esbuild/linux-arm64": "0.19.12", + "@esbuild/linux-ia32": "0.19.12", + "@esbuild/linux-loong64": "0.19.12", + "@esbuild/linux-mips64el": "0.19.12", + "@esbuild/linux-ppc64": "0.19.12", + "@esbuild/linux-riscv64": "0.19.12", + "@esbuild/linux-s390x": "0.19.12", + "@esbuild/linux-x64": "0.19.12", + "@esbuild/netbsd-x64": "0.19.12", + "@esbuild/openbsd-x64": "0.19.12", + "@esbuild/sunos-x64": "0.19.12", + "@esbuild/win32-arm64": "0.19.12", + "@esbuild/win32-ia32": "0.19.12", + "@esbuild/win32-x64": "0.19.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.44", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rc-util": { + "version": "5.44.4", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz", + "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/react": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-mini": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/tar-mini/-/tar-mini-0.2.0.tgz", + "integrity": "sha512-+qfUHz700DWnRutdUsxRRVZ38G1Qr27OetwaMYTdg8hcPxf46U0S1Zf76dQMWRBmusOt2ZCK5kbIaiLkoGO7WQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.0.tgz", + "integrity": "sha512-STmSFzhY4ljuhz14bg9LkMTk3d98IO6DIArnTY6MeBwiD1Za2StcQtz7fzOUnRCqrHSD5+OS2reg4HOz1eoLnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.19.3", + "postcss": "^8.4.35", + "rollup": "^4.2.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-plugin-compression2": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/vite-plugin-compression2/-/vite-plugin-compression2-2.5.3.tgz", + "integrity": "sha512-ItPgqQWkcnBbVw7is9OKwiZ8v6+ju9rYROl5Lp6QfQDEx/d55AwJQb/KLpsQqsU9HoigYBsZ8tK6I02UwJNvEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0", + "tar-mini": "^0.2.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", + "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9cdec5e --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "omniai-web-preview", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "vite build", + "preview": "vite preview --host 127.0.0.1", + "type-check": "tsc -p tsconfig.json --noEmit", + "governance:check": "node scripts/check-governance.mjs", + "style:check": "node scripts/check-style-governance.mjs", + "smoke:generation:mocked": "node scripts/smoke-generation-mocked.mjs" + }, + "dependencies": { + "@ant-design/icons": "5.3.0", + "@xyflow/react": "12.10.2", + "react": "18.2.0", + "react-dom": "18.2.0", + "zustand": "5.0.13" + }, + "devDependencies": { + "@types/react": "18.2.0", + "@types/react-dom": "18.2.0", + "@vitejs/plugin-react": "4.2.1", + "playwright": "1.60.0", + "sharp": "0.34.5", + "typescript": "5.3.3", + "vite": "5.1.0", + "vite-plugin-compression2": "2.5.3" + } +} diff --git a/scripts/check-governance.mjs b/scripts/check-governance.mjs new file mode 100644 index 0000000..efd2246 --- /dev/null +++ b/scripts/check-governance.mjs @@ -0,0 +1,80 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const repoRoot = process.cwd(); +const mediaExtensions = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".mp4", ".mov", ".webm", ".avif"]); +const textExtensions = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".html", ".css", ".md", ".env", ".example"]); + +const scanRoots = ["src", "vite.config.ts", "index.html", "package.json", ".env.example"]; +const allowedFiles = new Set([ + normalizePath("src/data/ossAssets.ts"), + normalizePath("src/utils/ossImageOptimize.ts"), +]); + +const forbiddenPatterns = [ + { label: "frontend env config", pattern: /\b(?:import\.meta\.env|VITE_[A-Z0-9_]+)\b/ }, + { label: "direct provider proxy", pattern: /\/dashscope-api\b|dashscope\.aliyuncs\.com/i }, + { label: "third-party demo media host", pattern: /picsum\.photos|xiuxiu-pro(?:-new)?\.meitudata\.com|meitudata\.com/i }, + { label: "hard-coded provider secret marker", pattern: /Bearer\s+sk-|DASHSCOPE_API_KEY|ACCESS_KEY_SECRET|SECRET_ACCESS_KEY/i }, + { label: "local media import", pattern: /from\s+["'][^"']*\/assets\/[^"']*\.(?:png|jpe?g|webp|gif|mp4|mov|webm|avif|svg)["']/i }, +]; + +const failures = []; + +function normalizePath(value) { + return value.replace(/\\/g, "/"); +} + +function walk(targetPath, visitor) { + if (!fs.existsSync(targetPath)) return; + const stat = fs.statSync(targetPath); + if (stat.isDirectory()) { + for (const entry of fs.readdirSync(targetPath)) { + if (entry === "node_modules" || entry === "dist" || entry === ".git") continue; + walk(path.join(targetPath, entry), visitor); + } + return; + } + visitor(targetPath, stat); +} + +function report(file, message) { + failures.push(`${normalizePath(path.relative(repoRoot, file))}: ${message}`); +} + +walk(path.join(repoRoot, "src", "assets"), (file) => { + if (mediaExtensions.has(path.extname(file).toLowerCase())) { + report(file, "media files must live in OSS, not src/assets"); + } +}); + +for (const root of scanRoots) { + walk(path.join(repoRoot, root), (file) => { + const relative = normalizePath(path.relative(repoRoot, file)); + const ext = path.extname(file).toLowerCase(); + if (!textExtensions.has(ext) && !relative.endsWith(".env.example")) return; + if (relative.startsWith("src/assets/")) return; + + const content = fs.readFileSync(file, "utf8"); + const isAllowed = allowedFiles.has(relative); + for (const rule of forbiddenPatterns) { + if (isAllowed && (rule.label === "third-party demo media host" || rule.label === "hard-coded provider secret marker")) { + continue; + } + if (rule.pattern.test(content)) { + report(file, `forbidden ${rule.label}`); + } + } + }); +} + +if (failures.length) { + console.error("Governance check failed:"); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); +} + +console.log("Governance check passed."); diff --git a/scripts/check-style-governance.mjs b/scripts/check-style-governance.mjs new file mode 100644 index 0000000..3f5d301 --- /dev/null +++ b/scripts/check-style-governance.mjs @@ -0,0 +1 @@ +import "./check-governance.mjs"; diff --git a/scripts/dynamic-analysis-v2.mjs b/scripts/dynamic-analysis-v2.mjs new file mode 100644 index 0000000..5d43054 --- /dev/null +++ b/scripts/dynamic-analysis-v2.mjs @@ -0,0 +1,301 @@ +/** + * Dynamic analysis without Playwright - uses Node.js to analyze module structure, + * dependency graph, import patterns, and potential runtime costs. + */ +import { readFileSync, readdirSync, statSync } from 'fs'; +import { join, relative, basename } from 'path'; + +const SRC = join(import.meta.dirname, '..', 'src'); +const DIST = join(import.meta.dirname, '..', 'dist'); + +const results = []; +function walk(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory() && entry.name !== 'node_modules') walk(full); + else if (/\.(tsx?|jsx?)$/.test(entry.name)) { + const content = readFileSync(full, 'utf-8'); + results.push({ file: relative(join(SRC, '..'), full), content, lines: content.split('\n').length }); + } + } +} +walk(SRC); + +// ─── 1. Dependency Graph Analysis ─── +console.log('═══════════════════════════════════════════════'); +console.log(' 1. MODULE DEPENDENCY GRAPH ANALYSIS'); +console.log('═══════════════════════════════════════════════'); + +const importMap = new Map(); // file -> [imports] +const importedBy = new Map(); // file -> [importers] + +for (const r of results) { + const imports = []; + // Match import statements + const importRe = /import\s+(?:.*?\s+from\s+)?['"]([^'"]+)['"]/g; + let m; + while ((m = importRe.exec(r.content)) !== null) { + imports.push(m[1]); + } + // Match dynamic imports + const dynRe = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g; + while ((m = dynRe.exec(r.content)) !== null) { + imports.push(`[dynamic]${m[1]}`); + } + importMap.set(r.file, imports); +} + +// Find circular dependencies +console.log('\n--- Circular Dependency Detection ---'); +function findCircular(file, visited = new Set(), path = []) { + if (visited.has(file)) { + if (path.includes(file)) { + console.log(` [CIRCULAR] ${path.slice(path.indexOf(file)).join(' -> ')} -> ${file}`); + } + return; + } + visited.add(file); + path.push(file); + const deps = importMap.get(file) || []; + for (const dep of deps) { + if (dep.startsWith('.') || dep.startsWith('/')) { + // Resolve relative path + const dir = file.split('/').slice(0, -1).join('/'); + const resolved = dep.replace(/^\.\//, dir + '/').replace(/^\.\.\//, ''); + // Find matching file + for (const r of results) { + if (r.file.includes(resolved) || r.file.includes(basename(resolved))) { + findCircular(r.file, new Set(visited), [...path]); + } + } + } + } +} + +// Check high-fanin files (imported by many) +const fanIn = new Map(); +for (const [file, imports] of importMap) { + for (const imp of imports) { + const key = imp.replace(/\[dynamic\]/, ''); + fanIn.set(key, (fanIn.get(key) || 0) + 1); + } +} + +console.log('\n--- High Fan-In Modules (most imported) ---'); +const sortedFanIn = [...fanIn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15); +for (const [mod, count] of sortedFanIn) { + const bar = '█'.repeat(Math.min(30, count)); + console.log(` ${mod.padEnd(50)} ${String(count).padStart(3)}x ${bar}`); +} + +// Check high-fanout files (import many) +console.log('\n--- High Fan-Out Modules (import most) ---'); +const sortedFanOut = [...importMap.entries()] + .map(([f, imps]) => [f, imps.length]) + .sort((a, b) => b[1] - a[1]).slice(0, 15); +for (const [file, count] of sortedFanOut) { + const bar = '█'.repeat(Math.min(30, count)); + console.log(` ${file.padEnd(50)} ${String(count).padStart(3)} deps ${bar}`); +} + +// Dynamic imports analysis (lazy loading effectiveness) +console.log('\n--- Lazy Loading (Dynamic Imports) ---'); +let dynamicImports = 0, staticImports = 0; +for (const [file, imports] of importMap) { + for (const imp of imports) { + if (imp.startsWith('[dynamic]')) dynamicImports++; + else staticImports++; + } +} +console.log(` Static imports: ${staticImports}`); +console.log(` Dynamic imports: ${dynamicImports}`); +console.log(` Lazy load ratio: ${((dynamicImports / (staticImports + dynamicImports)) * 100).toFixed(1)}%`); + +// Find files that should be lazy loaded but aren't +const largePages = results.filter(r => r.lines > 500 && r.file.includes('Page')); +for (const r of largePages) { + const isLazyImported = [...importMap.values()].some(imps => + imps.some(i => i.startsWith('[dynamic]') && i.includes(basename(r.file, '.tsx'))) + ); + if (!isLazyImported && !r.file.includes('App')) { + // Check if it's referenced in App.tsx + const appContent = results.find(x => x.file === 'src/App.tsx')?.content || ''; + if (appContent.includes(basename(r.file, '.tsx'))) { + console.log(` [INFO] ${r.file} (${r.lines} lines) - loaded via App.tsx`); + } + } +} + +// ─── 2. React Rendering Cost Analysis ─── +console.log('\n═══════════════════════════════════════════════'); +console.log(' 2. REACT RENDERING COST ANALYSIS'); +console.log('═══════════════════════════════════════════════'); + +// Count useState/useReducer per component (state update triggers re-render) +console.log('\n--- State Hook Density ---'); +for (const r of results) { + if (!r.file.endsWith('.tsx')) continue; + const stateHooks = (r.content.match(/useState\s*[<(]/g) || []).length; + const reducers = (r.content.match(/useReducer\s*[<(]/g) || []).length; + const effects = (r.content.match(/useEffect\s*\(/g) || []).length; + const memos = (r.content.match(/useMemo\s*\(/g) || []).length; + const callbacks = (r.content.match(/useCallback\s*[<(]/g) || []).length; + const refs = (r.content.match(/useRef\s*[<(]/g) || []).length; + + const totalHooks = stateHooks + reducers + effects + memos + callbacks + refs; + if (totalHooks > 15) { + const risk = totalHooks > 30 ? '🔴 HIGH' : totalHooks > 20 ? '🟡 MEDIUM' : '🟢 LOW'; + console.log(` ${risk} ${r.file}`); + console.log(` useState:${stateHooks} useReducer:${reducers} useEffect:${effects} useMemo:${memos} useCallback:${callbacks} useRef:${refs} (total:${totalHooks})`); + + // Check if there are many state updates that could be batched + const setters = r.content.match(/set\w+\(/g) || []; + if (setters.length > 20) { + console.log(` ⚠️ ${setters.length} state setter calls — potential for excessive re-renders`); + } + + // Check for missing useMemo on expensive computations + const expensiveInRender = (r.content.match(/\.map\(|\.filter\(|\.reduce\(|\.sort\(/g) || []).length; + if (expensiveInRender > 5 && memos === 0) { + console.log(` ⚠️ ${expensiveInRender} array operations in render body with 0 useMemo`); + } + } +} + +// ─── 3. useEffect Dependency Analysis ─── +console.log('\n═══════════════════════════════════════════════'); +console.log(' 3. useEffect DEPENDENCY ANALYSIS'); +console.log('═══════════════════════════════════════════════'); + +for (const r of results) { + if (!r.file.endsWith('.tsx')) continue; + // Find useEffect with no dependency array (runs every render) + const noDeps = (r.content.match(/useEffect\s*\(\s*\(\)\s*=>\s*\{[\s\S]*?\}\s*\)/g) || []).length; + // Find useEffect with empty deps + const emptyDeps = (r.content.match(/useEffect\s*\(\s*\(\)\s*=>\s*\{[\s\S]*?\}\s*,\s*\[\s*\]\s*\)/g) || []).length; + + if (noDeps > 0) { + console.log(` [RENDER-COST] ${r.file}: ${noDeps} useEffect(s) run EVERY render`); + } +} + +// ─── 4. Zustand Store Analysis ─── +console.log('\n═══════════════════════════════════════════════'); +console.log(' 4. ZUSTAND STORE ANALYSIS'); +console.log('═══════════════════════════════════════════════'); + +const storeFiles = results.filter(r => r.file.includes('store') || r.file.includes('Store')); +for (const r of storeFiles) { + const stateFields = (r.content.match(/^\s+\w+:/gm) || []).length; + const actions = (r.content.match(/^\s+\w+\s*:\s*(\(|function|\w+\s*=>)/gm) || []).length; + const subscribers = (r.content.match(/subscribe\s*\(/g) || []).length; + + console.log(`\n ${r.file}`); + console.log(` State fields: ~${stateFields}`); + console.log(` Actions: ~${actions}`); + console.log(` Subscribers: ${subscribers}`); + + // Check for selector usage patterns + if (r.content.includes('set(') && !r.content.includes('useShallow')) { + console.log(` ⚠️ Uses set() without useShallow — may cause unnecessary re-renders`); + } +} + +// ─── 5. Bundle Composition Analysis ─── +console.log('\n═══════════════════════════════════════════════'); +console.log(' 5. BUNDLE COMPOSITION ANALYSIS'); +console.log('═══════════════════════════════════════════════'); + +if (readdirSync(DIST).includes('assets')) { + const assets = readdirSync(join(DIST, 'assets')); + const jsFiles = assets.filter(f => f.endsWith('.js') && !f.endsWith('.br')); + const cssFiles = assets.filter(f => f.endsWith('.css') && !f.endsWith('.br')); + + let totalJs = 0, totalCss = 0; + const chunks = []; + for (const f of jsFiles) { + const size = statSync(join(DIST, 'assets', f)).size; + totalJs += size; + chunks.push({ name: f, sizeKB: size / 1024, type: 'js' }); + } + for (const f of cssFiles) { + const size = statSync(join(DIST, 'assets', f)).size; + totalCss += size; + chunks.push({ name: f, sizeKB: size / 1024, type: 'css' }); + } + + chunks.sort((a, b) => b.sizeKB - a.sizeKB); + + console.log(`\n Total JS: ${(totalJs / 1024).toFixed(1)} KB (${(totalJs / 1024 / 1024).toFixed(2)} MB)`); + console.log(` Total CSS: ${(totalCss / 1024).toFixed(1)} KB (${(totalCss / 1024 / 1024).toFixed(2)} MB)`); + console.log(` Total: ${((totalJs + totalCss) / 1024).toFixed(1)} KB`); + + // CSS is suspiciously large + const cssRatio = totalCss / totalJs; + if (cssRatio > 0.5) { + console.log(`\n ⚠️ CSS/JS ratio: ${(cssRatio * 100).toFixed(0)}% — CSS bundle may contain unused styles`); + console.log(` Consider: PurgeCSS, CSS Modules, or extracting to separate loads`); + } + + // Check initial load budget + const initialChunks = chunks.filter(c => + c.name.includes('index') || c.name.includes('vendor-react') || c.name.includes('vendor-antd') + ); + const initialLoad = initialChunks.reduce((s, c) => s + c.sizeKB, 0); + console.log(`\n Critical path (initial load):`); + for (const c of initialChunks) { + console.log(` ${c.name.padEnd(45)} ${c.sizeKB.toFixed(1).padStart(8)} KB`); + } + console.log(` ${'TOTAL'.padEnd(45)} ${initialLoad.toFixed(1).padStart(8)} KB`); + + if (initialLoad > 300) { + console.log(`\n ⚠️ Initial load > 300KB — consider code splitting or tree-shaking`); + } +} + +// ─── 6. API Client Efficiency ─── +console.log('\n═══════════════════════════════════════════════'); +console.log(' 6. API CLIENT EFFICIENCY'); +console.log('═══════════════════════════════════════════════'); + +const apiFiles = results.filter(r => r.file.includes('src/api/')); +for (const r of apiFiles) { + const fetchCalls = (r.content.match(/fetch\s*\(/g) || []).length; + const retries = (r.content.match(/retry|Retry|RETRY/g) || []).length; + const abortSignals = (r.content.match(/AbortSignal|AbortController/g) || []).length; + const timeouts = (r.content.match(/timeout|Timeout|setTimeout/g) || []).length; + + if (fetchCalls > 0) { + console.log(`\n ${r.file}`); + console.log(` fetch calls: ${fetchCalls}`); + console.log(` retry logic: ${retries > 0 ? '✅' : '❌ none'}`); + console.log(` abort signals: ${abortSignals > 0 ? '✅' : '❌ none'}`); + console.log(` timeouts: ${timeouts}`); + } +} + +// ─── 7. TypeScript Compilation Metrics ─── +console.log('\n═══════════════════════════════════════════════'); +console.log(' 7. TYPE COMPLEXITY'); +console.log('═══════════════════════════════════════════════'); + +let totalTypes = 0, totalInterfaces = 0, totalEnums = 0, totalGenerics = 0; +for (const r of results) { + totalTypes += (r.content.match(/^export\s+type\s+/gm) || []).length; + totalTypes += (r.content.match(/^type\s+/gm) || []).length; + totalInterfaces += (r.content.match(/^export\s+interface\s+/gm) || []).length; + totalInterfaces += (r.content.match(/^interface\s+/gm) || []).length; + totalEnums += (r.content.match(/enum\s+\w+/g) || []).length; + totalGenerics += (r.content.match(/<\w+(?:\s*,\s*\w+)*>/g) || []).length; +} + +console.log(` Type aliases: ${totalTypes}`); +console.log(` Interfaces: ${totalInterfaces}`); +console.log(` Enums: ${totalEnums}`); +console.log(` Generic usages: ${totalGenerics}`); +console.log(` Total TS files: ${results.length}`); + +// ─── Final Summary ─── +console.log('\n═══════════════════════════════════════════════'); +console.log(' ANALYSIS COMPLETE'); +console.log('═══════════════════════════════════════════════'); diff --git a/scripts/dynamic-analysis.mjs b/scripts/dynamic-analysis.mjs new file mode 100644 index 0000000..ae8106e --- /dev/null +++ b/scripts/dynamic-analysis.mjs @@ -0,0 +1,305 @@ +/** + * Dynamic performance analysis using Playwright. + * Measures: page load, bundle sizes, memory, rendering, network. + */ +import { chromium } from 'playwright'; +import { readFileSync, readdirSync, statSync } from 'fs'; +import { join } from 'path'; + +const DIST = join(import.meta.dirname, '..', 'dist'); +const PORT = 4174; + +// ─── Bundle analysis from dist ─── +function analyzeBundles() { + const assets = readdirSync(join(DIST, 'assets')); + const jsFiles = assets.filter(f => f.endsWith('.js')); + const cssFiles = assets.filter(f => f.endsWith('.css')); + const brFiles = assets.filter(f => f.endsWith('.js.br')); + + let totalJsSize = 0, totalCssSize = 0, totalBrSize = 0; + const bundles = []; + + for (const f of jsFiles) { + const size = statSync(join(DIST, 'assets', f)).size; + totalJsSize += size; + bundles.push({ name: f, sizeKB: (size / 1024).toFixed(2), type: 'js' }); + } + for (const f of cssFiles) { + const size = statSync(join(DIST, 'assets', f)).size; + totalCssSize += size; + bundles.push({ name: f, sizeKB: (size / 1024).toFixed(2), type: 'css' }); + } + for (const f of brFiles) { + const size = statSync(join(DIST, 'assets', f)).size; + totalBrSize += size; + } + + bundles.sort((a, b) => parseFloat(b.sizeKB) - parseFloat(a.sizeKB)); + + console.log('\n═══════════════════════════════════════════════'); + console.log(' BUNDLE SIZE ANALYSIS'); + console.log('═══════════════════════════════════════════════'); + console.log(`\nTotal JS (raw): ${(totalJsSize / 1024).toFixed(1)} KB`); + console.log(`Total CSS (raw): ${(totalCssSize / 1024).toFixed(1)} KB`); + console.log(`Total JS (brotli): ${(totalBrSize / 1024).toFixed(1)} KB`); + console.log(`\nTop 15 bundles by raw size:`); + for (const b of bundles.slice(0, 15)) { + const bar = '█'.repeat(Math.min(40, Math.round(parseFloat(b.sizeKB) / 5))); + console.log(` ${b.name.padEnd(45)} ${String(b.sizeKB).padStart(8)} KB ${bar}`); + } + + // Identify oversized chunks + console.log('\n⚠️ Oversized chunks (>50KB raw):'); + for (const b of bundles.filter(b => parseFloat(b.sizeKB) > 50)) { + console.log(` [WARN] ${b.name} = ${b.sizeKB} KB`); + } + + return { totalJsSize, totalCssSize, totalBrSize, bundles }; +} + +// ─── Runtime performance with Playwright ─── +async function runtimeAnalysis() { + console.log('\n═══════════════════════════════════════════════'); + console.log(' RUNTIME PERFORMANCE ANALYSIS'); + console.log('═══════════════════════════════════════════════'); + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1920, height: 1080 } }); + const page = await context.newPage(); + + // Collect performance metrics + const perfEntries = []; + page.on('console', msg => { + if (msg.type() === 'info' && msg.text().startsWith('[PERF]')) { + perfEntries.push(msg.text()); + } + }); + + // Track network requests + const networkRequests = []; + page.on('request', req => { + networkRequests.push({ + url: req.url(), + method: req.method(), + startTime: Date.now() + }); + }); + + const networkResponses = []; + page.on('response', res => { + networkResponses.push({ + url: res.url(), + status: res.status(), + size: res.headers()['content-length'] || '0', + endTime: Date.now() + }); + }); + + // Measure page load + console.log('\n--- Page Load Performance ---'); + const navStart = Date.now(); + + try { + const response = await page.goto(`http://127.0.0.1:${PORT}/`, { + waitUntil: 'networkidle', + timeout: 30000 + }); + const loadTime = Date.now() - navStart; + console.log(` Initial page load: ${loadTime}ms`); + console.log(` HTTP status: ${response.status()}`); + + // Get navigation timing from the browser + const navTiming = await page.evaluate(() => { + const perf = performance.getEntriesByType('navigation')[0]; + if (!perf) return null; + return { + dns: Math.round(perf.domainLookupEnd - perf.domainLookupStart), + tcp: Math.round(perf.connectEnd - perf.connectStart), + ttfb: Math.round(perf.responseStart - perf.requestStart), + download: Math.round(perf.responseEnd - perf.responseStart), + domInteractive: Math.round(perf.domInteractive), + domComplete: Math.round(perf.domComplete), + loadEvent: Math.round(perf.loadEventEnd), + transferSize: perf.transferSize, + }; + }); + + if (navTiming) { + console.log(`\n Navigation Timing:`); + console.log(` DNS lookup: ${navTiming.dns}ms`); + console.log(` TCP connect: ${navTiming.tcp}ms`); + console.log(` TTFB: ${navTiming.ttfb}ms`); + console.log(` Download: ${navTiming.download}ms`); + console.log(` DOM interactive: ${navTiming.domInteractive}ms`); + console.log(` DOM complete: ${navTiming.domComplete}ms`); + console.log(` Load event end: ${navTiming.loadEvent}ms`); + console.log(` Transfer size: ${(navTiming.transferSize / 1024).toFixed(1)} KB`); + } + + // Resource timing + const resources = await page.evaluate(() => { + return performance.getEntriesByType('resource').map(r => ({ + name: r.name.split('/').pop(), + type: r.initiatorType, + duration: Math.round(r.duration), + size: r.transferSize, + })); + }); + + console.log(`\n--- Resource Loading ---`); + console.log(` Total resources: ${resources.length}`); + const totalTransfer = resources.reduce((s, r) => s + r.size, 0); + console.log(` Total transfer: ${(totalTransfer / 1024).toFixed(1)} KB`); + + const slowResources = resources.filter(r => r.duration > 100).sort((a, b) => b.duration - a.duration); + if (slowResources.length > 0) { + console.log(`\n Slow resources (>100ms):`); + for (const r of slowResources.slice(0, 10)) { + console.log(` [SLOW] ${r.name.padEnd(40)} ${r.duration}ms (${(r.size/1024).toFixed(1)}KB)`); + } + } + + // Memory analysis + console.log(`\n--- Memory Analysis ---`); + const memory = await page.evaluate(() => { + if (performance.memory) { + return { + usedJSHeap: performance.memory.usedJSHeapSize, + totalJSHeap: performance.memory.totalJSHeapSize, + heapLimit: performance.memory.jsHeapSizeLimit, + }; + } + return null; + }); + + if (memory) { + console.log(` Used JS heap: ${(memory.usedJSHeap / 1024 / 1024).toFixed(1)} MB`); + console.log(` Total JS heap: ${(memory.totalJSHeap / 1024 / 1024).toFixed(1)} MB`); + console.log(` Heap limit: ${(memory.heapLimit / 1024 / 1024).toFixed(1)} MB`); + console.log(` Heap utilization: ${((memory.usedJSHeap / memory.heapLimit) * 100).toFixed(1)}%`); + } else { + console.log(' Memory API not available (Chromium flag needed: --enable-precise-memory-info)'); + } + + // DOM complexity + console.log(`\n--- DOM Complexity ---`); + const domStats = await page.evaluate(() => { + const allElements = document.querySelectorAll('*'); + const tagCounts = {}; + let maxDepth = 0; + let totalNodes = allElements.length; + + allElements.forEach(el => { + const tag = el.tagName.toLowerCase(); + tagCounts[tag] = (tagCounts[tag] || 0) + 1; + let depth = 0, parent = el.parentElement; + while (parent) { depth++; parent = parent.parentElement; } + if (depth > maxDepth) maxDepth = depth; + }); + + return { totalNodes, maxDepth, tagCounts }; + }); + + console.log(` Total DOM nodes: ${domStats.totalNodes}`); + console.log(` Max DOM depth: ${domStats.maxDepth}`); + console.log(` Top 10 tags:`); + const sortedTags = Object.entries(domStats.tagCounts).sort((a, b) => b[1] - a[1]); + for (const [tag, count] of sortedTags.slice(0, 10)) { + console.log(` <${tag}>: ${count}`); + } + + // DOM warnings + if (domStats.totalNodes > 1500) { + console.log(` ⚠️ DOM nodes > 1500 — may cause sluggish rendering`); + } + if (domStats.maxDepth > 15) { + console.log(` ⚠️ DOM depth > 15 — may slow style/layout calculations`); + } + + // React-specific analysis: check for unnecessary re-renders + console.log(`\n--- Render Performance ---`); + const renderMetrics = await page.evaluate(() => { + // Check if React DevTools hook exists + const hasReact = !!window.__REACT_DEVTOOLS_GLOBAL_HOOK__; + return { + hasReact, + eventListeners: typeof getEventListeners !== 'undefined' ? 'available' : 'not-in-page-context', + }; + }); + console.log(` React DevTools: ${renderMetrics.hasReact ? 'detected' : 'not detected'}`); + + // Measure interaction performance - simulate scroll + console.log(`\n--- Interaction Performance ---`); + const scrollStart = Date.now(); + await page.evaluate(() => { + const container = document.querySelector('.app-shell-content') || document.documentElement; + for (let i = 0; i < 10; i++) { + container.scrollTop = i * 100; + } + container.scrollTop = 0; + }); + const scrollTime = Date.now() - scrollStart; + console.log(` 10x scroll ops: ${scrollTime}ms`); + + // Navigate to different routes to test lazy loading + const routes = ['#/', '#/canvas', '#/workbench', '#/ecommerce', '#/image-workbench', '#/home']; + console.log(`\n--- Route Navigation (Lazy Loading) ---`); + for (const route of routes) { + const routeStart = Date.now(); + await page.goto(`http://127.0.0.1:${PORT}/${route}`, { waitUntil: 'domcontentloaded', timeout: 15000 }); + const routeTime = Date.now() - routeStart; + console.log(` ${route.padEnd(30)} ${routeTime}ms`); + } + + // Memory after navigation + const memoryAfter = await page.evaluate(() => { + if (performance.memory) { + return { + usedJSHeap: performance.memory.usedJSHeapSize, + totalJSHeap: performance.memory.totalJSHeapSize, + }; + } + return null; + }); + + if (memoryAfter) { + console.log(`\n--- Memory After Route Navigation ---`); + console.log(` Used JS heap: ${(memoryAfter.usedJSHeap / 1024 / 1024).toFixed(1)} MB`); + console.log(` Total JS heap: ${(memoryAfter.totalJSHeap / 1024 / 1024).toFixed(1)} MB`); + if (memory) { + const delta = memoryAfter.usedJSHeap - memory.usedJSHeap; + console.log(` Heap delta: ${(delta > 0 ? '+' : '')}${(delta / 1024 / 1024).toFixed(1)} MB`); + } + } + + // Network summary + console.log(`\n--- Network Summary ---`); + console.log(` Total requests: ${networkResponses.length}`); + const totalNetworkSize = networkResponses.reduce((s, r) => s + parseInt(r.size || '0'), 0); + console.log(` Total response: ${(totalNetworkSize / 1024).toFixed(1)} KB`); + const failedRequests = networkResponses.filter(r => r.status >= 400); + if (failedRequests.length > 0) { + console.log(` Failed requests: ${failedRequests.length}`); + for (const r of failedRequests) { + console.log(` [${r.status}] ${r.url}`); + } + } + + } catch (err) { + console.log(`\n ❌ Error during runtime analysis: ${err.message}`); + } finally { + await browser.close(); + } +} + +// ─── Main ─── +console.log('╔═══════════════════════════════════════════════╗'); +console.log('║ OmniAI Web Preview - Performance Analysis ║'); +console.log('╚═══════════════════════════════════════════════╝'); + +const bundleResult = analyzeBundles(); +await runtimeAnalysis(); + +console.log('\n═══════════════════════════════════════════════'); +console.log(' ANALYSIS COMPLETE'); +console.log('═══════════════════════════════════════════════'); diff --git a/scripts/smoke-generation-mocked.mjs b/scripts/smoke-generation-mocked.mjs new file mode 100644 index 0000000..ddf1a75 --- /dev/null +++ b/scripts/smoke-generation-mocked.mjs @@ -0,0 +1,72 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const repoRoot = process.cwd(); +const failures = []; + +function read(relativePath) { + return fs.readFileSync(path.join(repoRoot, relativePath), "utf8"); +} + +function assertMatch(label, content, pattern) { + if (!pattern.test(content)) { + failures.push(label); + } +} + +function assertNoMatch(label, content, pattern) { + if (pattern.test(content)) { + failures.push(label); + } +} + +const serverConnection = read("src/api/serverConnection.ts"); +const generationClient = read("src/api/aiGenerationClient.ts"); +const ecommerceVideoService = read("src/features/ecommerce/ecommerceVideoService.ts"); +const workbenchPersistence = read("src/features/workbench/workbenchResultPersistence.ts"); + +assertMatch( + "serverConnection must build same-origin /api URLs", + serverConnection, + /return\s+`\/api\/\$\{cleanPath\}`;/, +); +assertNoMatch( + "frontend generation flow must not use fixed VITE environment config", + `${serverConnection}\n${generationClient}`, + /\b(?:import\.meta\.env|VITE_[A-Z0-9_]+)\b/, +); +assertNoMatch( + "frontend generation flow must not call provider hosts directly", + generationClient, + /dashscope\.aliyuncs\.com|\/dashscope-api\b|Bearer\s+sk-/i, +); +assertMatch("image generation must go through the app API", generationClient, /buildApiUrl\("ai\/image"\)/); +assertMatch("video generation must go through the app API", generationClient, /serverRequest<\{ taskId: string \}>\("ai\/video"/); +assertMatch("binary uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-binary"\)/); +assertMatch("URL uploads must go through the app OSS API", generationClient, /serverRequest<\{ url: string; signedUrl\?: string; ossKey\?: string \}>\("oss\/upload-by-url"/); +assertMatch( + "ecommerce video history must durable-copy media before saving", + ecommerceVideoService, + /buildDurableVideoHistoryPayload\(payload\)/, +); +assertMatch( + "ecommerce video history must filter temporary provider URLs on read", + ecommerceVideoService, + /items:\s*history\.items\.map\(removeTemporaryHistoryUrls\)/, +); +assertMatch( + "workbench results must persist generated media through OSS", + workbenchPersistence, + /uploadAssetByUrl\(/, +); + +if (failures.length) { + console.error("Mocked generation smoke check failed:"); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); +} + +console.log("Mocked generation smoke check passed."); diff --git a/scripts/static-analysis.mjs b/scripts/static-analysis.mjs new file mode 100644 index 0000000..afa32f9 --- /dev/null +++ b/scripts/static-analysis.mjs @@ -0,0 +1,148 @@ +import { readdirSync, readFileSync, statSync } from 'fs'; +import { join, relative } from 'path'; + +const SRC = join(import.meta.dirname, '..', 'src'); +const results = []; + +function walk(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory() && entry.name !== 'node_modules') walk(full); + else if (/\.(tsx?|jsx?)$/.test(entry.name)) { + const content = readFileSync(full, 'utf-8'); + const lines = content.split('\n').length; + results.push({ file: relative(join(SRC, '..'), full), lines, content }); + } + } +} +walk(SRC); +results.sort((a, b) => b.lines - a.lines); + +console.log('=== TOP 30 FILES BY LINE COUNT ==='); +for (const r of results.slice(0, 30)) { + console.log(`${String(r.lines).padStart(5)} ${r.file}`); +} + +// Detect nested loops (3+ levels) +console.log('\n=== NESTED LOOP DETECTION (3+ levels) ==='); +const loopPatterns = [ + /for\s*\(/g, /while\s*\(/g, /\.forEach\s*\(/g, /\.map\s*\(/g, + /\.filter\s*\(/g, /\.reduce\s*\(/g, /\.some\s*\(/g, /\.every\s*\(/g, + /\.flatMap\s*\(/g, /\.find\s*\(/g +]; + +for (const r of results) { + const lines = r.content.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + let loopCount = 0; + for (const p of loopPatterns) { + p.lastIndex = 0; + if (p.test(line)) loopCount++; + } + // Check surrounding context for nesting + if (loopCount > 0 || /for\s*\(/.test(line) || /\.map\(/.test(line) || /\.forEach\(/.test(line) || /\.filter\(/.test(line) || /\.reduce\(/.test(line)) { + // Count loop keywords on this single line + let singleLineLoops = 0; + for (const p of loopPatterns) { + p.lastIndex = 0; + const matches = line.match(new RegExp(p.source, 'g')); + if (matches) singleLineLoops += matches.length; + } + if (singleLineLoops >= 2) { + console.log(` [NESTED] ${r.file}:${i + 1} (${singleLineLoops} loops on one line)`); + console.log(` ${line.trim().substring(0, 120)}`); + } + } + } +} + +// Detect missing cleanup in useEffect +console.log('\n=== MEMORY LEAK RISK: useEffect without cleanup ==='); +for (const r of results) { + if (!r.file.endsWith('.tsx') && !r.file.endsWith('.ts')) continue; + const content = r.content; + // Find useEffect blocks that contain setInterval/setTimeout/addEventListener but no return + const useEffectRegex = /useEffect\s*\(\s*\(\)\s*=>\s*\{([\s\S]*?)\}\s*,/g; + let match; + while ((match = useEffectRegex.exec(content)) !== null) { + const body = match[1]; + const hasTimer = /setInterval|setTimeout/.test(body); + const hasListener = /addEventListener/.test(body); + const hasSubscribe = /\.subscribe\(/.test(body); + const hasCleanup = /return\s*\(\)\s*=>|return\s*function|return\s*\(\{/.test(body); + + if ((hasTimer || hasListener || hasSubscribe) && !hasCleanup) { + const lineNum = content.substring(0, match.index).split('\n').length; + console.log(` [RISK] ${r.file}:${lineNum}`); + if (hasTimer) console.log(` - Has setInterval/setTimeout without cleanup`); + if (hasListener) console.log(` - Has addEventListener without cleanup`); + if (hasSubscribe) console.log(` - Has subscribe without cleanup`); + console.log(` ${body.trim().substring(0, 200)}`); + console.log(''); + } + } +} + +// Detect objects/arrays/functions created in render body (not memoized) +console.log('\n=== REDUNDANT COMPUTATION: Non-memoized values in components ==='); +for (const r of results) { + if (!r.file.endsWith('.tsx')) continue; + const lines = r.content.split('\n'); + // Look for const x = [...], const x = {...}, const x = (...) => patterns outside useMemo + let inUseMemo = 0; + for (let i = 0; i < lines.length; i++) { + if (/useMemo\s*\(/.test(lines[i])) inUseMemo++; + if (inUseMemo > 0 && /\)/.test(lines[i])) { + // Rough heuristic - not perfect + } + if (inUseMemo === 0) { + // Expensive operations in render + if (/\.map\s*\(.*\.map\s*\(/.test(lines[i])) { + console.log(` [PERF] ${r.file}:${i + 1} - Chained .map calls in render`); + console.log(` ${lines[i].trim().substring(0, 120)}`); + } + if (/\.filter\s*\(.*\.map\s*\(/.test(lines[i]) || /\.map\s*\(.*\.filter\s*\(/.test(lines[i])) { + console.log(` [PERF] ${r.file}:${i + 1} - filter+map chain in render`); + console.log(` ${lines[i].trim().substring(0, 120)}`); + } + } + } +} + +// Detect deeply nested conditionals (4+ levels) +console.log('\n=== HIGH COMPLEXITY: Deep nesting ==='); +for (const r of results) { + const lines = r.content.split('\n'); + let maxIndent = 0; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line.trim() === '') continue; + const indent = line.match(/^(\s*)/)[1].length; + // Only flag if inside if/else/switch/ternary + if (indent >= 16 && /if\s*\(|else|switch\s*\(|case\s+/.test(line.trim())) { + console.log(` [DEEP] ${r.file}:${i + 1} (indent=${indent})`); + console.log(` ${line.trim().substring(0, 120)}`); + } + } +} + +// Detect inline style objects in JSX (recreated every render) +console.log('\n=== REDUNDANT: Inline style objects in JSX ==='); +for (const r of results) { + if (!r.file.endsWith('.tsx')) continue; + const lines = r.content.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (/style\s*=\s*\{\s*\{/.test(lines[i]) && !/useMemo/.test(lines[i])) { + console.log(` [INLINE] ${r.file}:${i + 1}`); + console.log(` ${lines[i].trim().substring(0, 120)}`); + } + } +} + +// Total stats +console.log('\n=== SUMMARY ==='); +console.log(`Total files: ${results.length}`); +console.log(`Total lines: ${results.reduce((s, r) => s + r.lines, 0)}`); +console.log(`Files > 500 lines: ${results.filter(r => r.lines > 500).length}`); +console.log(`Files > 1000 lines: ${results.filter(r => r.lines > 1000).length}`); diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..c1145a3 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,587 @@ +import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; +import { + BugOutlined, + CheckCircleFilled, + CloseOutlined, + HomeOutlined, + IdcardOutlined, + LockOutlined, + LoadingOutlined, + LoginOutlined, + LogoutOutlined, + MailOutlined, + MobileOutlined, + PictureOutlined, + SafetyOutlined, + UserOutlined, + VideoCameraOutlined, + WalletOutlined, +} from "@ant-design/icons"; +import ErrorBoundary from "./components/ErrorBoundary"; +import ToastContainer from "./components/toast/ToastContainer"; +import { toast } from "./components/toast/toastStore"; +import EcommercePage from "./features/ecommerce/EcommercePage"; +import { ossAssets } from "./data/ossAssets"; +import { keyServerClient } from "./api/keyServerClient"; +import { setUserMaxConcurrency } from "./api/generationConcurrency"; +import { + SERVER_SESSION_EXPIRED_EVENT, + SERVER_SESSION_REPLACED_EVENT, + clearAllUserStorage, + type ServerSessionReplacedDetail, +} from "./api/serverConnection"; +import { initNotificationPermission } from "./utils/generationNotifier"; +import { reportError } from "./utils/errorReporting"; +import { loadDarkGreenTheme } from "./styles/loadDarkGreenTheme"; +import { useAppStore, useSessionStore } from "./stores"; +import type { WebUserSession } from "./types"; +import "./styles/ecommerce-standalone.css"; + +type AuthMode = "login" | "register"; +type AuthMethod = "account" | "email" | "phone"; + +interface LocalProfilePageProps { + session: WebUserSession; + balance: number; + imageCount: number; + videoCount: number; + onBack: () => void; + onBugFeedback: () => void; + onLogout: () => void; +} + +const profileWorks = [ + { title: "主图套图生成", desc: "电商主图与场景图自动生成", image: ossAssets.ecommerce.templateCases[0], type: "图像", time: "6/9 18:13" }, + { title: "A+详情页设计", desc: "产品卖点与长图详情版式", image: ossAssets.ecommerce.templateCases[1], type: "图像", time: "6/9 10:11" }, + { title: "短视频广告", desc: "产品展示短视频脚本与画面", image: ossAssets.ecommerce.productSet.hosting, type: "视频", time: "6/9 10:05" }, + { title: "模特图生成", desc: "服饰商品真人上身展示", image: ossAssets.ecommerce.tryOn.tryA, type: "图像", time: "6/9 10:03" }, + { title: "商品场景图", desc: "按平台比例输出营销素材", image: ossAssets.ecommerce.detail.gridA, type: "图像", time: "6/9 10:01" }, + { title: "高度复刻", desc: "参考图结构复刻与商品替换", image: ossAssets.ecommerce.detail.gridB, type: "图像", time: "6/9 09:39" }, + { title: "详情模块", desc: "功能卖点、参数和包装模块", image: ossAssets.ecommerce.detail.gridC, type: "图像", time: "6/8 21:20" }, + { title: "广告视频", desc: "商品卖点短视频预览", image: ossAssets.ecommerce.tryOn.jacketResultA, type: "视频", time: "6/8 19:42" }, + { title: "平台素材", desc: "淘宝/天猫投放图批量生成", image: ossAssets.ecommerce.detail.gridD, type: "图像", time: "6/8 18:26" }, +]; + +function LocalAvatar({ session, size = "md" }: { session: WebUserSession; size?: "sm" | "md" | "lg" }) { + const displayName = session.user.displayName || session.user.username || "用户"; + const label = displayName.trim().slice(0, 1).toUpperCase() || "用"; + const avatarUrl = session.user.avatarUrl; + return ( + + {avatarUrl ? {displayName} : {label}} + + ); +} + +function LocalProfilePage({ session, balance, imageCount, videoCount, onBack, onBugFeedback, onLogout }: LocalProfilePageProps) { + const displayName = session.user.displayName || session.user.username || "用户"; + const workCount = Math.max(imageCount + videoCount, profileWorks.length); + const projectCount = Math.max(1, Math.round(workCount / 18)); + const assetCount = Math.max(1, Math.round(workCount / 20)); + + return ( +
+
+ +
+
+ + +
+
+ {["我的作品", "我的项目", "我的资产", "社区发布"].map((item, index) => ( + + ))} +
+ +
+
+
+ 代表作 + 最近完成的高质量生成内容 +
+ {workCount} 项 +
+
+ {profileWorks.map((work) => ( +
+ +
+ {work.type} + {work.title} +

{work.desc}

+ 已完成 · {work.time} +
+
+ ))} +
+
+
+
+
+ ); +} + +function App() { + const session = useSessionStore((s) => s.session); + const setSession = useSessionStore((s) => s.setSession); + const clearSessionState = useSessionStore((s) => s.clearSession); + const usage = useAppStore((s) => s.usage); + const setUsage = useAppStore((s) => s.setUsage); + + const [authOpen, setAuthOpen] = useState(false); + const [authMode, setAuthMode] = useState("login"); + const [authMethod, setAuthMethod] = useState("account"); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [betaCode, setBetaCode] = useState(""); + const [authSubmitting, setAuthSubmitting] = useState(false); + const [authError, setAuthError] = useState(null); + const [sessionNotice, setSessionNotice] = useState(null); + const [profileMenuOpen, setProfileMenuOpen] = useState(false); + const [currentPage, setCurrentPage] = useState<"workspace" | "profile">("workspace"); + + useEffect(() => { + void loadDarkGreenTheme(); + document.documentElement.dataset.theme = "dark"; + document.documentElement.dataset.uiTheme = "dark-green"; + document.documentElement.style.colorScheme = "dark"; + document.body.classList.add("ecommerce-standalone-body"); + return () => { + document.body.classList.remove("ecommerce-standalone-body"); + }; + }, []); + + useEffect(() => { + const splash = document.getElementById("app-boot-splash"); + if (splash) { + splash.style.opacity = "0"; + const timer = window.setTimeout(() => splash.remove(), 350); + return () => window.clearTimeout(timer); + } + }, []); + + useEffect(() => { + initNotificationPermission(); + }, []); + + useEffect(() => { + const handleUnhandled = (event: ErrorEvent) => { + reportError(event.error || new Error(event.message), "unhandled"); + }; + const handleRejection = (event: PromiseRejectionEvent) => { + reportError(event.reason instanceof Error ? event.reason : new Error(String(event.reason)), "rejection"); + }; + window.addEventListener("error", handleUnhandled); + window.addEventListener("unhandledrejection", handleRejection); + return () => { + window.removeEventListener("error", handleUnhandled); + window.removeEventListener("unhandledrejection", handleRejection); + }; + }, []); + + const refreshUsage = useCallback(async () => { + try { + setUsage(await keyServerClient.getUsageSummary()); + } catch { + // Usage is helpful but should not block the standalone generator. + } + }, [setUsage]); + + const completeAuth = useCallback( + async (nextSession: WebUserSession) => { + setSession(nextSession); + setUserMaxConcurrency(nextSession.user.maxConcurrency); + setAuthOpen(false); + setAuthError(null); + await refreshUsage(); + if (nextSession.user.email && !nextSession.user.emailVerified) { + toast.info("邮箱尚未验证,部分功能可能受限"); + } + }, + [refreshUsage, setSession], + ); + + const clearAuthenticatedState = useCallback(() => { + clearAllUserStorage(); + clearSessionState(); + setUserMaxConcurrency(null); + setUsage({ + balanceCents: 0, + imageUsed: 0, + videoUsed: 0, + textUsed: 0, + source: "preview", + }); + }, [clearSessionState, setUsage]); + + useEffect(() => { + let cancelled = false; + + const loadSession = async () => { + try { + const nextSession = await keyServerClient.getCurrentSession(); + if (cancelled) return; + setSession(nextSession); + setUserMaxConcurrency(nextSession?.user.maxConcurrency); + if (nextSession) await refreshUsage(); + } catch { + if (!cancelled) clearAuthenticatedState(); + } + }; + + void loadSession(); + return () => { + cancelled = true; + }; + }, [clearAuthenticatedState, refreshUsage, setSession]); + + useEffect(() => { + const handleSessionInvalid = (event: Event) => { + const detail = (event as CustomEvent).detail; + clearAuthenticatedState(); + setSessionNotice(detail?.message || "登录状态已失效,请重新登录。"); + setAuthOpen(true); + }; + + window.addEventListener(SERVER_SESSION_REPLACED_EVENT, handleSessionInvalid); + window.addEventListener(SERVER_SESSION_EXPIRED_EVENT, handleSessionInvalid); + return () => { + window.removeEventListener(SERVER_SESSION_REPLACED_EVENT, handleSessionInvalid); + window.removeEventListener(SERVER_SESSION_EXPIRED_EVENT, handleSessionInvalid); + }; + }, [clearAuthenticatedState]); + + const openAuth = useCallback((mode: AuthMode = "login") => { + setAuthMode(mode); + setAuthError(null); + setSessionNotice(null); + setAuthOpen(true); + }, []); + + const handleSubmitAuth = async () => { + if (!username.trim() || !password) { + setAuthError(authMethod === "email" ? "请输入邮箱和密码" : authMethod === "phone" ? "请输入手机号和验证码/密码" : "请输入用户名和密码"); + return; + } + setAuthSubmitting(true); + setAuthError(null); + try { + const nextSession = + authMode === "login" + ? await keyServerClient.login({ username, password }) + : await keyServerClient.register({ username, password, betaCode }); + await completeAuth(nextSession); + } catch (error) { + setAuthError(error instanceof Error ? error.message : "登录失败,请稍后重试。"); + } finally { + setAuthSubmitting(false); + } + }; + + const handleLogout = () => { + setProfileMenuOpen(false); + setCurrentPage("workspace"); + clearAuthenticatedState(); + toast.info("已退出登录"); + }; + + const balance = Math.max(usage.balanceCents, 0) / 100; + const displayName = session?.user.displayName || session?.user.username || "用户"; + const actualWorkCount = Math.max(usage.imageUsed + usage.videoUsed, 0); + const shownWorkCount = Math.max(actualWorkCount, profileWorks.length); + + const avatarMenuStats = useMemo( + () => [ + { icon: , label: "UID", value: session?.user.id ?? "-" }, + { icon: , label: "积分", value: `${balance.toFixed(2)} 积分` }, + { icon: , label: "图片", value: usage.imageUsed }, + { icon: , label: "视频", value: usage.videoUsed }, + { icon: , label: "作品", value: shownWorkCount }, + ], + [balance, session?.user.id, shownWorkCount, usage.imageUsed, usage.videoUsed], + ); + + const handleOpenProfile = () => { + setProfileMenuOpen(false); + setCurrentPage("profile"); + }; + + const handleOpenWorkspace = () => { + setProfileMenuOpen(false); + setCurrentPage("workspace"); + }; + + const handleBugFeedback = () => { + setProfileMenuOpen(false); + toast.info("Bug 反馈入口已保留,后续可接入反馈页面。"); + }; + + return ( +
+
+ +
+ {session ? ( +
+ + {(Math.max(usage.balanceCents, 0) / 100).toFixed(2)} 积分 + + + {profileMenuOpen ? ( + <> + + + +
+ + + ) : null} +
+ ) : ( + + )} +
+ + +
+ {currentPage === "profile" && session ? ( + + ) : ( + + +
+ 加载中... +
+ } + > + undefined} + onOpenProject={() => undefined} + onDeleteProject={() => undefined} + onImportWorkflow={() => undefined} + onCreateTask={() => undefined} + onRequireLogin={() => openAuth("login")} + initialTemplate={null} + onInitialTemplateConsumed={() => undefined} + /> +
+
+ )} +
+ + {authOpen ? ( +
+ + +

{authMode === "login" ? "欢迎回来" : "创建账号"}

+

{authMode === "login" ? "登录后继续你的 AI 创作之旅" : "注册即可免费体验全部功能"}

+ {sessionNotice ?

{sessionNotice}

: null} + +
+ + +
+ +
+ + + +
+ + {authMode === "register" ? ( + + ) : null} + + {authMethod === "phone" ? ( + <> + + + + ) : ( + <> + + + + )} + + {authError ?

{authError}

: null} + {authMode === "login" && authMethod !== "phone" ? : null} + +

{authMode === "login" ? "登录" : "注册"}即表示同意 《用户协议》《隐私政策》

+
其他方式
+ + +
+ ) : null} + + + + ); +} + +export default App; diff --git a/src/api/adVideoPlanClient.ts b/src/api/adVideoPlanClient.ts new file mode 100644 index 0000000..f767d95 --- /dev/null +++ b/src/api/adVideoPlanClient.ts @@ -0,0 +1,355 @@ +import { buildApiUrl, buildAuthHeaders } from "./serverConnection"; + +const TEXT_MODELS = ["qwen-max", "qwen-plus", "qwen-turbo"]; +const VISION_MODELS = ["qwen3.7-plus", "qwen-vl-plus", "qwen-vl-max"]; + +export interface AdVideoUserConfig { + platform: string; + aspectRatio: string; + durationSeconds: number; + style: string; + language: string; + market: string; + needVoiceover: boolean; + needSubtitle: boolean; + conversionFocus: "conversion" | "brand"; +} + +export interface ProductSummary { + product_name: string; + category: string; + appearance: string; + materials: string[]; + colors: string[]; + core_features: string[]; + target_users: string[]; + usage_scenarios: string[]; + selling_points: string[]; + risk_notes: string[]; +} + +export interface SellingPoint { + point: string; + evidence: string; + ad_expression: string; +} + +export interface SellingPointResult { + primary_selling_points: SellingPoint[]; + secondary_selling_points: SellingPoint[]; + unsupported_claims: string[]; + compliance_warnings: string[]; +} + +export interface CreativeOption { + creative_id: string; + creative_type: string; + hook: string; + target_user: string; + main_message: string; + emotional_tone: string; + recommended_platform: string; + reason: string; +} + +export interface StoryboardScene { + scene_id: number; + duration: string; + scene_goal: string; + visual_description: string; + product_focus: string; + camera_movement: string; + background: string; + lighting: string; + subtitle: string; + voiceover: string; + transition: string; +} + +export interface Storyboard { + video_title: string; + duration: string; + aspect_ratio: string; + target_platform: string; + language: string; + scenes: StoryboardScene[]; +} + +export interface VideoPrompt { + scene_id: number; + positive_prompt: string; + negative_prompt: string; + reference_requirements: string; + consistency_rules: string; + text_overlay: string; +} + +export interface ComplianceCheck { + risk_level: "low" | "medium" | "high"; + issues: Array<{ field: string; problem: string; suggestion: string }>; + allow_video_generation: boolean; +} + +function extractJson(text: string): unknown { + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/); + const raw = fenced ? fenced[1].trim() : text.trim(); + const start = raw.search(/[[{]/); + const slice = start >= 0 ? raw.slice(start) : raw; + try { + return JSON.parse(slice); + } catch { + throw new Error("AI 返回内容不是有效的 JSON"); + } +} + +interface ChatMessage { + role: "system" | "user"; + content: string; +} + +const MAX_RETRIES = 3; +const RETRY_BASE_MS = 2000; +const CHAT_TIMEOUT_MS = 180_000; // 3 minutes per AI call (server times out at 120s + network slack) + +// 5xx, 429, network failures, timeouts, and AbortError-from-timeout are all retryable +function isTransientError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + const msg = err.message.toLowerCase(); + if (/\b(429|500|502|503|504|520|521|522|524)\b/.test(msg)) return true; + if (msg.includes("signal timed out") || msg.includes("timeout")) return true; + if (msg.includes("failed to fetch") || msg.includes("networkerror") || msg.includes("network error")) return true; + if (msg.includes("ai 调用失败") || msg.includes("图片理解调用失败")) return true; // generic upstream failures + return false; +} + +async function retryOnTransient(fn: () => Promise, signal?: AbortSignal): Promise { + let lastErr: unknown; + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + return await fn(); + } catch (err) { + lastErr = err; + if (signal?.aborted) throw err; + // External AbortError caused by our timeoutSignal — retryable + if (err instanceof Error && err.name === "AbortError" && !signal?.aborted) { + if (attempt === MAX_RETRIES) throw err; + const delay = RETRY_BASE_MS * 2 ** attempt + Math.random() * 1000; + await new Promise((r) => setTimeout(r, delay)); + continue; + } + if (attempt === MAX_RETRIES) throw err; + if (!isTransientError(err)) throw err; + const delay = RETRY_BASE_MS * 2 ** attempt + Math.random() * 1000; + await new Promise((r) => setTimeout(r, delay)); + } + } + throw lastErr instanceof Error ? lastErr : new Error("AI 调用失败:已重试多次"); +} + +async function chat( + systemPrompt: string, + userContent: string, + options?: { model?: string; signal?: AbortSignal }, +): Promise { + const candidateModels = options?.model ? [options.model] : TEXT_MODELS; + let lastError: Error | null = null; + + for (const model of candidateModels) { + try { + return await retryOnTransient(async () => { + const messages: ChatMessage[] = [ + { role: "system", content: systemPrompt }, + { role: "user", content: userContent }, + ]; + const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS); + const combinedSignal = options?.signal + ? AbortSignal.any([options.signal, timeoutSignal]) + : timeoutSignal; + const res = await fetch(buildApiUrl("ai/chat"), { + method: "POST", + headers: buildAuthHeaders(), + body: JSON.stringify({ model, messages, stream: false, temperature: 0.4 }), + signal: combinedSignal, + }); + if (!res.ok) { + const errBody = await res.text().catch(() => ""); + throw new Error(`AI 调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`); + } + const payload = await res.json(); + const content: string = + payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; + if (!content) throw new Error("模型未返回有效内容"); + return content; + }, options?.signal); + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + if (options?.signal?.aborted) throw lastError; + // If user pinned a specific model, don't fall back to others + if (options?.model) throw lastError; + // Try next model in fallback chain + } + } + throw lastError ?? new Error("所有候选模型均不可用"); +} + +async function visionChat( + systemPrompt: string, + text: string, + imageUrls: string[], + signal?: AbortSignal, +): Promise { + const content = [ + ...imageUrls.map((url) => ({ type: "image_url", image_url: { url } })), + { type: "text", text }, + ]; + const messages = [ + { role: "system", content: systemPrompt }, + { role: "user", content }, + ]; + + let lastError: Error | null = null; + for (const model of VISION_MODELS) { + const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS); + const combinedSignal = signal + ? AbortSignal.any([signal, timeoutSignal]) + : timeoutSignal; + try { + const out = await retryOnTransient(async () => { + const res = await fetch(buildApiUrl("ai/chat"), { + method: "POST", + headers: buildAuthHeaders(), + body: JSON.stringify({ model, messages, stream: false, temperature: 0.3 }), + signal: combinedSignal, + }); + if (!res.ok) { + const errBody = await res.text().catch(() => ""); + if (errBody.includes("image format")) throw new Error("IMAGE_FORMAT_FALLBACK"); + throw new Error(`图片理解调用失败 (${res.status})${errBody ? `: ${errBody.slice(0, 120)}` : ""}`); + } + const payload = await res.json(); + const result: string = + payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; + if (!result) throw new Error("图片理解未返回有效内容"); + return result; + }, signal); + return out; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + if (signal?.aborted) throw lastError; + // Continue trying next vision model on transient failures, image format errors, or upstream errors + if (lastError.message === "IMAGE_FORMAT_FALLBACK") continue; + if (lastError.message.includes("图片理解调用失败")) continue; + if (isTransientError(lastError)) continue; + throw lastError; + } + } + throw lastError ?? new Error("图片理解调用失败,所有模型均不可用"); +} + +const IMAGE_UNDERSTANDING_PROMPT = `你是电商产品图片分析专家。请分析用户提供的产品图片,识别产品主体、外观、颜色、材质、形状、尺寸感、品牌标识、关键部件、可视化卖点和适合展示的镜头角度。请用简洁的中文段落描述,不要编造图片中看不到的信息。`; + +export async function analyzeProductImages( + imageUrls: string[], + signal?: AbortSignal, +): Promise { + if (imageUrls.length === 0) return ""; + return visionChat(IMAGE_UNDERSTANDING_PROMPT, "请分析这些产品图片的视觉特征。", imageUrls, signal); +} + +const PRODUCT_SUMMARY_PROMPT = `你是商品信息理解专家。根据产品图片理解结果和说明书文本,输出结构化的商品信息。严格按以下 JSON 格式返回,不要任何额外解释: +{"product_name":"","category":"","appearance":"","materials":[],"colors":[],"core_features":[],"target_users":[],"usage_scenarios":[],"selling_points":[],"risk_notes":[]} +要求:只描述资料中真实存在的信息,不要编造说明书或图片中不存在的功能。risk_notes 列出可能涉及夸大、医疗功效、绝对化用语等风险点。`; + +export async function buildProductSummary( + imageDescription: string, + manualText: string, + signal?: AbortSignal, +): Promise { + const userContent = `【产品图片理解结果】\n${imageDescription || "(无图片)"}\n\n【产品说明书/详情文本】\n${manualText || "(无文本)"}`; + const text = await chat(PRODUCT_SUMMARY_PROMPT, userContent, { signal }); + return extractJson(text) as ProductSummary; +} + +const SELLING_POINT_PROMPT = `你是电商卖点提炼专家。将商品信息拆分为不同层级卖点。严格按以下 JSON 格式返回,不要任何额外解释: +{"primary_selling_points":[{"point":"","evidence":"","ad_expression":""}],"secondary_selling_points":[{"point":"","evidence":"","ad_expression":""}],"unsupported_claims":[],"compliance_warnings":[]} +要求:每个卖点必须有来源依据(evidence),依据来自输入的商品信息。不得凭空增加功能。无依据的卖点放入 unsupported_claims。涉及夸大、医疗、绝对化用语的放入 compliance_warnings。`; + +export async function extractSellingPoints( + summary: ProductSummary, + signal?: AbortSignal, +): Promise { + const text = await chat(SELLING_POINT_PROMPT, `【商品结构化信息】\n${JSON.stringify(summary, null, 2)}`, { signal }); + return extractJson(text) as SellingPointResult; +} + +function configBlock(config: AdVideoUserConfig): string { + return `【用户配置】\n目标平台:${config.platform}\n视频比例:${config.aspectRatio}\n时长:${config.durationSeconds}秒\n广告风格:${config.style}\n语言:${config.language}\n目标市场:${config.market}\n旁白:${config.needVoiceover ? "需要" : "不需要"}\n字幕:${config.needSubtitle ? "需要" : "不需要"}\n侧重:${config.conversionFocus === "conversion" ? "强转化" : "品牌展示"}`; +} + +const CREATIVE_PROMPT = `你是电商广告创意专家。根据商品卖点和用户配置,生成至少 3 个差异化的广告创意方向。严格按以下 JSON 格式返回,不要任何额外解释: +{"creative_options":[{"creative_id":"A","creative_type":"","hook":"","target_user":"","main_message":"","emotional_tone":"","recommended_platform":"","reason":""}]} +要求:每个方向围绕真实卖点,有清晰广告逻辑,方向之间有明显差异。`; + +export async function generateCreativeOptions( + selling: SellingPointResult, + config: AdVideoUserConfig, + signal?: AbortSignal, +): Promise { + const userContent = `【卖点】\n${JSON.stringify(selling.primary_selling_points, null, 2)}\n\n${configBlock(config)}`; + const text = await chat(CREATIVE_PROMPT, userContent, { signal }); + const parsed = extractJson(text) as { creative_options?: CreativeOption[] }; + return Array.isArray(parsed.creative_options) ? parsed.creative_options : []; +} + +const STORYBOARD_PROMPT = `你是电商短视频分镜师。根据选定的广告创意方向、商品信息和用户配置,输出结构化视频分镜。严格按以下 JSON 格式返回,不要任何额外解释: +{"video_title":"","duration":"","aspect_ratio":"","target_platform":"","language":"","scenes":[{"scene_id":1,"duration":"3s","scene_goal":"","visual_description":"","product_focus":"","camera_movement":"","background":"","lighting":"","subtitle":"","voiceover":"","transition":""}]} +要求:开头3秒有吸引点,中段展示核心卖点,结尾有行动号召。各镜头时长之和等于配置总时长。不要出现说明书中不存在的功能,不要设计视频模型难以稳定生成的复杂动作。`; + +export async function generateStoryboard( + creative: CreativeOption, + summary: ProductSummary, + config: AdVideoUserConfig, + signal?: AbortSignal, +): Promise { + const userContent = `【选定创意方向】\n${JSON.stringify(creative, null, 2)}\n\n【商品信息】\n${JSON.stringify(summary, null, 2)}\n\n${configBlock(config)}`; + const text = await chat(STORYBOARD_PROMPT, userContent, { signal }); + return extractJson(text) as Storyboard; +} + +const VIDEO_PROMPT_PROMPT = `你是 AI 视频模型提示词工程师。为每个分镜生成视频模型提示词。严格按以下 JSON 格式返回一个数组,不要任何额外解释: +[{"scene_id":1,"positive_prompt":"","negative_prompt":"","reference_requirements":"","consistency_rules":"","text_overlay":""}] +正向提示词需包含:产品主体、外观、颜色、材质、使用场景、镜头构图、镜头运动、光线风格、背景环境、广告质感、画面节奏。 +负面提示词需包含:不改变产品外观/颜色、不添加不存在的部件、不生成错误Logo、不生成模糊文字、不生成虚假功能演示、不生成畸形手部、不生成夸张功效、不生成医学暗示。 +字幕和文字建议后期叠加(text_overlay),不要让视频模型直接生成文字。`; + +export async function generateVideoPrompts( + storyboard: Storyboard, + summary: ProductSummary, + signal?: AbortSignal, +): Promise { + const userContent = `【分镜脚本】\n${JSON.stringify(storyboard.scenes, null, 2)}\n\n【产品外观特征(一致性参考)】\n外观:${summary.appearance}\n颜色:${summary.colors.join("、")}\n材质:${summary.materials.join("、")}`; + const text = await chat(VIDEO_PROMPT_PROMPT, userContent, { signal }); + const parsed = extractJson(text); + return Array.isArray(parsed) ? (parsed as VideoPrompt[]) : []; +} + +const COMPLIANCE_PROMPT = `你是电商广告合规质检专家。检查文案和卖点是否存在虚假宣传、绝对化用语(如"最""第一""100%")、医疗功效暗示、高风险品类违规表达。严格按以下 JSON 格式返回,不要任何额外解释: +{"risk_level":"low","issues":[{"field":"","problem":"","suggestion":""}],"allow_video_generation":true} +risk_level 取值 low/medium/high。存在高风险违规时 allow_video_generation 设为 false。`; + +export async function checkCompliance( + summary: ProductSummary, + selling: SellingPointResult, + storyboard: Storyboard, + signal?: AbortSignal, +): Promise { + const userContent = `【卖点】\n${JSON.stringify(selling, null, 2)}\n\n【分镜文案/旁白/字幕】\n${JSON.stringify(storyboard.scenes.map((s) => ({ subtitle: s.subtitle, voiceover: s.voiceover })), null, 2)}\n\n【风险点】\n${summary.risk_notes.join("、")}`; + const text = await chat(COMPLIANCE_PROMPT, userContent, { signal }); + return extractJson(text) as ComplianceCheck; +} + + + + + + diff --git a/src/api/aiGenerationClient.ts b/src/api/aiGenerationClient.ts new file mode 100644 index 0000000..5e78c26 --- /dev/null +++ b/src/api/aiGenerationClient.ts @@ -0,0 +1,559 @@ +import { + buildApiUrl, + buildAuthHeaders, + isRecord, + readJsonResponse, + serverRequest, + throwResponseError, +} from "./serverConnection"; +import { isOptionalApiRouteMissing } from "./apiErrorUtils"; +import type { WebGenerationPreviewTask } from "../types"; + +export interface ImageGenInput { + projectId?: string; + conversationId?: number; + model: string; + prompt: string; + ratio?: string; + quality?: string; + gridMode?: string; + referenceUrls?: string[]; +} + +export interface ImageProviderDebug { + requestedModel?: string; + effectiveModel?: string; + primaryProvider?: string; + fallbackProviders?: string[]; + route?: string[]; + candidates?: Array<{ + provider?: string; + transport?: string; + model?: string; + requestedModel?: string; + billingProvider?: string; + fallbackOf?: string; + }>; +} + +export interface ImageTaskCreateResponse { + taskId: string; + providerDebug?: ImageProviderDebug; +} + +type ImageRouteDebugEntry = Record & { + at: string; + label: string; +}; + +export interface VideoGenInput { + projectId?: string; + conversationId?: number; + model: string; + prompt: string; + ratio?: string; + duration?: number; + quality?: string; + resolution?: string; + frameMode?: string; + referenceUrls?: string[]; + imageUrl?: string; + audioUrl?: string; + muted?: boolean; + hasReferenceVideo?: boolean; + style?: "speech" | "sing" | "performance" | string; +} + +export interface VideoEditInput { + projectId?: string; + conversationId?: number; + videoUrl: string; + referenceUrls: string[]; + prompt?: string; + model?: string; + ratio?: string; + resolution?: string; +} + +export interface VideoSuperResolveInput { + projectId?: string; + conversationId?: number; + videoUrl: string; + bitRate?: number; + provider?: string; + style?: number; + videoFps?: number; + minLen?: 540 | 720; + useSR?: boolean; + animateEmotion?: boolean; +} + +export interface EraseSubtitlesInput { + videoUrl: string; + bx?: number; + by?: number; + bw?: number; + bh?: number; +} + +export interface ImageEditInput { + imageUrl: string; + function: string; + prompt?: string; + n?: number; +} + +export interface ImageSuperResolveInput { + projectId?: string; + conversationId?: number; + imageUrl: string; + scale?: "2x" | "4x" | number; +} + +export interface UploadAssetInput { + dataUrl: string; + name?: string; + mimeType?: string; + scope?: "profile-avatar" | "profile-background" | string; +} + +export interface UploadAssetByUrlInput { + sourceUrl: string; + name?: string; + mimeType?: string; + scope?: string; +} + +export type ChatMessageContent = + | string + | Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }>; + +export interface ChatInput { + model: string; + messages: Array<{ role: string; content: ChatMessageContent }>; + stream?: boolean; + temperature?: number; +} + +export interface ChatUsage { + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; +} + +export interface AiTaskStatus { + taskId: string; + projectId?: string; + conversationId?: number | null; + clientQueueId?: string | null; + type: "image" | "video"; + status: "pending" | "running" | "completed" | "failed" | "cancelled"; + progress: number; + resultUrl: string | null; + error: string | null; + params?: Record; + createdAt: string; + updatedAt: string; + completedAt?: string | null; +} + +function normalizeTaskStatus(status: AiTaskStatus["status"]): WebGenerationPreviewTask["status"] { + if (status === "running" || status === "completed" || status === "failed") return status; + if (status === "cancelled") return "failed"; + return "queued"; +} + +function taskTitle(task: AiTaskStatus): string { + const prompt = typeof task.params?.prompt === "string" ? task.params.prompt.trim() : ""; + if (prompt) return prompt.length > 20 ? `${prompt.slice(0, 20)}...` : prompt; + return task.type === "video" ? "\u89c6\u9891\u751f\u6210\u4efb\u52a1" : "\u56fe\u50cf\u751f\u6210\u4efb\u52a1"; +} + +function toPreviewTask(task: AiTaskStatus): WebGenerationPreviewTask { + return { + id: task.taskId, + title: taskTitle(task), + type: task.type, + status: normalizeTaskStatus(task.status), + progress: Math.max(0, Math.min(100, Math.trunc(task.progress || 0))), + prompt: typeof task.params?.prompt === "string" ? task.params.prompt : taskTitle(task), + createdAt: task.createdAt, + projectId: task.projectId || undefined, + outputUrl: task.resultUrl || undefined, + source: "server", + errorMessage: task.error || undefined, + }; +} + +function parseContentDispositionFilename(value: string | null): string | undefined { + if (!value) return undefined; + const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/i); + if (utf8Match?.[1]) { + try { + return decodeURIComponent(utf8Match[1].trim()); + } catch { + return utf8Match[1].trim(); + } + } + const plainMatch = value.match(/filename="?([^";]+)"?/i); + return plainMatch?.[1]?.trim() || undefined; +} + +function extractTaskList(payload: unknown): AiTaskStatus[] { + if (Array.isArray(payload)) return payload as AiTaskStatus[]; + if (!isRecord(payload)) return []; + const rows = payload.tasks ?? payload.items; + return Array.isArray(rows) ? (rows as AiTaskStatus[]) : []; +} + +function getStoredSessionRole(): string { + try { + if (typeof window === "undefined") return ""; + const raw = window.localStorage.getItem("omniai-web-session"); + if (!raw) return ""; + const session = JSON.parse(raw); + return String(session?.user?.role || "").trim().toLowerCase(); + } catch { + return ""; + } +} + +function emitImageRouteDebug(label: string, payload: Record): void { + // Only emit console logs for admin users — hides enterprise routing details + if (getStoredSessionRole() === "admin") { + const entry: ImageRouteDebugEntry = { + at: new Date().toISOString(), + label, + ...payload, + }; + try { + console.log(`${label} ${JSON.stringify(entry)}`); + } catch { + console.log(label, entry); + } + } + + if (typeof window === "undefined") return; + const debugWindow = window as Window & { __OMNIAI_IMAGE_ROUTE_DEBUG__?: ImageRouteDebugEntry[] }; + const previousEntries = Array.isArray(debugWindow.__OMNIAI_IMAGE_ROUTE_DEBUG__) + ? debugWindow.__OMNIAI_IMAGE_ROUTE_DEBUG__ + : []; + const entry: ImageRouteDebugEntry = { at: new Date().toISOString(), label, ...payload }; + debugWindow.__OMNIAI_IMAGE_ROUTE_DEBUG__ = [...previousEntries.slice(-19), entry]; +} + +let taskHistoryRouteMissing = false; + +const TASK_SUBMIT_TIMEOUT_MS = 90_000; +const TASK_STATUS_TIMEOUT_MS = 20_000; +const NON_RETRYING_REQUEST = { maxRetries: 0 }; + +export const aiGenerationClient = { + async createImageTask(input: ImageGenInput): Promise { + const requestUrl = buildApiUrl("ai/image"); + emitImageRouteDebug("[ai/image-request]", { + url: requestUrl, + model: input.model, + ratio: input.ratio, + quality: input.quality, + gridMode: input.gridMode, + referenceCount: input.referenceUrls?.length || 0, + projectId: input.projectId, + conversationId: input.conversationId, + }); + const payload = await serverRequest("ai/image", { + method: "POST", + body: input, + timeoutMs: TASK_SUBMIT_TIMEOUT_MS, + maxRetries: NON_RETRYING_REQUEST.maxRetries, + fallbackMessage: "Image generation request failed", + }); + if (payload.providerDebug) { + emitImageRouteDebug("[ai/image-provider-debug]", payload.providerDebug as Record); + } + return payload; + }, + + async createVideoTask(input: VideoGenInput): Promise<{ taskId: string }> { + return serverRequest<{ taskId: string }>("ai/video", { + method: "POST", + body: input, + timeoutMs: TASK_SUBMIT_TIMEOUT_MS, + maxRetries: NON_RETRYING_REQUEST.maxRetries, + fallbackMessage: "Video generation request failed", + }); + }, + + async createVideoSuperResolveTask(input: VideoSuperResolveInput): Promise<{ taskId: string }> { + return serverRequest<{ taskId: string }>("ai/video/super-resolve", { + method: "POST", + body: input, + timeoutMs: TASK_SUBMIT_TIMEOUT_MS, + maxRetries: NON_RETRYING_REQUEST.maxRetries, + fallbackMessage: "Video super-resolution request failed", + }); + }, + + async createEraseSubtitlesTask(input: EraseSubtitlesInput): Promise<{ taskId: string }> { + return serverRequest<{ taskId: string }>("ai/video/erase-subtitles", { + method: "POST", + body: input, + timeoutMs: TASK_SUBMIT_TIMEOUT_MS, + maxRetries: NON_RETRYING_REQUEST.maxRetries, + fallbackMessage: "Subtitle removal request failed", + }); + }, + + async createVideoEditTask(input: VideoEditInput): Promise<{ taskId: string }> { + return serverRequest<{ taskId: string }>("ai/video/edit", { + method: "POST", + body: { ...input, model: input.model || "happyhorse-1.0-video-edit" }, + timeoutMs: TASK_SUBMIT_TIMEOUT_MS, + maxRetries: NON_RETRYING_REQUEST.maxRetries, + fallbackMessage: "Video edit request failed", + }); + }, + + async createImageSuperResolveTask(input: ImageSuperResolveInput): Promise<{ taskId: string }> { + return serverRequest<{ taskId: string }>("ai/image/super-resolve", { + method: "POST", + body: input, + timeoutMs: TASK_SUBMIT_TIMEOUT_MS, + maxRetries: NON_RETRYING_REQUEST.maxRetries, + fallbackMessage: "Image super-resolution request failed", + }); + }, + + async createImageEditTask(input: ImageEditInput): Promise<{ taskId: string }> { + return serverRequest<{ taskId: string }>("ai/image/edit", { + method: "POST", + body: input, + timeoutMs: TASK_SUBMIT_TIMEOUT_MS, + maxRetries: NON_RETRYING_REQUEST.maxRetries, + fallbackMessage: "Image edit request failed", + }); + }, + + async cancelTask(taskId: string): Promise { + try { + await serverRequest(`ai/tasks/${taskId}/cancel`, { + method: "PATCH", + maxRetries: NON_RETRYING_REQUEST.maxRetries, + fallbackMessage: "Task cancel failed", + }); + } catch (error) { + if (isOptionalApiRouteMissing(error)) return; + throw error; + } + }, + + async getTaskStatus(taskId: string): Promise { + return serverRequest(`ai/tasks/${taskId}`, { + timeoutMs: TASK_STATUS_TIMEOUT_MS, + fallbackMessage: "Task status request failed", + }); + }, + + async downloadTaskResult(taskId: string): Promise<{ blob: Blob; filename?: string; contentType?: string }> { + const res = await fetch(buildApiUrl(`ai/tasks/${encodeURIComponent(taskId)}/download`), { + method: "GET", + headers: buildAuthHeaders(), + }); + if (!res.ok) { + await throwResponseError(res, "Task result download failed"); + } + const blob = await res.blob(); + return { + blob, + filename: parseContentDispositionFilename(res.headers.get("content-disposition")), + contentType: res.headers.get("content-type") || blob.type || undefined, + }; + }, + + async listTasks(params?: { limit?: number; status?: string; type?: string; projectId?: string }): Promise { + if (taskHistoryRouteMissing) return []; + const search = new URLSearchParams(); + if (params?.limit) search.set("limit", String(params.limit)); + if (params?.status) search.set("status", params.status); + if (params?.type) search.set("type", params.type); + if (params?.projectId) search.set("projectId", params.projectId); + try { + const payload = await serverRequest(`ai/tasks${search.toString() ? `?${search}` : ""}`, { + fallbackMessage: "Task history request failed", + }); + return extractTaskList(payload).map(toPreviewTask); + } catch (error) { + if (isOptionalApiRouteMissing(error)) { + taskHistoryRouteMissing = true; + return []; + } + throw error; + } + }, + + async bindTaskToConversation(taskId: string, conversationId: number): Promise { + try { + await serverRequest(`ai/tasks/${taskId}/conversation`, { + method: "PATCH", + body: { conversationId }, + maxRetries: NON_RETRYING_REQUEST.maxRetries, + fallbackMessage: "Task conversation binding failed", + }); + } catch (error) { + if (isOptionalApiRouteMissing(error)) return; + throw error; + } + }, + + async uploadAsset(input: UploadAssetInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> { + return serverRequest<{ url: string; signedUrl?: string; ossKey?: string }>("oss/upload", { + method: "POST", + body: input, + maxRetries: NON_RETRYING_REQUEST.maxRetries, + fallbackMessage: "Asset upload failed", + }); + }, + + async uploadAssetBinary(blob: Blob, options?: { name?: string; mimeType?: string; scope?: string }): Promise<{ url: string; signedUrl?: string; ossKey?: string }> { + const form = new FormData(); + form.append("file", blob, options?.name || "upload.png"); + if (options?.scope) form.append("scope", options.scope); + if (options?.mimeType) form.append("mimeType", options.mimeType); + // Exclude Content-Type so browser auto-sets multipart/form-data with boundary + const { "Content-Type": _ct, ...authHeaders } = buildAuthHeaders(); + const res = await fetch(buildApiUrl("oss/upload-binary"), { + method: "POST", + headers: authHeaders, + body: form, + }); + if (!res.ok) { + await throwResponseError(res, "Binary asset upload failed"); + } + return readJsonResponse<{ url: string; ossKey?: string }>(res, "Binary asset upload response failed"); + }, + + async uploadAssetByUrl(input: UploadAssetByUrlInput): Promise<{ url: string; signedUrl?: string; ossKey?: string }> { + return serverRequest<{ url: string; signedUrl?: string; ossKey?: string }>("oss/upload-by-url", { + method: "POST", + body: input, + maxRetries: NON_RETRYING_REQUEST.maxRetries, + fallbackMessage: "Asset upload by URL failed", + }); + }, + + subscribeTaskStatus( + taskId: string, + onUpdate: (task: Pick) => void, + ): () => void { + const url = buildApiUrl(`ai/tasks/${taskId}/stream`); + const controller = new AbortController(); + + (async () => { + try { + const res = await fetch(url, { + headers: { ...buildAuthHeaders(), Accept: "text/event-stream" }, + signal: controller.signal, + }); + if (!res.ok || !res.body) return; + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + try { + const data = JSON.parse(line.slice(6)); + onUpdate(data); + } catch { /* ignore */ } + } + } + } catch { /* aborted or network error */ } + })(); + + return () => controller.abort(); + }, + + async streamChat( + input: ChatInput, + onChunk: (text: string) => void, + signal?: AbortSignal, + onUsage?: (usage: ChatUsage) => void, + ): Promise { + const res = await fetch(buildApiUrl("ai/chat"), { + method: "POST", + headers: buildAuthHeaders(), + body: JSON.stringify({ ...input, stream: true }), + signal, + }); + if (!res.ok) { + await throwResponseError(res, "Chat request failed"); + } + + const reader = res.body?.getReader(); + if (!reader) throw new Error("\u65e0\u6cd5\u8bfb\u53d6\u54cd\u5e94\u6d41"); + + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const payload = line.slice(6).trim(); + if (!payload) continue; + try { + const chunk = JSON.parse(payload) as { + delta?: string; + done?: boolean; + error?: string; + usage?: ChatUsage & { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + }; + }; + if (chunk.error) throw new Error(chunk.error); + if (chunk.usage) { + onUsage?.({ + promptTokens: chunk.usage.promptTokens ?? chunk.usage.prompt_tokens, + completionTokens: chunk.usage.completionTokens ?? chunk.usage.completion_tokens, + totalTokens: chunk.usage.totalTokens ?? chunk.usage.total_tokens, + }); + } + if (chunk.delta) onChunk(chunk.delta); + if (chunk.done) return; + } catch (e) { + if (e instanceof SyntaxError) continue; + throw e; + } + } + } + }, + + async chatCompletion(input: ChatInput, signal?: AbortSignal): Promise { + const res = await fetch(buildApiUrl("ai/chat"), { + method: "POST", + headers: buildAuthHeaders(), + body: JSON.stringify({ ...input, stream: false }), + signal, + }); + if (!res.ok) { + await throwResponseError(res, "Chat completion failed"); + } + const json = await readJsonResponse<{ content?: string }>(res, "Chat completion response failed"); + return (json as { content?: string }).content || ""; + }, +}; diff --git a/src/api/apiErrorUtils.ts b/src/api/apiErrorUtils.ts new file mode 100644 index 0000000..c7adf58 --- /dev/null +++ b/src/api/apiErrorUtils.ts @@ -0,0 +1,3 @@ +export function isOptionalApiRouteMissing(error: unknown): boolean { + return typeof error === "object" && error !== null && "status" in error && Number(error.status) === 404; +} diff --git a/src/api/assetClient.ts b/src/api/assetClient.ts new file mode 100644 index 0000000..954835e --- /dev/null +++ b/src/api/assetClient.ts @@ -0,0 +1,129 @@ +import type { WebAssetItem } from "../types"; +import { isRecord, serverRequest } from "./serverConnection"; + +export interface ServerAssetItem extends WebAssetItem { + id: string; + url?: string | null; + ossKey?: string | null; + tags: string[]; + sourceTaskId?: string | null; + sourceProjectId?: string | null; + metadata?: Record; + createdAt?: string; +} + +export interface CreateAssetInput { + type: WebAssetItem["type"] | "image" | "asset" | "other"; + name: string; + description?: string; + url?: string; + imageUrl?: string; + ossKey?: string; + tags?: string[]; + status?: WebAssetItem["status"] | "pending" | "failed"; + sourceTaskId?: string; + sourceProjectId?: string; + metadata?: Record; +} + +export interface DeleteAssetOptions { + cleanupUserData?: boolean; +} + +function toStringValue(value: unknown, fallback = ""): string { + if (typeof value === "string") return value.trim() || fallback; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return fallback; +} + +function normalizeAssetType(value: unknown): WebAssetItem["type"] { + const type = toStringValue(value); + if ( + type === "character" || + type === "scene" || + type === "prop" || + type === "video" || + type === "image" || + type === "asset" || + type === "other" + ) { + return type; + } + return "other"; +} + +function normalizeAssetStatus(value: unknown): WebAssetItem["status"] { + const status = toStringValue(value); + if ( + status === "ready" || + status === "draft" || + status === "reviewing" || + status === "pending" || + status === "failed" + ) { + return status; + } + return "ready"; +} + +function normalizeTags(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const tags: string[] = []; + for (const item of value) { + const tag = toStringValue(item); + if (tag) tags.push(tag); + } + return tags; +} + +function normalizeAsset(raw: unknown): ServerAssetItem { + const item = isRecord(raw) ? raw : {}; + const url = toStringValue(item.url ?? item.imageUrl) || null; + return { + id: toStringValue(item.id, `asset-${Date.now()}`), + type: normalizeAssetType(item.type), + name: toStringValue(item.name, "未命名素材"), + description: toStringValue(item.description, "从服务器资产库同步的素材。"), + imageUrl: url || "", + url, + ossKey: toStringValue(item.ossKey ?? item.oss_key) || null, + tags: normalizeTags(item.tags), + status: normalizeAssetStatus(item.status), + sourceTaskId: toStringValue(item.sourceTaskId ?? item.source_task_id) || null, + sourceProjectId: toStringValue(item.sourceProjectId ?? item.source_project_id) || null, + metadata: isRecord(item.metadata) ? item.metadata : {}, + createdAt: toStringValue(item.createdAt ?? item.created_at), + updatedAt: toStringValue(item.updatedAt ?? item.updated_at, "刚刚"), + }; +} + +function extractAssets(payload: unknown): ServerAssetItem[] { + if (Array.isArray(payload)) return payload.map(normalizeAsset); + if (!isRecord(payload)) return []; + const rows = payload.assets ?? payload.items; + return Array.isArray(rows) ? rows.map(normalizeAsset) : []; +} + +export const assetClient = { + async list(params?: { type?: string; q?: string; status?: string }): Promise { + const search = new URLSearchParams(); + if (params?.type && params.type !== "all") search.set("type", params.type); + if (params?.q) search.set("q", params.q); + if (params?.status) search.set("status", params.status); + const query = search.toString(); + return extractAssets(await serverRequest(`assets${query ? `?${query}` : ""}`)); + }, + + async create(input: CreateAssetInput): Promise { + const payload = await serverRequest<{ asset: unknown }>("assets", { + method: "POST", + body: input, + }); + return normalizeAsset(payload.asset ?? payload); + }, + + async delete(id: string, options?: DeleteAssetOptions): Promise { + const path = options?.cleanupUserData ? `assets/${encodeURIComponent(id)}?cleanupUserData=1` : `assets/${encodeURIComponent(id)}`; + await serverRequest(path, { method: "DELETE" }); + }, +}; diff --git a/src/api/betaApplicationClient.ts b/src/api/betaApplicationClient.ts new file mode 100644 index 0000000..39921b2 --- /dev/null +++ b/src/api/betaApplicationClient.ts @@ -0,0 +1,139 @@ +import { serverRequest } from "./serverConnection"; + +export interface BetaApplicationInput { + name: string; + email: string; + phone: string; + wechat: string; + industry: string; + company: string; + city: string; + aiTools: string; + aiDuration: string; + aiTrack: string; + aiDirection: string[]; + weeklyUsage: string; + feedbackWilling: string; + wantFeature: string[]; + selfStatement: string; + signature: string; + applicationDate: string; + agreeRules: boolean; +} + +export type BetaApplicationStatus = "pending" | "approved" | "rejected"; + +export interface BetaApplicationItem extends BetaApplicationInput { + id: number; + userId: number | null; + username: string | null; + status: BetaApplicationStatus; + inviteCode: string | null; + reviewNote: string | null; + reviewedBy: number | null; + reviewerUsername: string | null; + reviewedAt: string | null; + ipAddress: string | null; + userAgent: string | null; + createdAt: string; + updatedAt: string; +} + +export interface BetaApplicationSubmitResult { + id: number; + status: BetaApplicationStatus; + createdAt: string; +} + +function readString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function readNullableString(value: unknown): string | null { + return typeof value === "string" && value ? value : null; +} + +function readNumberOrNull(value: unknown): number | null { + if (value === null || value === undefined || value === "") return null; + const next = Number(value); + return Number.isFinite(next) ? next : null; +} + +function readStringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; +} + +function normalizeStatus(value: unknown): BetaApplicationStatus { + return value === "approved" || value === "rejected" ? value : "pending"; +} + +function normalizeApplication(raw: unknown): BetaApplicationItem { + const item = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record) : {}; + return { + id: Number(item.id) || 0, + userId: readNumberOrNull(item.userId), + username: readNullableString(item.username), + name: readString(item.name), + email: readString(item.email), + phone: readString(item.phone), + wechat: readString(item.wechat), + industry: readString(item.industry), + company: readString(item.company), + city: readString(item.city), + aiTools: readString(item.aiTools), + aiDuration: readString(item.aiDuration), + aiTrack: readString(item.aiTrack), + aiDirection: readStringArray(item.aiDirection), + weeklyUsage: readString(item.weeklyUsage), + feedbackWilling: readString(item.feedbackWilling), + wantFeature: readStringArray(item.wantFeature), + selfStatement: readString(item.selfStatement), + signature: readString(item.signature), + applicationDate: readString(item.applicationDate), + agreeRules: item.agreeRules === true, + status: normalizeStatus(item.status), + inviteCode: readNullableString(item.inviteCode), + reviewNote: readNullableString(item.reviewNote), + reviewedBy: readNumberOrNull(item.reviewedBy), + reviewerUsername: readNullableString(item.reviewerUsername), + reviewedAt: readNullableString(item.reviewedAt), + ipAddress: readNullableString(item.ipAddress), + userAgent: readNullableString(item.userAgent), + createdAt: readString(item.createdAt), + updatedAt: readString(item.updatedAt), + }; +} + +export const betaApplicationClient = { + async submit(input: BetaApplicationInput): Promise { + const payload = await serverRequest<{ application: BetaApplicationSubmitResult }>("beta-applications", { + method: "POST", + body: input, + maxRetries: 0, + fallbackMessage: "提交内测申请失败", + }); + return payload.application; + }, + + async listAdminApplications(status?: BetaApplicationStatus | ""): Promise { + const query = status ? `?status=${encodeURIComponent(status)}` : ""; + const payload = await serverRequest<{ applications?: unknown[] }>(`admin/beta-applications${query}`, { + fallbackMessage: "读取内测申请失败", + }); + return Array.isArray(payload.applications) ? payload.applications.map(normalizeApplication) : []; + }, + + async reviewApplication( + id: number, + action: "approve" | "reject", + reviewNote?: string, + ): Promise { + const payload = await serverRequest<{ application: unknown }>(`admin/beta-applications/${id}`, { + method: "PATCH", + body: { action, reviewNote }, + maxRetries: 0, + fallbackMessage: "审核内测申请失败", + }); + return normalizeApplication(payload.application); + }, +}; diff --git a/src/api/communityClient.ts b/src/api/communityClient.ts new file mode 100644 index 0000000..706fb2a --- /dev/null +++ b/src/api/communityClient.ts @@ -0,0 +1,207 @@ +import { isRecord, serverRequest } from "./serverConnection"; + +export interface ServerCommunityAsset { + id?: number; + assetType: "image" | "video" | "project" | "workflow" | "asset" | "cover" | "other"; + title?: string | null; + url?: string | null; + ossKey?: string | null; + metadata?: Record; + sortOrder?: number; +} + +export interface ServerCommunityCase { + id: number; + userId?: number; + username?: string | null; + projectId?: string | null; + title: string; + description?: string | null; + coverUrl?: string | null; + tags: string[]; + metadata: Record; + status: "pending" | "approved" | "rejected"; + reviewNote?: string | null; + publishedAt?: string | null; + copyCount: number; + favoriteCount: number; + likeCount: number; + isFavorited: boolean; + isLiked: boolean; + createdAt: string; + updatedAt: string; + assets: ServerCommunityAsset[]; +} + +export interface PublishCommunityCaseInput { + projectId?: string | null; + title: string; + description?: string | null; + coverUrl?: string | null; + tags?: string[]; + metadata?: Record; + assets?: Array<{ + assetType?: ServerCommunityAsset["assetType"]; + title?: string; + url?: string; + ossKey?: string; + metadata?: Record; + sortOrder?: number; + }>; +} + +function toNumber(value: unknown, fallback = 0): number { + const numeric = typeof value === "number" ? value : Number(value); + return Number.isFinite(numeric) ? numeric : fallback; +} + +function toStringValue(value: unknown, fallback = ""): string { + if (typeof value === "string") return value.trim() || fallback; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return fallback; +} + +function toStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const result: string[] = []; + for (const item of value) { + const text = toStringValue(item); + if (text) result.push(text); + } + return result; +} + +function toMetadata(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +function normalizeAsset(raw: unknown): ServerCommunityAsset { + const asset = isRecord(raw) ? raw : {}; + const assetType = toStringValue(asset.assetType ?? asset.asset_type ?? asset.type, "other"); + return { + id: Number.isFinite(Number(asset.id)) ? Number(asset.id) : undefined, + assetType: + assetType === "image" || + assetType === "video" || + assetType === "image" || + assetType === "project" || + assetType === "workflow" || + assetType === "asset" || + assetType === "cover" + ? assetType + : "other", + title: toStringValue(asset.title) || null, + url: toStringValue(asset.url) || null, + ossKey: toStringValue(asset.ossKey ?? asset.oss_key) || null, + metadata: toMetadata(asset.metadata), + sortOrder: toNumber(asset.sortOrder ?? asset.sort_order), + }; +} + +function normalizeCase(raw: unknown): ServerCommunityCase { + const item = isRecord(raw) ? raw : {}; + const status = toStringValue(item.status, "pending"); + return { + id: toNumber(item.id), + userId: Number.isFinite(Number(item.userId ?? item.user_id)) ? Number(item.userId ?? item.user_id) : undefined, + username: toStringValue(item.username) || null, + projectId: toStringValue(item.projectId ?? item.project_id) || null, + title: toStringValue(item.title, "未命名案例"), + description: toStringValue(item.description) || null, + coverUrl: toStringValue(item.coverUrl ?? item.cover_url) || null, + tags: toStringArray(item.tags), + metadata: toMetadata(item.metadata), + status: status === "approved" || status === "rejected" ? status : "pending", + reviewNote: toStringValue(item.reviewNote ?? item.review_note) || null, + publishedAt: toStringValue(item.publishedAt ?? item.published_at) || null, + copyCount: toNumber(item.copyCount ?? item.copy_count), + favoriteCount: toNumber(item.favoriteCount ?? item.favorite_count), + likeCount: toNumber(item.likeCount ?? item.like_count), + isFavorited: Boolean(item.isFavorited ?? item.is_favorited), + isLiked: Boolean(item.isLiked ?? item.is_liked), + createdAt: toStringValue(item.createdAt ?? item.created_at, new Date().toISOString()), + updatedAt: toStringValue(item.updatedAt ?? item.updated_at, new Date().toISOString()), + assets: Array.isArray(item.assets) ? item.assets.map(normalizeAsset) : [], + }; +} + +function extractCases(payload: unknown): ServerCommunityCase[] { + if (Array.isArray(payload)) return payload.map(normalizeCase); + if (!isRecord(payload)) return []; + const rows = payload.cases ?? payload.items; + return Array.isArray(rows) ? rows.map(normalizeCase) : []; +} + +export const communityClient = { + async listApprovedCases( + params: number | { limit?: number; q?: string; category?: string; tag?: string; sort?: string } = 30, + ): Promise { + const search = new URLSearchParams(); + if (typeof params === "number") { + search.set("limit", String(params)); + } else { + search.set("limit", String(params.limit ?? 30)); + if (params.q) search.set("q", params.q); + if (params.category && params.category !== "全部") search.set("category", params.category); + if (params.tag) search.set("tag", params.tag); + if (params.sort) search.set("sort", params.sort); + } + return extractCases(await serverRequest(`community/cases?${search.toString()}`)); + }, + + async listMyCases(): Promise { + return extractCases(await serverRequest("community/me/cases")); + }, + + async listCasesForReview(status: "" | ServerCommunityCase["status"] = "pending"): Promise { + const search = new URLSearchParams(); + if (status) search.set("status", status); + const query = search.toString(); + return extractCases(await serverRequest(`admin/community/cases${query ? `?${query}` : ""}`)); + }, + + async publishCase(input: PublishCommunityCaseInput): Promise { + const payload = await serverRequest<{ case: unknown }>("community/cases", { + method: "POST", + body: input, + }); + return normalizeCase(payload.case); + }, + + async updateReviewStatus( + caseId: number | string, + status: Exclude, + reviewNote: string, + ): Promise { + const payload = await serverRequest<{ case: unknown }>(`admin/community/cases/${encodeURIComponent(String(caseId))}/status`, { + method: "PATCH", + body: { status, reviewNote, review_note: reviewNote }, + }); + return normalizeCase(payload.case); + }, + + async copyCase(caseId: number, input?: { projectId?: string; name?: string; ossKey?: string }): Promise { + await serverRequest(`community/cases/${caseId}/copy`, { + method: "POST", + body: input || {}, + }); + }, + + async setReaction( + caseId: number, + reactionType: "favorite" | "like", + active: boolean, + ): Promise<{ favoriteCount: number; likeCount: number; isFavorited: boolean; isLiked: boolean }> { + const payload = await serverRequest<{ stats: unknown }>(`community/cases/${caseId}/reactions`, { + method: "POST", + body: { reactionType, active }, + }); + const stats = isRecord(payload.stats) ? payload.stats : {}; + return { + favoriteCount: toNumber(stats.favoriteCount), + likeCount: toNumber(stats.likeCount), + isFavorited: Boolean(stats.isFavorited), + isLiked: Boolean(stats.isLiked), + }; + }, +}; diff --git a/src/api/conversationClient.ts b/src/api/conversationClient.ts new file mode 100644 index 0000000..562ebc8 --- /dev/null +++ b/src/api/conversationClient.ts @@ -0,0 +1,67 @@ +import { serverRequest } from "./serverConnection"; + +export interface ConversationSummary { + id: number; + title: string; + mode: string; + createdAt: string; + updatedAt: string; +} + +export interface ConversationDetail extends ConversationSummary { + messages: ConversationMessage[]; +} + +export interface ConversationMessage { + id: string; + role: "user" | "assistant"; + author: string; + mode: string; + body: string; + createdAt: string; + status?: string; + taskId?: string; + conversationId?: number; + taskProgress?: number; + taskStatusLabel?: string; + attachments?: Array<{ kind: string; name: string; token: string; previewUrl?: string; remoteUrl?: string }>; + resultUrl?: string; + resultType?: string; + resultOriginalUrl?: string; + resultOssKey?: string; + resultMimeType?: string; + result?: { title: string; summary: string; specs: string[] }; +} + +export interface DeleteConversationOptions { + cleanupUserData?: boolean; +} + +export const conversationClient = { + async list(): Promise { + const data = await serverRequest<{ conversations: ConversationSummary[] }>("conversations"); + return data.conversations || []; + }, + + async create(title: string, mode: string, messages?: ConversationMessage[]): Promise { + const data = await serverRequest<{ conversation: ConversationSummary }>("conversations", { + method: "POST", + body: { title, mode, messages }, + }); + return data.conversation; + }, + + async get(id: number): Promise { + const data = await serverRequest<{ conversation: ConversationDetail }>(`conversations/${id}`); + return data.conversation; + }, + + async update(id: number, data: { title?: string; messages?: ConversationMessage[] }): Promise { + await serverRequest(`conversations/${id}`, { method: "PUT", body: data }); + }, + + async delete(id: number, options?: DeleteConversationOptions): Promise { + const path = options?.cleanupUserData ? `conversations/${id}?cleanupUserData=1` : `conversations/${id}`; + await serverRequest(path, { method: "DELETE" }); + }, +}; diff --git a/src/api/draftClient.ts b/src/api/draftClient.ts new file mode 100644 index 0000000..c5c72e0 --- /dev/null +++ b/src/api/draftClient.ts @@ -0,0 +1,53 @@ +import { isRecord, serverRequest } from "./serverConnection"; + +export interface WebDraft { + id: string; + scope: string; + targetId: string; + payload: TPayload; + createdAt: string; + updatedAt: string; +} + +function toStringValue(value: unknown, fallback = ""): string { + if (typeof value === "string") return value.trim() || fallback; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return fallback; +} + +function normalizeDraft(raw: unknown): WebDraft { + const item = isRecord(raw) ? raw : {}; + return { + id: toStringValue(item.id, `draft-${Date.now()}`), + scope: toStringValue(item.scope), + targetId: toStringValue(item.targetId ?? item.target_id, "default"), + payload: item.payload, + createdAt: toStringValue(item.createdAt ?? item.created_at), + updatedAt: toStringValue(item.updatedAt ?? item.updated_at), + }; +} + +function extractDrafts(payload: unknown): WebDraft[] { + if (Array.isArray(payload)) return payload.map(normalizeDraft); + if (!isRecord(payload)) return []; + const rows = payload.drafts ?? payload.items; + return Array.isArray(rows) ? rows.map(normalizeDraft) : []; +} + +export const draftClient = { + async list(params?: { scope?: string; targetId?: string }): Promise { + const search = new URLSearchParams(); + if (params?.scope) search.set("scope", params.scope); + if (params?.targetId) search.set("targetId", params.targetId); + const query = search.toString(); + return extractDrafts(await serverRequest(`drafts${query ? `?${query}` : ""}`)); + }, + + async save(input: { scope: string; targetId?: string; payload: TPayload }): Promise> { + const payload = await serverRequest<{ draft: unknown }>("drafts", { + method: "PUT", + body: input, + }); + return normalizeDraft(payload.draft ?? payload) as WebDraft; + }, +}; diff --git a/src/api/generationConcurrency.ts b/src/api/generationConcurrency.ts new file mode 100644 index 0000000..a4e3b56 --- /dev/null +++ b/src/api/generationConcurrency.ts @@ -0,0 +1,73 @@ +type GenerationKind = "image" | "video"; + +interface GenerationSlot { + id: string; + userKey: string; + kind: GenerationKind; + createdAt: number; +} + +const DEFAULT_MAX_ACTIVE_GENERATION_TASKS = 3; +const STALE_SLOT_MS = 6 * 60 * 60 * 1000; +const activeSlots = new Map(); + +let userMaxConcurrency: number | null = null; + +export function setUserMaxConcurrency(limit: number | null | undefined): void { + userMaxConcurrency = typeof limit === "number" && limit > 0 ? limit : null; +} + +function getEffectiveLimit(): number { + return userMaxConcurrency ?? DEFAULT_MAX_ACTIVE_GENERATION_TASKS; +} + +export function getGenerationUserKey(userId?: string | number | null): string { + return userId === undefined || userId === null || userId === "" ? "anonymous" : String(userId); +} + +function pruneStaleSlots(now = Date.now()): void { + activeSlots.forEach((slot, id) => { + if (now - slot.createdAt > STALE_SLOT_MS) { + activeSlots.delete(id); + } + }); +} + +export function getActiveGenerationTaskCount(userKey: string): number { + pruneStaleSlots(); + let count = 0; + activeSlots.forEach((slot) => { + if (slot.userKey === userKey) count += 1; + }); + return count; +} + +export function claimGenerationSlot(input: { + userKey: string; + kind: GenerationKind; + id?: string; +}): () => void { + pruneStaleSlots(); + const activeCount = getActiveGenerationTaskCount(input.userKey); + const effectiveLimit = getEffectiveLimit(); + if (activeCount >= effectiveLimit) { + throw new Error(`当前账号同时最多生成 ${effectiveLimit} 个图片/视频任务,请等待已有任务完成后再提交。`); + } + + const id = input.id || `generation-slot-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + activeSlots.set(id, { + id, + userKey: input.userKey, + kind: input.kind, + createdAt: Date.now(), + }); + + return () => { + activeSlots.delete(id); + }; +} + +export function releaseGenerationSlot(id: string | undefined | null): void { + if (!id) return; + activeSlots.delete(id); +} diff --git a/src/api/keyServerClient.ts b/src/api/keyServerClient.ts new file mode 100644 index 0000000..d86109a --- /dev/null +++ b/src/api/keyServerClient.ts @@ -0,0 +1,1022 @@ +import type { + WebCanvasWorkflow, + WebCanvasWorkflowNode, + WebEnterpriseUsageSummary, + WebProjectSummary, + WebUsageSummary, + WebUserSession, +} from "../types"; +import { + getErrorMessage, + getServerBaseUrl, + isRecord, + isServerRequestError, + readStoredSession, + serverRequest, + unwrapApiPayload, + writeStoredSession, +} from "./serverConnection"; + +interface LoginInput { + username: string; + password: string; +} + +interface RegisterInput extends LoginInput { + betaCode: string; +} + +interface EmailAuthInput { + email: string; + password: string; + username?: string; + code?: string; + betaCode?: string; +} + +interface EmailCodeInput { + email: string; + code: string; + purpose?: "register" | "login"; +} + +interface ForgotPasswordInput { + email: string; +} + +interface ResetPasswordInput { + email: string; + code: string; + newPassword: string; +} + +interface PhoneAuthInput { + phone: string; + code: string; + password?: string; + betaCode?: string; +} + +interface UpdateProfileInput { + avatarUrl?: string | null; + avatarOssKey?: string | null; + bio?: string | null; + backgroundUrl?: string | null; + profileBackgroundUrl?: string | null; +} + +interface DeleteProjectOptions { + cleanupUserData?: boolean; +} + +export interface RechargeOrderInput { + planId: string; + paymentMethod: "wechat" | "alipay" | "bank"; +} + +export interface RechargeOrderResult { + orderId: string; + status: string; + payUrl?: string | null; + qrCodeUrl?: string | null; + message?: string | null; +} + +export interface WechatLoginTicket { + configured: boolean; + url?: string; + state?: string; + message?: string; +} + +export interface WechatLoginSessionStatus { + status: "pending" | "completed" | "failed" | "expired" | "missing" | "consumed" | string; + error?: string; + session?: WebUserSession; +} + +const getBaseUrl = getServerBaseUrl; +const request = serverRequest; +const isHttpError = isServerRequestError; +const PROJECT_CONTENT_ENRICH_CONCURRENCY = 1; +let projectContentEnrichmentDisabled = false; + +function toNumber(value: unknown, fallback = 0): number { + const numberValue = typeof value === "number" ? value : Number(value); + return Number.isFinite(numberValue) ? numberValue : fallback; +} + +function toStringValue(value: unknown, fallback = ""): string { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed || fallback; + } + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + return fallback; +} + +function toNullableString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed || null; +} + +function toNullableId(value: unknown): number | string | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + return toNullableString(value); +} + +function toIdValue(value: unknown, fallback: string): number | string { + if (typeof value === "number" && Number.isFinite(value)) return value; + return toStringValue(value, fallback); +} + +function isPlaceholderProjectText(value: string | null | undefined): boolean { + if (!value) return true; + return /^(新建项目|新建创作|未命名项目|Untitled project)$/i.test(value.trim()); +} + +function isPlaceholderProjectDescription(value: string | null | undefined): boolean { + if (!value) return true; + return /^(从空白画布开始|从空白画布开始,直接进入节点式创作。|最近更新的项目)$/i.test(value.trim()); +} + +function chooseProjectText(contentText: string | null, currentText: string | null, fallback: string): string { + if (contentText && (!isPlaceholderProjectText(contentText) || isPlaceholderProjectText(currentText))) { + return contentText; + } + return currentText || contentText || fallback; +} + +function chooseProjectDescription(contentText: string | null, currentText: string | null): string | null { + if (contentText && (!isPlaceholderProjectDescription(contentText) || isPlaceholderProjectDescription(currentText))) { + return contentText; + } + return currentText || contentText || null; +} + +function pickFirstString(...values: unknown[]): string | null { + for (const value of values) { + const next = toNullableString(value); + if (next) return next; + } + return null; +} + +function toProjectSummary( + raw: Record, + source: WebProjectSummary["source"], +): WebProjectSummary { + return { + id: toStringValue(raw.id), + name: toStringValue(raw.name, "未命名项目"), + description: toNullableString(raw.description), + thumbnailUrl: toNullableString(raw.thumbnail_url ?? raw.thumbnailUrl), + updatedAt: toStringValue(raw.updated_at ?? raw.updatedAt, "刚刚"), + storyboardCount: toNumber(raw.storyboard_count ?? raw.storyboardCount), + imageCount: toNumber(raw.image_count ?? raw.imageCount), + videoCount: toNumber(raw.video_count ?? raw.videoCount), + source, + }; +} + +function buildProjectSummaryFromWorkflow( + workflow: WebCanvasWorkflow, + source: WebProjectSummary["source"], + errorMessage?: string, +): WebProjectSummary { + const previewNode = workflow.nodes.find((node) => node.previewUrl); + return { + id: workflow.id, + name: workflow.title, + description: workflow.description, + thumbnailUrl: previewNode?.previewUrl ?? null, + updatedAt: "刚刚", + storyboardCount: workflow.nodes.length, + imageCount: workflow.nodes.filter((node) => node.kind === "image").length, + videoCount: workflow.nodes.filter((node) => node.kind === "video").length, + source, + ...(errorMessage ? { errorMessage } : {}), + }; +} + +function unwrapProjectContentPayload(payload: unknown): unknown { + const unwrapped = unwrapApiPayload(payload); + return isRecord(unwrapped) && "content" in unwrapped ? unwrapped.content : unwrapped; +} + +function getContentWorkflowRecord(content: unknown): Record | null { + if (!isRecord(content)) return null; + const workflow = content.workflowData ?? content.workflow ?? content.workflow_data; + return isRecord(workflow) ? workflow : null; +} + +function getContentNodes(content: unknown): Record[] { + const workflow = getContentWorkflowRecord(content); + const nodeSource = + (workflow && Array.isArray(workflow.nodes) ? workflow.nodes : null) ?? + (isRecord(content) && Array.isArray(content.nodes) ? content.nodes : null); + return Array.isArray(nodeSource) ? nodeSource.filter(isRecord) : []; +} + +function getContentArray(content: unknown, key: string): Record[] { + if (!isRecord(content)) return []; + const value = content[key]; + return Array.isArray(value) ? value.filter(isRecord) : []; +} + +function countNodesByKind(nodes: Record[], kind: string): number { + return nodes.filter((node) => node.kind === kind || node.type === kind).length; +} + +function pickProjectPreviewUrl(content: unknown): string | null { + if (!isRecord(content)) return null; + const workflow = getContentWorkflowRecord(content); + const nodes = getContentNodes(content); + const storyboards = getContentArray(content, "storyboards"); + const videos = getContentArray(content, "videos"); + + return ( + pickFirstString( + content.thumbnailUrl, + content.thumbnail_url, + content.coverUrl, + content.cover_url, + workflow?.thumbnailUrl, + workflow?.thumbnail_url, + nodes.find((node) => pickFirstString(node.previewUrl, node.preview_url, node.imageUrl, node.image_url)) + ?.previewUrl, + nodes.find((node) => pickFirstString(node.previewUrl, node.preview_url, node.imageUrl, node.image_url)) + ?.preview_url, + nodes.find((node) => pickFirstString(node.previewUrl, node.preview_url, node.imageUrl, node.image_url)) + ?.imageUrl, + nodes.find((node) => pickFirstString(node.previewUrl, node.preview_url, node.imageUrl, node.image_url)) + ?.image_url, + storyboards.find((item) => pickFirstString(item.imageUrl, item.image_url, item.coverUrl, item.cover_url)) + ?.imageUrl, + storyboards.find((item) => pickFirstString(item.imageUrl, item.image_url, item.coverUrl, item.cover_url)) + ?.image_url, + storyboards.find((item) => pickFirstString(item.imageUrl, item.image_url, item.coverUrl, item.cover_url)) + ?.coverUrl, + storyboards.find((item) => pickFirstString(item.imageUrl, item.image_url, item.coverUrl, item.cover_url)) + ?.cover_url, + videos.find((item) => pickFirstString(item.coverUrl, item.cover_url, item.thumbnailUrl, item.thumbnail_url)) + ?.coverUrl, + videos.find((item) => pickFirstString(item.coverUrl, item.cover_url, item.thumbnailUrl, item.thumbnail_url)) + ?.cover_url, + videos.find((item) => pickFirstString(item.coverUrl, item.cover_url, item.thumbnailUrl, item.thumbnail_url)) + ?.thumbnailUrl, + videos.find((item) => pickFirstString(item.coverUrl, item.cover_url, item.thumbnailUrl, item.thumbnail_url)) + ?.thumbnail_url, + ) || null + ); +} + +function mergeProjectSummaryWithContent(project: WebProjectSummary, payload: unknown): WebProjectSummary { + const content = unwrapProjectContentPayload(payload); + if (!isRecord(content)) return project; + + const workflow = getContentWorkflowRecord(content); + const nodes = getContentNodes(content); + const storyboards = getContentArray(content, "storyboards"); + const videos = getContentArray(content, "videos"); + const contentTitle = pickFirstString(content.name, content.projectName, content.title, workflow?.title); + const contentDescription = pickFirstString( + content.description, + content.projectDescription, + content.summary, + workflow?.description, + ); + const imageCount = + countNodesByKind(nodes, "image") || + storyboards.filter((item) => pickFirstString(item.imageUrl, item.image_url, item.coverUrl, item.cover_url)).length; + const videoCount = + countNodesByKind(nodes, "video") || + videos.length || + storyboards.filter((item) => pickFirstString(item.videoUrl, item.video_url)).length; + const storyboardCount = nodes.length || storyboards.length; + + return { + ...project, + name: chooseProjectText(contentTitle, project.name, project.name || "未命名项目"), + description: chooseProjectDescription(contentDescription, project.description ?? null), + thumbnailUrl: pickProjectPreviewUrl(content) || project.thumbnailUrl || null, + storyboardCount: storyboardCount || project.storyboardCount, + imageCount: imageCount || project.imageCount, + videoCount: videoCount || project.videoCount, + }; +} + +async function enrichProjectSummariesWithContent(projects: WebProjectSummary[]): Promise { + if (projectContentEnrichmentDisabled) return projects; + + const enriched = [...projects]; + + for (let index = 0; index < projects.length; index += PROJECT_CONTENT_ENRICH_CONCURRENCY) { + if (projectContentEnrichmentDisabled) break; + const batch = projects.slice(index, index + PROJECT_CONTENT_ENRICH_CONCURRENCY); + const results = await Promise.allSettled( + batch.map(async (project) => { + const payload = await request(`/projects/${encodeURIComponent(project.id)}/content?resolveMedia=1`); + return mergeProjectSummaryWithContent(project, payload); + }), + ); + + results.forEach((result, offset) => { + const targetIndex = index + offset; + if (result.status === "fulfilled") { + enriched[targetIndex] = result.value; + } else { + const status = isHttpError(result.reason) ? result.reason.status : undefined; + if (status === 404 || (typeof status === "number" && status >= 500)) { + projectContentEnrichmentDisabled = true; + } + enriched[targetIndex] = { + ...enriched[targetIndex], + errorMessage: getErrorMessage(result.reason), + }; + } + }); + } + + return enriched; +} + +function createWorkflowFingerprint(workflow: WebCanvasWorkflow): string { + const payload = JSON.stringify({ + title: workflow.title, + description: workflow.description, + settings: workflow.settings, + nodes: workflow.nodes.map((node) => ({ + id: node.id, + kind: node.kind, + label: node.label, + detail: node.detail, + previewUrl: node.previewUrl || "", + })), + edges: workflow.edges.map((edge) => ({ + id: edge.id, + source: edge.source, + target: edge.target, + label: edge.label || "", + })), + }); + + let hash = 0x811c9dc5; + for (let i = 0; i < payload.length; i += 1) { + hash ^= payload.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + + return `wf-${(hash >>> 0).toString(16).padStart(8, "0")}`; +} + +function toActivePackages(value: unknown): WebUserSession["user"]["activePackages"] { + if (!Array.isArray(value)) return undefined; + + const packages: NonNullable = []; + for (const entry of value) { + if (!isRecord(entry)) continue; + packages.push({ + name: toStringValue(entry.name, "Preview package"), + expiresAt: toStringValue(entry.expiresAt ?? entry.expires_at, ""), + remainingImage: toNumber(entry.remainingImage ?? entry.remaining_image), + remainingVideo: toNumber(entry.remainingVideo ?? entry.remaining_video), + remainingText: toNumber(entry.remainingText ?? entry.remaining_text), + }); + } + return packages; +} + +function normalizeUser(raw: unknown): WebUserSession["user"] | null { + const payload = unwrapApiPayload(raw); + const candidate = isRecord(payload) && isRecord(payload.user) ? payload.user : payload; + if (!isRecord(candidate)) return null; + + const id = candidate.id ?? candidate.userId ?? candidate.user_id; + const username = toStringValue(candidate.username ?? candidate.name, "预览用户"); + if (id === undefined || !username) return null; + + return { + id: typeof id === "number" && Number.isFinite(id) ? id : toStringValue(id), + username, + displayName: toNullableString(candidate.displayName ?? candidate.display_name ?? candidate.nickname ?? candidate.name), + bio: toNullableString(candidate.bio ?? candidate.profileBio ?? candidate.profile_bio ?? candidate.description ?? candidate.intro), + avatarUrl: toNullableString(candidate.avatarUrl ?? candidate.avatar_url), + backgroundUrl: toNullableString( + candidate.profileBackgroundUrl ?? + candidate.profile_background_url ?? + candidate.backgroundUrl ?? + candidate.background_url ?? + candidate.coverUrl ?? + candidate.cover_url, + ), + email: toNullableString(candidate.email), + emailVerified: candidate.emailVerified === true || candidate.email_verified === true || candidate.email_verified === 1, + phone: toNullableString(candidate.phone), + authProvider: toNullableString(candidate.authProvider ?? candidate.auth_provider), + sessionId: toNullableString(candidate.sessionId ?? candidate.session_id), + sessionStartedAt: toNullableString(candidate.sessionStartedAt ?? candidate.session_started_at), + role: toNullableString(candidate.role) ?? undefined, + accountType: toNullableString(candidate.accountType ?? candidate.account_type) ?? undefined, + enterpriseId: toNullableString(candidate.enterpriseId ?? candidate.enterprise_id), + enterpriseName: toNullableString(candidate.enterpriseName ?? candidate.enterprise_name), + enterpriseRole: toNullableString(candidate.enterpriseRole ?? candidate.enterprise_role) ?? undefined, + enterpriseAdminUserId: toNullableId(candidate.enterpriseAdminUserId ?? candidate.enterprise_admin_user_id), + balanceCents: toNumber( + candidate.balanceCents ?? candidate.balance_cents ?? candidate.userBalanceCents ?? candidate.user_balance_cents, + ), + enterpriseBalanceCents: toNumber( + candidate.enterpriseBalanceCents ?? + candidate.enterprise_balance_cents ?? + candidate.enterpriseBalance ?? + candidate.enterprise_balance, + ), + maxConcurrency: toNumber(candidate.maxConcurrency ?? candidate.max_concurrency), + activePackages: toActivePackages(candidate.activePackages ?? candidate.active_packages), + }; +} + +function extractProjectRows(payload: unknown): Record[] { + const unwrapped = unwrapApiPayload(payload); + if (Array.isArray(unwrapped)) { + return unwrapped.filter(isRecord); + } + if (!isRecord(unwrapped)) return []; + + const rows = unwrapped.projects ?? unwrapped.items; + return Array.isArray(rows) ? rows.filter(isRecord) : []; +} + +function isCanvasWorkflow(value: unknown): value is WebCanvasWorkflow { + if (!isRecord(value)) return false; + return ( + typeof value.id === "string" && + typeof value.title === "string" && + typeof value.description === "string" && + typeof value.version === "number" && value.version >= 1 && + isRecord(value.settings) && + Array.isArray(value.nodes) && + Array.isArray(value.edges) + ); +} + +function isLegacyWorkflowData(value: unknown): value is Record { + if (!isRecord(value)) return false; + return ( + typeof value.version === "number" && + Array.isArray(value.nodes) && + Array.isArray(value.edges) + ); +} + +function migrateLegacyWorkflowData(old: Record, wrapper: Record, projectId: string): WebCanvasWorkflow { + const viewport = isRecord(old.viewport) ? old.viewport : {}; + return { + id: old.id as string || projectId, + version: 1, + title: String(wrapper.name || wrapper.title || "未命名项目"), + description: String(wrapper.description || ""), + source: (wrapper.source as WebCanvasWorkflow["source"]) || "blank", + settings: { + model: String(isRecord(old.settings) ? old.settings.model || "omni-水果 Pro" : "omni-水果 Pro"), + ratio: String(isRecord(old.settings) ? old.settings.ratio || "1:1" : "1:1"), + duration: String(isRecord(old.settings) ? old.settings.duration || "0s" : "0s"), + resolution: String(isRecord(old.settings) ? old.settings.resolution || "2K" : "2K"), + }, + viewport: { + x: Number(viewport.x || 0), + y: Number(viewport.y || 0), + zoom: Number(viewport.zoom || viewport.scale || 1), + }, + nodes: (Array.isArray(old.nodes) ? old.nodes : []).map((n: unknown) => isRecord(n) ? { ...n, position: isRecord(n.position) ? { ...n.position } : { x: 0, y: 0 } } : n) as WebCanvasWorkflowNode[], + edges: Array.isArray(old.edges) ? old.edges : [], + packages: Array.isArray(old.packages) ? old.packages : [], + }; +} + +function cloneWorkflow(workflow: WebCanvasWorkflow): WebCanvasWorkflow { + return { + ...workflow, + settings: { ...workflow.settings }, + nodes: workflow.nodes.map((node) => ({ ...node, position: { ...node.position } })), + edges: workflow.edges.map((edge) => ({ ...edge })), + }; +} + +function normalizeProjectContent(payload: unknown, projectId: string): WebCanvasWorkflow { + const unwrapped = unwrapApiPayload(payload); + const content = isRecord(unwrapped) && "content" in unwrapped ? unwrapped.content : unwrapped; + + // New format: content.workflowData is a full WebCanvasWorkflow + if (isRecord(content) && isCanvasWorkflow(content.workflowData)) { + return cloneWorkflow({ ...content.workflowData, id: content.workflowData.id || projectId }); + } + + // New format: content.workflow is a full WebCanvasWorkflow + if (isRecord(content) && isCanvasWorkflow(content.workflow)) { + return cloneWorkflow({ ...content.workflow, id: content.workflow.id || projectId }); + } + + // Content itself is a WebCanvasWorkflow + if (isCanvasWorkflow(content)) { + return cloneWorkflow({ ...content, id: content.id || projectId }); + } + + // Legacy format: wrapper has name/description, workflowData has nodes/edges/viewport + if (isRecord(content) && isLegacyWorkflowData(content.workflowData)) { + return migrateLegacyWorkflowData(content.workflowData, content, projectId); + } + + throw new Error("Project content did not include a canvas workflow"); +} + +function normalizeLoginResult(payload: unknown): WebUserSession | null { + const unwrapped = unwrapApiPayload(payload); + if (!isRecord(unwrapped) || typeof unwrapped.token !== "string") return null; + + const user = normalizeUser(unwrapped.user ?? unwrapped); + if (!user) return null; + + return { + token: unwrapped.token, + user, + source: "server", + }; +} + +function updateStoredSessionUser(user: WebUserSession["user"]): WebUserSession | null { + const stored = readStoredSession(); + if (!stored) return null; + + const session: WebUserSession = { + ...stored, + user: { + ...stored.user, + ...user, + }, + }; + writeStoredSession(session); + return session; +} + +function normalizeUsageSummary(payload: unknown): WebUsageSummary { + const unwrapped = unwrapApiPayload(payload); + const raw = isRecord(unwrapped) ? unwrapped : {}; + + return { + balanceCents: toNumber( + raw.balanceCents ?? raw.balance_cents ?? raw.currentBalanceCents ?? raw.current_balance_cents, + ), + enterpriseBalanceCents: + toOptionalNumber( + raw.enterpriseBalanceCents ?? + raw.enterprise_balance_cents ?? + raw.enterpriseBalance ?? + raw.enterprise_balance, + ) ?? undefined, + imageUsed: toNumber(raw.imageUsed ?? raw.image_used ?? raw.imageCount ?? raw.image_count), + videoUsed: toNumber(raw.videoUsed ?? raw.video_used ?? raw.videoCount ?? raw.video_count), + textUsed: toNumber( + raw.textUsed ?? raw.text_used ?? raw.textTokens ?? raw.text_tokens ?? raw.total_prompt_tokens, + ), + source: "server", + }; +} + +function toOptionalNumber(value: unknown): number | null { + if (value === undefined || value === null || value === "") return null; + const numberValue = typeof value === "number" ? value : Number(value); + return Number.isFinite(numberValue) ? numberValue : null; +} + +function normalizeEnterpriseUsageSummary(payload: unknown): WebEnterpriseUsageSummary { + const unwrapped = unwrapApiPayload(payload); + const raw = isRecord(unwrapped) ? unwrapped : {}; + const memberRows = Array.isArray(raw.members) ? raw.members.filter(isRecord) : []; + const modelRows = Array.isArray(raw.modelBreakdown) + ? raw.modelBreakdown.filter(isRecord) + : Array.isArray(raw.model_breakdown) + ? raw.model_breakdown.filter(isRecord) + : []; + const recordRows = Array.isArray(raw.records) + ? raw.records.filter(isRecord) + : Array.isArray(raw.items) + ? raw.items.filter(isRecord) + : []; + const trendRows = Array.isArray(raw.dailyTrend) + ? raw.dailyTrend.filter(isRecord) + : Array.isArray(raw.daily_trend) + ? raw.daily_trend.filter(isRecord) + : []; + + return { + enterpriseId: toStringValue(raw.enterpriseId ?? raw.enterprise_id), + enterpriseName: toStringValue(raw.enterpriseName ?? raw.enterprise_name, "Enterprise"), + balanceCents: toNumber(raw.balanceCents ?? raw.balance_cents ?? raw.enterpriseBalanceCents ?? raw.enterprise_balance_cents), + totalUsedCents: toNumber(raw.totalUsedCents ?? raw.total_used_cents ?? raw.usedCents ?? raw.used_cents), + members: memberRows.map((member, index) => ({ + userId: toIdValue(member.userId ?? member.user_id ?? member.id, `member-${index + 1}`), + username: toStringValue(member.username ?? member.userName ?? member.user_name ?? member.name, "employee"), + displayName: toNullableString(member.displayName ?? member.display_name ?? member.nickname ?? member.name), + role: toStringValue(member.role ?? member.enterpriseRole ?? member.enterprise_role, "employee"), + usedCents: toNumber( + member.usedCents ?? + member.used_cents ?? + member.amountCents ?? + member.amount_cents ?? + member.totalUsedCents ?? + member.total_used_cents, + ), + taskCount: toNumber(member.taskCount ?? member.task_count ?? member.calls ?? member.count), + lastUsedAt: toNullableString(member.lastUsedAt ?? member.last_used_at ?? member.updatedAt ?? member.updated_at), + })), + modelBreakdown: modelRows.map((model) => ({ + model: toStringValue(model.model ?? model.modelId ?? model.model_id, "unknown"), + usedCents: toNumber(model.usedCents ?? model.used_cents ?? model.amountCents ?? model.amount_cents), + taskCount: toNumber(model.taskCount ?? model.task_count ?? model.calls ?? model.count), + })), + dailyTrend: trendRows.map((row) => ({ + date: toStringValue(row.date ?? row.day), + usedCents: toNumber(row.usedCents ?? row.used_cents ?? row.amountCents ?? row.amount_cents), + taskCount: toNumber(row.taskCount ?? row.task_count ?? row.count), + })), + records: recordRows.map((record, index) => ({ + id: toStringValue(record.id ?? record.ledgerId ?? record.ledger_id ?? record.taskId ?? record.task_id, `record-${index + 1}`), + userId: toIdValue(record.userId ?? record.user_id ?? record.memberId ?? record.member_id, "unknown"), + username: toStringValue(record.username ?? record.userName ?? record.user_name ?? record.name, "employee"), + model: toStringValue(record.model ?? record.modelId ?? record.model_id, "unknown"), + taskType: toStringValue(record.taskType ?? record.task_type ?? record.type, "image"), + resolution: toNullableString(record.resolution ?? record.quality), + durationSeconds: toOptionalNumber(record.durationSeconds ?? record.duration_seconds ?? record.duration), + amountCents: toNumber(record.amountCents ?? record.amount_cents ?? record.usedCents ?? record.used_cents), + prompt: toNullableString(record.prompt), + status: toStringValue(record.status, "completed"), + createdAt: toStringValue(record.createdAt ?? record.created_at ?? record.updatedAt ?? record.updated_at), + })), + source: "server", + }; +} + +function normalizeRechargeOrder(payload: unknown): RechargeOrderResult { + const raw = unwrapApiPayload(payload); + if (!isRecord(raw)) { + return { orderId: `local-${Date.now()}`, status: "pending", message: "订单已提交,请联系客服确认到账。" }; + } + + return { + orderId: toStringValue(raw.orderId ?? raw.order_id ?? raw.id, `local-${Date.now()}`), + status: toStringValue(raw.status, "pending"), + payUrl: toNullableString(raw.payUrl ?? raw.pay_url ?? raw.checkoutUrl ?? raw.checkout_url), + qrCodeUrl: toNullableString(raw.qrCodeUrl ?? raw.qr_code_url ?? raw.qrcodeUrl), + message: toNullableString(raw.message ?? raw.notice), + }; +} + +function buildProjectUpsertPayload(workflow: WebCanvasWorkflow, session: WebUserSession): Record { + const userId = String(session.user.id).replace(/[^a-zA-Z0-9_-]/g, ""); + const projectId = workflow.id.trim(); + const ossKey = `users/${userId}/projects/${projectId}/current/project.json`; + + return { + id: projectId, + name: workflow.title.trim() || "未命名项目", + description: workflow.description.trim() || null, + ossKey, + thumbnailUrl: workflow.nodes.find((node) => node.previewUrl)?.previewUrl || null, + storyboardCount: workflow.nodes.length, + imageCount: workflow.nodes.filter((node) => node.kind === "image").length, + videoCount: workflow.nodes.filter((node) => node.kind === "video").length, + fileSize: JSON.stringify(workflow).length, + fingerprint: createWorkflowFingerprint(workflow), + deviceId: "web", + baseRevision: null, + forceOverwrite: true, + saveReason: "create", + }; +} + +export const keyServerClient = { + getBaseUrl, + getStoredSession: readStoredSession, + updateStoredSessionUser, + clearSession() { + writeStoredSession(null); + }, + async login(input: LoginInput): Promise { + const session = normalizeLoginResult( + await request("/auth/login", { + method: "POST", + body: { + username: input.username.trim(), + password: input.password, + }, + }), + ); + if (!session) { + throw new Error("Login response did not include a token and user"); + } + + writeStoredSession(session); + return session; + }, + async register(input: RegisterInput): Promise { + const session = normalizeLoginResult( + await request("/auth/register", { + method: "POST", + body: { + username: input.username.trim(), + password: input.password, + betaCode: input.betaCode.trim(), + }, + }), + ); + if (!session) { + throw new Error("Register response did not include a token and user"); + } + + writeStoredSession(session); + return session; + }, + async loginEmail(input: EmailAuthInput): Promise { + const session = normalizeLoginResult( + await request("/auth/login-email", { + method: "POST", + body: { + email: input.email.trim(), + password: input.password, + }, + }), + ); + if (!session) { + throw new Error("Email login response did not include a token and user"); + } + + writeStoredSession(session); + return session; + }, + async registerEmail(input: EmailAuthInput): Promise { + const session = normalizeLoginResult( + await request("/auth/register-email", { + method: "POST", + body: { + email: input.email.trim(), + username: input.username?.trim() || undefined, + password: input.password, + code: input.code?.trim() || undefined, + betaCode: input.betaCode?.trim() || undefined, + }, + }), + ); + if (!session) { + throw new Error("Email register response did not include a token and user"); + } + + writeStoredSession(session); + return session; + }, + async sendSmsCode(phone: string, purpose: "login" | "register", betaCode?: string): Promise<{ cooldownSeconds?: number; ttlSeconds?: number }> { + return request<{ cooldownSeconds?: number; ttlSeconds?: number }>("/auth/sms/send", { + method: "POST", + body: { phone: phone.trim(), purpose, betaCode: betaCode?.trim() || undefined }, + }); + }, + async sendEmailCode(email: string, purpose: "login" | "register" | "reset", betaCode?: string): Promise<{ cooldownSeconds?: number; ttlSeconds?: number; devCode?: string }> { + return request<{ cooldownSeconds?: number; ttlSeconds?: number; devCode?: string }>("/auth/email/send-code", { + method: "POST", + body: { email: email.trim(), purpose, betaCode: betaCode?.trim() || undefined }, + }); + }, + async verifyEmail(input: EmailCodeInput): Promise<{ success: boolean }> { + return request<{ success: boolean }>("/auth/email/verify", { + method: "POST", + body: { email: input.email.trim(), code: input.code.trim(), purpose: input.purpose || "register" }, + }); + }, + async forgotPassword(input: ForgotPasswordInput): Promise<{ success: boolean; message?: string }> { + return request<{ success: boolean; message?: string }>("/auth/forgot-password", { + method: "POST", + body: { email: input.email.trim() }, + }); + }, + async resetPassword(input: ResetPasswordInput): Promise<{ success: boolean; message?: string }> { + return request<{ success: boolean; message?: string }>("/auth/reset-password", { + method: "POST", + body: { email: input.email.trim(), code: input.code.trim(), newPassword: input.newPassword }, + }); + }, + async loginPhone(input: PhoneAuthInput): Promise { + const session = normalizeLoginResult( + await request("/auth/login-phone", { + method: "POST", + body: { + phone: input.phone.trim(), + code: input.code.trim(), + }, + }), + ); + if (!session) { + throw new Error("Phone login response did not include a token and user"); + } + + writeStoredSession(session); + return session; + }, + async registerPhone(input: PhoneAuthInput): Promise { + const session = normalizeLoginResult( + await request("/auth/register-phone", { + method: "POST", + body: { + phone: input.phone.trim(), + code: input.code.trim(), + password: input.password || "", + betaCode: input.betaCode?.trim() || undefined, + }, + }), + ); + if (!session) { + throw new Error("Phone register response did not include a token and user"); + } + + writeStoredSession(session); + return session; + }, + async getWechatLoginTicket(): Promise { + const browserCrypto = typeof globalThis !== "undefined" ? globalThis.crypto : undefined; + const state = + browserCrypto && "randomUUID" in browserCrypto + ? browserCrypto.randomUUID().replace(/-/g, "") + : `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`; + return request(`/auth/wechat/login-url?state=${encodeURIComponent(state)}`); + }, + async getWechatLoginSession(state: string): Promise { + const response = await request(`/auth/wechat/session?state=${encodeURIComponent(state)}`); + const raw = isRecord(response) ? response : {}; + const session = normalizeLoginResult(raw); + if (session) { + writeStoredSession(session); + return { status: "completed", session }; + } + + const status = toStringValue(raw.status, "pending"); + return { + status, + error: toNullableString(raw.error) ?? undefined, + }; + }, + async updateProfile(input: UpdateProfileInput): Promise { + const user = normalizeUser( + await request("/auth/profile", { + method: "PUT", + body: { + ...input, + profileBackgroundUrl: input.profileBackgroundUrl ?? input.backgroundUrl ?? undefined, + }, + }), + ); + if (!user) { + throw new Error("Profile response did not include a user"); + } + + updateStoredSessionUser(user); + return user; + }, + async getCurrentSession(): Promise { + const stored = readStoredSession(); + if (!stored) { + return null; + } + + try { + const user = normalizeUser(await request("/auth/me", { token: stored.token })); + if (!user) { + throw new Error("Current-user response did not include a user"); + } + + const session: WebUserSession = { ...stored, user, source: "server", errorMessage: undefined }; + writeStoredSession(session); + return session; + } catch (error) { + if (isHttpError(error) && (error.status === 401 || error.status === 403)) { + writeStoredSession(null); + return null; + } + return { + ...stored, + source: "server", + errorMessage: getErrorMessage(error), + }; + } + }, + async listProjects(): Promise { + const summaries = extractProjectRows(await request("/projects")).map((project) => + toProjectSummary(project, "server"), + ); + return enrichProjectSummariesWithContent(summaries); + }, + async getProjectContent(projectId: string): Promise { + const stored = readStoredSession(); + if (!stored) { + throw new Error("需要先登录"); + } + + const safeProjectId = encodeURIComponent(projectId.trim()); + if (!safeProjectId) { + throw new Error("Project id is required"); + } + + const response = await request(`/projects/${safeProjectId}/content?resolveMedia=1`); + return normalizeProjectContent(response, projectId); + }, + async getUsageSummary(): Promise { + const stored = readStoredSession(); + return normalizeUsageSummary(await request("/user/usage/summary", { token: stored?.token })); + }, + async getEnterpriseUsageSummary(): Promise { + const stored = readStoredSession(); + return normalizeEnterpriseUsageSummary(await request("/enterprise/usage/summary", { token: stored?.token })); + }, + async getPersonalUsageSummary(): Promise { + const stored = readStoredSession(); + return normalizeEnterpriseUsageSummary(await request("/user/usage/credits", { token: stored?.token })); + }, + async createRechargeOrder(input: RechargeOrderInput): Promise { + const response = await request("/payments/recharge-orders", { + method: "POST", + body: input, + }); + return normalizeRechargeOrder(response); + }, + async createProjectSpace(workflow: WebCanvasWorkflow): Promise { + const stored = readStoredSession(); + if (!stored) { + const error = new Error("需要先登录"); + throw error; + } + + const payload = buildProjectUpsertPayload(workflow, stored); + const response = await request("/projects/upsert", { + method: "POST", + body: payload, + }); + const projectPayload = isRecord(response) ? response.project ?? response : response; + if (!isRecord(projectPayload)) { + throw new Error("Project response did not include a project"); + } + + return toProjectSummary(projectPayload, "server"); + }, + async saveProjectContent(projectId: string, workflow: WebCanvasWorkflow): Promise { + const stored = readStoredSession(); + if (!stored) { + throw new Error("需要先登录"); + } + + const response = await request(`projects/${encodeURIComponent(projectId)}/content`, { + method: "PUT", + body: { + content: { + name: workflow.title, + description: workflow.description, + workflowData: workflow, + nodes: workflow.nodes, + edges: workflow.edges, + }, + meta: { + name: workflow.title, + description: workflow.description, + thumbnailUrl: workflow.nodes.find((node) => node.previewUrl)?.previewUrl || null, + storyboardCount: workflow.nodes.length, + imageCount: workflow.nodes.filter((node) => node.kind === "image").length, + videoCount: workflow.nodes.filter((node) => node.kind === "video").length, + }, + saveReason: "web-create", + deviceId: "web", + forceOverwrite: true, + }, + }); + const rawProject = isRecord(response) && isRecord(response.project) ? response.project : response; + if (!isRecord(rawProject)) { + return buildProjectSummaryFromWorkflow(workflow, "server"); + } + return toProjectSummary(rawProject, "server"); + }, + async deleteProject(projectId: string, options?: DeleteProjectOptions): Promise { + const stored = readStoredSession(); + if (!stored) { + throw new Error("需要先登录"); + } + + const path = options?.cleanupUserData ? `projects/${encodeURIComponent(projectId)}?cleanupUserData=1` : `projects/${encodeURIComponent(projectId)}`; + await request(path, { + method: "DELETE", + }); + }, + + async getClientErrors(page = 1): Promise<{ items: import("../components/AdminMonitor").ClientErrorItem[]; total: number }> { + const data = await request<{ items: import("../components/AdminMonitor").ClientErrorItem[]; total: number }>(`/client-errors?page=${page}`); + return data; + }, +}; diff --git a/src/api/modelCapabilitiesClient.ts b/src/api/modelCapabilitiesClient.ts new file mode 100644 index 0000000..22614bf --- /dev/null +++ b/src/api/modelCapabilitiesClient.ts @@ -0,0 +1,104 @@ +import { isOptionalApiRouteMissing } from "./apiErrorUtils"; +import { isRecord, serverRequest } from "./serverConnection"; + +export interface ModelCapabilityOption { + value: string; + label: string; + description?: string; + badge?: string; + enabled?: boolean; + status?: "available" | "maintenance" | "disabled" | string; +} + +export interface WebModelCapabilities { + source: "server" | "fallback"; + imageModels: ModelCapabilityOption[]; + videoModels: ModelCapabilityOption[]; + chatModels: ModelCapabilityOption[]; + updatedAt?: string; +} + +function toStringValue(value: unknown, fallback = ""): string { + if (typeof value === "string") return value.trim() || fallback; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return fallback; +} + +function normalizeModelOption(raw: unknown): ModelCapabilityOption | null { + if (typeof raw === "string") { + const value = raw.trim(); + return value ? { value, label: value } : null; + } + if (!isRecord(raw)) return null; + + const value = toStringValue(raw.value ?? raw.id ?? raw.model ?? raw.modelKey ?? raw.model_key); + if (!value) return null; + + const status = toStringValue(raw.status); + const enabled = raw.enabled === undefined ? status !== "maintenance" && status !== "disabled" : Boolean(raw.enabled); + if (!enabled) return null; + + const label = toStringValue(raw.label ?? raw.displayName ?? raw.display_name ?? raw.name, value); + + return { + value, + label: + value === "wan2.7-image-pro" + ? label.replace(/\s*4k\b/i, "").trim() || "wan 2.7 Pro" + : label, + description: toStringValue(raw.description) || undefined, + badge: toStringValue(raw.badge) || undefined, + enabled, + status: status || "available", + }; +} + +function normalizeModelList(value: unknown): ModelCapabilityOption[] { + if (!Array.isArray(value)) return []; + const options: ModelCapabilityOption[] = []; + for (const item of value) { + const option = normalizeModelOption(item); + if (option) options.push(option); + } + return options; +} + +function createFallbackCapabilities(): WebModelCapabilities { + return { + source: "fallback", + imageModels: [], + videoModels: [], + chatModels: [], + }; +} + +let modelCapabilitiesRouteMissing = false; + +export const modelCapabilitiesClient = { + async get(name = "web-model-capabilities"): Promise { + if (modelCapabilitiesRouteMissing) return createFallbackCapabilities(); + + let payload: unknown; + try { + payload = await serverRequest(`public/config/profile?name=${encodeURIComponent(name)}`); + } catch (error) { + if (isOptionalApiRouteMissing(error)) { + modelCapabilitiesRouteMissing = true; + return createFallbackCapabilities(); + } + throw error; + } + + const raw = isRecord(payload) && isRecord(payload.config) ? payload.config : payload; + const config = isRecord(raw) ? raw : {}; + const models = isRecord(config.models) ? config.models : {}; + + return { + source: "server", + imageModels: normalizeModelList(config.imageModels ?? config.image_models ?? models.image), + videoModels: normalizeModelList(config.videoModels ?? config.video_models ?? models.video), + chatModels: normalizeModelList(config.chatModels ?? config.chat_models ?? models.chat ?? models.agent ?? models.text), + updatedAt: toStringValue((payload as { updatedAt?: unknown; updated_at?: unknown })?.updatedAt ?? (payload as { updated_at?: unknown })?.updated_at), + }; + }, +}; diff --git a/src/api/notificationClient.ts b/src/api/notificationClient.ts new file mode 100644 index 0000000..b50afb4 --- /dev/null +++ b/src/api/notificationClient.ts @@ -0,0 +1,173 @@ +import type { WebNotification, WebNotificationType, WebViewKey } from "../types"; +import { isOptionalApiRouteMissing } from "./apiErrorUtils"; +import { isRecord, isServerRequestError, serverRequest, writeStoredSession } from "./serverConnection"; + +interface CreateNotificationInput { + type: WebNotificationType; + title: string; + description?: string; + targetType?: string; + targetId?: string; + metadata?: Record; +} + +const NOTIFICATION_VIEW_BY_TARGET: Record = { + task: "workbench", + generation_task: "workbench", + community_case: "login", + asset: "assets", + project: "canvas", + draft: "workbench", +}; + +let notificationsRouteMissing = false; +let notificationsUnauthorized = false; + +function toStringValue(value: unknown, fallback = ""): string { + if (typeof value === "string") return value.trim() || fallback; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return fallback; +} + +function normalizeType(value: unknown): WebNotificationType { + const type = toStringValue(value); + if ( + type === "task_completed" || + type === "task_failed" || + type === "review_pending" || + type === "review_passed" || + type === "review_rejected" || + type === "credits_low" || + type === "session_expired" + ) { + return type; + } + return "info"; +} + +function normalizeNotification(raw: unknown): WebNotification { + const item = isRecord(raw) ? raw : {}; + const targetType = toStringValue(item.targetType ?? item.target_type) || null; + const targetId = toStringValue(item.targetId ?? item.target_id) || undefined; + const readAt = toStringValue(item.readAt ?? item.read_at) || null; + return { + id: toStringValue(item.id, `notice-${Date.now()}`), + type: normalizeType(item.type), + title: toStringValue(item.title, "通知"), + description: toStringValue(item.description), + createdAt: toStringValue(item.createdAt ?? item.created_at, new Date().toISOString()), + isRead: Boolean(item.isRead ?? item.is_read ?? readAt), + targetType, + targetId, + targetView: targetType ? NOTIFICATION_VIEW_BY_TARGET[targetType] : undefined, + readAt, + metadata: isRecord(item.metadata) ? item.metadata : {}, + }; +} + +function extractNotifications(payload: unknown): WebNotification[] { + if (Array.isArray(payload)) return payload.map(normalizeNotification); + if (!isRecord(payload)) return []; + const rows = payload.notifications ?? payload.items; + return Array.isArray(rows) ? rows.map(normalizeNotification) : []; +} + +function isUnauthorized(error: unknown): boolean { + return isServerRequestError(error) && (error.status === 401 || error.status === 403); +} + +function handleUnauthorizedNotifications(): void { + notificationsUnauthorized = true; + writeStoredSession(null); +} + +export const notificationClient = { + async list(): Promise { + if (notificationsRouteMissing || notificationsUnauthorized) return []; + try { + return extractNotifications(await serverRequest("notifications")); + } catch (error) { + if (isOptionalApiRouteMissing(error)) { + notificationsRouteMissing = true; + return []; + } + if (isUnauthorized(error)) { + handleUnauthorizedNotifications(); + return []; + } + throw error; + } + }, + + async create(input: CreateNotificationInput): Promise { + if (notificationsRouteMissing || notificationsUnauthorized) { + return normalizeNotification({ + ...input, + id: `local-notice-${Date.now()}`, + createdAt: new Date().toISOString(), + }); + } + try { + const payload = await serverRequest<{ notification: unknown }>("notifications", { + method: "POST", + body: input, + }); + return normalizeNotification(payload.notification ?? payload); + } catch (error) { + if (isOptionalApiRouteMissing(error)) { + notificationsRouteMissing = true; + return normalizeNotification({ + ...input, + id: `local-notice-${Date.now()}`, + createdAt: new Date().toISOString(), + }); + } + if (isUnauthorized(error)) { + handleUnauthorizedNotifications(); + return normalizeNotification({ + ...input, + id: `local-notice-${Date.now()}`, + createdAt: new Date().toISOString(), + }); + } + throw error; + } + }, + + async markRead(id: string, isRead = true): Promise { + if (notificationsRouteMissing || notificationsUnauthorized) return; + try { + await serverRequest(`notifications/${id}/read`, { + method: "PATCH", + body: { isRead }, + }); + } catch (error) { + if (isOptionalApiRouteMissing(error)) { + notificationsRouteMissing = true; + return; + } + if (isUnauthorized(error)) { + handleUnauthorizedNotifications(); + return; + } + throw error; + } + }, + + async markAllRead(): Promise { + if (notificationsRouteMissing || notificationsUnauthorized) return; + try { + await serverRequest("notifications/read-all", { method: "POST" }); + } catch (error) { + if (isOptionalApiRouteMissing(error)) { + notificationsRouteMissing = true; + return; + } + if (isUnauthorized(error)) { + handleUnauthorizedNotifications(); + return; + } + throw error; + } + }, +}; diff --git a/src/api/projectTaskClient.ts b/src/api/projectTaskClient.ts new file mode 100644 index 0000000..65c96aa --- /dev/null +++ b/src/api/projectTaskClient.ts @@ -0,0 +1,154 @@ +import type { WebGenerationPreviewTask } from "../types"; +import { isRecord, serverRequest } from "./serverConnection"; + +type ServerTaskStatus = "pending" | "running" | "completed" | "failed" | "cancelled"; + +interface ServerProjectTask { + id: string; + projectId?: string | null; + clientQueueId?: string | null; + type: "image" | "video"; + status: ServerTaskStatus; + params?: Record; + resultUrl?: string | null; + progress?: number; + error?: string | null; + createdAt?: string; + updatedAt?: string; +} + +export interface ProjectTaskUpsertInput { + clientQueueId: string; + type: "image" | "video"; + status: ServerTaskStatus; + params?: Record; + providerTaskId?: string | null; + resultUrl?: string | null; + progress?: number; + error?: string | null; + dedupeKey?: string | null; + sourceDeviceId?: string | null; + createdAt?: string | null; + completedAt?: string | null; +} + +function toStringValue(value: unknown, fallback = ""): string { + if (typeof value === "string") return value.trim() || fallback; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return fallback; +} + +function toNumber(value: unknown, fallback = 0): number { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : fallback; +} + +function normalizeStatus(value: unknown): WebGenerationPreviewTask["status"] { + const status = toStringValue(value); + if (status === "running" || status === "completed" || status === "failed") return status; + if (status === "cancelled") return "failed"; + return "queued"; +} + +function normalizeTask(raw: unknown): ServerProjectTask | null { + if (!isRecord(raw)) return null; + const type = toStringValue(raw.type); + if (type !== "image" && type !== "video") return null; + + return { + id: toStringValue(raw.id), + projectId: toStringValue(raw.projectId ?? raw.project_id) || null, + clientQueueId: toStringValue(raw.clientQueueId ?? raw.client_queue_id) || null, + type, + status: toStringValue(raw.status, "pending") as ServerTaskStatus, + params: isRecord(raw.params) ? raw.params : {}, + resultUrl: toStringValue(raw.resultUrl ?? raw.result_url) || null, + progress: toNumber(raw.progress), + error: toStringValue(raw.error) || null, + createdAt: toStringValue(raw.createdAt ?? raw.created_at), + updatedAt: toStringValue(raw.updatedAt ?? raw.updated_at), + }; +} + +function extractTasks(payload: unknown): ServerProjectTask[] { + const normalizeTasks = (rows: unknown[]): ServerProjectTask[] => { + const tasks: ServerProjectTask[] = []; + for (const row of rows) { + const task = normalizeTask(row); + if (task) tasks.push(task); + } + return tasks; + }; + + if (Array.isArray(payload)) return normalizeTasks(payload); + if (!isRecord(payload)) return []; + const rows = payload.tasks ?? payload.items; + return Array.isArray(rows) ? normalizeTasks(rows) : []; +} + +function taskTitle(task: ServerProjectTask): string { + const prompt = toStringValue(task.params?.prompt); + if (prompt) return prompt.length > 20 ? `${prompt.slice(0, 20)}...` : prompt; + return task.type === "video" ? "视频生成任务" : "图像生成任务"; +} + +function toPreviewTask(task: ServerProjectTask): WebGenerationPreviewTask { + return { + id: task.clientQueueId || task.id, + title: taskTitle(task), + type: task.type, + status: normalizeStatus(task.status), + progress: Math.max(0, Math.min(100, Math.trunc(task.progress || 0))), + prompt: toStringValue(task.params?.prompt, taskTitle(task)), + createdAt: task.createdAt || task.updatedAt || "", + projectId: task.projectId || undefined, + outputUrl: task.resultUrl || undefined, + source: "server", + errorMessage: task.error || undefined, + }; +} + +async function listProjectTasks(projectId: string): Promise { + const payload = await serverRequest(`projects/${encodeURIComponent(projectId)}/tasks`); + return extractTasks(payload).map(toPreviewTask); +} + +export const projectTaskClient = { + async list(projectId: string): Promise { + return listProjectTasks(projectId); + }, + + async listForProjects(projectIds: string[]): Promise { + const uniqueIds = new Set(); + for (const projectId of projectIds) { + const id = projectId.trim(); + if (id) uniqueIds.add(id); + } + const results = await Promise.all(Array.from(uniqueIds, (id) => listProjectTasks(id))); + return results.flat(); + }, + + async upsert(projectId: string, input: ProjectTaskUpsertInput): Promise { + const payload = await serverRequest<{ task: unknown }>( + `projects/${encodeURIComponent(projectId)}/tasks/upsert`, + { + method: "POST", + body: input, + }, + ); + const task = normalizeTask(payload.task ?? payload); + if (!task) throw new Error("Project task response did not include a task"); + return toPreviewTask(task); + }, + + async batchUpsert(projectId: string, tasks: ProjectTaskUpsertInput[]): Promise { + const payload = await serverRequest<{ tasks: unknown }>( + `projects/${encodeURIComponent(projectId)}/tasks/batch-upsert`, + { + method: "POST", + body: { tasks }, + }, + ); + return extractTasks(payload).map(toPreviewTask); + }, +}; diff --git a/src/api/providerHealthClient.ts b/src/api/providerHealthClient.ts new file mode 100644 index 0000000..6552661 --- /dev/null +++ b/src/api/providerHealthClient.ts @@ -0,0 +1,39 @@ +import { serverRequest } from "./serverConnection"; + +export interface ProviderHealthEntry { + status: string; + lastCheck: string | null; + lastError: string | null; + details: Record | null; +} + +export interface CallStatRow { + provider: string; + model: string; + status: string; + count: string; + avg_ms: string | null; + total_cost: string | null; +} + +export interface KeyStatRow { + provider: string; + total_keys: string; + active_keys: string; + current_load: string; +} + +export interface ProviderHealthResponse { + health: Record; + callStats: CallStatRow[]; + keyStats: KeyStatRow[]; + checkedAt: string; +} + +export const providerHealthClient = { + async getStatus(): Promise { + return serverRequest("admin/providers/status", { + fallbackMessage: "Provider health request failed", + }); + }, +}; diff --git a/src/api/publicConfigClient.ts b/src/api/publicConfigClient.ts new file mode 100644 index 0000000..6398ede --- /dev/null +++ b/src/api/publicConfigClient.ts @@ -0,0 +1,51 @@ +import { isOptionalApiRouteMissing } from "./apiErrorUtils"; +import { isRecord, serverRequest } from "./serverConnection"; + +export interface WebPublicConfig { + contactEmail?: string; + contactPhone?: string; + companyAddress?: string; + icpRecord?: string; +} + +function readString(config: Record, keys: string[]): string | undefined { + for (const key of keys) { + const value = config[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + return undefined; +} + +function normalizePublicConfig(raw: unknown): WebPublicConfig { + const config = isRecord(raw) && isRecord(raw.config) ? raw.config : raw; + if (!isRecord(config)) return {}; + + return { + contactEmail: readString(config, ["contactEmail", "contact_email", "supportEmail", "support_email"]), + contactPhone: readString(config, ["contactPhone", "contact_phone", "supportPhone", "support_phone"]), + companyAddress: readString(config, ["companyAddress", "company_address", "address"]), + icpRecord: readString(config, ["icpRecord", "icp_record", "filingInfo", "filing_info"]), + }; +} + +let cachedPublicConfig: WebPublicConfig | null = null; +let publicConfigRouteMissing = false; + +export const publicConfigClient = { + async get(): Promise { + if (cachedPublicConfig) return cachedPublicConfig; + if (publicConfigRouteMissing) return {}; + + try { + const payload = await serverRequest("public/config/profile?name=web-public-config"); + cachedPublicConfig = normalizePublicConfig(payload); + return cachedPublicConfig; + } catch (error) { + if (isOptionalApiRouteMissing(error)) { + publicConfigRouteMissing = true; + return {}; + } + throw error; + } + }, +}; diff --git a/src/api/referenceUploadService.ts b/src/api/referenceUploadService.ts new file mode 100644 index 0000000..89f60ff --- /dev/null +++ b/src/api/referenceUploadService.ts @@ -0,0 +1,84 @@ +import { aiGenerationClient } from "./aiGenerationClient"; + +interface UploadEntry { + promise: Promise; + url: string | null; + status: "pending" | "done" | "failed"; +} + +const uploadCache = new Map(); + +function fileToDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || "")); + reader.onerror = () => reject(reader.error || new Error("文件读取失败")); + reader.readAsDataURL(file); + }); +} + +function buildCacheKey(file: File, fingerprint?: string): string { + if (fingerprint) return fingerprint; + return `${file.name}__${file.size}__${file.lastModified}`; +} + +export function preUploadReference( + file: File, + name: string, + fingerprint?: string, +): Promise { + const key = buildCacheKey(file, fingerprint); + const cached = uploadCache.get(key); + if (cached) return cached.promise; + + const scope = file.type.startsWith("video/") ? "reference-video" : "reference-image"; + + const promise = (async () => { + try { + const dataUrl = await fileToDataUrl(file); + const uploaded = await aiGenerationClient.uploadAsset({ + dataUrl, + name, + mimeType: file.type, + scope, + }); + const entry = uploadCache.get(key); + if (entry) { + entry.url = uploaded.url; + entry.status = "done"; + } + return uploaded.url; + } catch (error) { + const entry = uploadCache.get(key); + if (entry) entry.status = "failed"; + console.warn("[referenceUpload] pre-upload failed:", error); + return null; + } + })(); + + uploadCache.set(key, { promise, url: null, status: "pending" }); + return promise; +} + +export function getPreUploadedUrl( + file: File, + fingerprint?: string, +): string | null { + const key = buildCacheKey(file, fingerprint); + return uploadCache.get(key)?.url ?? null; +} + +export async function resolvePreUploadedUrl( + file: File, + name: string, + fingerprint?: string, +): Promise { + const key = buildCacheKey(file, fingerprint); + const cached = uploadCache.get(key); + if (cached) return cached.promise; + return preUploadReference(file, name, fingerprint); +} + +export function clearUploadCache(): void { + uploadCache.clear(); +} diff --git a/src/api/reportClient.ts b/src/api/reportClient.ts new file mode 100644 index 0000000..12a02bb --- /dev/null +++ b/src/api/reportClient.ts @@ -0,0 +1,71 @@ +import { serverRequest } from "./serverConnection"; + +export interface ReportInput { + reportType: string; + targetType?: string; + targetId?: string; + contactName?: string; + contactEmail?: string; + contactPhone?: string; + title: string; + description: string; + pageUrl?: string; +} + +export interface AdminReportItem { + id: number; + userId?: number | null; + username?: string | null; + reportType?: string | null; + targetType?: string | null; + targetId?: string | null; + contactName?: string | null; + contactEmail?: string | null; + contactPhone?: string | null; + title: string; + description: string; + pageUrl?: string | null; + status: string; + ipAddress?: string | null; + userAgent?: string | null; + createdAt: string; + updatedAt?: string | null; +} + +function normalizeReport(raw: unknown): AdminReportItem { + const item = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record) : {}; + return { + id: Number(item.id) || 0, + userId: item.userId == null ? null : Number(item.userId), + username: typeof item.username === "string" ? item.username : null, + reportType: typeof item.reportType === "string" ? item.reportType : null, + targetType: typeof item.targetType === "string" ? item.targetType : null, + targetId: typeof item.targetId === "string" ? item.targetId : null, + contactName: typeof item.contactName === "string" ? item.contactName : null, + contactEmail: typeof item.contactEmail === "string" ? item.contactEmail : null, + contactPhone: typeof item.contactPhone === "string" ? item.contactPhone : null, + title: typeof item.title === "string" && item.title.trim() ? item.title : "未命名举报", + description: typeof item.description === "string" ? item.description : "", + pageUrl: typeof item.pageUrl === "string" ? item.pageUrl : null, + status: typeof item.status === "string" && item.status.trim() ? item.status : "pending", + ipAddress: typeof item.ipAddress === "string" ? item.ipAddress : null, + userAgent: typeof item.userAgent === "string" ? item.userAgent : null, + createdAt: typeof item.createdAt === "string" ? item.createdAt : "", + updatedAt: typeof item.updatedAt === "string" ? item.updatedAt : null, + }; +} + +export const reportClient = { + async submit(input: ReportInput): Promise<{ id: number; status: string; createdAt: string }> { + const payload = await serverRequest<{ report: { id: number; status: string; createdAt: string } }>("reports", { + method: "POST", + body: input, + }); + return payload.report; + }, + + async listAdminReports(): Promise { + const payload = await serverRequest<{ reports?: unknown[] }>("admin/reports"); + return Array.isArray(payload.reports) ? payload.reports.map(normalizeReport) : []; + }, +}; diff --git a/src/api/scriptEvalClient.ts b/src/api/scriptEvalClient.ts new file mode 100644 index 0000000..bc11345 --- /dev/null +++ b/src/api/scriptEvalClient.ts @@ -0,0 +1,204 @@ +import { serverRequest } from "./serverConnection"; + +export interface ScriptEvalResult { + totalScore: number; + grade: string; + dimensionScores: Record; + subScores?: Record>; + evidence?: Record; + summary: string; + issues: string[]; + highlights: string[]; + suggestions: string[]; +} + +const MODEL = "qwen3.7-max"; + +const EVAL_OUTPUT_CONTRACT = ` +强制输出 JSON,主维度键名必须严格为: +hook(20), plot(20), character(15), logic(15), visual(15), content(15)。 +不要把 dialogue 作为主维度返回;台词对白作为 character/plot/content 的证据和子项分析。 + +同时返回 subScores 和 evidence: +- subScores:每个主维度 3-5 个细分参数,分值按该维度满分拆分。 +- evidence:每个主维度 1-3 条具体证据,必须指向场景、台词、设定、冲突或段落。 + +返回结构: +{ + "dimensionScores": { "hook": 数字, "plot": 数字, "character": 数字, "logic": 数字, "visual": 数字, "content": 数字 }, + "subScores": { + "hook": { "openingImpact": 数字, "suspenseChain": 数字, "sceneHook": 数字 }, + "plot": { "structure": 数字, "rhythm": 数字, "conflict": 数字, "reversal": 数字 }, + "character": { "motivation": 数字, "arc": 数字, "voice": 数字, "relationship": 数字 }, + "logic": { "causality": 数字, "worldRules": 数字, "foreshadowing": 数字, "continuity": 数字 }, + "visual": { "sceneDetail": 数字, "shotPotential": 数字, "aigcFeasibility": 数字 }, + "content": { "theme": 数字, "emotion": 数字, "marketFit": 数字, "originality": 数字 } + }, + "evidence": { "hook": ["..."], "plot": ["..."], "character": ["..."], "logic": ["..."], "visual": ["..."], "content": ["..."] }, + "summary": "200-300字综合评价", + "issues": ["具体扣分点,带维度和证据", ...], + "highlights": ["具体亮点,带维度和证据", ...], + "suggestions": ["按优先级排列的改稿建议", ...] +}`; + +const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。 + +【剧本类型识别】 +收到剧本后,首先判断类型:AIGC短剧/漫剧(单集5-30分钟,竖屏平台,高密度反转、强节奏)或传统影视剧本(单集40分钟以上,长视频平台,完整起承转合)。类型判定将影响各维度的评价侧重点。 + +【评分体系(100分制,六个维度)】 +1. hook 钩子设计(20分):开篇钩子、集末钩子、场景内钩子、悬念链完整性。短剧前3秒须有即时爆点;长剧第一幕结束前须建立核心悬念。 +2. plot 剧情结构(20分):结构框架、节奏控制、冲突设计、逻辑自洽。短剧"每分钟有事件",反转密度加分;长剧需处理好B线C线与主线交织。 +3. character 角色塑造(18分):主角弧光、角色辨识度、角色动机、配角质量。短剧角色须在前2分钟建立;长剧需要内在矛盾和多阶段成长。 +4. dialogue 台词对白(15分):角色语言区分度、信息传递效率、潜台词与留白、金句与记忆点。 +5. visual 画面表现(15分):场景描写质量、视觉叙事技巧、镜头感与节奏、制作可行性。AIGC需考虑AI生成技术边界与一致性。 +6. content 内容深度(12分):主题表达、情感共鸣、社会/人性洞察。 + +【评分铁律】 +- 扣分必须明确指出剧本中的具体段落/场景/台词。 +- 严禁给出任何维度的满分,必须有扣分理由。 +- 优缺点都要充分展开,不可只批不夸或只夸不批。 +- 不因题材类型偏见降低评分,不因某一方面出色而抬高其他维度(避免光环效应)。 +- 敢于拉开各维度分数差距,避免全部给中等分数。 + +【等级标准】按总分百分比:S≥90 | A 80-89 | B 70-79 | C 60-69 | D<60。 + +请严格按以下 JSON 格式返回(不要包含任何其他文字,不要用代码块包裹以外的说明): +{ + "dimensionScores": { "hook": 数字, "plot": 数字, "character": 数字, "dialogue": 数字, "visual": 数字, "content": 数字 }, + "summary": "200-300字综合评价,概括整体质量、市场潜力与目标受众匹配度", + "issues": ["每条指出具体维度的扣分点并引用剧本原文位置", ...], + "highlights": ["核心亮点,引用剧本具体场景", ...], + "suggestions": ["按优先级排列的改进建议(最优先/次优先/可优化)", ...] +}`; + +const DIMENSION_WEIGHTS: Record = { + hook: { maxScore: 20 }, + plot: { maxScore: 20 }, + character: { maxScore: 15 }, + logic: { maxScore: 15 }, + visual: { maxScore: 15 }, + content: { maxScore: 15 }, +}; + +function computeTotalAndGrade(scores: Record): { totalScore: number; grade: string } { + const totalScore = Math.round( + Object.entries(DIMENSION_WEIGHTS).reduce((sum, [key, dim]) => { + return sum + Math.max(0, Math.min(dim.maxScore, scores[key] ?? 0)); + }, 0), + ); + const grade = totalScore >= 90 ? "S" : totalScore >= 80 ? "A" : totalScore >= 70 ? "B" : totalScore >= 60 ? "C" : "D"; + return { totalScore, grade }; +} + +function extractJson(text: string): unknown { + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/); + const raw = fenced ? fenced[1].trim() : text.trim(); + return JSON.parse(raw); +} + +function normalizeScoreValue(value: unknown, maxScore: number): number { + const score = Number(value); + if (!Number.isFinite(score)) return 0; + return Math.max(0, Math.min(maxScore, Math.round(score * 10) / 10)); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function normalizeEvidenceItems(source: unknown[], limit: number): string[] { + const items: string[] = []; + for (const item of source) { + const value = String(item).trim(); + if (!value) continue; + items.push(value); + if (items.length >= limit) break; + } + return items; +} + +function normalizeNestedScores(value: unknown): Record> { + if (!isRecord(value)) return {}; + + const normalized: Record> = {}; + for (const [dimensionKey, dimension] of Object.entries(DIMENSION_WEIGHTS)) { + const source = value[dimensionKey] ?? (dimensionKey === "logic" ? value.dialogue : undefined); + if (!isRecord(source)) continue; + + const entries = Object.entries(source) + .map(([key, score]) => [key, normalizeScoreValue(score, dimension.maxScore)] as const) + .filter(([, score]) => score > 0); + if (entries.length > 0) normalized[dimensionKey] = Object.fromEntries(entries); + } + + return normalized; +} + +function normalizeEvidence(value: unknown): Record { + if (!isRecord(value)) return {}; + + const normalized: Record = {}; + for (const dimensionKey of Object.keys(DIMENSION_WEIGHTS)) { + const source = value[dimensionKey] ?? (dimensionKey === "logic" ? value.dialogue : undefined); + if (!Array.isArray(source)) continue; + + const items = normalizeEvidenceItems(source, 3); + if (items.length > 0) normalized[dimensionKey] = items; + } + + return normalized; +} + +export async function evaluateScript(script: string, signal?: AbortSignal): Promise { + const payload = await serverRequest<{ + content?: string; + choices?: Array<{ message?: { content?: string } }>; + text?: string; + }>("ai/chat", { + method: "POST", + body: { + model: MODEL, + messages: [ + { role: "system", content: EVAL_SYSTEM_PROMPT }, + { role: "system", content: EVAL_OUTPUT_CONTRACT }, + { role: "user", content: `请评测以下剧本:\n\n${script.slice(0, 8000)}` }, + ], + stream: false, + temperature: 0.3, + max_tokens: 4096, + }, + signal, + timeoutMs: 180_000, + maxRetries: 0, + fallbackMessage: "评测请求失败", + }); + + const content: string = payload?.content ?? payload?.choices?.[0]?.message?.content ?? payload?.text ?? ""; + + if (!content) throw new Error("模型未返回有效内容"); + + const parsed = extractJson(content) as Record; + const dimensionScores: Record = {}; + const rawScores = parsed.dimensionScores as Record | undefined; + if (!rawScores || typeof rawScores !== "object") throw new Error("评分格式异常"); + + for (const key of Object.keys(DIMENSION_WEIGHTS)) { + const rawValue = key === "logic" ? rawScores.logic ?? rawScores.dialogue : rawScores[key]; + dimensionScores[key] = normalizeScoreValue(rawValue, DIMENSION_WEIGHTS[key].maxScore); + } + + const { totalScore, grade } = computeTotalAndGrade(dimensionScores); + + return { + totalScore, + grade, + dimensionScores, + subScores: normalizeNestedScores(parsed.subScores), + evidence: normalizeEvidence(parsed.evidence), + summary: String(parsed.summary || ""), + issues: Array.isArray(parsed.issues) ? parsed.issues.map(String) : [], + highlights: Array.isArray(parsed.highlights) ? parsed.highlights.map(String) : [], + suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions.map(String) : [], + }; +} diff --git a/src/api/serverConnection.ts b/src/api/serverConnection.ts new file mode 100644 index 0000000..4327da1 --- /dev/null +++ b/src/api/serverConnection.ts @@ -0,0 +1,425 @@ +import type { WebUserSession } from "../types"; + +export const SERVER_SESSION_STORAGE_KEY = "omniai-web-session"; +export const SERVER_SESSION_REPLACED_EVENT = "omniai:session-replaced"; +export const SERVER_SESSION_EXPIRED_EVENT = "omniai:session-expired"; + +export type ServerConnectionState = "checking" | "connected" | "degraded"; + +export interface ServerConnectionHealth { + state: ServerConnectionState; + baseUrl: string; + checkedAt: string; + errorMessage?: string; +} + +export interface ServerRequestOptions { + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + body?: unknown; + token?: string; + headers?: Record; + raw?: boolean; + signal?: AbortSignal; + /** Per-request timeout in ms. Defaults to DEFAULT_REQUEST_TIMEOUT_MS. Pass 0 to disable. */ + timeoutMs?: number; + /** Defaults to 2. Use 0 for non-idempotent task submission endpoints. */ + maxRetries?: number; + fallbackMessage?: string; +} + +export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; + +export interface ServerSessionReplacedDetail { + status?: number; + code?: string; + message: string; +} + +export class ServerRequestError extends Error { + status?: number; + code?: string; + payload?: unknown; + + constructor(message: string, status?: number, payload?: unknown) { + super(message); + this.name = "ServerRequestError"; + this.status = status; + this.payload = payload; + + if (isRecord(payload) && typeof payload.code === "string") { + this.code = payload.code; + } + } +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function compactMessage(value: string): string { + return value.replace(/\s+/g, " ").trim().slice(0, 240); +} + +export function getServerBaseUrl(): string { + return ""; +} + +export function buildApiUrl(path: string): string { + const cleanPath = path.replace(/^\/+/, ""); + return `/api/${cleanPath}`; +} + +export function canUseSessionStorage(): boolean { + return typeof window !== "undefined" && typeof window.sessionStorage !== "undefined"; +} + +function canUseLocalStorage(): boolean { + return typeof window !== "undefined" && typeof window.localStorage !== "undefined"; +} + +function parseStoredSession(raw: string | null): WebUserSession | null { + if (!raw) return null; + + try { + const parsed = JSON.parse(raw) as unknown; + return isRecord(parsed) && typeof parsed.token === "string" && isRecord(parsed.user) + ? (parsed as unknown as WebUserSession) + : null; + } catch { + return null; + } +} + +export function readStoredSession(): WebUserSession | null { + let fallbackSession: WebUserSession | null = null; + + try { + if (canUseLocalStorage()) { + const localSession = parseStoredSession(window.localStorage.getItem(SERVER_SESSION_STORAGE_KEY)); + if (localSession) return localSession; + } + } catch { + // Fall through to the legacy session-scoped copy. + } + + try { + if (canUseSessionStorage()) { + fallbackSession = parseStoredSession(window.sessionStorage.getItem(SERVER_SESSION_STORAGE_KEY)); + } + } catch { + fallbackSession = null; + } + + if (fallbackSession && canUseLocalStorage()) { + try { + window.localStorage.setItem(SERVER_SESSION_STORAGE_KEY, JSON.stringify(fallbackSession)); + } catch { + // Migrating the legacy session is best-effort. + } + } + + return fallbackSession; +} + +export function writeStoredSession(session: WebUserSession | null): void { + try { + if (canUseLocalStorage()) { + if (session) { + window.localStorage.setItem(SERVER_SESSION_STORAGE_KEY, JSON.stringify(session)); + } else { + window.localStorage.removeItem(SERVER_SESSION_STORAGE_KEY); + } + } + } catch { + // Browser persistence is a convenience layer, not a hard dependency. + } + + try { + if (canUseSessionStorage()) { + if (session) { + window.sessionStorage.setItem(SERVER_SESSION_STORAGE_KEY, JSON.stringify(session)); + } else { + window.sessionStorage.removeItem(SERVER_SESSION_STORAGE_KEY); + } + } + } catch { + // Keep the local copy as the primary persistence layer. + } +} + +export function clearAllUserStorage(): void { + writeStoredSession(null); + + try { + if (typeof window === "undefined") return; + const legacyKeys = ["omniai:token", "omniai:session"]; + for (const key of legacyKeys) { + window.localStorage.removeItem(key); + window.sessionStorage.removeItem(key); + } + const prefixKeys = [ + "omniai-web-profile-ui", + "omniai:more-recent-tools", + "omniai:generation-queue", + "omniai-canvas-saved-assets", + ]; + for (let i = window.localStorage.length - 1; i >= 0; i--) { + const key = window.localStorage.key(i); + if (key && prefixKeys.some((p) => key.startsWith(p))) { + window.localStorage.removeItem(key); + } + } + for (let i = window.sessionStorage.length - 1; i >= 0; i--) { + const key = window.sessionStorage.key(i); + if (key && prefixKeys.some((p) => key.startsWith(p))) { + window.sessionStorage.removeItem(key); + } + } + } catch { + // best-effort cleanup + } +} + +export function getStoredToken(): string | null { + return readStoredSession()?.token ?? null; +} + +export function buildAuthHeaders(tokenOverride?: string, extraHeaders?: Record): Record { + const token = (tokenOverride ?? getStoredToken() ?? "").trim(); + return { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(extraHeaders || {}), + }; +} + +export function parseResponseBody(text: string): unknown { + const trimmed = text.trim(); + if (!trimmed) return null; + + try { + return JSON.parse(trimmed); + } catch { + return trimmed; + } +} + +export function unwrapApiPayload(payload: unknown): unknown { + if (!isRecord(payload)) return payload; + + const nested = payload.data ?? payload.result ?? payload.payload; + return nested === undefined ? payload : nested; +} + +export function getPayloadMessage(payload: unknown): string | null { + if (typeof payload === "string") { + const message = compactMessage(payload); + if (/^]|]|]|^<\?xml|]/i.test(message)) { + return null; + } + return message || null; + } + if (!isRecord(payload)) return null; + + const message = payload.error ?? payload.message ?? payload.errorMessage; + if (typeof message !== "string") return null; + + const compacted = compactMessage(message); + if (/^]|]|]|^<\?xml|]/i.test(compacted)) { + return null; + } + return compacted || null; +} + +function getPayloadCode(payload: unknown): string | undefined { + return isRecord(payload) && typeof payload.code === "string" ? payload.code : undefined; +} + +let lastSessionReplacedEventAt = 0; + +let lastSessionExpiredEventAt = 0; + +function isNonAuthErrorCode(code: string | undefined): boolean { + if (!code) return false; + return [ + "ENTERPRISE_VIDEO_MODEL_NOT_ALLOWED", + "INSUFFICIENT_BALANCE", + "INSUFFICIENT_ENTERPRISE_BALANCE", + ].includes(code); +} + +function isAuthFailureResponse(status: number, payload: unknown): boolean { + if (status === 401) return true; + if (status !== 403) return false; + + const code = getPayloadCode(payload); + if (code === "SESSION_REPLACED" || code === "TOKEN_EXPIRED" || code === "ACCOUNT_DISABLED") return true; + + const message = getPayloadMessage(payload) || ""; + return /账号已禁用|登录已过期|登录状态|session|token|企业信息不存在/i.test(message); +} + +function notifySessionExpired(status: number, response: Response, payload: unknown): void { + if (status !== 401 && status !== 403) return; + if (typeof window === "undefined") return; + // Auth endpoints (login/register/me) surface their own errors — a wrong + // password must not be mistaken for an expired session. + if (/\/auth\//i.test(response.url)) return; + // SESSION_REPLACED has its own dedicated handling/modal. + if (getPayloadCode(payload) === "SESSION_REPLACED") return; + // If the user never had a session, a 401 is expected — not a session expiry. + if (!readStoredSession()) return; + // Deliberate early-exit for unauthenticated users — not a real auth failure. + if (getPayloadCode(payload) === "NOT_LOGGED_IN") return; + // Non-auth 403 errors (enterprise model access, insufficient balance) must + // not trigger session expiry. + if (status === 403 && isNonAuthErrorCode(getPayloadCode(payload))) return; + if (!isAuthFailureResponse(status, payload)) return; + + const now = Date.now(); + if (now - lastSessionExpiredEventAt < 1500) return; + lastSessionExpiredEventAt = now; + window.dispatchEvent( + new CustomEvent(SERVER_SESSION_EXPIRED_EVENT, { + detail: { status, code: getPayloadCode(payload), message: "登录状态已失效,请重新登录。" }, + }), + ); +} + +function notifySessionReplaced(status: number, payload: unknown, fallbackMessage: string): void { + const code = getPayloadCode(payload); + const message = getPayloadMessage(payload) || fallbackMessage || "您已在别处登录"; + const isSessionReplaced = code === "SESSION_REPLACED" || message.includes("您已在别处登录"); + if (!isSessionReplaced || typeof window === "undefined") return; + if (!readStoredSession()) return; + + const now = Date.now(); + if (now - lastSessionReplacedEventAt < 1500) return; + lastSessionReplacedEventAt = now; + window.dispatchEvent( + new CustomEvent(SERVER_SESSION_REPLACED_EVENT, { + detail: { status, code, message }, + }), + ); +} + +export function getErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error || "Unknown API error"); +} + +export function isServerRequestError(error: unknown): error is ServerRequestError { + return error instanceof ServerRequestError; +} + +export async function readJsonResponse(response: Response, fallbackMessage: string): Promise { + const payload = parseResponseBody(await response.text().catch(() => "")); + if (!response.ok) { + const message = + getPayloadMessage(payload) || + compactMessage(response.statusText) || + `${fallbackMessage} (${response.status})`; + notifySessionReplaced(response.status, payload, message); + notifySessionExpired(response.status, response, payload); + throw new ServerRequestError(message, response.status, payload); + } + + return unwrapApiPayload(payload) as T; +} + +export async function throwResponseError(response: Response, fallbackMessage: string): Promise { + const payload = parseResponseBody(await response.text().catch(() => "")); + const message = + getPayloadMessage(payload) || + compactMessage(response.statusText) || + `${fallbackMessage} (${response.status})`; + notifySessionReplaced(response.status, payload, message); + notifySessionExpired(response.status, response, payload); + throw new ServerRequestError(message, response.status, payload); +} + +function isRetryable(error: unknown): boolean { + if (error instanceof ServerRequestError) { + const s = error.status; + return s === 429 || (s !== undefined && s >= 500); + } + return error instanceof TypeError || (error instanceof DOMException && error.name !== "AbortError"); +} + +function getRetryDelay(attempt: number, error: unknown): number { + if (error instanceof ServerRequestError && error.status === 429) { + return Math.min(5000, 2000 * attempt); + } + return Math.min(4000, 300 * 2 ** attempt); +} + +const MAX_RETRIES = 2; + +export async function serverRequest(path: string, options?: ServerRequestOptions): Promise { + let lastError: unknown; + const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const maxRetries = options?.maxRetries ?? MAX_RETRIES; + const fallbackMessage = options?.fallbackMessage || "Request failed"; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const controller = timeoutMs > 0 ? new AbortController() : null; + const timeoutId = + controller && typeof window !== "undefined" + ? window.setTimeout(() => controller.abort(new DOMException("Request timed out", "TimeoutError")), timeoutMs) + : null; + const onCallerAbort = () => controller?.abort((options?.signal as AbortSignal)?.reason); + if (controller && options?.signal) { + if (options.signal.aborted) controller.abort(options.signal.reason); + else options.signal.addEventListener("abort", onCallerAbort, { once: true }); + } + + try { + const headers = buildAuthHeaders(options?.token, options?.headers); + const response = await fetch(buildApiUrl(path), { + method: options?.method || "GET", + headers, + body: options?.body === undefined ? undefined : JSON.stringify(options.body), + signal: controller ? controller.signal : options?.signal, + credentials: "include", + }); + + const payload = await readJsonResponse(response, fallbackMessage); + return (options?.raw ? payload : unwrapApiPayload(payload)) as T; + } catch (error) { + lastError = error; + if (attempt < maxRetries && isRetryable(error) && !options?.signal?.aborted) { + await new Promise((r) => setTimeout(r, getRetryDelay(attempt, error))); + continue; + } + throw error; + } finally { + if (timeoutId !== null) window.clearTimeout(timeoutId); + options?.signal?.removeEventListener("abort", onCallerAbort); + } + } + + throw lastError; +} + +export async function checkServerHealth(timeoutMs = 4500): Promise { + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs); + + try { + await serverRequest("/health", { signal: controller.signal }); + return { + state: "connected", + baseUrl: getServerBaseUrl(), + checkedAt: new Date().toISOString(), + }; + } catch (error) { + return { + state: "degraded", + baseUrl: getServerBaseUrl(), + checkedAt: new Date().toISOString(), + errorMessage: getErrorMessage(error), + }; + } finally { + window.clearTimeout(timeoutId); + } +} diff --git a/src/api/taskSubscription.ts b/src/api/taskSubscription.ts new file mode 100644 index 0000000..3084581 --- /dev/null +++ b/src/api/taskSubscription.ts @@ -0,0 +1,128 @@ +import { aiGenerationClient } from "./aiGenerationClient"; +import { + buildLocalTimeoutMessage, + getTaskTimeoutPolicy, + isTaskLocallyTimedOut, +} from "../utils/taskLifecycle"; + +export interface TaskProgressEvent { + taskId: string; + status: string; + progress: number; + resultUrl?: string | null; + error?: string | null; +} + +export interface WaitForTaskOptions { + onProgress?: (event: TaskProgressEvent) => void; + abortRef?: { current: boolean }; + timeoutMs?: number; + noProgressTimeoutMs?: number; + startedAt?: number; + kind?: "image" | "video" | "text"; + model?: string | null; + operation?: string | null; +} + +const POLL_INTERVAL = 3000; + +export function waitForTask( + taskId: string, + options: WaitForTaskOptions = {}, +): Promise { + const { onProgress, abortRef } = options; + const timeoutPolicy = getTaskTimeoutPolicy({ + kind: options.kind, + model: options.model, + operation: options.operation, + }); + const timeoutMs = options.timeoutMs ?? timeoutPolicy.maxRuntimeMs; + const noProgressTimeoutMs = options.noProgressTimeoutMs ?? timeoutPolicy.noProgressTimeoutMs; + const startedAt = options.startedAt ?? Date.now(); + + return new Promise((resolve, reject) => { + let settled = false; + let cleanup: (() => void) | null = null; + let timeoutId: ReturnType | null = null; + let fallbackTimerId: ReturnType | null = null; + let lastProgress = 0; + let lastProgressAt = startedAt; + + const settle = (fn: () => void) => { + if (settled) return; + settled = true; + if (timeoutId) clearTimeout(timeoutId); + if (fallbackTimerId) clearTimeout(fallbackTimerId); + if (cleanup) cleanup(); + fn(); + }; + + timeoutId = setTimeout( + () => settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video")))), + timeoutMs, + ); + + const handleUpdate = (event: TaskProgressEvent) => { + if (settled) return; + if (abortRef?.current) { + settle(() => resolve(null)); + return; + } + const progress = Number(event.progress || 0); + if (progress > lastProgress || event.status === "completed") { + lastProgress = Math.max(lastProgress, progress); + lastProgressAt = Date.now(); + } + onProgress?.(event); + if (event.status === "completed") { + settle(() => resolve(event.resultUrl || null)); + } else if (event.status === "failed" || event.status === "cancelled") { + settle(() => reject(new Error(event.error || "任务失败,请稍后重试"))); + } + }; + + cleanup = aiGenerationClient.subscribeTaskStatus(taskId, handleUpdate); + + fallbackTimerId = setTimeout(() => { + if (settled) return; + if (cleanup) cleanup(); + startPolling(); + }, 5000); + + function startPolling() { + const poll = async () => { + while (!settled) { + if (abortRef?.current) { + settle(() => resolve(null)); + return; + } + await new Promise((r) => setTimeout(r, POLL_INTERVAL)); + if (settled || abortRef?.current) return; + const timeoutReason = isTaskLocallyTimedOut({ + startedAt, + lastProgressAt, + progress: lastProgress, + policy: { ...timeoutPolicy, noProgressTimeoutMs }, + }); + if (timeoutReason) { + settle(() => reject(new Error(buildLocalTimeoutMessage(options.kind || "video")))); + return; + } + try { + const task = await aiGenerationClient.getTaskStatus(taskId); + handleUpdate({ + taskId, + status: task.status, + progress: task.progress || 0, + resultUrl: task.resultUrl, + error: task.error, + }); + } catch (e) { + if (!settled) settle(() => reject(e)); + } + } + }; + void poll(); + } + }); +} diff --git a/src/api/uploadWithProgress.ts b/src/api/uploadWithProgress.ts new file mode 100644 index 0000000..149420a --- /dev/null +++ b/src/api/uploadWithProgress.ts @@ -0,0 +1,57 @@ +import { buildApiUrl, buildAuthHeaders, readJsonResponse, throwResponseError } from "./serverConnection"; + +export interface UploadProgressOptions { + onProgress?: (percent: number) => void; + signal?: AbortSignal; +} + +export async function uploadAssetWithProgress( + input: { dataUrl: string; name?: string; mimeType?: string; scope?: string }, + options?: UploadProgressOptions, +): Promise<{ url: string; signedUrl?: string; ossKey?: string }> { + const { onProgress, signal } = options || {}; + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + const url = buildApiUrl("oss/upload"); + const headers = buildAuthHeaders(); + + xhr.open("POST", url); + Object.entries(headers).forEach(([k, v]) => xhr.setRequestHeader(k, v)); + + xhr.upload.addEventListener("progress", (e) => { + if (e.lengthComputable && onProgress) { + onProgress(Math.round((e.loaded / e.total) * 100)); + } + }); + + xhr.addEventListener("load", async () => { + const fakeResponse = new Response(xhr.responseText, { + status: xhr.status, + statusText: xhr.statusText, + headers: { "Content-Type": "application/json" }, + }); + try { + if (!fakeResponse.ok) { + await throwResponseError(fakeResponse, "Asset upload failed"); + } + const result = await readJsonResponse<{ url: string; ossKey?: string }>( + fakeResponse.clone(), + "Asset upload response failed", + ); + resolve(result); + } catch (e) { + reject(e); + } + }); + + xhr.addEventListener("error", () => reject(new Error("上传失败,请检查网络连接"))); + xhr.addEventListener("abort", () => reject(new Error("上传已取消"))); + + if (signal) { + signal.addEventListener("abort", () => xhr.abort()); + } + + xhr.send(JSON.stringify(input)); + }); +} diff --git a/src/api/webGenerationGateway.ts b/src/api/webGenerationGateway.ts new file mode 100644 index 0000000..16ae3e7 --- /dev/null +++ b/src/api/webGenerationGateway.ts @@ -0,0 +1,110 @@ +import type { WebGenerationPreviewTask } from "../types"; +import { aiGenerationClient } from "./aiGenerationClient"; +import { resolveVideoRequestModel } from "../utils/resolveVideoModel"; +import { ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../utils/enterpriseVideoPolicy"; + +function formatPreviewTaskTimestamp(date = new Date()): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + return `${year}-${month}-${day} ${hours}:${minutes}`; +} + +export interface CreatePreviewTaskInput { + title: string; + type: WebGenerationPreviewTask["type"]; + prompt: string; + params?: { + existingTaskId?: string; + projectId?: string; + conversationId?: number; + model?: string; + ratio?: string; + quality?: string; + resolution?: string; + gridMode?: string; + duration?: number; + frameMode?: string; + referenceUrls?: string[]; + audioUrl?: string; + muted?: boolean; + hasReferenceVideo?: boolean; + }; +} + +export const webGenerationGateway = { + async createPreviewTask(input: CreatePreviewTaskInput): Promise { + const { type, params } = input; + const prompt = input.prompt.trim(); + const title = input.title.trim() || "未命名任务"; + const createdAt = formatPreviewTaskTimestamp(); + + try { + let taskId: string; + + if (params?.existingTaskId) { + taskId = params.existingTaskId; + } else if (type === "image") { + const result = await aiGenerationClient.createImageTask({ + projectId: params?.projectId, + conversationId: params?.conversationId, + model: params?.model || "nano-banana-pro", + prompt, + ratio: params?.ratio || "16:9", + quality: params?.quality || "1K", + gridMode: params?.gridMode || "single", + referenceUrls: params?.referenceUrls, + }); + taskId = result.taskId; + } else if (type === "video") { + const refs = params?.referenceUrls; + let model: string = params?.model || ENTERPRISE_DEFAULT_VIDEO_MODEL; + model = resolveVideoRequestModel({ model, referenceUrls: refs }); + const result = await aiGenerationClient.createVideoTask({ + projectId: params?.projectId, + conversationId: params?.conversationId, + model, + prompt, + ratio: params?.ratio || "16:9", + duration: params?.duration || 5, + quality: params?.quality || params?.resolution || "1080P", + resolution: params?.resolution || params?.quality || "1080P", + frameMode: params?.frameMode || "start-end", + referenceUrls: params?.referenceUrls, + audioUrl: params?.audioUrl, + muted: params?.muted ?? false, + hasReferenceVideo: params?.hasReferenceVideo ?? false, + }); + taskId = result.taskId; + } else { + taskId = `web-task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + } + + return { + id: taskId, + title, + type, + status: "queued", + progress: 5, + prompt, + createdAt, + source: "server", + projectId: params?.projectId, + }; + } catch (err) { + return { + id: `web-task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + title, + type, + status: "failed", + progress: 0, + prompt, + createdAt, + source: "server", + errorMessage: err instanceof Error ? err.message : "请求失败,请稍后重试", + }; + } + }, +}; diff --git a/src/assets/platform-logos/aliexpress.webp b/src/assets/platform-logos/aliexpress.webp new file mode 100644 index 0000000000000000000000000000000000000000..2bcdd78519674a3c3cb5789ac76149ff8fe9c2cc GIT binary patch literal 822 zcmV-61IheSNk&F40{{S5MM6+kP&il$0000G0001A003VA06|PpNE84700B^twgFTB zrfXs+B9eOa{^u+83sz7#AoKzN0B{ljodGIf0AK(%y zcpbP+lj?);28a(he?T5aePAEWzr=e0e!zNc`w;(u|CQ_k`y;EWoP7XY`#v-B6~NbV zEDs7^c$w>ZH`ovLQ+;%&-c3H#5-0Y`HB$X|IXbSrD8u9hcwI){NY&cKDwaCT6iEL7V>IKU2Og|?;(A>g&wbq18wsd zJZyb~{$E1@RY(JZL!F3v8nn#~S0);!83C?q_qzP;XoNke3d=Mglgj7r#Q0eAaIeRQ zni|UGyh8w68r~vh^YpxKiv9thJ>#ze?M!>3_2`6NTw$%{xfXAZ{nDP&F^+kz_cXS4 zq;bh*sdu?om>IrZx(W*%6iP-iiP=&&- zQQ&!G8)m}9`n_g8K;66Eo|V0p50JZLaw%MIB4=naZ6KGk%|9#RoT@SJeEp$vI4yDV z&iAK3VXf1qyc+-H`e+a>^G48O*>$G)e^_8PQ@+m<*@6Evu7SFiC7)RKOMtWVN328x zW_@_aaJWi4Ex;4Cf2H!Dre>J-bm#G12hppBY?1I5hyPhzeG%R(zzgc@8z(>FLf`9k zmH23H#OwemuHdqW0PCOSRnnN<7U&l`gM}`~I8J58uCLGHE@R@&ocK;BCXd;PO(wU;bzE!}>P4 zFXaE;KQ4KJqv!n>r!Qz8*uI7TsqP8qEMK%7+c`Ce9J=eK@ZeGe65$*BRPOwQ>4?NjjD_r|9}A!NFDeK(qa( zqvntFgWVVOAt@7gC5qqWs{pHl4a%j4Te|unPnG}jXg!?jWtF6v;_6>3kev#u;L=yDRsOse zzaG4}yvKZx|Ha4!uaJG2r`}!Tfb;+C4*4mICvf^rfDCyDs*=+e{lonYJ#^hOZHHHt z-(2JRz0(R!EBas58{z@0{aA%69l9?|!SDZjzkINU&fp;t9`FzSpOd@$D&!Vm(|UjX z6leVlJ()b5ps8%Cbvf{%>=J}33r;7L~?!m4Kcct5G^ zVBxDzOS|9h^{#8ErD@@W&9AWZ)k8-gMDicFjPElWFPK;^;{CCs2ns_9`%(^vPg%o> zgWP!r9DN^m4N5#w8W75EqWG(~X*gQF_U|UY*6#Jzx?ippivJrwaQe?MDUe;uq)5C> zRGKU;u#DWc`t7GrNfy3;!&tO#OFztC5&j!bkg8_tzSH^6Z`;j>#k{QR6ch+{_&^5F&Q{XdC-Uh;^~AQ*>q)|WfJzp}tZ`SMah?s9p!Mz= z-{%{=0zGt!Pdjxpoq<#N?W_Okun|2nF1b_P4|{@G$bRl5@C--InV{q=TWpOh^0rel zJM_hU0;9zrR8waNlN%~Vn`0Kf!4M=7FmQ6xAuiCUs9^T3`M$0(qZG=l0VynUq^JM@ G0000%uGZxM literal 0 HcmV?d00001 diff --git a/src/assets/platform-logos/douyin.webp b/src/assets/platform-logos/douyin.webp new file mode 100644 index 0000000000000000000000000000000000000000..6a3ff4a6cba92a009c890001498fa0c8e30cc595 GIT binary patch literal 1634 zcmV-o2A%m*Nk&Fm1^@t8MM6+kP&gn?1^@uC9{`;JDqsL$06t+Rk3}RRAsNbzBtQlP zvH)o`T;*5bKOjF6c}nDT^Ilr#m*P>qQO4C`{WUNl_*hI<&6LwMyz_*${ef3${SCH zp@hNoB+>Krg3XM4Wsre;ljBgrcfJEq9ekCtuxd(MEjoO%d}YB_NC+kVA5zI)weuSa zf+-68?0fdVWKq>tLeTgklcU-fgDC2G{+`iHG;g^K|L<)Q2M}KzpyuL3d`ta*PKQFy z$|5CzUhAmgi($05`Pb?foz>~7ocu_~Sg=FTf(|l9wvoD^COV?eEc{G-HU2n4nuOX0 zq`7izBR>q6CYT*Gkk!lJ;L?1;Z~D9fQf>8k#8ASBn^KP31#NGc72Xv(2 z10kv}U&v$mEBh(IQ&627g;?M59KhK>=Vf>D#;%j3CzdUl8w`n|hIq{ztTXbtE^Yz2 z$JJn&?&VPpN!IL~DS?}8cYXuRab$`_=h-dl@B1*$F7W9cUo&p+UOvmcWh!2=It~h& zezzFnZ=i!cu5(&UG{x3v$nN1$ia*ZDQ{a|6?~__0_$QC`Ez(2@?{D$1u0hwq=n0=>o-k4@Plhhhln);gP$`^$ zSBT!C0$3Czc?08&j3MtggIjM}J^(A-r4DLil&t>ptlc@^x*m}%&jWfVy*PD!^YN5u z4?#_K&Ga5lk>TACTQtRh*xq>ri(A0IjMqH+Cquh9v*)7#jKjcME^gBG#K22f6D%kU1D<@j0E!`60ly4DF{^D~pJCLZ^~E^b8B-6rm*cfvAY(%QPd3ZKv;phHXOlC%r?e_A4j zmf_gR9)HPaUQQlo{IbSEEe?<7Aw_+^JJ`;^*EOQ!Q!zuiP$!=br~ml|dr+OME3$0G z#y4v^T$UGm{D!It->Q=L9$!z4bRMM5bIW~kw~+Uv-hVMa72dnw+uoN<)CdQu5Gv7y z<-JD6Pj~Vcp^hXAG*pB^L};i3E>87HHn|#j-MG^)4TelkN}q@b6gO|`I_bbVwN-zB zi}m;y#F(lT4t6(xq1Sa;IVx9Bzbupo=ls?8EO89>`ccI=jRlG!@Na@XqMi5!v3Xzl g`c_!r*UjhZ>4UcmgJ?ymX^`*t3h=f9e!r*y03O#ZYXATM literal 0 HcmV?d00001 diff --git a/src/assets/platform-logos/ebay.webp b/src/assets/platform-logos/ebay.webp new file mode 100644 index 0000000000000000000000000000000000000000..2efde47e90ed8c0eaeffd3c9b8fde207a3d0042d GIT binary patch literal 2078 zcmV+(2;uiqNk&E%2mkp`j|dSXh7z31&Np z`*8#RpBZHiTHOb){6Rne>Hq2ew(seGYWm^&SE%3rA|+O9m>t@DOW`f(Qft~b?0o9_ zjpa@CFZ(n2f$#(MkM>L5zx4yzFQ5nXPv-q$zJq_UUYUQj-w5BeAGd!2U$x)=@Ie|0 z@ST*lll&h5KB_50upfK5BK4bm%4vE(O$YSqJTg=}5BVNybl|_)Q7#B7YQ@LVbH=z# zl)vIzuRUEwcj)Uj-R<`9_^I;QRcm(MCVEi~mq>?xR?MV94Y9rb!)t_2vVq+mwrV3D z((7t9gr6VSh;Q~$+bjK9;WElnkL}7DcBYDHxjTD|<~6ck0%;c|G}KF2zBkVlX$l)U zH-Kpn0RH`=4dbx_O|cHH;(o>fyaOFUyF91UM!0|g{-DyT*+tqoR4qQ@WT~C#Imr`~ z`~9_jV=#{Dd~9Ijcb6O1fcQXJ8kooT^JC)RoZ@8!8ZOtb%uHZBpGZ%wk<$G`1M}yF z64An;Y4-~8-XAQ>+ucC9Zwc{BSX)|RXeVDQs8hg;J%(99yI?7qJ55Y;6hR)!+Eepc zn%V)h5G)%kLi#YE>?gN!Jz7Lv!{DQn9W=FGE-jCMt%^pC%v{R~N$;CYpNHJX zG7dEH#X&MFd@#1<+=iEmo8+44|C2ZYHUyaha=_?E)xQEwcb|LMllFR9&M=b-jLV@N z@Iqe@rW6sAPpb@A!6_s2o(?@+M>$PypON~Phq)ZyBqYVFCrOAoO2|F?+!SDW|pi{ zTnMJ0d-j}k>l(OOHV+hjZ~8d9mZz`7Fm$PRt}pNf6GKr^ci-5EMG-__n&zX30ikq+ z#+7FtNt*WjPKFj;+7B;J z=h?8Bbk{fyqf)4^d8s<3;!Gpx%<#h+n<~8qe>c7A>+>^iIQn1WZup!!6yLfq^49*X z{FkaEs=gJ!Nq~EpLG_Qn;sB>ecA_@6l;wkC2;iT#_vkgkd}eZEW)fTh5mHfE=1O#J zMiWcx5OxVbXOo_%xo*5aUDM<&`Pj~$g$!7B>>_jI+5j>!u6>Ax98b5XZz!r2X_ue4 z!qwL-Wrw0>>Tv%hgJ)`wwLCZ}mep>8HM z4|~k(d;#Z{Fmu(?Hrlf;>6>>yojk{9C(Z=OLm4VO+AkGOj$zI8OXZRO6_`0Y23Of3 z=4tmK8RvY|wxPRRz{Q%Tz1+);SS-UZ1g~^meT!V%9^gXa_CpN%58Dw{iTi`{TRtiO zA-}63>^F+2f>hW%#{8GLcr<4nr_1k-s`N&apx;GQsJ)`uvK`FSlbjj4;gqlUuGjtg zAuMado&~DJSomkd=hD^6hj0&7F9GtYcU591JUYtgSJMul*Q ziye+E%1oHAeXb|1E&EQBHPAjsKQVz!qRl(v*@5Gmb83%4ZSv}w`}tE9$|fRC5g{@} z1I)>r-M@jRs>uad|1e5n&ez;w(}#8S!Lkd$xX+9z4&3ct098~w~ z<43mr(Oi4@iDRTT?M3rcvJxVmxucyX#kmvAcf0;d=`-FJpvrA1{A5@_h!N*k`QzID zV#CvUd^vmWiWSF4@y0aub$kEvi9_z5@~q5Ryq}=RfAnXwA~WX zJd`J`AR8>pLIBWnSEohfbtKCz=Wl6Lz3!kO=|m-eD}eIFWw^Rf=!Lqv!!`zJqA2pD zYZl0vJg|j(R}&xwHtspsKA(~3w_{o^|iy(d>|~0`EeWKQ^I=sr;d->v<}zi zCNLgPq$k$M>3*So03RJH*?O4MUHX$rUy{6QReHamCwquxl?C7yqx@86`&i&|-rFKl z_zlfsz+V^xs0Re@%J3t9L=VU%Yk_Wn*AEwIQb|Nq(_4ghMoGo)enH+`f)BhkjIDb$3U)IO<{$Bxv1{+_1^G~RMgxV$`UB%<$ z)Hj$1I{(3RTvK>q>O@907MY3aM{x%+kc=j{)H-q&|b?Z^tQ z(&ih%O*^wd^569NobqB1h0_UpFblgdOS6dyeqb&WrwV?Y4@{T2DzeB|S{-HO@-r$EdKDf(<69RfCSQ95*~ zMRmZ2-AnxKe7KJ|GEdHHTk%C#G4*BPVMNwN{u^K7O+1WI(v2-*f$XPW3XTkh_=-F~ zI+PTqbGh`^IhPDBK_dUaiGcdrTgf+%&W~s%{D_eqzBu$c3JPc_t!8iDsBv6yq4P>J zTty*2+t7q@mZ|u=lyfg}VqoTV03R&<-Cm2=BW=d9Mw)rpX@AbxBJj#w0W}up?ctEB zi{L#mKH$^s|E@MIfZ_&L1eKuUt4o!n6s7j2tma)Ce5MkHddMX|5Z9u7PlaFi88K6S zSC0_?gbKLL*CBJFKUKw#-;MLX7? zR}ycfH*TPwC^o9<6hHLJT6bSIIbl^=ovLOzLLDGblK^P=S$o43dJKd>2NbJ z&qiqB20?Z@wmdU?kqd45m%D>nyx)G9Pm7&@xF2ZL06{5bcGc6Kz`9FX}R4tj*FlZp*5H0aA#PK zdHFvoG>*=Bd1VAw#~eDd0B(!LxykE0QJcyx;}MVsW7i~mfgDvu%AiGa z7hQ>K_&EYvc;wam0ggX5J=hwIgj5Lvb?$u@1va`P$4>5~29k7)V8_Gc9$B)YL@YBm z>}Jw5``HA4BI?WidUPlj%84GF8#*0fo>eJ_`VEtM}34?ceE zh`PYxHGn~ZD8FePsO{ipOX_%bhrp{NV#k1)%E2W6X~JTZ7`63r_}>55A829JaBuY zb3<0vjw?4;$x~ZFVs1g9-{LIL+W2|l>|Ohf`CUEc6G^?+ah4W=$We!lIfUS)6VUfb z^N^s*tNC-4I<7Y8qq=KR25M?A=k&{@=uIMdb$&Jd=nT5H>0mGxN-q^ACz;T9FOf3b zg}!GWuPLRbgQA!kfF1bEc`BWF)RdRf93}^oFWQCt@E|M3G%Ik!?_bTSyc&Za`1C(% z$J3PDV+|e?iJ(KFWobL8)v|=R9{!OJce)acB zOaV{~P8PL0^vCkI)SKVM6u0ssMqT~(;c}7W{|h{yKw{@F+NDJWxato*0c}R%!RS=f z?^*9PFLQTD3rY$SVs_#a{~B4Z6+l`2llIqOAML2AjGoCYdAb}TKS=v~#M6n_rL>JW kghSi^Tx?qb#0;zw%GEk(@@8&zo9#_aE^oky|0k&c0K`uU`Tzg` literal 0 HcmV?d00001 diff --git a/src/assets/platform-logos/jd.webp b/src/assets/platform-logos/jd.webp new file mode 100644 index 0000000000000000000000000000000000000000..793751d9974a53e4b9f23c59d4979d6f301fd6db GIT binary patch literal 2432 zcmV-`34iudNk&F^2><|BMM6+kP&il$0000G0001A003VA06|PpNEQG9009p${}B;_ zOxlJzpYxd55z+q`0DTTtP&gnw2><|aDgd1UDqsL$06t+RkVT{+p_dwlWIzT4wg6zp z>i>ZJK-HQ`vK{V`!VpH`#t+f@GbiX|8qFwu&?et6P`f!Lpaaa57U2R{jdD0 z_-FdB;h&g10KHHCQ+X5sel`6={AcD*n1-VFto;mgrg(R-2l6lN-m1UT|L6ZX=a=(; z^562k!@rmRS^u5b^Y%;G87NqCu)++0Uv8oHpT{HE{MDCai@bIAs8_s>m>bx7!w?sS z`PpSZ++KfQ6BtU5ui4rpfDK4b;K{>)ikIkw(G> zOd$(AHN)?Gl{lIwYOe=K*yOA>28-!Z;iM(Oe7F8nRa4sbvoge8Q$j}x0MlF6e-Xy0X?j!qAlzP* zKC-T3l@It-)v(FW;^RB!XU1_8_4W=#c*I28daRdV#E8peznL;RPAe&pYZdb3e8x|f z305*_nO9w8=K1IrjN96`$wV&%Yz)$L;znVL?JYA3CKF{;9SaEf zvS=(!2s@F^70>_=5Q=ew<>^h~;&1Y1iO!ZRE_SUtugnlj>d6N=!Lx@&RRP&Kf9~n- z|8av^j3@sPW6#m67%a~=nKgs+ez1vM{UTEvAy4je@!~Y%s~%kqIn@WF_thKq>u+@3 zA<(-vvBD)65Nux-b+MM-U~`!nq}7YadTRLcRP~|sIBKG}7URAx2QC^PxhF(x8@(SP z@~s{{IBnXWps|;mSa`_1XV%}~LL8k$mplI9P5#OmrNmKwj=W40;7+ajQh?{dj;vPN z?YU`j$zKF#7b;jgrrka>oI;un@8G6)1fWHlZTd-4B7{&40IUvB9v3FmH&0%*?*yAy z6lyF5AnS$#@Ybw6#r;R^ zow1J$7ONnNEH~b;n3^4+Jf4OZ<0RX)eiqR}XKZK6s{D6Y2aMDgS7|YX@o;fyj&Jwd zpOhE~)PS(3dpBk#@X<6ZfPA6M_rY8Ha_XoaH{!YbtSa`}E|!)PQ^%1WBBejEoNJmL zfi87w4qqc}7Pq~xw+YMFBIDBo?HqUw+O%7XXS$snLmtkRkQX99uTH3k(~pohkil43 z%>?3Cs_Xd%$9^tyT?HG-&9~6Wy~JlP4(lDJ%n@!8{y2)B(X`e@-6#(C_%=uRz8!iq zqh&!vp^C?jEn*1YRB?CSj`+<6WA0%Ba+4P~KT6+56S92E6NhJiuN7n0v74aQ#IR@-gz8{b!+O9dL|9gEU`lESrrDKeP6n!A~t@%^1I(o|L zSMk*6e#Ml43tYvvFmx`jqk|y^hrhRkkkD?-R2EMYYnTw&aqiiO>uBBn1k>$K#t-p) zp27vV3CJAuCyc&)w%0)2OZRK71<)1pP`!S6>mFODhRf<4Dr{pHNh?TksLSTjvh7xG zEza8Qq`K4hK6$Wr||`AWN}hDu5sqa6`@tJe>~>u=N!y{38PrxjzYc{}Ax;s>US_4LIN zGW<~)Wcso#fyEh1g8WKK}ADr{L|sJx7gCANkv$T85~hRRR~lSRAJ#ZOzd z&v9H+lY^62bU|A!b)jD5ylE)wI}~1dYwI+tg&!0hI*{}@tUfjJsaswtirRgiuC$^A zhEDE^^UseSm zH|#p>(kreUktSmL9Sd;dzyrV_M34IiWZvyu+iCq^hkSiP17953`%(GBfCI4o@$Qh9 z7D*G#=t$UrUdUwXPkpkYL;t_=i^aQw6qmp|k4fEp+jG)W1Ll(ZsQ2C);B6GOEs8`m z-6Dd5(=*c@<5x4)k|)%A`maM^wr}68)T=Q++Ys{ZsNib%)J!SlhI@z#b5zGvtVpe1 y1X+ce(`xuS#+h$)aK&WavjU0z{0F^$@h?DnYgZ%6Bg*NP!wYTk?axfe0002F=+lY- literal 0 HcmV?d00001 diff --git a/src/assets/platform-logos/lazada.webp b/src/assets/platform-logos/lazada.webp new file mode 100644 index 0000000000000000000000000000000000000000..91d13757bbf44d6db72a8e20727ccfea7a82511b GIT binary patch literal 1998 zcmV;<2Qm0kNk&G-2LJ$9MM6+kP&il$0000G0001A003VA06|PpNE84700B^twgFTB zrfXs+B9i#={_j)b4OUP%AdCk90B|M%odGIf0AK(CTAOuapR+o{A1=DuV2pnz;7FLv-1J_4dl)4kI)1A z$D9YT7oZ32SElE&Z|-;P-?SxR-@khv`&s*e_cL{Wz3Kz^pJ}~||4Z5T@*n0g=gC|C zGxG2Gf6Q-M{U857_5-W#_(<{eMbul=K8)&3thJjG7dBQKt^4a1Q&tmIw|JK;3|z$A zNO<6)pQ28a+uAfB;wb=Y(kGH^_a1AO#7c%DF6q^z7<1A45&aHP*}WI=FUY*qw$NBS zq{tljO)!@d#;QuRe$f?h>E2FLqd*K;ILS;%Rvvi~}6@4}-)j zM{(X3@M)8cgROCiVHYZ+`3vr2uo+gFeKStW(r01oTVKB7ZnhNEv;6DN@~1?@A@Bta;n58NZr+k{2|1kOC2{PA6h z3Kmp8xC;4Q&nSk)RN4=eHAzb{oy9o@u`h3;0xp}OHFL$!Y$7i!h{I1`hV@? zcHa_R{R>5SnT}Qi62#OGOSahvP{l4}&?HG%I_0&lZw-Dp14dUEBimkVB|wl9{iL<= z+)#&hd3$Uyno3c=DyXM(l`&VBKle%}(5`n#|Z;JD{OOy~qfBzE*)BajGqP|8> z7BOuXm8ndN?L@93Z=%~MK!s}xZ`m@gxzg&cV!&3a&V;JdNS-=>r4HZOMMBh23o7al zRFfxQ01Y+s;wK3|gU}xl_ubDkbR?j;3?d)q38I8va?KPvwH!a-Ci#eT#{F-d?CmxQ zQDzXWkUM!(F16;mT~@Uv3LO5mL>8nPcgP0|I=FY^iuTNK@}buQxUD%%xP;Y^P2L(b z9Di&oVn2d(B;rjXA$~m^I^iG93U!Z}+u@wo z<^{Ss(Lyyyc-E&{Of?(pdwK6X4#~=S=HTYc$-jaN^Yr;O81V`L_{PESf_m8ddwO5` z(%1L$r@`(#mH&^7OO36O2sWnP@^R8WlwF^OzZ=lVI+u6|St(X<=*Q^^#92@-L^ule z66>A!dTfh^ex zesXIG{JA`j@W;%Qrbwz7_y5jfPl=LiUzP^>%xh@ZHU-V^eMj;~zvB2_mg<%UtNK&$ zPaKjMlPhSQL43`&37{&<$)2jewoTp7KdO|mZ-?+F5}RT%HaJL`s_b{Oku9hYDqGnLl0pOeCLS!u8r2hFgnXwMSL$-Fw#XcYRIahi= zP!c*dug8(PFZUz+3s%0TIoA=<2AXL8SU-pl`ntWTTlK|lXO&U z)dY5!YDA3VJmUcejZ)!?r({*7Obaq@B>C5aD!7*~iId4rOnmx0e1!c|+sQ2pb4jXs zKJZk6%wGcphiNHgE{%I3_A`sA=kba|SCS3{xAHT9Cjm$LiG`h;O3ghpY@m}6Pg#OA zsyC>80MQJ<6*oqP#0=P>tXH^LM9U*1C@>nR4n+_CQWx12OT_B$hhpy{+CZ!C3kon3 gX|;Vi)i-^Lj5nk{+D&k@(UH787A?mYWq<$x0FIW{2mk;8 literal 0 HcmV?d00001 diff --git a/src/assets/platform-logos/pinduoduo.webp b/src/assets/platform-logos/pinduoduo.webp new file mode 100644 index 0000000000000000000000000000000000000000..6ab68a2ef5aa561aac23fdb9f878b8db91fbe3d3 GIT binary patch literal 3442 zcmV-&4UO_rNk&F$4FCXFMM6+kP&il$0000G0001A003VA06|PpND=@5009sZ5dNQb zK@$;4e)oU>1CSq9P&gnm4FCY}HvpXhDqsL$06t+NjYT9Op&48@WIzT4wg6s?^FLtx zX6-(|oeS~5@^|HCyZ!_4ujGHo4yL+~%Uh9u*8S0Y{r@@a1N0xT2kF=O|LuOv|GEF8 z_7mm<_6zlg-hcg;LFfFYLaI8-)KH`t|q!?O(UPsehvIr{CZ8UZ7u#e;NM& z`DMcEE?%Hssr>i-|M(Bb&p*H3zbd`O`iuT&+ynJ9{RfoigAaxK0e>z2(f<4Ux8;Y@ zU-Dn~Khb%_{I~oE{vU8ZHD7QOB#+@pN^qcaIV7d-?QjZ zL3-m6Y0JmX8t=00R>=$$PZ02+{_y|EPy9j0))R6A3^9(%x0;6o3F)7id7d?pq7S2w zhFjJ08lohxqPw9s>*zy^P*j{uUiLs-IW)qZu7#7tfRdr}Vm03tuCbY$u@}w&+1_<_&h=0XJ0cUAPtDwAFPXKD98LO=i@VKi2HeUzutGTky2wz z>()^lgB1V({`y97cE+Y^(MDVC`b)(-K8L@3UjK|!=zI6o@BEctY^=PLq}4{Z=ML2Q9!9>O_%qV_T=}vg?0@ag>}Pz~tOv^a zkfj>vfP`J1fsi!Q;HRRx@;)hYPqI8&8r(uYxB1aAc2q8Xi#W^>^zYrQBWrOnS4cbz zR4@7p0=&#v&}6hNxB;{WCiHQ{HFwnnAD;6=SYK|70i&5VV**iK(jJzhJr>M+^y2<8 z-mo{v3D706e$HWIlBs<_3i9lE4QB+K5}>QcClMnDcYe1)vK%d^J5~|%F?!xho=BvX zyoeVcRkJjr@`(>9?~`wj%jZNRa1vlwP~R^DM~Q5 z6kz=uA?-^;%q%wLqikK96Z*YnO)h9A5EVv3o2SRQdyO*YEOTPs zjx9_UlF}N$Sf0#o9COZ*6YX*`@3crt!heAEu>XRQ$Az0p7 z5~%K`*y+G6gO2F4$c@Z#w{z2&V1Kv=g8bqvbIPsyA#3`S`%_B1WdDTcv*T^<*@>ET z{UQjyd;{~`|B1j0ih39l_H4$f98pv3KY?Qrq^>F@Rfq}B@p!?E5?k{PJd@rKrf}SEldKP?Y||@t#X2d&NH1Eyp2}4-p0}?jQgu zA)j_iE*48R_KF~RAW#ar!D(V7iF^Dfzz6vnVIGD8NlDNdbz3J1KjZ5KPv4^CZ*OxB z4N+HwZO0fqV9p=&m1!+@_i&kyV3Z=2_2pY%q-Qp#b}pikj%r<#WPQ0fw-PzWJ--G( zqJez1G{F|08v-vrEO&?ZHRA%Yq&t~U^kDJ}{Vz@_K@hHj2~d(JLr*2=49ZrlP%6yl z*fsG|YePz=Oaz21oa<oyEaQHEw;UJVogP14%@lkqaa)5$YWW0(OuQ~Tp!@iHDbJ@&L5+Ft8fQkB z+jZ)iu}na2hwJF3gRp)Qkg2J6%2NxVwGoVm_KJezP>R3sRYEko^KE*k_OquL=@=rR zv97WnoEZ|MVr`SEou3>lMo)kn>+@7;;e`ja#(2BqE1Lf7H_s&3XW<>TCAB@Jms zBgDPa-CS#K(^-e{^md{gs5z2Qy$)5AUz(Yw6ugnnRjc?%LUqH^CN3i1;o?^Spe+Ga zEmK>HJeUkgi_Fma4Ebyxw3HDm9}-mdpz4d3L(Hqa8>jGo!07ecHEZl`7t!|pW3dSV zvY zL|yAc69;tAI8Z^TzjP#%(CCc&p>I&2hEluirQUn>X$lk8&D`@6{Qs8J<>blMfw_UY z*ehnQu#m=$+tT`tA-RTSv*&pm?PUhhzopy_7wU4~asOQ|oS!L2I6VpTs$V*~KP+GY zU=x9Q;+ia2ENf2bv7oRc)6yX^mc{l0%FRf*R6(@^ref7b2N4i!q`Ti@J19cukgt-7 zsh;{_i{IbEuR;HdjM$bn8rkMl=jt@bf`=P4-_J+ZSDG8=p^TgEU>nu*suw604x<*} zxuZlJ4KcL{`D)t@Ss==#_u5}PO{8Oa(dh$3@3IQ|ShD?4R-;quTt9!(u4jtrCJ0qz z1w&)P217Vj4Z6k*Jwy5Da98!c&&8j0AU+Rt*YGy@9DEyCEiTKAOjaP+@bzm~<@pTn zRVdoi5PHW{mWmBFSqR%-F9iHPiaDAL&S!)A+6Q-;BYLcW7O9+#T>w4NCwI{+`KS(>BaE7{iDWRW)qXb zyviD?@jxTeL!+$8ir0nB$65*kJAT0FX4v#K_y(TvP5|p|f)g6f0xA@ohK1diW#9I; zy}tV_el!lJm#N`yG9|Z6p~TpwJSg%g`83R!fiJE!ze%z_L6- zCymHT$q-JD41tes&K!&+!Pl3=Fs$zs>u^4N&Fr&bMBwL9UH;F8&38r-4Xt>)r7yh& zSIoD#tDj$!7$%j_%32=u(2W5E;xC_qkS>Xz9W{Yj2e^D@P~6rlPlvD%AJMXO?oN@Y zwYO2y?G=}{3~F^z_aHz|F^EyhI12KrD~Fze8dHXqyd4 z)BuvW0Btb1tqXx>IFfupd6UGj+R13(`}qZ1yL9LKxuik^ny+HX}?pfmFv=&KZvp= z<%{Wb@#*g9LfKZ!^*$g8wI|g=)6KV(jt%Yy1`h@kbUI3fm#m_lgalw{9-kpBd*%Ss ztIJ^GV}3(kzl$OOPzf8YP6u)D(e_|VEeLF0UFt2sEHY2ldHqdbbhqH!2VX>cnTirQCT|d&Ne5Q>lz|LS@Htp@Zlw@fCP8*L`4Agy=1)v zNm3zy0k0e9-X@>grN9(LBw6NA5!EhXKU|(ML4w~gtX`OI|9_bfDZA4OB+eQ+Fm0yR z1YWPi!~96LZ<}_qZ+|c+YLM}V=QsO>cW!XENudllc)$T)OAlWQZ(6RzRhbGr z;&V(l3*b;+cNY-+b|>zl6^l!ompB-hv~GTggn#q$tG}8$TJ*{xmNjB1p*6FXt;(w( z?GM>z{FX5#Ni!BzcD-_}_eBgdg>2-I_QF`-b1?Zo&Wp)A=POP*m`VetQe(zBn$&&Q zPl@o*#G1`i{<6XqpA2HA9`mA$`2n?DhPu=b{$oCWLum`>lY`qD##A2p zWhA=4>7Gmhh+IT6TCEHc&YQFRB~VPK7a+eF9w>`G#xL}aFYmO>p+TTu|AM)PV3&GR z_x=h^cWFU|D>T-tt45h1aj&C6gDSD(o*|5Mx^08wsZot)xi!ekZN5&Wx9wS}j~ UzZ;C9*T<t2sHLFX?)XJL?*Tss_q0RH{JfB=TxtewtvR4MKkR5HxrwSzx? zwQVMY23*KNjD3KCcOy4pP6yx=k(IdF>bbTFZ}sILiY~!`0fqd6c>f7hKlz{AbPg~3 z*8`o0D$i%%YhtY(y2&!&ompH2efZ@dUIXeMz&NdPSo+l)m|EH+O|OYKo3D%<=VHDU zVIFv3j7r$lJNMA#y?!A0Fly z)Z96bgMEAcw==}4W+_v!LtVeb4F~+N&gi9c@pW8PT5FJ~$KC4=3NbJ&{;f9tP>!A< zMKti_zK(cB>c(+5o&BuDRLiaU9J|{TN;dG=OuiPlOe3yRbgs zGaW?VVqwu6H3M`V(E?UXUqDS4fzm=cOTNLXjMPg5Ue6^HmCnoB;4E6NXlSV&R2OkR zzA^aN`b3c_eY?z8y(#^q^x9Nt=IbS{A(e4taL;9Qjv@I@S@^BkG++_hE)6;KBF~k z0B^ROA;vn`;HW6l42p6e9KlNeWic4Qf`#jpt=p@1J)X+I>f u(P;lPFBCn>#N9sTZ89C)BO6*OBJH>zw@p>&w2WVQqOSJjy8R{w# literal 0 HcmV?d00001 diff --git a/src/assets/platform-logos/taobao.webp b/src/assets/platform-logos/taobao.webp new file mode 100644 index 0000000000000000000000000000000000000000..855a1e2b8b65d7670eecb39d41c51750e88ae868 GIT binary patch literal 1542 zcmV+h2Ko6?Nk&Hg1pok7MM6+kP&il$0000G0001A003VA06|PpNDlx2009sP5dNoK z5E03~n4jzm09H^qAjky(0B{@todGIf0AK(jkw>{I~woGIdRasz5+m(N-qV+k6 zGU{70BSqyX;P3)<0DLA*bl)8ejvznI;i9*MNYdRy_slWU!epaA9$nqm0092}6rcdN zU9Rns(^Mo9=QfyO^Q8R0x2|mu60~D z)_SCq3SD!?ly2k_9vY^HaQzL3F!XeM6^8T~Y#r)VA4o)L2-p01`k}KClNqicf5{$N zn#=**<|?M&`Ou;FcjoM7buvFJYct zGYB!qAIivoiF5;r_CK7{aMI>Fc8p?$pH!({`*Y(we_(d#p@-cF#T zOUY8vF=)uw@ks3)B4V9Wl8`M9Jdd=agw-?;QF~kogkvKSWhiOu zu14R4thW+tU%n6V5HXGxZDt4vaUg`DiJi3Km(&HONK?cn}UXKmgx(}2CR62*Mnxm21vg6l`(EY+fS zZ$P}i1#cUrW?W>hu2|z)4|0Q@G-P=L^2te@zu@f8yyMFv4<~u>wjK9Ue4Z{pXxGP3 za`S{^lm^e0^E2`(ZHIHqk7bz0UEHdd0}lm>VLNO#t!-%$x!a>cQ{2kfjhPQDP)Pe1 zoBAYk0t;1k&7SRqwKF$ZmabdC^wlBkc;Pi~Pi)mag}(WNQSCs6_;A_C?cslUm{k3? zH(a3l!_w!twYHa0=h(?B7jKJjrdT%9R23^Vo1ECZ=S=Acx2@R;oqOc`wT81DIxR4# zL#;i0?0hXZMR_jtrPf_%Uz*zh@Li|?u!r4;+OV5<4~^SENu^5FV%-&Lt>3gP_MfHbP!3%qJzVPoxT4WPi+<=2w- zc8_?Xb*Q3}fLWdD-`VOMM%lmq)GO$WZ_=Jp38$(p`D0uWgrJtlf`H;S!T*jtZias5 zJ>ftoN_l?^jeu&^1WO8{-4TgYm&89rHZAZv9d*|@zB(rfBZ##=Zy}$CS+E5JQ95xc z3M7#{kSgwSVvC}$^K*0l;Xs0YRFOI<-axs@bHWk5+&scAZa>%|^oWnAq`_1dfO`

2*$!MW1FDseue{7y~)Z+zU}{N{dR08NTok{-;812c5J)2(%dA&(=^BnASRb zZgXXL_P4|Pijmzn&X)VjR0Lp#bMu$iidGr6Wr;f4>LgH^SW?_dQsNpPUa|IITVsXk z;3jqH*S|I*sA;=oDHoA}A^FwkE|*0G84A_MjSu1d)En4rT*tqDXRTm5tkElmx-R_S zI&e!uuGJ4!9_#Z!Vf61uFd0000008--tvH$=8 literal 0 HcmV?d00001 diff --git a/src/assets/platform-logos/tiktok-shop.webp b/src/assets/platform-logos/tiktok-shop.webp new file mode 100644 index 0000000000000000000000000000000000000000..00254fca6d130758a8161ced5cdc12de760e5485 GIT binary patch literal 1984 zcmV;x2S4~yNk&Gv2LJ$9MM6+kP&il$0000G0001A003VA06|PpNEiSB00B_iwh`q0 zmp*jt*c%ZO08zH|{rCRsPF7GjAbJM?08k_VodGIf0AK(I*2fD8#^ z0G1|FcjMpzws*yYsc)1AS)ZWSNjK~t?6(+>ukgciCf_@dtTQZ1C5eRAQ`efrdHhYJF+ZkPB6tWv6= zALKtSufPc`u2&2I0RH|IrjM+4WxMB%VO;@5t)mX{Ch=j80(61qIO!pSJ#zQ2i${wV zZgKcoeAEVp36{{9mjg5;;&{)2cAO#|wf0Q&eq>%u51og92p{w&o%o)6bJwZ^xS{Pq z=ZHa8eF(ee4L1_03+_Ext-QJOVK84k9+=!ql@c40b$~aTVcaMWhof}Tu7F|{y@1Y< zZn9~-g;66{plGw@6!F9C9#|QS`rPj)Z>u$~yIfd>P>t|#jt@Kng3kLo|BT>Ybt8jE zd6MSsV!E)=8Eqx@gMoJwy_Zq74)stO8!#h{*O^>GnN%Qo==1M-f1cZcqu$7A1)o2T zXY(zkg%;vKynC0{VrUA2g|D)rDs=0=g^cE`Z~XM%d7w)+j}8ctU;h0+jG1F3b`oOz zkH>Mjb`2Wv8go6g#xWHyyCY^!XVek!=rs6Ew(}A_>}y77qK?1*fBjsd`II#MU=Gat zQzyjf35Mjp25(9>!iXYBntj@@0t|f{XdjxDgSv7+$Z;HtHc9ylf}dp~XifA*X7GI5 zxE5lwcsyVC@vm|ONV!%4QNl|g`k#Q+Jl49gk%$)Q>uI-l#0X$h?|%GyEy&U*mLYO97$s|>2d_%{<*da_fQcUjV%S67F~SjqNm>^3V07l3pDS~fGnBjKbMsj63vaEd}8o9O?kT?~CL z`c{)=j?rBcrPTrXi&gei$^dLs^(KMBg7?+vi$!3dp)iH8u)jI@}~KI+p3 zZHO3r=803}cM^Z&zo_5ngjl}W!XmKqP9V1oJ#C~>QCmgIvprP$$qvPbg08wsI|&}2 zk;ng0g^w0J)@!)JPl#o^x|tm^X-U*Lu{-+ryLYVU}-p zD65aW1T3_MG%XN4ag)3s>Adb0%e?Ct6nOtFi^3(-pN!;)M!A)AJD*+V20JYNr>}9? zf}dhq1zR2$@fPh|RW_>orC?Of&zAYK=b6!73LuAr^PBC30>z2qFdi_STS1=o@$L36 z@$elhnb#k}RxCmVbXJ26$ba^oBx(UX$J_~9*!9Q9YhRi&L71Pr2Nl9s-0G_5&b&;B z&CQGZ7x&ogVArEI#m073s{@c@Ys4Y5DQ2)pOwM86;oECS6(YEQh{M-2d-Pr$A5)do2=D1zQrilt*!u$8YL@Yqk9f8 zDRzWG6r9ZRWSRf8|FKB{N04+njQ=V$W$+i0a&uoVEs;?LNZJHDo{UJptjX8xXOb>M z_vh1|Q-7rXB*H^OutQ#RB6(7zJEaK0RDmgXRJfeH}+@Jv-V@) zv-XqzC;X3Pzx#U5BnkER^qe&9DgGCI>nDGZ;{!>=%fDd#3jH(e8~VHdFV+Ltb0;0L zasS(MUz`rXeht{ka^@4b1Fya-qLz+*&29!({bP*qNS#e9L}~fc;-{uOMHU|o>{z&g zFGyz$D&-Oe%qRIX?`}Relf{-Xi#e#zb$S}i@^T&_99{&@@-U)*0092}1ON&Dxjt#z z(A3$d!)K#)!uqjx_f$jR#iCjIRiIXP{ta(i1Dcyl&cK&fR;l7q=B*8c$7)oLQ5{Y>tAY%;%Tg1spbqOHFZnF2@B@=^g z9ndypZlZ4$-ju_LXxV1}*OTy@1Ol7)L^6~kyWaZD`L6&eXeq*SYbCqXkZv33{!5=Y zB(`^x21)_hB-kaeSFJ1h99rdU-o9QnFsuU($h#xq5(eubg;#Ba?|s=(s76P~2Jlv) zOVtf$DIUo{SG^~1idi*nxL@>EgnM409%${P49JMtt9t9UJm!K zLLj$kj;i4)I-5o*yZkSU(<8g`7O~`i&V8x*t)XwW_JeXVt#cb`{hyT_8o5V zD=spQp+tn7xgXUypN{B5;`2?Ts_By7kz8YcB5d(Ik%sWl39#8O2?OLOFVtUfwJUPj zV7I|EFOGgUv+gBVw!LxJTs_m7&zr`L_IpwFC{JN3|AZHPb9k5x&QpWI>ORYC>ONE|1*U$gZ+yl{}h4At`) zWsfPw2&UCRkiArihTK6)KArGJ6U|LdHt+ObcMv{C?O4u#IvLm6Y2*HI { + try { return sessionStorage.getItem(STORAGE_KEY) === "1"; } catch { return false; } + }); + const [errors, setErrors] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const intervalRef = useRef>(); + + const fetchErrors = useCallback(async (p = 1) => { + setLoading(true); + try { + const data = await keyServerClient.getClientErrors(p); + setErrors(data.items); + setTotal(data.total); + setPage(p); + } catch { /* silent */ } + setLoading(false); + }, []); + + useEffect(() => { + if (!open) return; + void fetchErrors(1); + intervalRef.current = setInterval(() => fetchErrors(1), POLL_INTERVAL); + return () => clearInterval(intervalRef.current); + }, [open, fetchErrors]); + + useEffect(() => { + try { sessionStorage.setItem(STORAGE_KEY, open ? "1" : "0"); } catch { /* */ } + }, [open]); + + const maxPage = Math.max(1, Math.ceil(total / 50)); + + if (!open) { + return ( + + ); + } + + return ( +

+
+ 客户端错误 ({total}) +
+ + +
+
+
+ {errors.length === 0 ? ( +
暂无错误
+ ) : ( + errors.map((err) => ( +
+ + {err.source} + {err.message.slice(0, 120)} + {err.count} + + +
+
URL: {err.url}
+
User: {err.user_id || "匿名"}
+ {err.stack ?
{err.stack.slice(0, 1000)}
: null} +
+
+ )) + )} +
+ {maxPage > 1 ? ( +
+ + {page} / {maxPage} + +
+ ) : null} +
+ ); +} + +export default AdminMonitor; diff --git a/src/components/AnimatedPanel.tsx b/src/components/AnimatedPanel.tsx new file mode 100644 index 0000000..53e0df8 --- /dev/null +++ b/src/components/AnimatedPanel.tsx @@ -0,0 +1,54 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; + +interface AnimatedPanelProps { + open: boolean; + children: ReactNode; + className?: string; + /** Duration in ms for the exit animation before unmounting. */ + exitDuration?: number; +} + +export function AnimatedPanel({ open, children, className, exitDuration = 140 }: AnimatedPanelProps) { + const [mounted, setMounted] = useState(open); + const [visible, setVisible] = useState(open); + const timerRef = useRef(null); + + useEffect(() => { + if (open) { + if (timerRef.current) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + setMounted(true); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setVisible(true); + }); + }); + } else { + setVisible(false); + timerRef.current = window.setTimeout(() => { + setMounted(false); + timerRef.current = null; + }, exitDuration); + } + }, [open, exitDuration]); + + useEffect(() => { + return () => { + if (timerRef.current) { + window.clearTimeout(timerRef.current); + } + }; + }, []); + + if (!mounted) return null; + + return ( +
+ {children} +
+ ); +} \ No newline at end of file diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx new file mode 100644 index 0000000..a130343 --- /dev/null +++ b/src/components/AppShell.tsx @@ -0,0 +1,541 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { publicConfigClient, type WebPublicConfig } from "../api/publicConfigClient"; +import { toast } from "./toast/toastStore"; +import type { ServerConnectionHealth } from "../api/serverConnection"; +import { ossAssets } from "../data/ossAssets"; +import { canManageCommunityCases, canReviewCommunity } from "../features/community-review/communityPermissions"; +import type { WebNavItem, WebNotification, WebUsageSummary, WebUserSession, WebViewKey } from "../types"; +import NotificationCenter from "./NotificationCenter"; +import BetaApplicationModal from "./BetaApplicationModal"; +import { AnimatedPanel } from "./AnimatedPanel"; +import AdminMonitor from "./AdminMonitor"; +import CookieConsentBanner from "./CookieConsentBanner"; +import { loadRechargeModal, type RechargeModalComponent } from "./RechargeModal/loadRechargeModal"; +import { ShellIcon } from "./ShellIcon"; +import { loadDarkGreenTheme } from "../styles/loadDarkGreenTheme"; + +interface AppShellProps { + activeView: WebViewKey; + navItems: WebNavItem[]; + session: WebUserSession | null; + usage: WebUsageSummary; + notifications: WebNotification[]; + backendHealth: ServerConnectionHealth; + workspaceExpanded: boolean; + onSelectView: (view: WebViewKey) => void; + onLogout: () => void; + onOpenLogin: () => void; + onMarkNotificationRead?: (id: string, isRead?: boolean) => void; + onMarkAllNotificationsRead?: () => void; + children: ReactNode; +} + +const BRAND_LOGO_URL = ossAssets.brand.logo; +const TOOL_SURFACE_VIEW_SET = new Set([ + "workbench", + "canvas", + "more", + "scriptTokens", + "tokenUsage", + "ecommerceTemplates", + "sizeTemplate", + "imageWorkbench", + "resolutionUpscale", + "digitalHuman", + "dialogGenerator", + "avatarConsole", + "characterMix", +] as WebViewKey[]); +const PRIMARY_NAV_ORDER: WebViewKey[] = [ + "workbench", + "ecommerce", + "sizeTemplate", + "canvas", + "scriptTokens", + "tokenUsage", + "more", + "assets", + "community", +]; + +function formatBalance(cents: number): string { + const value = Math.max(0, cents) / 100; + return `${value.toFixed(2)} 积分`; +} + +function canReviewBetaApplications(session: WebUserSession | null): boolean { + const role = String(session?.user.role || "").trim().toLowerCase(); + const username = String(session?.user.username || "").trim().toLowerCase(); + return role === "admin" || username === "xqy1912"; +} + +function AppShell({ + activeView, + navItems, + session, + usage, + notifications, + backendHealth, + workspaceExpanded, + onSelectView, + onLogout, + onOpenLogin, + onMarkNotificationRead, + onMarkAllNotificationsRead, + children, +}: AppShellProps) { + const activePackage = session?.user.activePackages?.[0]; + const profileRef = useRef(null); + const submenuHideTimerRef = useRef(null); + const [profileOpen, setProfileOpen] = useState(false); + const [rechargeOpen, setRechargeOpen] = useState(false); + const [RechargeModal, setRechargeModal] = useState(null); + const [infoOpen, setInfoOpen] = useState(false); + const [betaOpen, setBetaOpen] = useState(false); + const infoRef = useRef(null); + const [openSubmenuKey, setOpenSubmenuKey] = useState(null); + const [publicConfig, setPublicConfig] = useState({}); + const prevActiveViewRef = useRef(activeView); + const [navJustActivated, setNavJustActivated] = useState(null); + const isAuthView = activeView === "login"; + const isImmersiveView = activeView === "agent" || activeView === "avatarConsole"; + const showFloatingNav = !isAuthView && !isImmersiveView && activeView !== "home"; + const showPageScrollActions = showFloatingNav && !TOOL_SURFACE_VIEW_SET.has(activeView); + + const visibleNavItems = useMemo( + () => { + const navItemByKey = new Map(navItems.map((item) => [item.key, item])); + return PRIMARY_NAV_ORDER + .map((key) => navItemByKey.get(key)) + .filter((item): item is WebNavItem => Boolean(item)); + }, + [navItems], + ); + + useEffect(() => { + if (activeView !== prevActiveViewRef.current) { + setNavJustActivated(activeView); + prevActiveViewRef.current = activeView; + const timer = window.setTimeout(() => setNavJustActivated(null), 320); + return () => window.clearTimeout(timer); + } + }, [activeView]); + + useEffect(() => { + if (typeof document === "undefined") { + return; + } + + void loadDarkGreenTheme(); + document.documentElement.dataset.theme = "dark"; + document.documentElement.dataset.uiTheme = "dark-green"; + document.documentElement.style.colorScheme = "dark"; + + const metaThemeColor = document.querySelector("meta[name='theme-color']"); + if (metaThemeColor) { + metaThemeColor.content = "#0d0d0f"; + } + }, []); + + useEffect(() => { + let cancelled = false; + publicConfigClient + .get() + .then((config) => { + if (!cancelled) setPublicConfig(config); + }) + .catch(() => { + if (!cancelled) setPublicConfig({}); + }); + + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!profileOpen) return; + + const handlePointerDown = (event: PointerEvent) => { + if (!profileRef.current?.contains(event.target as Node)) { + setProfileOpen(false); + } + }; + + document.addEventListener("pointerdown", handlePointerDown); + return () => document.removeEventListener("pointerdown", handlePointerDown); + }, [profileOpen]); + + useEffect(() => { + if (!infoOpen) return; + const handleInfoOutside = (event: PointerEvent) => { + if (!infoRef.current?.contains(event.target as Node)) { + setInfoOpen(false); + } + }; + document.addEventListener("pointerdown", handleInfoOutside); + return () => document.removeEventListener("pointerdown", handleInfoOutside); + }, [infoOpen]); + + useEffect(() => { + if (!session) { + setProfileOpen(false); + } + }, [session]); + + useEffect(() => { + return () => { + if (submenuHideTimerRef.current) { + window.clearTimeout(submenuHideTimerRef.current); + } + }; + }, []); + + useEffect(() => { + if (!rechargeOpen || RechargeModal) return; + + let cancelled = false; + void loadRechargeModal().then((component) => { + if (!cancelled) { + setRechargeModal(() => component); + } + }); + + return () => { + cancelled = true; + }; + }, [RechargeModal, rechargeOpen]); + + const showSubmenu = (key: WebViewKey) => { + if (submenuHideTimerRef.current) { + window.clearTimeout(submenuHideTimerRef.current); + submenuHideTimerRef.current = null; + } + setOpenSubmenuKey(key); + }; + + const scheduleHideSubmenu = () => { + if (submenuHideTimerRef.current) { + window.clearTimeout(submenuHideTimerRef.current); + } + submenuHideTimerRef.current = window.setTimeout(() => { + setOpenSubmenuKey(null); + submenuHideTimerRef.current = null; + }, 1500); + }; + + const scrollActivePage = (direction: "top" | "bottom") => { + if (typeof document === "undefined") return; + + const targets = [ + ...Array.from(document.querySelectorAll(".web-shell__page")), + ...Array.from( + document.querySelectorAll( + ".workbench-landing-page, .ecommerce-landing-page, .workspace-page-shell__content, .community-page", + ), + ), + document.scrollingElement as HTMLElement | null, + ].filter((target): target is HTMLElement => Boolean(target)); + + targets.forEach((target) => { + const top = direction === "top" ? 0 : target.scrollHeight; + target.scrollTo({ top, behavior: "smooth" }); + }); + }; + + const displayName = session?.user.displayName || session?.user.username || "预览创作者"; + const avatarLabel = displayName.trim().slice(0, 1).toUpperCase() || "创"; + const avatarUrl = session?.user.avatarUrl || null; + const isEnterpriseAccount = Boolean(session?.user.enterpriseId || session?.user.accountType === "enterprise"); + const displayedBalanceCents = + session && isEnterpriseAccount + ? (usage.enterpriseBalanceCents ?? session.user.enterpriseBalanceCents ?? usage.balanceCents) + : usage.balanceCents; + const displayedBalanceLabel = session ? formatBalance(displayedBalanceCents) : "0 积分"; + const showCommunityReview = canReviewCommunity(session); + const showCommunityCaseAdd = canManageCommunityCases(session); + const showBetaApplicationReview = canReviewBetaApplications(session); + + return ( +
+
+ {showFloatingNav ? ( + + ) : null} + {showPageScrollActions ? ( +
+ + +
+ ) : null} + +
+ {!isImmersiveView ? ( +
+ +
+ + {session && ( + onSelectView(view)} + onMarkRead={onMarkNotificationRead} + onMarkAllRead={onMarkAllNotificationsRead} + /> + )} +
+ + +
+
备案信息
+
{publicConfig.icpRecord || "由服务器配置"}
+
公司地址
+
{publicConfig.companyAddress || "由服务器配置"}
+
联系电话
+
{publicConfig.contactPhone || "由服务器配置"}
+
+ +
+
+ +
+ + +
+ + {avatarUrl ? {displayName} : avatarLabel} + +
+ {displayName} + {session ? session.user.role || "已登录" : "预览模式"} +
+
+
+
UID
+
{session?.user.id || "preview"}
+
{isEnterpriseAccount ? "企业积分" : "积分"}
+
{displayedBalanceLabel}
+
图片
+
{usage.imageUsed}
+
视频
+
{usage.videoUsed}
+
+
+ {session?.source === "server" ? "服务器会话" : "预览会话"} + +
+ + + {showCommunityReview ? ( + <> + + + ) : null} + {showBetaApplicationReview ? ( + + ) : null} + {showCommunityCaseAdd ? ( + <> + + + ) : null} +
+
+
+
+ ) : null} +
{children}
+
+
+ {session?.user.role === "admin" ? : null} + {rechargeOpen && RechargeModal ? ( + setRechargeOpen(false)} currentBalance={displayedBalanceCents} /> + ) : null} + setBetaOpen(false)} /> +
+ ); +} + +export default AppShell; diff --git a/src/components/BeforeAfterCompare.tsx b/src/components/BeforeAfterCompare.tsx new file mode 100644 index 0000000..0a67142 --- /dev/null +++ b/src/components/BeforeAfterCompare.tsx @@ -0,0 +1,108 @@ +import { useRef, useState, type CSSProperties } from "react"; + +interface BeforeAfterCompareProps { + sourceSrc: string; + resultSrc: string; + sourceLabel?: string; + resultLabel?: string; + sourceAlt?: string; + resultAlt?: string; + className?: string; + onSourceLoad?: (width: number, height: number) => void; +} + +const MIN_POSITION = 5; +const MAX_POSITION = 95; + +function clamp(value: number) { + return Math.min(MAX_POSITION, Math.max(MIN_POSITION, value)); +} + +export default function BeforeAfterCompare({ + sourceSrc, + resultSrc, + sourceLabel, + resultLabel, + sourceAlt = "原图", + resultAlt = "结果", + className = "", + onSourceLoad, +}: BeforeAfterCompareProps) { + const stageRef = useRef(null); + const [position, setPosition] = useState(50); + + const updatePosition = (clientX: number) => { + const stage = stageRef.current; + if (!stage) return; + const rect = stage.getBoundingClientRect(); + if (!rect.width) return; + setPosition(clamp(((clientX - rect.left) / rect.width) * 100)); + }; + + return ( +
+
+ {sourceAlt} { + onSourceLoad?.(event.currentTarget.naturalWidth, event.currentTarget.naturalHeight); + }} + /> +
+
+ {resultAlt} +
+ {sourceLabel && ( +
{sourceLabel}
+ )} + {resultLabel && ( +
{resultLabel}
+ )} +
{ + if (event.key === "ArrowLeft") { + event.preventDefault(); + setPosition((current) => clamp(current - 2)); + } + if (event.key === "ArrowRight") { + event.preventDefault(); + setPosition((current) => clamp(current + 2)); + } + }} + onPointerDown={(event) => { + event.currentTarget.setPointerCapture(event.pointerId); + updatePosition(event.clientX); + }} + onPointerMove={(event) => { + if (!event.currentTarget.hasPointerCapture(event.pointerId)) return; + updatePosition(event.clientX); + }} + onPointerUp={(event) => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }} + onPointerCancel={(event) => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }} + > + +
+
+ ); +} diff --git a/src/components/BetaApplicationModal.tsx b/src/components/BetaApplicationModal.tsx new file mode 100644 index 0000000..619082a --- /dev/null +++ b/src/components/BetaApplicationModal.tsx @@ -0,0 +1,349 @@ +import { CloseOutlined, ExperimentOutlined } from "@ant-design/icons"; +import { useState } from "react"; +import { betaApplicationClient } from "../api/betaApplicationClient"; + +interface BetaApplicationModalProps { + open: boolean; + onClose: () => void; +} + +/* ── Form state ── */ +interface BetaFormData { + name: string; + email: string; + phone: string; + wechat: string; + industry: string; + company: string; + city: string; + aiTools: string; + aiDuration: string; + aiTrack: string; + aiDirection: string[]; + weeklyUsage: string; + feedbackWilling: string; + wantFeature: string[]; + selfStatement: string; + signature: string; + applicationDate: string; + agreeRules: boolean; +} + +const INITIAL_FORM: BetaFormData = { + name: "", + email: "", + phone: "", + wechat: "", + industry: "", + company: "", + city: "", + aiTools: "", + aiDuration: "", + aiTrack: "", + aiDirection: [], + weeklyUsage: "", + feedbackWilling: "", + wantFeature: [], + selfStatement: "", + signature: "", + applicationDate: "", + agreeRules: false, +}; + +/* ── Option groups (from the docx) ── */ +const AI_DURATION_OPTIONS = ["1年以内", "1-3年", "3-5年", "5年以上"]; +const AI_TRACK_OPTIONS = ["是,长期承接相关业务", "业余创作", "新手学习"]; +const AI_DIRECTION_OPTIONS = [ + "AI短剧批量制作", "漫剧剧情生成", "自媒体短视频", "电商图文及视频素材", + "MCN商业内容", "企业宣传视频", "个人兴趣创作", "其他", +]; +const WEEKLY_USAGE_OPTIONS = ["7次及以上", "1-3次", "空闲时间使用"]; +const FEEDBACK_OPTIONS = ["全力配合深度反馈", "简单体验留言", "仅使用不反馈"]; +const WANT_FEATURE_OPTIONS = [ + "一站式短剧漫剧完整AIGC工作流", "电商素材自动化创作流程", + "多模态智能中枢全能创作", "批量自动化创作流程", "全新未公开AI创作玩法", +]; + +/* ── Helper: single-select radio group ── */ +function RadioGroup({ + name, options, value, onChange, +}: { + name: string; + options: string[]; + value: string; + onChange: (v: string) => void; +}) { + return ( +
+ {options.map((opt) => ( + + ))} +
+ ); +} + +/* ── Helper: multi-select checkbox group ── */ +function CheckboxGroup({ + options, value, onChange, +}: { + options: string[]; + value: string[]; + onChange: (v: string[]) => void; +}) { + return ( +
+ {options.map((opt) => ( + + ))} +
+ ); +} + +/* ── Helper: text field ── */ +function TextField({ + label, value, onChange, placeholder, +}: { + label: string; + value: string; + onChange: (v: string) => void; + placeholder?: string; +}) { + return ( +
+ {label} + onChange(e.target.value)} + placeholder={placeholder ?? "请填写"} + /> +
+ ); +} + +const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => { + const [form, setForm] = useState(INITIAL_FORM); + const [submitting, setSubmitting] = useState(false); + const [message, setMessage] = useState<{ tone: "success" | "error"; text: string } | null>(null); + + const update = (key: K, value: BetaFormData[K]) => { + setForm((prev) => ({ ...prev, [key]: value })); + setMessage(null); + }; + + const close = () => { + if (submitting) return; + onClose(); + }; + + const validate = () => { + if (!form.name.trim()) return "请填写姓名 / 常用昵称"; + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) return "请填写用于接收内测码的有效邮箱"; + if (!form.phone.trim()) return "请填写联系手机号码"; + if (!form.wechat.trim()) return "请填写微信账号"; + if (!form.selfStatement.trim()) return "请填写申请自述"; + if (!form.signature.trim()) return "请填写申请人确认签字"; + if (!form.applicationDate.trim()) return "请填写申请日期"; + if (!form.agreeRules) return "请先阅读并同意内测规则"; + return null; + }; + + const submit = async () => { + if (submitting) return; + const validationError = validate(); + if (validationError) { + setMessage({ tone: "error", text: validationError }); + return; + } + + setSubmitting(true); + setMessage(null); + try { + await betaApplicationClient.submit({ + ...form, + name: form.name.trim(), + email: form.email.trim(), + phone: form.phone.trim(), + wechat: form.wechat.trim(), + industry: form.industry.trim(), + company: form.company.trim(), + city: form.city.trim(), + aiTools: form.aiTools.trim(), + aiDuration: form.aiDuration.trim(), + aiTrack: form.aiTrack.trim(), + weeklyUsage: form.weeklyUsage.trim(), + feedbackWilling: form.feedbackWilling.trim(), + selfStatement: form.selfStatement.trim(), + signature: form.signature.trim(), + applicationDate: form.applicationDate.trim(), + }); + setForm(INITIAL_FORM); + setMessage({ tone: "success", text: "申请已提交,请留意预留邮箱中的审核结果。" }); + } catch (error) { + setMessage({ tone: "error", text: error instanceof Error ? error.message : "提交内测申请失败" }); + } finally { + setSubmitting(false); + } + }; + + if (!open) return null; + + return ( +
+ + + + {/* ── Body (scrollable document) ── */} +
+ + {/* 一、个人基础信息 */} +
+

一、个人基础信息

+
+ update("name", v)} /> + update("email", v)} placeholder="审核通过后内测码将发送到此邮箱" /> + update("phone", v)} /> + update("wechat", v)} /> + update("industry", v)} /> + update("company", v)} /> + update("city", v)} /> +
+
+ + {/* 二、AI从业与使用经历 */} +
+

二、AI 从业与使用经历

+
+ update("aiTools", v)} placeholder="例如:Midjourney / Stable Diffusion / ChatGPT 等" /> +
+ AI 内容创作从业时长 + update("aiDuration", v)} /> +
+
+ 是否深耕 AI 短剧、漫剧、数字视频、电商赛道 + update("aiTrack", v)} /> +
+
+ 日常主要创作方向(可多选) + update("aiDirection", v)} /> +
+
+
+ + {/* 三、内测使用意向调研 */} +
+

三、内测使用意向调研

+
+
+ 每周可稳定登录使用内测平台次数 + update("weeklyUsage", v)} /> +
+
+ 是否愿意积极反馈产品 BUG、优化建议、功能需求 + update("feedbackWilling", v)} /> +
+
+ 本次最想体验 OmniAI 核心功能(可多选) + update("wantFeature", v)} /> +
+
+
+ + {/* 四、申请自述 */} +
+

四、申请自述 (必填)

+

请简述自身 AI 创作优势、业务需求,以及加入本次封闭内测的理由:

+