From e9d7f8832a8033fdcf885514ea1fd414934e533e Mon Sep 17 00:00:00 2001 From: zhangtuo Date: Tue, 20 Jan 2026 18:20:03 +0800 Subject: [PATCH] Initial commit: clean project structure - Backend: FastAPI + SQLAlchemy + Celery (Python 3.11+) - Frontend: Vue 3 + TypeScript + Pinia + Tailwind - Admin Frontend: separate Vue 3 app for management - Docker Compose: 9 services orchestration - Specs: design prototypes, memory system PRD, product roadmap Cleanup performed: - Removed temporary debug scripts from backend root - Removed deprecated admin_app.py (embedded UI) - Removed duplicate docs from admin-frontend - Updated .gitignore for Vite cache and egg-info --- .claude/settings.local.json | 44 + .../specs/design/BRAND-VISUAL-DIRECTIONS.md | 153 + .claude/specs/design/PAGE-HIFI-LAYOUT-SPEC.md | 230 ++ .../specs/design/WEB-HIFI-PROTOTYPE-SPEC.md | 299 ++ .claude/specs/design/figma-html/theme-a.zip | Bin 0 -> 15518 bytes .../figma-html/theme-a/account-settings.html | 59 + .../figma-html/theme-a/admin-providers.html | 74 + .../theme-a/child-profile-detail.html | 88 + .../figma-html/theme-a/child-profiles.html | 58 + .../specs/design/figma-html/theme-a/home.html | 111 + .../design/figma-html/theme-a/index.html | 33 + .../design/figma-html/theme-a/login.html | 28 + .../design/figma-html/theme-a/my-stories.html | 84 + .../design/figma-html/theme-a/not-found.html | 20 + .../figma-html/theme-a/push-settings.html | 78 + .../figma-html/theme-a/story-detail.html | 73 + .../specs/design/figma-html/theme-a/style.css | 217 ++ .../figma-html/theme-a/universe-detail.html | 64 + .../design/figma-html/theme-a/universes.html | 64 + .../figma-html/theme-b/account-settings.html | 59 + .../figma-html/theme-b/admin-providers.html | 74 + .../theme-b/child-profile-detail.html | 88 + .../figma-html/theme-b/child-profiles.html | 58 + .../specs/design/figma-html/theme-b/home.html | 111 + .../design/figma-html/theme-b/index.html | 33 + .../design/figma-html/theme-b/login.html | 28 + .../design/figma-html/theme-b/my-stories.html | 84 + .../design/figma-html/theme-b/not-found.html | 20 + .../figma-html/theme-b/push-settings.html | 78 + .../figma-html/theme-b/story-detail.html | 73 + .../specs/design/figma-html/theme-b/style.css | 217 ++ .../figma-html/theme-b/universe-detail.html | 64 + .../design/figma-html/theme-b/universes.html | 64 + .../figma-html/theme-c/account-settings.html | 59 + .../figma-html/theme-c/admin-providers.html | 74 + .../theme-c/child-profile-detail.html | 88 + .../figma-html/theme-c/child-profiles.html | 58 + .../specs/design/figma-html/theme-c/home.html | 111 + .../design/figma-html/theme-c/index.html | 33 + .../design/figma-html/theme-c/login.html | 28 + .../design/figma-html/theme-c/my-stories.html | 84 + .../design/figma-html/theme-c/not-found.html | 20 + .../figma-html/theme-c/push-settings.html | 78 + .../figma-html/theme-c/story-detail.html | 73 + .../specs/design/figma-html/theme-c/style.css | 217 ++ .../figma-html/theme-c/universe-detail.html | 64 + .../design/figma-html/theme-c/universes.html | 64 + .../CHILD-PROFILE-MODEL.md | 429 +++ .../MEMORY-INTELLIGENCE-PRD.md | 455 +++ ...MEMORY-PERSONALIZATION-TECHNICAL-REPORT.md | 177 ++ .../memory-intelligence/PUSH-TRIGGER-RULES.md | 129 + .../STORY-UNIVERSE-MODEL.md | 231 ++ .../specs/product-roadmap/PRODUCT-VISION.md | 130 + .../product-roadmap/PROVIDER-PLATFORM-RFC.md | 677 +++++ .claude/specs/product-roadmap/ROADMAP.md | 169 ++ .../specs/robustness-improvement/dev-plan.md | 72 + .claude/ui-refactor-plan.md | 303 ++ .gitignore | 40 + CLAUDE.md | 134 + README.md | 90 + admin-frontend/.gitignore | 17 + admin-frontend/Dockerfile | 23 + admin-frontend/index.html | 13 + admin-frontend/nginx.conf | 37 + admin-frontend/package-lock.json | 2627 +++++++++++++++++ admin-frontend/package.json | 28 + admin-frontend/postcss.config.js | 6 + admin-frontend/public/favicon.svg | 3 + admin-frontend/public/landing.html | 399 +++ admin-frontend/src/App.vue | 53 + admin-frontend/src/api/client.ts | 45 + .../src/components/CreateStoryModal.vue | 376 +++ admin-frontend/src/components/NavBar.vue | 112 + .../src/components/ui/BaseButton.vue | 87 + admin-frontend/src/components/ui/BaseCard.vue | 43 + .../src/components/ui/BaseInput.vue | 71 + .../src/components/ui/BaseSelect.vue | 67 + .../src/components/ui/BaseTextarea.vue | 62 + .../src/components/ui/ConfirmModal.vue | 60 + .../src/components/ui/EmptyState.vue | 45 + .../src/components/ui/LoadingSpinner.vue | 36 + .../src/components/ui/LoginDialog.vue | 136 + admin-frontend/src/components/ui/index.ts | 8 + admin-frontend/src/i18n.ts | 27 + admin-frontend/src/locales/en.json | 154 + admin-frontend/src/locales/zh.json | 154 + admin-frontend/src/main.ts | 14 + admin-frontend/src/router.ts | 59 + admin-frontend/src/stores/storybook.ts | 38 + admin-frontend/src/stores/user.ts | 49 + admin-frontend/src/style.css | 243 ++ admin-frontend/src/views/AdminProviders.vue | 475 +++ .../src/views/ChildProfileDetail.vue | 181 ++ .../src/views/ChildProfileTimeline.vue | 221 ++ admin-frontend/src/views/ChildProfiles.vue | 174 ++ admin-frontend/src/views/Home.vue | 476 +++ admin-frontend/src/views/MyStories.vue | 189 ++ admin-frontend/src/views/StoryDetail.vue | 312 ++ admin-frontend/src/views/StorybookViewer.vue | 197 ++ admin-frontend/src/views/UniverseDetail.vue | 208 ++ admin-frontend/src/views/Universes.vue | 203 ++ admin-frontend/src/vite-env.d.ts | 7 + admin-frontend/tailwind.config.js | 46 + admin-frontend/tsconfig.json | 24 + admin-frontend/tsconfig.node.json | 11 + admin-frontend/vite.config.ts | 29 + backend/.env.example | 115 + backend/.gitignore | 27 + backend/Dockerfile | 27 + backend/alembic.ini | 38 + backend/alembic/README.md | 20 + backend/alembic/env.py | 68 + .../0001_init_providers_and_story_mode.py | 45 + .../versions/0002_add_api_key_to_providers.py | 29 + .../0003_add_provider_monitoring_tables.py | 100 + .../versions/0004_add_child_profiles.py | 42 + ...005_add_story_universes_and_story_links.py | 67 + ...006_add_reading_events_and_memory_items.py | 78 + .../0007_add_push_configs_and_events.py | 68 + .../versions/0008_add_pages_to_stories.py | 25 + backend/app/__init__.py | 0 backend/app/admin_main.py | 61 + backend/app/api/__init__.py | 0 backend/app/api/admin_providers.py | 307 ++ backend/app/api/admin_reload.py | 14 + backend/app/api/auth.py | 272 ++ backend/app/api/memories.py | 268 ++ backend/app/api/profiles.py | 280 ++ backend/app/api/push_configs.py | 120 + backend/app/api/reading_events.py | 120 + backend/app/api/stories.py | 605 ++++ backend/app/api/universes.py | 201 ++ backend/app/core/__init__.py | 0 backend/app/core/admin_auth.py | 72 + backend/app/core/celery_app.py | 33 + backend/app/core/config.py | 76 + backend/app/core/deps.py | 39 + backend/app/core/logging.py | 48 + backend/app/core/prompts.py | 190 ++ backend/app/core/security.py | 25 + backend/app/db/__init__.py | 0 backend/app/db/admin_models.py | 119 + backend/app/db/database.py | 50 + backend/app/db/models.py | 232 ++ backend/app/main.py | 80 + backend/app/services/__init__.py | 0 backend/app/services/achievement_extractor.py | 85 + backend/app/services/adapters/__init__.py | 21 + backend/app/services/adapters/base.py | 46 + .../app/services/adapters/image/__init__.py | 3 + .../services/adapters/image/antigravity.py | 214 ++ backend/app/services/adapters/image/cqtai.py | 252 ++ backend/app/services/adapters/registry.py | 73 + .../services/adapters/storybook/__init__.py | 1 + .../services/adapters/storybook/primary.py | 195 ++ .../app/services/adapters/text/__init__.py | 1 + backend/app/services/adapters/text/gemini.py | 164 + backend/app/services/adapters/text/models.py | 11 + backend/app/services/adapters/text/openai.py | 172 ++ backend/app/services/adapters/tts/__init__.py | 5 + backend/app/services/adapters/tts/edge_tts.py | 66 + .../app/services/adapters/tts/elevenlabs.py | 104 + backend/app/services/adapters/tts/minimax.py | 149 + backend/app/services/cost_tracker.py | 196 ++ backend/app/services/memory_service.py | 471 +++ backend/app/services/provider_cache.py | 31 + backend/app/services/provider_metrics.py | 248 ++ backend/app/services/provider_router.py | 432 +++ backend/app/services/secret_service.py | 207 ++ backend/app/tasks/__init__.py | 3 + backend/app/tasks/achievements.py | 82 + backend/app/tasks/memory.py | 29 + backend/app/tasks/push_notifications.py | 108 + backend/docs/code_review_report.md | 14 + backend/docs/memory_system_dev.md | 147 + backend/docs/memory_system_prd.md | 93 + backend/docs/provider_system.md | 246 ++ backend/pyproject.toml | 46 + backend/scripts/add_config_column.py | 27 + backend/scripts/fix_db_schema.py | 29 + backend/scripts/manual_init_db.py | 21 + backend/tests/__init__.py | 1 + backend/tests/conftest.py | 146 + backend/tests/test_auth.py | 65 + backend/tests/test_profiles.py | 78 + backend/tests/test_provider_router.py | 195 ++ backend/tests/test_push_configs.py | 77 + backend/tests/test_reading_events.py | 143 + backend/tests/test_stories.py | 257 ++ backend/tests/test_universes.py | 68 + docker-compose.yml | 174 ++ frontend/.gitignore | 17 + frontend/Dockerfile | 23 + frontend/docs/landing-page-refactor-spec.md | 639 ++++ frontend/index.html | 13 + frontend/nginx.conf | 30 + frontend/package-lock.json | 2627 +++++++++++++++++ frontend/package.json | 28 + frontend/postcss.config.js | 6 + frontend/public/favicon.svg | 3 + frontend/public/landing.html | 399 +++ frontend/src/App.vue | 53 + frontend/src/api/client.ts | 45 + frontend/src/components/AddMemoryModal.vue | 221 ++ frontend/src/components/CreateStoryModal.vue | 380 +++ frontend/src/components/MemoryList.vue | 226 ++ frontend/src/components/NavBar.vue | 212 ++ .../src/components/ui/AnalysisAnimation.vue | 139 + frontend/src/components/ui/BaseButton.vue | 87 + frontend/src/components/ui/BaseCard.vue | 43 + frontend/src/components/ui/BaseInput.vue | 71 + frontend/src/components/ui/BaseSelect.vue | 67 + frontend/src/components/ui/BaseTextarea.vue | 62 + frontend/src/components/ui/ConfirmModal.vue | 60 + frontend/src/components/ui/EmptyState.vue | 45 + frontend/src/components/ui/LoadingSpinner.vue | 36 + frontend/src/components/ui/LoginDialog.vue | 136 + frontend/src/components/ui/index.ts | 8 + frontend/src/i18n.ts | 27 + frontend/src/locales/en.json | 154 + frontend/src/locales/zh.json | 154 + frontend/src/main.ts | 14 + frontend/src/router.ts | 77 + frontend/src/stores/storybook.ts | 38 + frontend/src/stores/user.ts | 49 + frontend/src/style.css | 243 ++ frontend/src/views/AdminProviders.vue | 353 +++ frontend/src/views/ChildProfileDetail.vue | 274 ++ frontend/src/views/ChildProfileTimeline.vue | 221 ++ frontend/src/views/ChildProfiles.vue | 174 ++ frontend/src/views/Home.vue | 473 +++ frontend/src/views/MyStories.vue | 189 ++ frontend/src/views/StoryDetail.vue | 312 ++ frontend/src/views/StorybookViewer.vue | 197 ++ frontend/src/views/UniverseDetail.vue | 208 ++ frontend/src/views/Universes.vue | 203 ++ frontend/src/vite-env.d.ts | 7 + frontend/tailwind.config.js | 46 + frontend/tsconfig.json | 24 + frontend/tsconfig.node.json | 11 + frontend/vite.config.ts | 29 + 241 files changed, 33070 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .claude/specs/design/BRAND-VISUAL-DIRECTIONS.md create mode 100644 .claude/specs/design/PAGE-HIFI-LAYOUT-SPEC.md create mode 100644 .claude/specs/design/WEB-HIFI-PROTOTYPE-SPEC.md create mode 100644 .claude/specs/design/figma-html/theme-a.zip create mode 100644 .claude/specs/design/figma-html/theme-a/account-settings.html create mode 100644 .claude/specs/design/figma-html/theme-a/admin-providers.html create mode 100644 .claude/specs/design/figma-html/theme-a/child-profile-detail.html create mode 100644 .claude/specs/design/figma-html/theme-a/child-profiles.html create mode 100644 .claude/specs/design/figma-html/theme-a/home.html create mode 100644 .claude/specs/design/figma-html/theme-a/index.html create mode 100644 .claude/specs/design/figma-html/theme-a/login.html create mode 100644 .claude/specs/design/figma-html/theme-a/my-stories.html create mode 100644 .claude/specs/design/figma-html/theme-a/not-found.html create mode 100644 .claude/specs/design/figma-html/theme-a/push-settings.html create mode 100644 .claude/specs/design/figma-html/theme-a/story-detail.html create mode 100644 .claude/specs/design/figma-html/theme-a/style.css create mode 100644 .claude/specs/design/figma-html/theme-a/universe-detail.html create mode 100644 .claude/specs/design/figma-html/theme-a/universes.html create mode 100644 .claude/specs/design/figma-html/theme-b/account-settings.html create mode 100644 .claude/specs/design/figma-html/theme-b/admin-providers.html create mode 100644 .claude/specs/design/figma-html/theme-b/child-profile-detail.html create mode 100644 .claude/specs/design/figma-html/theme-b/child-profiles.html create mode 100644 .claude/specs/design/figma-html/theme-b/home.html create mode 100644 .claude/specs/design/figma-html/theme-b/index.html create mode 100644 .claude/specs/design/figma-html/theme-b/login.html create mode 100644 .claude/specs/design/figma-html/theme-b/my-stories.html create mode 100644 .claude/specs/design/figma-html/theme-b/not-found.html create mode 100644 .claude/specs/design/figma-html/theme-b/push-settings.html create mode 100644 .claude/specs/design/figma-html/theme-b/story-detail.html create mode 100644 .claude/specs/design/figma-html/theme-b/style.css create mode 100644 .claude/specs/design/figma-html/theme-b/universe-detail.html create mode 100644 .claude/specs/design/figma-html/theme-b/universes.html create mode 100644 .claude/specs/design/figma-html/theme-c/account-settings.html create mode 100644 .claude/specs/design/figma-html/theme-c/admin-providers.html create mode 100644 .claude/specs/design/figma-html/theme-c/child-profile-detail.html create mode 100644 .claude/specs/design/figma-html/theme-c/child-profiles.html create mode 100644 .claude/specs/design/figma-html/theme-c/home.html create mode 100644 .claude/specs/design/figma-html/theme-c/index.html create mode 100644 .claude/specs/design/figma-html/theme-c/login.html create mode 100644 .claude/specs/design/figma-html/theme-c/my-stories.html create mode 100644 .claude/specs/design/figma-html/theme-c/not-found.html create mode 100644 .claude/specs/design/figma-html/theme-c/push-settings.html create mode 100644 .claude/specs/design/figma-html/theme-c/story-detail.html create mode 100644 .claude/specs/design/figma-html/theme-c/style.css create mode 100644 .claude/specs/design/figma-html/theme-c/universe-detail.html create mode 100644 .claude/specs/design/figma-html/theme-c/universes.html create mode 100644 .claude/specs/memory-intelligence/CHILD-PROFILE-MODEL.md create mode 100644 .claude/specs/memory-intelligence/MEMORY-INTELLIGENCE-PRD.md create mode 100644 .claude/specs/memory-intelligence/MEMORY-PERSONALIZATION-TECHNICAL-REPORT.md create mode 100644 .claude/specs/memory-intelligence/PUSH-TRIGGER-RULES.md create mode 100644 .claude/specs/memory-intelligence/STORY-UNIVERSE-MODEL.md create mode 100644 .claude/specs/product-roadmap/PRODUCT-VISION.md create mode 100644 .claude/specs/product-roadmap/PROVIDER-PLATFORM-RFC.md create mode 100644 .claude/specs/product-roadmap/ROADMAP.md create mode 100644 .claude/specs/robustness-improvement/dev-plan.md create mode 100644 .claude/ui-refactor-plan.md create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 admin-frontend/.gitignore create mode 100644 admin-frontend/Dockerfile create mode 100644 admin-frontend/index.html create mode 100644 admin-frontend/nginx.conf create mode 100644 admin-frontend/package-lock.json create mode 100644 admin-frontend/package.json create mode 100644 admin-frontend/postcss.config.js create mode 100644 admin-frontend/public/favicon.svg create mode 100644 admin-frontend/public/landing.html create mode 100644 admin-frontend/src/App.vue create mode 100644 admin-frontend/src/api/client.ts create mode 100644 admin-frontend/src/components/CreateStoryModal.vue create mode 100644 admin-frontend/src/components/NavBar.vue create mode 100644 admin-frontend/src/components/ui/BaseButton.vue create mode 100644 admin-frontend/src/components/ui/BaseCard.vue create mode 100644 admin-frontend/src/components/ui/BaseInput.vue create mode 100644 admin-frontend/src/components/ui/BaseSelect.vue create mode 100644 admin-frontend/src/components/ui/BaseTextarea.vue create mode 100644 admin-frontend/src/components/ui/ConfirmModal.vue create mode 100644 admin-frontend/src/components/ui/EmptyState.vue create mode 100644 admin-frontend/src/components/ui/LoadingSpinner.vue create mode 100644 admin-frontend/src/components/ui/LoginDialog.vue create mode 100644 admin-frontend/src/components/ui/index.ts create mode 100644 admin-frontend/src/i18n.ts create mode 100644 admin-frontend/src/locales/en.json create mode 100644 admin-frontend/src/locales/zh.json create mode 100644 admin-frontend/src/main.ts create mode 100644 admin-frontend/src/router.ts create mode 100644 admin-frontend/src/stores/storybook.ts create mode 100644 admin-frontend/src/stores/user.ts create mode 100644 admin-frontend/src/style.css create mode 100644 admin-frontend/src/views/AdminProviders.vue create mode 100644 admin-frontend/src/views/ChildProfileDetail.vue create mode 100644 admin-frontend/src/views/ChildProfileTimeline.vue create mode 100644 admin-frontend/src/views/ChildProfiles.vue create mode 100644 admin-frontend/src/views/Home.vue create mode 100644 admin-frontend/src/views/MyStories.vue create mode 100644 admin-frontend/src/views/StoryDetail.vue create mode 100644 admin-frontend/src/views/StorybookViewer.vue create mode 100644 admin-frontend/src/views/UniverseDetail.vue create mode 100644 admin-frontend/src/views/Universes.vue create mode 100644 admin-frontend/src/vite-env.d.ts create mode 100644 admin-frontend/tailwind.config.js create mode 100644 admin-frontend/tsconfig.json create mode 100644 admin-frontend/tsconfig.node.json create mode 100644 admin-frontend/vite.config.ts create mode 100644 backend/.env.example create mode 100644 backend/.gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/README.md create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/versions/0001_init_providers_and_story_mode.py create mode 100644 backend/alembic/versions/0002_add_api_key_to_providers.py create mode 100644 backend/alembic/versions/0003_add_provider_monitoring_tables.py create mode 100644 backend/alembic/versions/0004_add_child_profiles.py create mode 100644 backend/alembic/versions/0005_add_story_universes_and_story_links.py create mode 100644 backend/alembic/versions/0006_add_reading_events_and_memory_items.py create mode 100644 backend/alembic/versions/0007_add_push_configs_and_events.py create mode 100644 backend/alembic/versions/0008_add_pages_to_stories.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/admin_main.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/admin_providers.py create mode 100644 backend/app/api/admin_reload.py create mode 100644 backend/app/api/auth.py create mode 100644 backend/app/api/memories.py create mode 100644 backend/app/api/profiles.py create mode 100644 backend/app/api/push_configs.py create mode 100644 backend/app/api/reading_events.py create mode 100644 backend/app/api/stories.py create mode 100644 backend/app/api/universes.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/admin_auth.py create mode 100644 backend/app/core/celery_app.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/deps.py create mode 100644 backend/app/core/logging.py create mode 100644 backend/app/core/prompts.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/db/__init__.py create mode 100644 backend/app/db/admin_models.py create mode 100644 backend/app/db/database.py create mode 100644 backend/app/db/models.py create mode 100644 backend/app/main.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/achievement_extractor.py create mode 100644 backend/app/services/adapters/__init__.py create mode 100644 backend/app/services/adapters/base.py create mode 100644 backend/app/services/adapters/image/__init__.py create mode 100644 backend/app/services/adapters/image/antigravity.py create mode 100644 backend/app/services/adapters/image/cqtai.py create mode 100644 backend/app/services/adapters/registry.py create mode 100644 backend/app/services/adapters/storybook/__init__.py create mode 100644 backend/app/services/adapters/storybook/primary.py create mode 100644 backend/app/services/adapters/text/__init__.py create mode 100644 backend/app/services/adapters/text/gemini.py create mode 100644 backend/app/services/adapters/text/models.py create mode 100644 backend/app/services/adapters/text/openai.py create mode 100644 backend/app/services/adapters/tts/__init__.py create mode 100644 backend/app/services/adapters/tts/edge_tts.py create mode 100644 backend/app/services/adapters/tts/elevenlabs.py create mode 100644 backend/app/services/adapters/tts/minimax.py create mode 100644 backend/app/services/cost_tracker.py create mode 100644 backend/app/services/memory_service.py create mode 100644 backend/app/services/provider_cache.py create mode 100644 backend/app/services/provider_metrics.py create mode 100644 backend/app/services/provider_router.py create mode 100644 backend/app/services/secret_service.py create mode 100644 backend/app/tasks/__init__.py create mode 100644 backend/app/tasks/achievements.py create mode 100644 backend/app/tasks/memory.py create mode 100644 backend/app/tasks/push_notifications.py create mode 100644 backend/docs/code_review_report.md create mode 100644 backend/docs/memory_system_dev.md create mode 100644 backend/docs/memory_system_prd.md create mode 100644 backend/docs/provider_system.md create mode 100644 backend/pyproject.toml create mode 100644 backend/scripts/add_config_column.py create mode 100644 backend/scripts/fix_db_schema.py create mode 100644 backend/scripts/manual_init_db.py create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_auth.py create mode 100644 backend/tests/test_profiles.py create mode 100644 backend/tests/test_provider_router.py create mode 100644 backend/tests/test_push_configs.py create mode 100644 backend/tests/test_reading_events.py create mode 100644 backend/tests/test_stories.py create mode 100644 backend/tests/test_universes.py create mode 100644 docker-compose.yml create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/docs/landing-page-refactor-spec.md create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/landing.html create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/components/AddMemoryModal.vue create mode 100644 frontend/src/components/CreateStoryModal.vue create mode 100644 frontend/src/components/MemoryList.vue create mode 100644 frontend/src/components/NavBar.vue create mode 100644 frontend/src/components/ui/AnalysisAnimation.vue create mode 100644 frontend/src/components/ui/BaseButton.vue create mode 100644 frontend/src/components/ui/BaseCard.vue create mode 100644 frontend/src/components/ui/BaseInput.vue create mode 100644 frontend/src/components/ui/BaseSelect.vue create mode 100644 frontend/src/components/ui/BaseTextarea.vue create mode 100644 frontend/src/components/ui/ConfirmModal.vue create mode 100644 frontend/src/components/ui/EmptyState.vue create mode 100644 frontend/src/components/ui/LoadingSpinner.vue create mode 100644 frontend/src/components/ui/LoginDialog.vue create mode 100644 frontend/src/components/ui/index.ts create mode 100644 frontend/src/i18n.ts create mode 100644 frontend/src/locales/en.json create mode 100644 frontend/src/locales/zh.json create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/router.ts create mode 100644 frontend/src/stores/storybook.ts create mode 100644 frontend/src/stores/user.ts create mode 100644 frontend/src/style.css create mode 100644 frontend/src/views/AdminProviders.vue create mode 100644 frontend/src/views/ChildProfileDetail.vue create mode 100644 frontend/src/views/ChildProfileTimeline.vue create mode 100644 frontend/src/views/ChildProfiles.vue create mode 100644 frontend/src/views/Home.vue create mode 100644 frontend/src/views/MyStories.vue create mode 100644 frontend/src/views/StoryDetail.vue create mode 100644 frontend/src/views/StorybookViewer.vue create mode 100644 frontend/src/views/UniverseDetail.vue create mode 100644 frontend/src/views/Universes.vue create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..a2e8441 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,44 @@ +{ + "permissions": { + "allow": [ + "Skill(codex)", + "Bash(pip install:*)", + "Bash(alembic upgrade:*)", + "Bash(uvicorn:*)", + "Bash(npm run dev)", + "Bash(python:*)", + "Bash(ruff check:*)", + "Bash(tasklist:*)", + "Bash(findstr:*)", + "Bash(pushd:*)", + "Bash(popd)", + "Bash(curl:*)", + "WebSearch", + "WebFetch(domain:www.novelai.net)", + "WebFetch(domain:www.storywizard.ai)", + "WebFetch(domain:www.oscarstories.com)", + "WebFetch(domain:www.moshi.com)", + "WebFetch(domain:www.calm.com)", + "WebFetch(domain:www.epic.com)", + "WebFetch(domain:www.headspace.com)", + "WebFetch(domain:www.getepic.com)", + "WebFetch(domain:www.tonies.com)", + "Bash(del:*)", + "Bash(netstat:*)", + "Bash(taskkill:*)", + "Bash(codex-wrapper:*)", + "Bash(dir /b /s /a-d /o-d)", + "Bash(dir:*)", + "Bash(.venv/Scripts/python:*)", + "Bash(.venv/Scripts/ruff check:*)", + "Bash(npm run build:*)", + "Bash(test -f \"F:\\\\Code\\\\dreamweaver-python\\\\backend\\\\.env\")", + "Bash(pytest:*)", + "Bash(npm run type-check:*)", + "Bash(npx vue-tsc:*)", + "Bash(ls:*)", + "Bash(git init:*)", + "Bash(git add:*)" + ] + } +} diff --git a/.claude/specs/design/BRAND-VISUAL-DIRECTIONS.md b/.claude/specs/design/BRAND-VISUAL-DIRECTIONS.md new file mode 100644 index 0000000..e80ff6a --- /dev/null +++ b/.claude/specs/design/BRAND-VISUAL-DIRECTIONS.md @@ -0,0 +1,153 @@ +# DreamWeaver 品牌视觉方向(Web 阶段) + +## 概述 + +提供三套高保真视觉方向,用于 Web MVP。三者的 UX 结构一致,仅在色彩、视觉重量与插画风格上不同。 + +--- + +## 方案 A:Soft Aurora(温暖高级) + +**理由** +- 家长信任感强,同时保留童趣与想象力。 +- 高级但不商业化。 + +**配色** +- 主色 600: #6C5CE7 +- 主色 500: #7C69FF +- 主色 100: #EAE7FF +- 强调粉: #FF8FB1 +- 强调蓝: #65C3FF +- 中性 900: #1F2430 +- 中性 700: #4B5563 +- 中性 500: #9AA3B2 +- 中性 200: #E5E7EB +- 中性 100: #F5F7FB +- 白色: #FFFFFF + +**渐变** +- Hero 背景:linear-gradient(135deg, #EAE7FF 0%, #FDF6FF 40%, #EAF6FF 100%) +- CTA 光晕:radial-gradient(circle at 30% 30%, #7C69FF 0%, #6C5CE7 50%, #4C3FCF 100%) + +**字体** +- 标题:Noto Sans SC / Inter +- 正文:Noto Sans SC / Inter +- 数字强调:Inter + +**插画风格** +- 柔和、低对比度、轻画笔质感。 +- 圆润形状、轻高光。 +- 角色简单轮廓与友好表情。 + +**图标风格** +- 1.5px 线宽,圆角端点。 +- 强调色点缀,避免过度饱和。 + +**组件建议** +- 按钮:主色实心 + 内阴影。 +- 卡片:大圆角 + 柔和阴影。 +- 输入:浅底色 + 主色焦点环。 + +--- + +## 方案 B:Storybook Minimal(极简编辑风) + +**理由** +- 强可读性,适合长文本阅读。 +- 简洁、专业、强调内容。 + +**配色** +- 主色 600: #3B82F6 +- 主色 500: #60A5FA +- 主色 100: #DBEAFE +- 强调金: #F5C542 +- 强调薄荷: #6EE7B7 +- 中性 900: #111827 +- 中性 700: #374151 +- 中性 500: #9CA3AF +- 中性 200: #E5E7EB +- 中性 100: #F9FAFB +- 白色: #FFFFFF + +**渐变** +- Hero 背景:linear-gradient(180deg, #F9FAFB 0%, #EEF2FF 100%) +- CTA 光晕:radial-gradient(circle at 40% 30%, #60A5FA 0%, #3B82F6 60%, #1D4ED8 100%) + +**字体** +- 标题:Inter / Noto Sans SC +- 正文:Inter / Noto Sans SC +- 阅读场景可提升行高和对比度。 + +**插画风格** +- 扁平化、线条干净、留白较多。 +- 色彩克制、视觉清爽。 + +**图标风格** +- 2px 线宽,极简。 + +**组件建议** +- 按钮:纯色、无明显渐变。 +- 卡片:细边框 + 极轻阴影。 +- 输入:白底 + 清晰边框。 + +--- + +## 方案 C:Playful Glow(活力明快) + +**理由** +- 视觉更鲜活,记忆点强。 +- 更偏童趣,但仍保持专业感。 + +**配色** +- 主色 600: #7C3AED +- 主色 500: #8B5CF6 +- 主色 100: #EDE9FE +- 强调珊瑚: #FB7185 +- 强调青蓝: #22D3EE +- 中性 900: #1F2937 +- 中性 700: #4B5563 +- 中性 500: #9CA3AF +- 中性 200: #E5E7EB +- 中性 100: #F5F5F7 +- 白色: #FFFFFF + +**渐变** +- Hero 背景:linear-gradient(135deg, #EDE9FE 0%, #FFE4F3 45%, #E0F7FF 100%) +- CTA 光晕:radial-gradient(circle at 30% 30%, #8B5CF6 0%, #7C3AED 60%, #5B21B6 100%) + +**字体** +- 标题:Noto Sans SC / Inter +- 正文:Noto Sans SC / Inter +- 强调色点到为止,避免花哨。 + +**插画风格** +- 更鲜艳、更活泼。 +- 大色块 + 轻纹理背景。 + +**图标风格** +- 1.5px 线宽 + 小实心点装饰。 + +**组件建议** +- 按钮:渐变或实心 + Hover 发光。 +- 卡片:更明显阴影 + 彩色边角。 +- 输入:轻微色彩底。 + +--- + +## 共享视觉资产 + +**封面比例** +- 列表卡片:21:9 +- 详情头图:16:9 + +**插画 vs 照片** +- 默认使用插画,避免真实儿童照片(隐私与合规)。 + +**空态插画** +- 统一 1 张主插画,做颜色变体复用。 + +--- + +## 推荐 + +建议 Web MVP 使用方案 A(Soft Aurora),兼顾温暖与信任。方案 B/C 可作为后续主题或 A/B 测试备选。 diff --git a/.claude/specs/design/PAGE-HIFI-LAYOUT-SPEC.md b/.claude/specs/design/PAGE-HIFI-LAYOUT-SPEC.md new file mode 100644 index 0000000..9af9670 --- /dev/null +++ b/.claude/specs/design/PAGE-HIFI-LAYOUT-SPEC.md @@ -0,0 +1,230 @@ +# DreamWeaver 高保真页面布局与组件规格(Web) + +## 范围 + +本文将每个页面映射为高保真布局规范:结构、核心组件与状态,便于在 Figma 中快速搭建。 + +--- + +## 全局布局 + +- 画布:1440 x 900 +- 内容容器:1200px 居中 +- 栅格:12 列,24px 间距 +- 基础间距:8pt + +--- + +## 全局组件 + +**顶部导航** +- 左:Logo + 产品名 +- 中:主导航 +- 右:搜索、孩子切换器、头像菜单 + +**主 CTA** +- 主色实心按钮 + +**卡片** +- 21:9 封面 +- 标题、标签、元信息、操作 + +**表单控件** +- 文本输入、选择器、日期、滑块、标签 + +**状态** +- 空态、加载、错误、离线 + +--- + +## 1) 登录 / 授权 + +**结构** +- 渐变背景 +- 居中卡片(420px 宽) + +**组件** +- Logo 组合 +- 标题 + 副标题 +- OAuth 按钮(GitHub、Google) +- 隐私说明 + +**状态** +- Loading(按钮 spinner) +- Error(行内错误) + +--- + +## 2) 首页:生成故事 + +**结构** +- 双栏布局(左表单、右预览) +- 顶部步骤条 + +**左侧表单** +- 孩子选择器(下拉 + 头像) +- 宇宙选择器(延续 / 新建) +- 关键词标签输入 +- 成长主题选择 +- 长度选择(分段按钮) +- 生成按钮 + +**右侧预览** +- 封面占位 +- 标题占位 +- 摘要预览 +- 进度指示(文本 -> 封面 -> 语音) + +**状态** +- 空预览 +- 生成中(进度) +- 封面失败(重试) + +--- + +## 3) 我的故事(列表) + +**结构** +- 工具条 + 网格列表 + +**工具条** +- 搜索 +- 筛选(孩子、标签) +- 排序(最新、最早) +- 视图切换(网格/列表) + +**网格卡片** +- 桌面端 3 列 +- Hover 操作:阅读、重生成封面、删除 + +**状态** +- 空列表 + CTA +- 骨架屏 + +--- + +## 4) 故事详情 + +**头图** +- 16:9 封面 +- 标题 + 元信息(孩子、宇宙、标签) +- 操作按钮:重生成封面、生成语音、分享 + +**正文** +- 正文阅读区 +- 成就面板 + +**音频** +- 底部吸附迷你播放器 + +**状态** +- 封面失败 +- 语音未生成 +- 语音加载中 + +--- + +## 5) 孩子档案 + +**列表视图** +- 头像卡片网格 +- CTA:添加档案 + +**详情视图** +- 头像头部 + 编辑按钮 +- Tabs:基础信息 / 兴趣与成长 / 故事宇宙 / 阅读记录 + +**编辑弹窗** +- 姓名、生日、性别 +- 兴趣标签 +- 成长主题 + +--- + +## 6) 故事宇宙 + +**列表视图** +- 宇宙卡片 + 摘要 +- CTA:新建宇宙 + +**详情视图** +- 摘要区 +- 分区:主角、角色、世界观、成就 + +**创建/编辑** +- 结构化表单 + 示例提示 + +--- + +## 7) 推送设置 + +**结构** +- 卡片式设置 + +**组件** +- 主开关 +- 时间选择 + 周期 +- 触发开关 +- 免打扰时段 +- 文案预览 +- 测试推送按钮 + +--- + +## 8) 账户设置 + +**组件** +- 个人信息 +- OAuth 连接 +- 数据导出/删除 +- 语言(预留) + +--- + +## 9) 管理后台:Providers + +**结构** +- 表格布局 + +**表格列** +- Provider 名称、类型、状态、延迟、最近检查 + +**操作** +- 编辑、禁用、重载 +- JSON 配置编辑器(弹窗) + +--- + +## 10) 404 / 错误 / 空态 + +**布局** +- 居中插画 + CTA + +--- + +## 交互规范 + +- 按钮 Hover:轻微放大 1.02 +- 卡片 Hover:抬升阴影 +- Toast:右上角,自动消失 +- 列表使用 Skeleton + +--- + +## 响应式规则(移动端阶段) + +- 顶部导航 -> 底部 Tab +- 双栏 -> 单栏 +- 详情页操作 -> 底部吸附按钮 + +--- + +## Figma 搭建清单 + +- 新建 Page:Design System +- 新建 Page:Web Screens +- 建立颜色与字体样式 +- 组件做 Variant +- 全部使用 Auto Layout +- 1440/1200/1024/768 建立栅格 +- 状态页复制并标注 diff --git a/.claude/specs/design/WEB-HIFI-PROTOTYPE-SPEC.md b/.claude/specs/design/WEB-HIFI-PROTOTYPE-SPEC.md new file mode 100644 index 0000000..9227d72 --- /dev/null +++ b/.claude/specs/design/WEB-HIFI-PROTOTYPE-SPEC.md @@ -0,0 +1,299 @@ +# DreamWeaver Web 高保真原型规范 (v1) + +## 范围与目标 + +- 目标:为 DreamWeaver 提供专业、Web 优先的高保真 UI/UX,既温暖有想象力,又让家长感到可信与高品质。 +- 受众:3-8 岁儿童的家长。 +- 阶段重点:Web 端(桌面/平板),同时制定响应式规则,方便后续移动端迁移。 +- 假设:界面语言为简体中文;管理端或系统字段可能含英文。 + +--- + +## 设计方向 + +- 氛围:温柔、治愈、有想象力,但保持简洁与高级感(避免过度幼儿化)。 +- 视觉风格:柔和渐变、圆润形状、插画风封面、轻量阴影、舒适中性色。 +- UX 原则:低阻力、流程清晰、反馈及时、错误可恢复、AI 失败时有明确兜底。 + +--- + +## 设计系统(Web) + +### 栅格与布局 + +- 基准:8pt 间距系统。 +- 容器:1200px 最大宽度,左右 24px 边距,12 列栅格。 +- 断点: + - 1440+(宽屏) + - 1200(标准桌面) + - 1024(横屏平板) + - 768(竖屏平板) + +### 色板 + +- 主色 600: #6C5CE7 +- 主色 500: #7C69FF +- 主色 100: #EAE7FF +- 强调粉: #FF8FB1 +- 强调蓝: #65C3FF +- 成功: #34C759 +- 警告: #F6A609 +- 错误: #FF5A5F + +- 中性 900: #1F2430 +- 中性 700: #4B5563 +- 中性 500: #9AA3B2 +- 中性 200: #E5E7EB +- 中性 100: #F5F7FB +- 白色: #FFFFFF + +### 字体 + +- 主体字体:PingFang SC, Noto Sans SC, Inter, system-ui +- H1:32/40,Semibold +- H2:24/32,Semibold +- H3:20/28,Semibold +- Body L:16/24,Regular +- Body M:14/22,Regular +- Caption:12/18,Regular + +### 圆角与阴影 + +- 圆角:12(卡片)、10(输入框)、8(按钮)、24(胶囊标签) +- 阴影 S:0 4 16 rgba(31,36,48,0.08) +- 阴影 M:0 10 30 rgba(31,36,48,0.12) + +### 核心组件 + +- 顶部导航:Logo、主 CTA、搜索、孩子切换器、头像菜单 +- 侧边导航(可用于设置/管理):图标 + 文案 +- 按钮:Primary / Secondary / Ghost / Destructive +- 输入:文本、文本域、数字、日期、选择、滑块 +- 标签:兴趣/成长主题(多选) +- 卡片:故事、孩子、宇宙、Provider +- 弹窗:创建/编辑表单 +- Toast:成功/错误/提示 +- Skeleton:列表与故事内容 +- 音频播放器:播放/暂停、进度、倍速、下载 +- 空态:插画 + CTA +- 错误态:行内错误 + 重试 + +--- + +## 信息架构(Web) + +顶级导航: + +- 生成故事(Home) +- 我的故事 +- 孩子档案 +- 故事宇宙 +- 推送设置 +- 账户设置 +- 管理后台(仅开启时显示) + +--- + +## 页面规格(高保真) + +### 1) 登录/授权 + +**布局** +- 渐变背景 + 居中卡片 +- Logo、Slogan、OAuth 按钮 + +**元素** +- 标题:“欢迎来到 DreamWeaver” +- 副标题:“为孩子生成独一无二的故事” +- 按钮:“使用 GitHub 登录”、“使用 Google 登录” +- 隐私说明:“我们仅使用公开信息创建账户” + +**状态** +- Loading:按钮 spinner +- Error:行内错误 + 重试 + +--- + +### 2) 生成故事(Home) + +**布局** +- 左表单 / 右预览双栏 +- 顶部步骤条 + +**主表单** +- 孩子选择器:头像 + 姓名 + 年龄,含“新建档案”入口 +- 宇宙选择器:默认“延续上一次”,可切“新建宇宙” +- 关键词输入(标签 + 手输) +- 成长主题选择(可选) +- 故事长度(短/中/长) +- 主要 CTA:“生成故事” + +**预览面板** +- 标题占位 +- 封面占位 +- 摘要预览 +- 错误态:封面失败提示 + 重试 + +**交互** +- Stepper:档案 → 宇宙 → 关键词 → 生成 +- 生成过程:阶段进度(文本/封面/语音) + +--- + +### 3) 我的故事(列表) + +**布局** +- 顶部工具条 + 网格列表 + +**卡片** +- 21:9 封面、标题、标签、所属孩子、更新时间 +- Hover 操作:继续阅读、重新生成封面、删除 + +**筛选** +- 孩子 +- 标签 +- 时间范围 + +**空态** +- 插画 + “开始生成第一个故事” + +--- + +### 4) 故事详情 + +**头图** +- 大封面 +- 标题 + 元信息(孩子、宇宙、标签、日期) +- 主操作:生成封面 / 生成语音 / 分享 + +**内容区** +- 正文排版(舒适行高) +- 成就模块(卡片式) + +**音频** +- 滚动时底部吸附播放器 +- 倍速切换(0.8/1.0/1.2) + +**状态** +- 封面失败:占位 + 重试 +- 语音未生成:显示 CTA + +--- + +### 5) 孩子档案 + +**列表** +- 头像卡片 + 基础信息 +- CTA:“添加档案” + +**详情** +- 档案头部 + 编辑 +- Tabs:基础信息 / 兴趣与成长 / 故事宇宙 / 阅读记录 + +**编辑弹窗** +- 姓名、生日、性别 +- 兴趣标签(多选) +- 成长主题(单选或多选) + +--- + +### 6) 故事宇宙 + +**列表** +- 宇宙卡片:主角、常驻角色、成就数量 +- CTA:“新建宇宙” + +**详情** +- 宇宙摘要 +- 可编辑区:主角 / 角色 / 世界观 +- 成就时间轴 + +**创建/编辑** +- 结构化表单 + 示例提示 + +--- + +### 7) 推送设置 + +**布局** +- 卡片式设置区 + +**设置项** +- 主开关:开启主动推送 +- 时间选择 + 周期 +- 触发类型(复选) +- 免打扰时段 + +**预览** +- 推送文案预览 +- 测试推送按钮 + +--- + +### 8) 账户设置 + +- 个人信息 +- OAuth 绑定 +- 数据隐私(导出/删除) +- 语言切换(预留) + +--- + +### 9) 管理后台(Provider) + +**表格** +- Provider 列表:状态、延迟、最近检查 +- 操作:编辑、禁用、重载 + +**详情** +- JSON 配置编辑器(等宽字体) +- 健康检查按钮 + +--- + +### 10) 404 / 错误 / 空态 + +- 友好插画 + 返回 CTA + +--- + +## 交互与动效 + +- 按钮:Hover 轻微放大(1.02) +- 卡片:Hover 提升阴影 +- Loading:列表 Skeleton、生成进度 +- Toast:右上角,3s 自动消失 + +--- + +## 可访问性 + +- 文本对比度 >= 4.5:1 +- 输入框/按钮焦点态清晰 +- 最小触控区域 44px + +--- + +## 响应式策略(移动端阶段) + +- 顶部导航改为底部 Tab +- 双栏变单栏 +- 详情页操作改为底部吸附操作条 +- 卡片 1 列展示,触控面积更大 + +--- + +## Figma 实现说明 + +- 全部使用 Auto Layout +- 统一命名:Page/Section/Component/State +- 按钮、输入、卡片做 Variant +- 颜色/字体/间距作为样式管理 + +--- + +## 交付物 + +- 设计系统库(色板、文字、组件) +- 全流程高保真页面 +- 原型链接(Figma 中生成) diff --git a/.claude/specs/design/figma-html/theme-a.zip b/.claude/specs/design/figma-html/theme-a.zip new file mode 100644 index 0000000000000000000000000000000000000000..0d57508268dcb569451ced597a0ca59b6573cc5d GIT binary patch literal 15518 zcmZ{r1yo&2vW9VY3-0dj4#C~sJ-7#l;O_1Y!6mr66Wj^z?*5p2CvV7o^9~E@G;6Q^ z*513Ty8EwcIZ0p;D1d)`B(;(>|N8RpKM?QM(ahM&m`0yY?)@s1_cp{|SII+S8_5F$ z0C>D#g7e`jeM3VVCu>I<2V+M^b8AxvS~Ev0%YsB{*%^NHfwQ!Goc>H2=wx)i)qW5U z4hryy3nP}^S0tjE{@^dUAxP_TL}r2l-wgP7dK#yPhbn*7t>^_q~(GHv$$;S#>JmDEqGo5l`}y@6D#N6hELuR&qXhU0mhn^;C*mIev{^9d4y z8r&W)B-VN7lEYINxQ{19@KZt^KpsVuT@GNFyl*jMM)wa}30A18fa~jBkWLh6n2=M= z=VD7kr0f{CPDpdCrz>teyM~Qg-YE4CGA3y43iSE z$$T>l@RG7_jnXMk_$f~?H;g55R}qGoyRrcezf)xp;9dtWc@BDL@XplW@5M0N-Bb^k zUR-5X%k2;}H@zB*6gbYsYWSb{C4T|zD7z$wfyK7}9KFBzUaS(8Im+@Qo^Rn>jE%FY zqb_}9%(}2}z39behPQwk9O;i!JJt|!Xd+dyZ+rCJ3Z0&tOm9W~M_+T)<7 zQ?#@V!)np#tM7_+T@b#?vhf_kXQypj(Y*Y%IFbZHzPzPgGGRF6jz9i4f8 z0CDA=#7s~HKD8*3~ zkFYJ#6J{ocdjVRW7AQkR} zHopii(0qgu4V0yi&AGcXtNY_7Ya|dS0VKp9V);?!1^CqweEl_kDo|di>K!}w>FOOX zgv4>m$Ea0_ZKgQvu5hv4t!xjBXL?;b4m#3Yc8})q@zGPmML$Fk`0Rwq^+c2fv$Z;z6C{3u@NkG$YN>NVw8i)8iPsq z3YM%FnUnWO>L_=Rbs$LD$KVo;=sedmXZ2G9@opU$RZFifkfzr|`)C7kx@((KZ;*n5 zSJyGxhKM+C31*CpYr61kpr(1Q#b2jz&cI`?n?;Xv@=h`ooJuT&b;ns)i25@`8Aaal zz5)WWcFolKiPZAO9>fnDu0y-{sa~5nZ*7ILXHNt)-4X|3dbBH zjf2se4^m!#z7?h;jXWJC@{MIM$CCT!;^=N1FDX2aDiNAh1cNyr0lY>cb&iyIza{{v zEw>Cc9c$4Hg%Fqrv1VFqnb1k@PdwplRh|?fh8}WNjw0YKJD_dJgoFWFqp%wA%+^nI znRf#z6*q9`O}M*N$Jt6$I!19D2U@ttRmY5l=()2n>w`8)%iz`LJu=)d_SG$E0-h9j z$XscwZO*)CWwR~r-@8WKCcRh!FRC6qU93>1>n(4aS?#$pisIb6t!TdGf$}DyXj-U& zW7NjRL~Tb%RbNY3+K_B9zAaQ6blqI#5F$h@Kfy0;$~Z`gCEs7snfFh~HtVVoVfj^R z>Op*ML*ZQ-6uM1HTU}j+z?j9O-AlF@VpN{ow$F$l@2J;d8W@;6TIVa!X=a_VZbnO^ zvr!vWr1U0GCR{iEg_YHNDMMFnR_c{FVs-x3ceU5~T3(1f2YKzdrTNm|+cXze@#UhU zhDebQ@J}bsZy6&&1OWipg$4j1_~68bX6BYg|8imzb4z0yBV$K>bIZRb)p8YGnGH=0J@Xg#*-Yy*`h-O-Sd-htHn=9xAA5~0^j4OO4Z{^EhMIlrHc?) zKTAt22``y;^>g1)9cvjP1CvHu!25VkoIQ`fIXpNz$Gl$N-Zh-AJZ)g*SLLQg6g=yG1@0Zj?VuQ zZU&&8%c#i6NTC=Kyf&g5Y!NSC7Mkd<9Gj9Q38ECvm|%3$1N>BV%6ywHGRCvy;#E?( zt#B0L6x_Pw}Q0RqrHRQ&RFDBVaMSD$mug)d`zC$xxanlV<%QXQkK6O`~= zh<%sd01OE>DL|eX9$XQYAHU5z%i?_BH&8!Qj^yj^NN&EH0G^nDHHI1%a4-k8!`+k} zZM*O~D=*8{y2ngZ69oL@6+E-(jftb#}=gk*T4 zc(l`Jx>$}i9>E_^jsqpL8Dkbju(#)^*uM7Fgc?$86xAM3MYoMl&+|ueVo=J)Kk09{ zmBE=eNK+#U^97^|oeqNc{8qIZ(w1u(&`Bf__4(0fp@0)3*xC}58@{f+ETl((O2+C; zEs)2!x1P<&RD34>RMLN?P&l~;DBG~OKR18;82D1m@~f#7Pf9~h5LTKcuNTPYju7AG z^jTdt$B0m$nht7Eu38vH8*~e!tE6Ke_w3f;FzTt8MXMi1XlrrIMc>;`^eu;Pws%_; zIneP}w68IS0tD?UbD}(u=LrXzv1WH+I5m)G`4?cP6b%F+k_6SEOTunS8Z$!Ks)WOd zK|(jV8uT$$Vei!fgmCoZCTx94f3q-P#rA5kH6T`nD{-PF!C(ST_3^pDtYm$ydrxkq zpUXwjhjT>u{BN1rShsxN5AGd5x1T(uXW5eQo=&{__>m!B^hVr!*oW(H>s_;`8c20$ zsY?l+Bj(%?Q=^gZ(ojz>oMLykIS_aePro-%&32*@4qZ2O}s=F7hT9rA{VvZli z$Z`sRR?QmTt?KMK0;6f$Tp3_`xQ1y(oLDKZ<}o!a*6>-w>63wL(3gbYCncvN(N(V5 zvQa>L(T{(>Io;rZnd{EC{)YNRO;C-~e%Tg)J=+f&WOSi&-5Gvq1IrYRX7BUzn%A`X zL?MLAT;}x;`2H@%RBH`|$AFXc%p2#1*ZF*IEf)=0(}?mPbLMYOZ40Cg;=*6Zp^4fs z;2Z_R0)zia-}TJUTDk zK6qrg+@0K+j_XW+eK=n0xg_%5G0UWL6NJzl>dOz$ETlWrwL9#a2o%$&6iDOQ7GYXH#qM;0a#{fuI26J=yTA;aDm z>Rz>nJJVV|v8M7_roj?W0@5AMQmS@x!#ETN$8bZu!H%~kyf_kBV(38Sm;Lz6@P>Ls zypKf8mQhXs6grrQKAhP?Bx0aMRT2)d9|SV7@AxPJX(B;M8k!%<-dFz{p)tQj5zte$ zU~NJ%j(pIKp#y-^Dl)6^aPx49g~*%fZr|8LA~bQSEo|HGX{Q4_`D&XB1rW*?y-SOb z*iJeAhQRmmp|$f z)0^pw8>N`?7~xi%;GU>%>+pq~V5)$cToMxE3C_{|4Nb@GaJlkoLeG!!Y_BSD0a<2d zeU+F2e_LPyc9);=>%e&_;VdqAKw*TaN2I9ZwUgh=pG23-Q$@b59l<6bh~{3bJl+{` zUodLa6=E*?9)uEDfg2G>Ba@&IL})tT1D^)^-dv4I$L&VLVpD4@8x29uisofH9%1c? zVs*cn0ga(@uyF|uU?AX$D`lV$(uVDMdmPewhCWsGsXW5j5T^P`Gb4D28@>=q?1o#? z0N&6Q-{marlx78^tX0X`!K;GcJoAFNtk^(*i#q7F&z^ER0%0YnsXaSm$%QV0KP_il znO!x>lRj{sZoy&I?WpFFYWKt4=DNjup!KrKOxMY%Y^giySbQon5wX3ODwFs1hsT9s`K_ z_bwGtbdeK^cx$hZa~Wst4?tqXbf#bm<9N|Iv62paV$bE3>;r+>$ru3m38ElC0)EZD zVyREJ<4Mo-;7Ccdpu6&^RC>%gC7c^R*_0p@Fr2=h2@5_ebpma!rpW@T(09r0!U7JH zdm7SX$RSD@#aAb?j$YjbIDn$OP?`nd9_Slvv zK+>Hnsi2ar))UeK2m}Vk>@p_{r5hTVfCpZ8=5+#n`pgx=Uq+8`wbl}Z>U`kWO+QbO z$FTXd*=iwU^)+REGJ6W}SFN05wn6)XONmaxzgk--KILJks>w9lVFY6Vl_#1x)$@T_twg)d zVS-RKp^bFQIFy>HPLxescvH(E%-978haC4<)jmIJ2piy1$!J3*4{`mfE=`-;gwfW@ zhW|p7spB?jCu4!ktWsx(1h9ey*XX(D@&M78B4u$W%8@-J#~({sJ2su()M$;P(wh1h zfx8NehVLuoTK8DWllg0^|)W6Fwxb3vk%-GQQZfnmbmo zxTbQmQ!y~~OcrGC4uIRQCaLV4@4nM?X_CrMdhPIK^A`Hqy1B{M%ouTv+e;(hPSs;O z>0v@C{qn07sOAsmTaG;Y;^yT37I~AiA<5D}N(-CVJM>}OVnuF0$fx)j^CD7Hgl5yn z80&5)wbTLLTEVh@c7&Dq+nfe89HN5r%>ZXf^qyc*UM4IC$i_5`p57-owXU;2HrX*7 zpIYgp$B!jM>>e^_d|lC6ZDW5pIP@PQV{y}h?{+P6JpZIyEvFBzhuxZ&tu3=S z`PCxpMX)F#Z?qc3fipWdfEc+Md z2{(qPw>x^t^tpx>V;oevPoJ+7v+Aqy$RcPpEU>k~$AF?l2b4EF9o?PN5pL`eNTBri zGNlRG;ctfa8*PbFp3Rs!Io(irfG%vFyyLz;j|~GqMcG%nB-bW?n(<=-dP=(nXkI7j zDl_Ays&3D|n!qf&%8;y|6x?$!sXzT0+NPafkKwR0x>P!SgY)E3ub$e%p2Cy_H`+wl zI0O&q?l2zq+EIe12zin^L5$K!{r#B#LbAKp(XPgXmm=lJ{nvWRfX`g$m@d_6foIkF zitB@pFd{NPzJFP1EmH)lw#p5noX_I`ca)>{EFT7_oe1q#z!)pzER;`9Q*Og_N zRpRc+x1C6cmXX-L@012D^eGY!miDBjtU!UQAu%yEY{eZ}EiP|JCyfW`W^M2@O5G`w zOn5M@|JbXvUfad`1HH?IU;qF}A0nW+wUM#w--=(tzZE}Xub^sENdkNPrCT5*_;%Y) z^e98q1(|f2wDGFOOgSCuF6u8pn9O2mgx$W{@R&$|5A>X?ry=i(-_xe^mO!E;*H!ED z?i+VTicWfZ+B_Ma%qVwCJFwP6#h<&}xzqUmZ$ukphKajjEUt2)dyfIu~5evBOua$#$l- zuphO9?3?nMG)~7?I_6cQxt5S|7sOv0o2oD?aag~vr!BV(Jq+YOipyJEL}Ii)4h z%?+C-2ZqFxitz)dlTr1iX=RMa4w}g;K|lhT3i3j>J%-q<#SQT^?m;mB0lDm%@8w&y z;izfmY7@Tt0XXpQM@UcW%yj z(S*k4PpWy}&BnT}QlApdLh$rLI7DXVc9Qqp2)y z9CS`df=`8}i(8uLS3jxY-e-TsMt&LGUiQMqfe#kO)jw#ULdwVd$$?#Okyk@VW!&_t z%%a!4g_{jN`suMuA!2F2I)=f?BMnPayzLcU#d?Y90B#w0+H5tiFy@7|0`}z}V-`hg za#i4c%!0hn+#koRrH!e%^O#TQcS5p1yZ~z1mqhJ1t+Eisr(v#a^0`1hf@@y7~0!)dwM*&<+8T= z!h@ZnI)7Gf#og+S%)Mw-Pr~#BK(ta{N0!;{tU6*KmcE4KvnVXyc`JsHTUQ5;)o`s0 z8~%VqCS6y@+GnK;q5!OE8=_TUttjf2_|3{9k2ggNc(^3r{~$*iU4v?*z#%?AR^RH` zEjb<65AZ-I9+!tHf;*jrei%>4XLL$%(HPw=_|R0xC&Jul5v~VvjM@IgM4Nq8yj*Bm zj|nL-aTobhJ#{1cxG?fC!=xV22og?^eFM;Uk`Ca4!#cC-(H{O$%GCV026f*fV`ZfP zQb?^~DmkF3*jy+iE(NmiW=a#PDEef>PhIJC4L_1?_pU|D<)%bbvzFX3_z48F&O6ZE zGT7DU3u9e3SVkw@TdL5|!spED{5z_w2 z+yZ_WG*CB87HsHZe%saCp?(Jq*h53C_3t}!!$uA~4 zY|O`SN2*_7GbMhKi~`0T4qf4Hk`fHHd9?2`?EH&QsZ6s54KGdve0!%ATG{oj8|l*pG$&aumm&Khgxk3-+zv{E<^>jVW(X`4pnBjUK4me(uAy$ z_H->qO_*Oh0IDXB22T5$Z?knVlis}yPvQN!Ol(vl)S-MIY>_Yk0G~btNvp3k4vseV z=ElDT*$JCvR+M(|g}2TF?mqKC)IQRuDeD2NAhODk1F9vx}`eT)y@JmsP$5cpDF= zm)!t260?=M<>e*+d}K+D`RY!W(k2m)Ky|hg{T_1 z(3p#`w^55B-6RB)C)#WlAWWwAT&%u=_PpH=5s@_)>ww-49i-g z<23XzP!*B`XG_?@eO8|r2cv;n6{E2EvQ9IbEA)*4{paRr>am=G;kG}*)*dgsf2pBR z98Fr8fgwasWZH4#E-tSVyyR7vku+}HNjmZcMU_fSz4M+>RZS%A)pvpF6x>`T%x}+b z9XE6C)2Z;FrIbUdj!4DckE1kgkD~kC&J(?%5|a|kTC&7D8M8wj4bI2nVl|hqWR3)6dct^{uto)-n$FdZ z?&NuY(qn@(U@jUQ`blyU8wp5nQEbOosJnC_H69|T)w7ZGs`;|oA|$ag1o2*hdfaUK zz=wW&jMQ}Nh3bI)q45$o7W1nw;xpK~9nje@yX<<5nx889k&9`RdJe;VP7ZPRgL=hD zX^Z05sG#{0S$FafpE(lO=7gX!#U1X|w$`U&oB=^sOSV0O>ezz#bX9f>pjWaH^9**a zoV7IY)2@_Qh}K=5eu?Fmp8o9`IV-08*dNtky;08>)J@?~NJ~q>=1b(?iOD3a8r8$| z*56!N78mi3(H7c*zO9npAEBP>v>&&voqO>Fi%wSWgd?Wxqjvj}ZSDumANyMwB(`Ze zE^Fpl8HvEB4(V{i<;z9;{dl^f`1H9_5pcu@hh?$fgjyuoIGZSKa1u{8z@ZC6lXaZm zU0UMn>YmP>Yn9@DmLu5&C$8U_jMNx71`69ZxPhgkZ@P!IwxmEQ&(Aiq^4|Tg`^~bN zr*T$%WA4T(H11@5-k))1?K?dAL~!n~) zHa}LL2$YqT6in>7;Wi58tKS_UM4yT2TH4nuliKM@0`+wca@9z97sP26{XH>z0)Yb4 z4QP}2J3Bu>rxR?!e6Uo)AbC{3ALsJM6ARsg&qc)Lz5z$rn7)YhICv$+x4BF|FrnI1|*r3yTN4r5#v@!`nySsC&p&&5$xqT?uQwYA2 z!6!k)!nz3)m~!F!G!^TV66b#gHKGf!TwB5AWM zg3yk&@4axTm0AOgQX+W}TQ~3{2t`1)D^x2ij5Z0RDRwJU3Ew(F)EKmUgH!AayVVs? zS!2WzX5#d762GJb*;~NrrLh7U4#$2Mqf2{=jfqPK#--7@hWFdyo@#~XO&?jO0c*+F z!6hDkG;8TFTcXX~C=xEm-y`{rR^SsJ*RQy~4&jj821moy1I4BMxg&q~dxw@pixv|M6LUSqR5Li<$XLRZV_Q5OyV8S%Wxg91N z=6x-}DnNU!!*O)$>R&n++y1s^o8NU?ddM{Jo|Pl_8-F?PDi4!GMB?y891G98EaPe z#=igTKtMR%uaqeX4gIxBE1M4o~vJrE}ZPGwz5p4HVZB zpFKYwQ)4L4L*=?bb?~EL;q3vd_FIK{gP#1fsNi4e%+Q^7#)YZjr)`txxAv_~ zn!80`rZ0&b6zCIdNO*JfuJl@)bbq9o15t^YSJx<&Y%?n<45|yA5=zh#RG3|FGw62U zy>bK>?#5F>yH!;kQGNC>K#}}}fQO5;6w-i4HUz)3nEUw+1|0lTC|Zx<-rD*3zMp_W zY!sVL68K9L+4#H{g!LQx%9{I;T^O9gngAp8G%=H;(N3NV;_~*T)fLfc*hQXZ9$CiK zZV(`(op3}w+euJQSBBn^0Q9tC2iiJ=mfezKET~cSa0kdoXuaWAT zr^~egQHC#r?odcN*VkONi$JCKtRBV(-~ZPN@axBHUV@6I z;<5^Ymucafz^p?6Y>EkE$w^Yar{z<$OrdPbH>av`r9|e~WBx2Dh+n;cSb`Ar&RT*{ zPF&~>8y40~FBD3gHJO*6YD8-yJbY6zbn|Ol)=w8-oF^U}?GYa?H{juMU!J!b(v3)W z7UcY2>=+f0yU`5Y<|rYb1)nj;PP-Cd%Jq8K0%LyKS!v2!wFTMMN0#@mIrr&J#{^`D|; zWQO=Zvydi)TtXKaatveb=7+;V8lDp$xVAPiVza@(QzBggM}?Pc=9^N*GJi^n(HH&- zOF}<^rk_%x-$o)YmwGm5f+ZjfND|HaRS?DXI-YZuJ12Z#tgtLgNxImIVh|RVf(NP_ z9ZB6a1_4>uoNtCfg14VM;s(tN0=qZx(T9ZqcZ*?MhkW_WUZBcHUDn^OSk2u9Uld2d zV3Rm#lT>_5WsZa;BnZRiKDkz_aM4ec@{ZRzP?&=_EM^|rvj`o&2`OTHhN19`NG5q|U5~7?O zv9WOTXl%su$?!J{OIPY?t=2u$~O&8!yHy-zJCfO+)k^5$KI zo5`zEh6j!{GAn55kML6`B!6&X;=XXA;6g@(I|re48dgIOF6VXKSfj-cC={0HNW&{` z{GtMYCMYMIB6~>YVv|+1@CsY5r)aq5MY=2p1DtYqt!DKg`U}I8(9e>YlY>^L_?!Y* zH>Dh771PHF(PqAzgX;v{&kgpcDsbrv{i`1HgScD?Vf}sB@w1snHMm!;i(;8xPH#~S~;M}@rm;+NU_%F~RYnpCLC14hL< zQ~Nr0c#f;9UPeyk?CIsClqq~Q-hTO6FLl81{yzOJ+fVe_N^=LPPR;Ys(@}Tx@a%8} z!nsiNsbs*W&u^?a+Dkbjd!DJZzq&+m4Ut=2Fl2K4BVqGc_+HipR(p1AbC)0LX?QD2 zZM_ju?sp7dll8|o)}_{P0qPG9o33);Lf|3^2dO=8y0uF+Og|qLTjON#_jJ_`Tw}=) zz1c%bv2MIR_f4Aqxh8!EE!gmV*94>A_q!i6BMy#VEsbdn9URKlRwFmxH@l*TTxuoF;A$8Keq02R*i3w3yF<6RQ8TLh7j`h>Vi41(u%VvETUS>Z zXA93)FK3!01*A{71o0*kmMLy|$P+CjS!D1VJzI`CG5+K!yiz7%pOrWBwl7ZY4v8)6 zH%kgr#IpEn)K;iZUqXq!-Ixaa;fWeZ)_sX5^qrZ#k^`Bzp1PGN-xKdCE7Gk7iBu%0 zlyGO=2HwLi8rA;eljz=v;i5XqjGu?@d11)%N_(4GTUxMI!Ij1Jw4w2eqVAMKoZJv; z3&R9!HYBFhs>OVQ8i)}oOb_YpPNKSl2Y?koLS0qQ+wQFxe9su*aL;T3QY#;?*4)ob zYse%YBpfba4XffQH$%H;(p&F_7d3~JnAl~1enLDLqq*ohKb~DeJ*(SAsL(q$2^Vik zuSgCZom#3BtHoJ3bFY6O*^fD(X(|~rB@`cCx`GYch7M4210fCJ^Tk7@Tf7;>3n%?{ zG1iIY#`ZFNSgvjrNla9!--M?p>?VoPmNNm+s^-Ixyv5QeJ8wya#6MfaN1z6s*uOo} zQv~>M9W)c`p>J+I&{2dC4mh-KKi1$#2IgNwEy-TbgzsCxyeCl>ahM^;l8NmY`JM%q{+)ih*nlU$wLvs*3(s7|=1 zC(*hW5d#MgIiQCk9#k-Y8;yzk2n>RnV+1pFG%<2PWLZR#$|<$Tv^c-=*s5cQ4YvRc z!(ZVsC{K%wIQtA2I%vfBX)Z@AY<4;=Wtx%d@&=Y3?Lu`cnqv|B`R2P=23O2rt-k=} zJfx^pwQw&5YBVlD!UlZB=(R4FT)UT$P4)I=csaYa{Y&@PP?IY1?kNG7%|(Wh<8xR0 z?s+%<TEq<1c6|ONt6oaG}Q3eA_Xz@+i2uj|E~cGgq9y)Oh1*)j{?t^ohtz z=LCi#!djOUcxRk30Ij%H0+{5?*2i!-+I4G2*fr?_ky!5bl!F8|(&O*o7A3+=z1lNj z!eQaFel5^e$T@a&d5`L1u;+F59n@!27LEK&FTc_sR|PvSG)U>5Vk97)3lS8npA5MG z-~rXxs~R?HIehGGw64d7rmgBcgf%3|Wh5SJjO1k!1y&2dL*P~`1L6oRMGZ@uSd(A>@ z2~XX(+TiKh*a>A7meCNBG}GwMp~A4{S3`70bN={e9f{FVBu0Xu5RDeJq*K9RLI{-i z7Cy)@aLK)}nicAr@`30{NJ>%Y+*4uS2oOsyVx19I2pU`&&THmrm6At3OIFehjFp)c z1>kbn0e^9>M^yKcINMEXCs&l;Z6L3}I^wn_e5#=%E%Z7^)TI?Ll7yVaIY1=^93b|Q zwQkHIQW+oY_gO4+kvmo>GSWRHHLMg#(QEXzMi0k$} z0NH7CVLs8jSd^0*jx=x#S%!0@nci3O)mCZ!$%VXZcX!JC<}p!!amW4@KSmenmn82b2cv9mJ||tHrwkVA z?u=`BhpmaqOHy4$qR9kj>(F}?mLCx9d zMax9gB!sG-Ut^&BLuwb?d~-wOt(*oG#pv7 zXWsK(BlEFv*2&u3`MqHFk8-;Ll?hoaQIvL~9q;7Rrm%=S6?wy_zxL)z@-jcGxLifm z&nlb;WcDEiAuFUJ2mtjDK?H#OiP0MbBN+GyZ*+&oj~{zGCL6v~R^xo)2sLsyUAxqot9;RAs8iktjtKVp-t;f`kcVkJ!(u z6NJQAucoVS9zufEl7pSv<=NjXt(g>LyP z4XJ6m+D|DJ_0&_25q%zrjEkjCCs5D>arh;248dTlgLw^y)HMS@#(%b61O_8i#U=yBk=Ov>(_^ozTi=oKAAEDDz3NN$BBE;xzx zA1M<5=c9x zE_JJG(=vJRB0XP6=}~YiU#7p4VLF4IRFFFBJ!X-fK-E;0cBA>_W6cqvh^_~A2llB{ zl@kW>Yp03WxLbtOsjXWo?>@vOs2Jn1X~Lf04{EC(+D<_2blS^JKebh7tcm+^jCJ*U z_NzwOSAJHa;_V2LFMKGEPwPY_D6^!Mt8!CT6QowIi>*DJBV{j{R(sk^xZo$)in4&AzI%F z*-RdFdD<2_qvBz2-`@Ur zKBoTN&yfXC+T-r|!rh%I_>c@8m!bFNdGvzG7*}zckxfn6TBd1O@I*Ia^Lbyt@{y8mO(z`L}XTMm^3v&(a#s+D`h0n(Q0i)Ym&l_M&{pVPf~? zp7HqcrTYrzn^*3XE+-IXkZ=IV!3+?JIqPtGH>bjU$u5B*dua)vU{R77(IeCf$B462 z;|VCGM63F*8v_~DKELOK!0G#7gb2APiOJVi7f}@$Mj<2`q2mfRndw@$0$P#2XFia3 zU87bZhDgF;5e@!CM2d05f}NM)R33mVj#pD|jAgE`?o{o;$VS>{w;S@KAbtqiTwoJ& z4wg%f!7P6Xsf32Gkao9>@VCmZr`Uf|Rb zkqZrhZr5QcG(`Emix;h69++TsBOo<&?*mjU$A2 zdT7|5j#PwGeM_>EV9<7OE?omy=-B!km^^N?$EPcBEz_rnKqE>LOmYOOV)!Rr>S?_p z+?t!qeu^ShK`6yoBO!9rHZ>Wa*|w!lYvi$se8U*bCg;|<$CyQ?{Ss!OO0>+^Tqu6z zM_p`A7$7(W`~}6)_sO?Pr8>ntmblLMo3MEr^HPqRtPsUM4wJUcExdARkZPCuA>Ee4KeFfnf4qA+f=#?+)#j1`F{U^Ewww_^lS&dkrE<0D{ifT?<0cB5KwcNZ9 zx;1KxSuWnxu6XSiO{>d=RZqH#5Pq5?Js>r#i^RPsz(gA-D61EcHnnP;X}3pIPoJ4X z8X8nNG3jtl0$1%l?|)q7JT|Kubn&Hf;dlecNdf}l0sMc*%!uE$v44FK{x<)6=nM(~ z|F8c50-yuTV=;oaIjjJlEB|fRz$pG||K0QdcQoyH&;RO&^!@kuakl?@Q0))g8qHtb z&;Q%nkNn;KC+^SFHy?2|@4@DOasTBk&PQ4tGxcAz-076jQ`Z0d}O3r{5{ZqTLIPoKJfl5F!;z2dzYGjGyYL-@DZfN#PHVu_-*HQ=>KZ} z^Je}`HGTvYy&wJj4f-?j_>se`^LM|$?X<*y=lq!{`^b^U`w!>;rp-RGn0n~`x`*HP zXXW4Rf8Nud!u&^;G|_)p|3{|(h-!uW`@Q_O_nZISW&c#gKBBnDKcfDvk$ptSg;M_g zM*eM2o&VkbE13Q#3-^D`!0QmPGqW5pctp95L52EAi>i_@% literal 0 HcmV?d00001 diff --git a/.claude/specs/design/figma-html/theme-a/account-settings.html b/.claude/specs/design/figma-html/theme-a/account-settings.html new file mode 100644 index 0000000..030ccfa --- /dev/null +++ b/.claude/specs/design/figma-html/theme-a/account-settings.html @@ -0,0 +1,59 @@ + + + + + + 账户设置 + + + +
+ +
+
+
+

个人信息

+
+ + +
+ +
+
+

账号安全

+
已绑定 GitHub、Google
+ +
+
+

数据隐私

+ + +
+
+
+
+ + diff --git a/.claude/specs/design/figma-html/theme-a/admin-providers.html b/.claude/specs/design/figma-html/theme-a/admin-providers.html new file mode 100644 index 0000000..4ddb60f --- /dev/null +++ b/.claude/specs/design/figma-html/theme-a/admin-providers.html @@ -0,0 +1,74 @@ + + + + + + 管理后台 - Providers + + + +
+ +
+
+

Providers 管理

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
名称类型状态延迟最近检查操作
text_primaryText健康420ms2 分钟前编辑 · 禁用 · 重载
image_primaryImage健康860ms5 分钟前编辑 · 禁用 · 重载
+ +
+
+ + diff --git a/.claude/specs/design/figma-html/theme-a/child-profile-detail.html b/.claude/specs/design/figma-html/theme-a/child-profile-detail.html new file mode 100644 index 0000000..a7c66e2 --- /dev/null +++ b/.claude/specs/design/figma-html/theme-a/child-profile-detail.html @@ -0,0 +1,88 @@ + + + + + + 孩子档案详情 + + + +
+ +
+
+
+
+
+
小明 · 5岁
+
男 · 生日 2020/05/12
+
+
+ +
+ +
+
基础信息
+
兴趣与成长
+
故事宇宙
+
阅读记录
+
+ +
+
+

兴趣标签

+
+ 太空 + 机器人 + 冒险 +
+
+
+

成长主题

+
+ 勇气 + 分享 +
+
+
+ +
+

故事宇宙

+
+
+
星际冒险
+
主角:小明船长 · 成就 3 个
+
+
+
梦幻森林
+
主角:森林守护者 · 成就 1 个
+
+
+
+
+
+ + diff --git a/.claude/specs/design/figma-html/theme-a/child-profiles.html b/.claude/specs/design/figma-html/theme-a/child-profiles.html new file mode 100644 index 0000000..000393d --- /dev/null +++ b/.claude/specs/design/figma-html/theme-a/child-profiles.html @@ -0,0 +1,58 @@ + + + + + + 孩子档案 + + + +
+ +
+
+

我的宝贝

+ +
+
+
+
+
小明 · 5岁
+
太空机器人
+
+
+
+
小红 · 3岁
+
公主动物
+
+
+
空态示例:添加一个孩子档案
+
+
+
+
+ + diff --git a/.claude/specs/design/figma-html/theme-a/home.html b/.claude/specs/design/figma-html/theme-a/home.html new file mode 100644 index 0000000..c028c08 --- /dev/null +++ b/.claude/specs/design/figma-html/theme-a/home.html @@ -0,0 +1,111 @@ + + + + + + 生成故事 + + + +
+ +
+
+ 档案 + 宇宙 + 关键词 + 生成 +
+
+
+

为谁创作故事

+
+
+ + +
+
+ + +
+
+
+ +
+ 太空 + 勇气 + 机器人 + 探索 +
+ +
+
+
+ + +
+
+ +
+ + + +
+
+
+
+ +
+
+ +
+

生成预览

+
+
故事标题占位
+

故事摘要将显示在这里,支持 2-3 行预览。

+
+
生成中:文本 → 封面 → 语音
+
+
+
封面生成失败,稍后重试
+ +
+
+
+
+
+ + diff --git a/.claude/specs/design/figma-html/theme-a/index.html b/.claude/specs/design/figma-html/theme-a/index.html new file mode 100644 index 0000000..622c4a9 --- /dev/null +++ b/.claude/specs/design/figma-html/theme-a/index.html @@ -0,0 +1,33 @@ + + + + + + DreamWeaver 原型入口 + + + + + + diff --git a/.claude/specs/design/figma-html/theme-a/login.html b/.claude/specs/design/figma-html/theme-a/login.html new file mode 100644 index 0000000..3226f72 --- /dev/null +++ b/.claude/specs/design/figma-html/theme-a/login.html @@ -0,0 +1,28 @@ + + + + + + 登录 / 授权 + + + +
+
+
+ +

欢迎来到 DreamWeaver

+

为孩子生成独一无二的故事

+
+ + +
+ +
+
+
+ + diff --git a/.claude/specs/design/figma-html/theme-a/my-stories.html b/.claude/specs/design/figma-html/theme-a/my-stories.html new file mode 100644 index 0000000..060b682 --- /dev/null +++ b/.claude/specs/design/figma-html/theme-a/my-stories.html @@ -0,0 +1,84 @@ + + + + + + 我的故事 + + + +
+ +
+
+ + + + + +
+ +
+
+
+
星际冒险 · 第三章
+
+ 太空勇气 +
+
小明 · 更新于 2 天前
+
+ + +
+
+
+
+
梦幻森林 · 朋友篇
+
+ 友谊动物 +
+
小红 · 更新于 5 天前
+
+ + +
+
+
+
空态示例:开始生成第一个故事
+ +
+
+
+
+ + diff --git a/.claude/specs/design/figma-html/theme-a/not-found.html b/.claude/specs/design/figma-html/theme-a/not-found.html new file mode 100644 index 0000000..6f65b6a --- /dev/null +++ b/.claude/specs/design/figma-html/theme-a/not-found.html @@ -0,0 +1,20 @@ + + + + + + 404 + + + +
+
+
+

404

+

页面走丢了,回到生成故事开始吧。

+ +
+
+
+ + diff --git a/.claude/specs/design/figma-html/theme-a/push-settings.html b/.claude/specs/design/figma-html/theme-a/push-settings.html new file mode 100644 index 0000000..6909466 --- /dev/null +++ b/.claude/specs/design/figma-html/theme-a/push-settings.html @@ -0,0 +1,78 @@ + + + + + + 推送设置 + + + +
+ +
+
+
+

主动推送

+
+
+ + +
+
+ + +
+
+
+ +
+ 时间触发 + 事件触发 + 行为触发 + 成长触发 +
+
+
+ +
+ + +
+
+
+
+

推送预览

+
“今晚给小明讲一个关于太空的故事,好吗?”
+ +
+
+
+
+ + diff --git a/.claude/specs/design/figma-html/theme-a/story-detail.html b/.claude/specs/design/figma-html/theme-a/story-detail.html new file mode 100644 index 0000000..0b732c1 --- /dev/null +++ b/.claude/specs/design/figma-html/theme-a/story-detail.html @@ -0,0 +1,73 @@ + + + + + + 故事详情 + + + +
+ +
+
+
+

星际冒险 · 勇气的种子

+
小明 · 星际冒险宇宙 · 2025/01/12
+
+ + + +
+
+
+
+

故事正文

+

夜空像一条温柔的河流,小明驾驶着飞船穿过星光……

+

他握紧操纵杆,鼓起勇气,向未知的星球靠近。

+

最终,小明发现了新的朋友,也学会了如何面对黑暗。

+
+
+

成就

+
+ 勇气 + 友谊 +
+
“克服了黑暗的恐惧”
+
“帮助了迷路的小伙伴”
+
+
+ +
+ +
+ +
+ +
+
+ + diff --git a/.claude/specs/design/figma-html/theme-a/style.css b/.claude/specs/design/figma-html/theme-a/style.css new file mode 100644 index 0000000..734e139 --- /dev/null +++ b/.claude/specs/design/figma-html/theme-a/style.css @@ -0,0 +1,217 @@ +:root { + --primary-600: #6C5CE7; + --primary-500: #7C69FF; + --primary-100: #EAE7FF; + --accent-pink: #FF8FB1; + --accent-sky: #65C3FF; + --success: #34C759; + --warning: #F6A609; + --error: #FF5A5F; + --neutral-900: #1F2430; + --neutral-700: #4B5563; + --neutral-500: #9AA3B2; + --neutral-200: #E5E7EB; + --neutral-100: #F5F7FB; + --hero-gradient: linear-gradient(135deg, #EAE7FF 0%, #FDF6FF 40%, #EAF6FF 100%); +} +* { box-sizing: border-box; } +:root { + --container-width: 1200px; + --gutter: 24px; + --radius-card: 12px; + --radius-input: 10px; + --radius-button: 8px; + --radius-pill: 24px; + --shadow-s: 0 4px 16px rgba(31,36,48,0.08); + --shadow-m: 0 10px 30px rgba(31,36,48,0.12); +} + +body { + margin: 0; + font-family: "PingFang SC", "Noto Sans SC", Inter, system-ui, -apple-system, sans-serif; + color: var(--neutral-900); + background: var(--neutral-100); +} + +a { color: var(--primary-600); text-decoration: none; } + +.page { min-height: 100vh; } +.container { + width: min(var(--container-width), 100% - 48px); + margin: 0 auto; +} + +.nav { + background: #fff; + border-bottom: 1px solid var(--neutral-200); + position: sticky; + top: 0; + z-index: 10; +} +.nav__inner { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 0; + gap: 16px; +} +.nav__left, .nav__center, .nav__right { display: flex; align-items: center; gap: 16px; } +.nav__logo { + display: flex; + align-items: center; + gap: 10px; + font-weight: 700; + color: var(--neutral-900); +} +.nav__logo-badge { + width: 28px; + height: 28px; + border-radius: 8px; + background: linear-gradient(135deg, var(--primary-500), var(--accent-sky)); +} +.nav__item { color: var(--neutral-700); font-weight: 500; } +.nav__item.active { color: var(--primary-600); } + +.hero { + background: var(--hero-gradient); + border-radius: 16px; + padding: 32px; + box-shadow: var(--shadow-s); +} + +.section { margin: 28px 0; } +.section-title { font-size: 20px; font-weight: 600; margin-bottom: 12px; } + +.grid { display: grid; gap: 16px; } +.grid-2 { grid-template-columns: repeat(2, minmax(0,1fr)); } +.grid-3 { grid-template-columns: repeat(3, minmax(0,1fr)); } + +.card { + background: #fff; + border-radius: var(--radius-card); + padding: 16px; + box-shadow: var(--shadow-s); +} +.card--flat { box-shadow: none; border: 1px solid var(--neutral-200); } +.card-cover { + width: 100%; + aspect-ratio: 21 / 9; + border-radius: 10px; + background: linear-gradient(135deg, var(--primary-100), #fff 60%, var(--accent-sky)); + margin-bottom: 12px; +} +.card-title { font-weight: 600; margin: 6px 0; } +.card-meta { color: var(--neutral-500); font-size: 12px; } + +.badge { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: var(--radius-pill); + background: var(--primary-100); + color: var(--primary-600); + font-size: 12px; +} + +.chips { display: flex; flex-wrap: wrap; gap: 8px; } +.chip { + padding: 6px 12px; + border-radius: var(--radius-pill); + border: 1px solid var(--neutral-200); + background: #fff; + font-size: 12px; +} +.chip.selected { background: var(--primary-100); border-color: var(--primary-500); color: var(--primary-600); } + +.btn { + height: 40px; + padding: 0 16px; + border-radius: var(--radius-button); + border: 1px solid transparent; + cursor: pointer; + font-weight: 600; +} +.btn--primary { background: var(--primary-600); color: #fff; } +.btn--secondary { background: #fff; border-color: var(--primary-600); color: var(--primary-600); } +.btn--ghost { background: transparent; color: var(--neutral-700); } +.btn--danger { background: #fff; border-color: var(--error); color: var(--error); } + +.input, select, textarea { + width: 100%; + padding: 10px 12px; + border-radius: var(--radius-input); + border: 1px solid var(--neutral-200); + background: #fff; + font-size: 14px; +} + +.row { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 12px; } + +.stepper { display: flex; gap: 10px; align-items: center; } +.step { + padding: 6px 12px; + border-radius: var(--radius-pill); + background: var(--neutral-100); + color: var(--neutral-700); + font-size: 12px; +} +.step.active { background: var(--primary-100); color: var(--primary-600); } + +.toolbar { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; } + +.table { width: 100%; border-collapse: collapse; } +.table th, .table td { border-bottom: 1px solid var(--neutral-200); padding: 12px 8px; text-align: left; font-size: 14px; } + +.avatar { + width: 40px; height: 40px; border-radius: 50%; + background: var(--primary-100); + display: inline-flex; align-items: center; justify-content: center; + font-weight: 700; color: var(--primary-600); +} + +.cover-hero { + aspect-ratio: 16 / 9; + border-radius: 14px; + background: linear-gradient(135deg, var(--primary-100), #fff 55%, var(--accent-pink)); +} + +.audio-player { + display: flex; align-items: center; gap: 12px; padding: 12px 16px; + border-radius: 12px; border: 1px solid var(--neutral-200); background: #fff; +} +.audio-bar { height: 6px; background: var(--neutral-200); border-radius: 999px; flex: 1; } +.audio-progress { width: 35%; height: 100%; background: var(--primary-600); border-radius: 999px; } + +.tabs { display: flex; gap: 8px; border-bottom: 1px solid var(--neutral-200); } +.tab { padding: 10px 12px; color: var(--neutral-700); } +.tab.active { color: var(--primary-600); border-bottom: 2px solid var(--primary-600); } + +.callout { + background: #fff; + border: 1px dashed var(--neutral-200); + border-radius: 12px; + padding: 12px; + color: var(--neutral-700); + font-size: 12px; +} + +.footer-note { color: var(--neutral-500); font-size: 12px; margin-top: 12px; } + +.hero-actions { display: flex; gap: 12px; flex-wrap: wrap; } + +.split { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 24px; +} + +@media (max-width: 1024px) { + .grid-3 { grid-template-columns: repeat(2, minmax(0,1fr)); } + .split { grid-template-columns: 1fr; } +} + +@media (max-width: 768px) { + .grid-2 { grid-template-columns: 1fr; } + .grid-3 { grid-template-columns: 1fr; } + .row { grid-template-columns: 1fr; } +} diff --git a/.claude/specs/design/figma-html/theme-a/universe-detail.html b/.claude/specs/design/figma-html/theme-a/universe-detail.html new file mode 100644 index 0000000..5de8aad --- /dev/null +++ b/.claude/specs/design/figma-html/theme-a/universe-detail.html @@ -0,0 +1,64 @@ + + + + + + 宇宙详情 + + + +
+ +
+
+

星际冒险

+
主角:小明船长 · 更新于 2025/01/12
+
+
+
+

主角设定

+
小明是来自地球的探险家,勇敢且好奇。
+
+
+

常驻角色

+
机器人小七、外星猫咪星星
+
+
+

世界观

+
星际学院、彩虹星云、飞船港湾
+
+
+

成就

+
克服恐惧 · 结交朋友 · 学会独立
+
+
+
+ +
+
+
+ + diff --git a/.claude/specs/design/figma-html/theme-a/universes.html b/.claude/specs/design/figma-html/theme-a/universes.html new file mode 100644 index 0000000..b257b50 --- /dev/null +++ b/.claude/specs/design/figma-html/theme-a/universes.html @@ -0,0 +1,64 @@ + + + + + + 故事宇宙 + + + +
+ +
+
+

故事宇宙

+ +
+
+
+
星际冒险
+
主角:小明船长
+
+ 伙伴:机器人小七 + 成就:3 +
+
+
+
梦幻森林
+
主角:森林守护者
+
+ 伙伴:魔法猫咪 + 成就:1 +
+
+
+
空态示例:创建第一个宇宙
+
+
+
+
+ + diff --git a/.claude/specs/design/figma-html/theme-b/account-settings.html b/.claude/specs/design/figma-html/theme-b/account-settings.html new file mode 100644 index 0000000..030ccfa --- /dev/null +++ b/.claude/specs/design/figma-html/theme-b/account-settings.html @@ -0,0 +1,59 @@ + + + + + + 账户设置 + + + +
+ +
+
+
+

个人信息

+
+ + +
+ +
+
+

账号安全

+
已绑定 GitHub、Google
+ +
+
+

数据隐私

+ + +
+
+
+
+ + diff --git a/.claude/specs/design/figma-html/theme-b/admin-providers.html b/.claude/specs/design/figma-html/theme-b/admin-providers.html new file mode 100644 index 0000000..4ddb60f --- /dev/null +++ b/.claude/specs/design/figma-html/theme-b/admin-providers.html @@ -0,0 +1,74 @@ + + + + + + 管理后台 - Providers + + + +
+ +
+
+

Providers 管理

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
名称类型状态延迟最近检查操作
text_primaryText健康420ms2 分钟前编辑 · 禁用 · 重载
image_primaryImage健康860ms5 分钟前编辑 · 禁用 · 重载
+ +
+
+ + diff --git a/.claude/specs/design/figma-html/theme-b/child-profile-detail.html b/.claude/specs/design/figma-html/theme-b/child-profile-detail.html new file mode 100644 index 0000000..a7c66e2 --- /dev/null +++ b/.claude/specs/design/figma-html/theme-b/child-profile-detail.html @@ -0,0 +1,88 @@ + + + + + + 孩子档案详情 + + + +
+ +
+
+
+
+
+
小明 · 5岁
+
男 · 生日 2020/05/12
+
+
+ +
+ +
+
基础信息
+
兴趣与成长
+
故事宇宙
+
阅读记录
+
+ +
+
+

兴趣标签

+
+ 太空 + 机器人 + 冒险 +
+
+
+

成长主题

+
+ 勇气 + 分享 +
+
+
+ +
+

故事宇宙

+
+
+
星际冒险
+
主角:小明船长 · 成就 3 个
+
+
+
梦幻森林
+
主角:森林守护者 · 成就 1 个
+
+
+
+
+
+ + diff --git a/.claude/specs/design/figma-html/theme-b/child-profiles.html b/.claude/specs/design/figma-html/theme-b/child-profiles.html new file mode 100644 index 0000000..000393d --- /dev/null +++ b/.claude/specs/design/figma-html/theme-b/child-profiles.html @@ -0,0 +1,58 @@ + + + + + + 孩子档案 + + + +
+ +
+
+

我的宝贝

+ +
+
+
+
+
小明 · 5岁
+
太空机器人
+
+
+
+
小红 · 3岁
+
公主动物
+
+
+
空态示例:添加一个孩子档案
+
+
+
+
+ + diff --git a/.claude/specs/design/figma-html/theme-b/home.html b/.claude/specs/design/figma-html/theme-b/home.html new file mode 100644 index 0000000..c028c08 --- /dev/null +++ b/.claude/specs/design/figma-html/theme-b/home.html @@ -0,0 +1,111 @@ + + + + + + 生成故事 + + + +
+ +
+
+ 档案 + 宇宙 + 关键词 + 生成 +
+
+
+

为谁创作故事

+
+
+ + +
+
+ + +
+
+
+ +
+ 太空 + 勇气 + 机器人 + 探索 +
+ +
+
+
+ + +
+
+ +
+ + + +
+
+
+
+ +
+
+ +
+

生成预览

+
+
故事标题占位
+

故事摘要将显示在这里,支持 2-3 行预览。

+
+
生成中:文本 → 封面 → 语音
+
+
+
封面生成失败,稍后重试
+ +
+
+
+
+
+ + diff --git a/.claude/specs/design/figma-html/theme-b/index.html b/.claude/specs/design/figma-html/theme-b/index.html new file mode 100644 index 0000000..622c4a9 --- /dev/null +++ b/.claude/specs/design/figma-html/theme-b/index.html @@ -0,0 +1,33 @@ + + + + + + DreamWeaver 原型入口 + + + + + + diff --git a/.claude/specs/design/figma-html/theme-b/login.html b/.claude/specs/design/figma-html/theme-b/login.html new file mode 100644 index 0000000..3226f72 --- /dev/null +++ b/.claude/specs/design/figma-html/theme-b/login.html @@ -0,0 +1,28 @@ + + + + + + 登录 / 授权 + + + +
+
+
+ +

欢迎来到 DreamWeaver

+

为孩子生成独一无二的故事

+
+ + +
+ +
+
+
+ + diff --git a/.claude/specs/design/figma-html/theme-b/my-stories.html b/.claude/specs/design/figma-html/theme-b/my-stories.html new file mode 100644 index 0000000..060b682 --- /dev/null +++ b/.claude/specs/design/figma-html/theme-b/my-stories.html @@ -0,0 +1,84 @@ + + + + + + 我的故事 + + + +
+ +
+
+ + + + + +
+ +
+
+
+
星际冒险 · 第三章
+
+ 太空勇气 +
+
小明 · 更新于 2 天前
+
+ + +
+
+
+
+
梦幻森林 · 朋友篇
+
+ 友谊动物 +
+
小红 · 更新于 5 天前
+
+ + +
+
+
+
空态示例:开始生成第一个故事
+ +
+
+
+
+ + diff --git a/.claude/specs/design/figma-html/theme-b/not-found.html b/.claude/specs/design/figma-html/theme-b/not-found.html new file mode 100644 index 0000000..6f65b6a --- /dev/null +++ b/.claude/specs/design/figma-html/theme-b/not-found.html @@ -0,0 +1,20 @@ + + + + + + 404 + + + +
+
+
+

404

+

页面走丢了,回到生成故事开始吧。

+ +
+
+
+ + diff --git a/.claude/specs/design/figma-html/theme-b/push-settings.html b/.claude/specs/design/figma-html/theme-b/push-settings.html new file mode 100644 index 0000000..6909466 --- /dev/null +++ b/.claude/specs/design/figma-html/theme-b/push-settings.html @@ -0,0 +1,78 @@ + + + + + + 推送设置 + + + +
+ +
+
+
+

主动推送

+
+
+ + +
+
+ + +
+
+
+ +
+ 时间触发 + 事件触发 + 行为触发 + 成长触发 +
+
+
+ +
+ + +
+
+
+
+

推送预览

+
“今晚给小明讲一个关于太空的故事,好吗?”
+ +
+
+
+
+ + diff --git a/.claude/specs/design/figma-html/theme-b/story-detail.html b/.claude/specs/design/figma-html/theme-b/story-detail.html new file mode 100644 index 0000000..0b732c1 --- /dev/null +++ b/.claude/specs/design/figma-html/theme-b/story-detail.html @@ -0,0 +1,73 @@ + + + + + + 故事详情 + + + +
+ +
+
+
+

星际冒险 · 勇气的种子

+
小明 · 星际冒险宇宙 · 2025/01/12
+
+ + + +
+
+
+
+

故事正文

+

夜空像一条温柔的河流,小明驾驶着飞船穿过星光……

+

他握紧操纵杆,鼓起勇气,向未知的星球靠近。

+

最终,小明发现了新的朋友,也学会了如何面对黑暗。

+
+
+

成就

+
+ 勇气 + 友谊 +
+
“克服了黑暗的恐惧”
+
“帮助了迷路的小伙伴”
+
+
+ +
+ +
+ +
+ +
+
+ + diff --git a/.claude/specs/design/figma-html/theme-b/style.css b/.claude/specs/design/figma-html/theme-b/style.css new file mode 100644 index 0000000..e3e7607 --- /dev/null +++ b/.claude/specs/design/figma-html/theme-b/style.css @@ -0,0 +1,217 @@ +:root { + --primary-600: #3B82F6; + --primary-500: #60A5FA; + --primary-100: #DBEAFE; + --accent-pink: #F5C542; + --accent-sky: #6EE7B7; + --success: #34C759; + --warning: #F6A609; + --error: #FF5A5F; + --neutral-900: #111827; + --neutral-700: #374151; + --neutral-500: #9CA3AF; + --neutral-200: #E5E7EB; + --neutral-100: #F9FAFB; + --hero-gradient: linear-gradient(180deg, #F9FAFB 0%, #EEF2FF 100%); +} +* { box-sizing: border-box; } +:root { + --container-width: 1200px; + --gutter: 24px; + --radius-card: 12px; + --radius-input: 10px; + --radius-button: 8px; + --radius-pill: 24px; + --shadow-s: 0 4px 16px rgba(31,36,48,0.08); + --shadow-m: 0 10px 30px rgba(31,36,48,0.12); +} + +body { + margin: 0; + font-family: "PingFang SC", "Noto Sans SC", Inter, system-ui, -apple-system, sans-serif; + color: var(--neutral-900); + background: var(--neutral-100); +} + +a { color: var(--primary-600); text-decoration: none; } + +.page { min-height: 100vh; } +.container { + width: min(var(--container-width), 100% - 48px); + margin: 0 auto; +} + +.nav { + background: #fff; + border-bottom: 1px solid var(--neutral-200); + position: sticky; + top: 0; + z-index: 10; +} +.nav__inner { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 0; + gap: 16px; +} +.nav__left, .nav__center, .nav__right { display: flex; align-items: center; gap: 16px; } +.nav__logo { + display: flex; + align-items: center; + gap: 10px; + font-weight: 700; + color: var(--neutral-900); +} +.nav__logo-badge { + width: 28px; + height: 28px; + border-radius: 8px; + background: linear-gradient(135deg, var(--primary-500), var(--accent-sky)); +} +.nav__item { color: var(--neutral-700); font-weight: 500; } +.nav__item.active { color: var(--primary-600); } + +.hero { + background: var(--hero-gradient); + border-radius: 16px; + padding: 32px; + box-shadow: var(--shadow-s); +} + +.section { margin: 28px 0; } +.section-title { font-size: 20px; font-weight: 600; margin-bottom: 12px; } + +.grid { display: grid; gap: 16px; } +.grid-2 { grid-template-columns: repeat(2, minmax(0,1fr)); } +.grid-3 { grid-template-columns: repeat(3, minmax(0,1fr)); } + +.card { + background: #fff; + border-radius: var(--radius-card); + padding: 16px; + box-shadow: var(--shadow-s); +} +.card--flat { box-shadow: none; border: 1px solid var(--neutral-200); } +.card-cover { + width: 100%; + aspect-ratio: 21 / 9; + border-radius: 10px; + background: linear-gradient(135deg, var(--primary-100), #fff 60%, var(--accent-sky)); + margin-bottom: 12px; +} +.card-title { font-weight: 600; margin: 6px 0; } +.card-meta { color: var(--neutral-500); font-size: 12px; } + +.badge { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: var(--radius-pill); + background: var(--primary-100); + color: var(--primary-600); + font-size: 12px; +} + +.chips { display: flex; flex-wrap: wrap; gap: 8px; } +.chip { + padding: 6px 12px; + border-radius: var(--radius-pill); + border: 1px solid var(--neutral-200); + background: #fff; + font-size: 12px; +} +.chip.selected { background: var(--primary-100); border-color: var(--primary-500); color: var(--primary-600); } + +.btn { + height: 40px; + padding: 0 16px; + border-radius: var(--radius-button); + border: 1px solid transparent; + cursor: pointer; + font-weight: 600; +} +.btn--primary { background: var(--primary-600); color: #fff; } +.btn--secondary { background: #fff; border-color: var(--primary-600); color: var(--primary-600); } +.btn--ghost { background: transparent; color: var(--neutral-700); } +.btn--danger { background: #fff; border-color: var(--error); color: var(--error); } + +.input, select, textarea { + width: 100%; + padding: 10px 12px; + border-radius: var(--radius-input); + border: 1px solid var(--neutral-200); + background: #fff; + font-size: 14px; +} + +.row { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 12px; } + +.stepper { display: flex; gap: 10px; align-items: center; } +.step { + padding: 6px 12px; + border-radius: var(--radius-pill); + background: var(--neutral-100); + color: var(--neutral-700); + font-size: 12px; +} +.step.active { background: var(--primary-100); color: var(--primary-600); } + +.toolbar { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; } + +.table { width: 100%; border-collapse: collapse; } +.table th, .table td { border-bottom: 1px solid var(--neutral-200); padding: 12px 8px; text-align: left; font-size: 14px; } + +.avatar { + width: 40px; height: 40px; border-radius: 50%; + background: var(--primary-100); + display: inline-flex; align-items: center; justify-content: center; + font-weight: 700; color: var(--primary-600); +} + +.cover-hero { + aspect-ratio: 16 / 9; + border-radius: 14px; + background: linear-gradient(135deg, var(--primary-100), #fff 55%, var(--accent-pink)); +} + +.audio-player { + display: flex; align-items: center; gap: 12px; padding: 12px 16px; + border-radius: 12px; border: 1px solid var(--neutral-200); background: #fff; +} +.audio-bar { height: 6px; background: var(--neutral-200); border-radius: 999px; flex: 1; } +.audio-progress { width: 35%; height: 100%; background: var(--primary-600); border-radius: 999px; } + +.tabs { display: flex; gap: 8px; border-bottom: 1px solid var(--neutral-200); } +.tab { padding: 10px 12px; color: var(--neutral-700); } +.tab.active { color: var(--primary-600); border-bottom: 2px solid var(--primary-600); } + +.callout { + background: #fff; + border: 1px dashed var(--neutral-200); + border-radius: 12px; + padding: 12px; + color: var(--neutral-700); + font-size: 12px; +} + +.footer-note { color: var(--neutral-500); font-size: 12px; margin-top: 12px; } + +.hero-actions { display: flex; gap: 12px; flex-wrap: wrap; } + +.split { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 24px; +} + +@media (max-width: 1024px) { + .grid-3 { grid-template-columns: repeat(2, minmax(0,1fr)); } + .split { grid-template-columns: 1fr; } +} + +@media (max-width: 768px) { + .grid-2 { grid-template-columns: 1fr; } + .grid-3 { grid-template-columns: 1fr; } + .row { grid-template-columns: 1fr; } +} diff --git a/.claude/specs/design/figma-html/theme-b/universe-detail.html b/.claude/specs/design/figma-html/theme-b/universe-detail.html new file mode 100644 index 0000000..5de8aad --- /dev/null +++ b/.claude/specs/design/figma-html/theme-b/universe-detail.html @@ -0,0 +1,64 @@ + + + + + + 宇宙详情 + + + +
+ +
+
+

星际冒险

+
主角:小明船长 · 更新于 2025/01/12
+
+
+
+

主角设定

+
小明是来自地球的探险家,勇敢且好奇。
+
+
+

常驻角色

+
机器人小七、外星猫咪星星
+
+
+

世界观

+
星际学院、彩虹星云、飞船港湾
+
+
+

成就

+
克服恐惧 · 结交朋友 · 学会独立
+
+
+
+ +
+
+
+ + diff --git a/.claude/specs/design/figma-html/theme-b/universes.html b/.claude/specs/design/figma-html/theme-b/universes.html new file mode 100644 index 0000000..b257b50 --- /dev/null +++ b/.claude/specs/design/figma-html/theme-b/universes.html @@ -0,0 +1,64 @@ + + + + + + 故事宇宙 + + + +
+ +
+
+

故事宇宙

+ +
+
+
+
星际冒险
+
主角:小明船长
+
+ 伙伴:机器人小七 + 成就:3 +
+
+
+
梦幻森林
+
主角:森林守护者
+
+ 伙伴:魔法猫咪 + 成就:1 +
+
+
+
空态示例:创建第一个宇宙
+
+
+
+
+ + diff --git a/.claude/specs/design/figma-html/theme-c/account-settings.html b/.claude/specs/design/figma-html/theme-c/account-settings.html new file mode 100644 index 0000000..030ccfa --- /dev/null +++ b/.claude/specs/design/figma-html/theme-c/account-settings.html @@ -0,0 +1,59 @@ + + + + + + 账户设置 + + + +
+ +
+
+
+

个人信息

+
+ + +
+ +
+
+

账号安全

+
已绑定 GitHub、Google
+ +
+
+

数据隐私

+ + +
+
+
+
+ + diff --git a/.claude/specs/design/figma-html/theme-c/admin-providers.html b/.claude/specs/design/figma-html/theme-c/admin-providers.html new file mode 100644 index 0000000..4ddb60f --- /dev/null +++ b/.claude/specs/design/figma-html/theme-c/admin-providers.html @@ -0,0 +1,74 @@ + + + + + + 管理后台 - Providers + + + +
+ +
+
+

Providers 管理

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
名称类型状态延迟最近检查操作
text_primaryText健康420ms2 分钟前编辑 · 禁用 · 重载
image_primaryImage健康860ms5 分钟前编辑 · 禁用 · 重载
+ +
+
+ + diff --git a/.claude/specs/design/figma-html/theme-c/child-profile-detail.html b/.claude/specs/design/figma-html/theme-c/child-profile-detail.html new file mode 100644 index 0000000..a7c66e2 --- /dev/null +++ b/.claude/specs/design/figma-html/theme-c/child-profile-detail.html @@ -0,0 +1,88 @@ + + + + + + 孩子档案详情 + + + +
+ +
+
+
+
+
+
小明 · 5岁
+
男 · 生日 2020/05/12
+
+
+ +
+ +
+
基础信息
+
兴趣与成长
+
故事宇宙
+
阅读记录
+
+ +
+
+

兴趣标签

+
+ 太空 + 机器人 + 冒险 +
+
+
+

成长主题

+
+ 勇气 + 分享 +
+
+
+ +
+

故事宇宙

+
+
+
星际冒险
+
主角:小明船长 · 成就 3 个
+
+
+
梦幻森林
+
主角:森林守护者 · 成就 1 个
+
+
+
+
+
+ + diff --git a/.claude/specs/design/figma-html/theme-c/child-profiles.html b/.claude/specs/design/figma-html/theme-c/child-profiles.html new file mode 100644 index 0000000..000393d --- /dev/null +++ b/.claude/specs/design/figma-html/theme-c/child-profiles.html @@ -0,0 +1,58 @@ + + + + + + 孩子档案 + + + +
+ +
+
+

我的宝贝

+ +
+
+
+
+
小明 · 5岁
+
太空机器人
+
+
+
+
小红 · 3岁
+
公主动物
+
+
+
空态示例:添加一个孩子档案
+
+
+
+
+ + diff --git a/.claude/specs/design/figma-html/theme-c/home.html b/.claude/specs/design/figma-html/theme-c/home.html new file mode 100644 index 0000000..c028c08 --- /dev/null +++ b/.claude/specs/design/figma-html/theme-c/home.html @@ -0,0 +1,111 @@ + + + + + + 生成故事 + + + +
+ +
+
+ 档案 + 宇宙 + 关键词 + 生成 +
+
+
+

为谁创作故事

+
+
+ + +
+
+ + +
+
+
+ +
+ 太空 + 勇气 + 机器人 + 探索 +
+ +
+
+
+ + +
+
+ +
+ + + +
+
+
+
+ +
+
+ +
+

生成预览

+
+
故事标题占位
+

故事摘要将显示在这里,支持 2-3 行预览。

+
+
生成中:文本 → 封面 → 语音
+
+
+
封面生成失败,稍后重试
+ +
+
+
+
+
+ + diff --git a/.claude/specs/design/figma-html/theme-c/index.html b/.claude/specs/design/figma-html/theme-c/index.html new file mode 100644 index 0000000..622c4a9 --- /dev/null +++ b/.claude/specs/design/figma-html/theme-c/index.html @@ -0,0 +1,33 @@ + + + + + + DreamWeaver 原型入口 + + + + + + diff --git a/.claude/specs/design/figma-html/theme-c/login.html b/.claude/specs/design/figma-html/theme-c/login.html new file mode 100644 index 0000000..3226f72 --- /dev/null +++ b/.claude/specs/design/figma-html/theme-c/login.html @@ -0,0 +1,28 @@ + + + + + + 登录 / 授权 + + + +
+
+
+ +

欢迎来到 DreamWeaver

+

为孩子生成独一无二的故事

+
+ + +
+ +
+
+
+ + diff --git a/.claude/specs/design/figma-html/theme-c/my-stories.html b/.claude/specs/design/figma-html/theme-c/my-stories.html new file mode 100644 index 0000000..060b682 --- /dev/null +++ b/.claude/specs/design/figma-html/theme-c/my-stories.html @@ -0,0 +1,84 @@ + + + + + + 我的故事 + + + +
+ +
+
+ + + + + +
+ +
+
+
+
星际冒险 · 第三章
+
+ 太空勇气 +
+
小明 · 更新于 2 天前
+
+ + +
+
+
+
+
梦幻森林 · 朋友篇
+
+ 友谊动物 +
+
小红 · 更新于 5 天前
+
+ + +
+
+
+
空态示例:开始生成第一个故事
+ +
+
+
+
+ + diff --git a/.claude/specs/design/figma-html/theme-c/not-found.html b/.claude/specs/design/figma-html/theme-c/not-found.html new file mode 100644 index 0000000..6f65b6a --- /dev/null +++ b/.claude/specs/design/figma-html/theme-c/not-found.html @@ -0,0 +1,20 @@ + + + + + + 404 + + + +
+
+
+

404

+

页面走丢了,回到生成故事开始吧。

+ +
+
+
+ + diff --git a/.claude/specs/design/figma-html/theme-c/push-settings.html b/.claude/specs/design/figma-html/theme-c/push-settings.html new file mode 100644 index 0000000..6909466 --- /dev/null +++ b/.claude/specs/design/figma-html/theme-c/push-settings.html @@ -0,0 +1,78 @@ + + + + + + 推送设置 + + + +
+ +
+
+
+

主动推送

+
+
+ + +
+
+ + +
+
+
+ +
+ 时间触发 + 事件触发 + 行为触发 + 成长触发 +
+
+
+ +
+ + +
+
+
+
+

推送预览

+
“今晚给小明讲一个关于太空的故事,好吗?”
+ +
+
+
+
+ + diff --git a/.claude/specs/design/figma-html/theme-c/story-detail.html b/.claude/specs/design/figma-html/theme-c/story-detail.html new file mode 100644 index 0000000..0b732c1 --- /dev/null +++ b/.claude/specs/design/figma-html/theme-c/story-detail.html @@ -0,0 +1,73 @@ + + + + + + 故事详情 + + + +
+ +
+
+
+

星际冒险 · 勇气的种子

+
小明 · 星际冒险宇宙 · 2025/01/12
+
+ + + +
+
+
+
+

故事正文

+

夜空像一条温柔的河流,小明驾驶着飞船穿过星光……

+

他握紧操纵杆,鼓起勇气,向未知的星球靠近。

+

最终,小明发现了新的朋友,也学会了如何面对黑暗。

+
+
+

成就

+
+ 勇气 + 友谊 +
+
“克服了黑暗的恐惧”
+
“帮助了迷路的小伙伴”
+
+
+ +
+ +
+ +
+ +
+
+ + diff --git a/.claude/specs/design/figma-html/theme-c/style.css b/.claude/specs/design/figma-html/theme-c/style.css new file mode 100644 index 0000000..1e2e620 --- /dev/null +++ b/.claude/specs/design/figma-html/theme-c/style.css @@ -0,0 +1,217 @@ +:root { + --primary-600: #7C3AED; + --primary-500: #8B5CF6; + --primary-100: #EDE9FE; + --accent-pink: #FB7185; + --accent-sky: #22D3EE; + --success: #34C759; + --warning: #F6A609; + --error: #FF5A5F; + --neutral-900: #1F2937; + --neutral-700: #4B5563; + --neutral-500: #9CA3AF; + --neutral-200: #E5E7EB; + --neutral-100: #F5F5F7; + --hero-gradient: linear-gradient(135deg, #EDE9FE 0%, #FFE4F3 45%, #E0F7FF 100%); +} +* { box-sizing: border-box; } +:root { + --container-width: 1200px; + --gutter: 24px; + --radius-card: 12px; + --radius-input: 10px; + --radius-button: 8px; + --radius-pill: 24px; + --shadow-s: 0 4px 16px rgba(31,36,48,0.08); + --shadow-m: 0 10px 30px rgba(31,36,48,0.12); +} + +body { + margin: 0; + font-family: "PingFang SC", "Noto Sans SC", Inter, system-ui, -apple-system, sans-serif; + color: var(--neutral-900); + background: var(--neutral-100); +} + +a { color: var(--primary-600); text-decoration: none; } + +.page { min-height: 100vh; } +.container { + width: min(var(--container-width), 100% - 48px); + margin: 0 auto; +} + +.nav { + background: #fff; + border-bottom: 1px solid var(--neutral-200); + position: sticky; + top: 0; + z-index: 10; +} +.nav__inner { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 0; + gap: 16px; +} +.nav__left, .nav__center, .nav__right { display: flex; align-items: center; gap: 16px; } +.nav__logo { + display: flex; + align-items: center; + gap: 10px; + font-weight: 700; + color: var(--neutral-900); +} +.nav__logo-badge { + width: 28px; + height: 28px; + border-radius: 8px; + background: linear-gradient(135deg, var(--primary-500), var(--accent-sky)); +} +.nav__item { color: var(--neutral-700); font-weight: 500; } +.nav__item.active { color: var(--primary-600); } + +.hero { + background: var(--hero-gradient); + border-radius: 16px; + padding: 32px; + box-shadow: var(--shadow-s); +} + +.section { margin: 28px 0; } +.section-title { font-size: 20px; font-weight: 600; margin-bottom: 12px; } + +.grid { display: grid; gap: 16px; } +.grid-2 { grid-template-columns: repeat(2, minmax(0,1fr)); } +.grid-3 { grid-template-columns: repeat(3, minmax(0,1fr)); } + +.card { + background: #fff; + border-radius: var(--radius-card); + padding: 16px; + box-shadow: var(--shadow-s); +} +.card--flat { box-shadow: none; border: 1px solid var(--neutral-200); } +.card-cover { + width: 100%; + aspect-ratio: 21 / 9; + border-radius: 10px; + background: linear-gradient(135deg, var(--primary-100), #fff 60%, var(--accent-sky)); + margin-bottom: 12px; +} +.card-title { font-weight: 600; margin: 6px 0; } +.card-meta { color: var(--neutral-500); font-size: 12px; } + +.badge { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: var(--radius-pill); + background: var(--primary-100); + color: var(--primary-600); + font-size: 12px; +} + +.chips { display: flex; flex-wrap: wrap; gap: 8px; } +.chip { + padding: 6px 12px; + border-radius: var(--radius-pill); + border: 1px solid var(--neutral-200); + background: #fff; + font-size: 12px; +} +.chip.selected { background: var(--primary-100); border-color: var(--primary-500); color: var(--primary-600); } + +.btn { + height: 40px; + padding: 0 16px; + border-radius: var(--radius-button); + border: 1px solid transparent; + cursor: pointer; + font-weight: 600; +} +.btn--primary { background: var(--primary-600); color: #fff; } +.btn--secondary { background: #fff; border-color: var(--primary-600); color: var(--primary-600); } +.btn--ghost { background: transparent; color: var(--neutral-700); } +.btn--danger { background: #fff; border-color: var(--error); color: var(--error); } + +.input, select, textarea { + width: 100%; + padding: 10px 12px; + border-radius: var(--radius-input); + border: 1px solid var(--neutral-200); + background: #fff; + font-size: 14px; +} + +.row { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 12px; } + +.stepper { display: flex; gap: 10px; align-items: center; } +.step { + padding: 6px 12px; + border-radius: var(--radius-pill); + background: var(--neutral-100); + color: var(--neutral-700); + font-size: 12px; +} +.step.active { background: var(--primary-100); color: var(--primary-600); } + +.toolbar { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; } + +.table { width: 100%; border-collapse: collapse; } +.table th, .table td { border-bottom: 1px solid var(--neutral-200); padding: 12px 8px; text-align: left; font-size: 14px; } + +.avatar { + width: 40px; height: 40px; border-radius: 50%; + background: var(--primary-100); + display: inline-flex; align-items: center; justify-content: center; + font-weight: 700; color: var(--primary-600); +} + +.cover-hero { + aspect-ratio: 16 / 9; + border-radius: 14px; + background: linear-gradient(135deg, var(--primary-100), #fff 55%, var(--accent-pink)); +} + +.audio-player { + display: flex; align-items: center; gap: 12px; padding: 12px 16px; + border-radius: 12px; border: 1px solid var(--neutral-200); background: #fff; +} +.audio-bar { height: 6px; background: var(--neutral-200); border-radius: 999px; flex: 1; } +.audio-progress { width: 35%; height: 100%; background: var(--primary-600); border-radius: 999px; } + +.tabs { display: flex; gap: 8px; border-bottom: 1px solid var(--neutral-200); } +.tab { padding: 10px 12px; color: var(--neutral-700); } +.tab.active { color: var(--primary-600); border-bottom: 2px solid var(--primary-600); } + +.callout { + background: #fff; + border: 1px dashed var(--neutral-200); + border-radius: 12px; + padding: 12px; + color: var(--neutral-700); + font-size: 12px; +} + +.footer-note { color: var(--neutral-500); font-size: 12px; margin-top: 12px; } + +.hero-actions { display: flex; gap: 12px; flex-wrap: wrap; } + +.split { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 24px; +} + +@media (max-width: 1024px) { + .grid-3 { grid-template-columns: repeat(2, minmax(0,1fr)); } + .split { grid-template-columns: 1fr; } +} + +@media (max-width: 768px) { + .grid-2 { grid-template-columns: 1fr; } + .grid-3 { grid-template-columns: 1fr; } + .row { grid-template-columns: 1fr; } +} diff --git a/.claude/specs/design/figma-html/theme-c/universe-detail.html b/.claude/specs/design/figma-html/theme-c/universe-detail.html new file mode 100644 index 0000000..5de8aad --- /dev/null +++ b/.claude/specs/design/figma-html/theme-c/universe-detail.html @@ -0,0 +1,64 @@ + + + + + + 宇宙详情 + + + +
+ +
+
+

星际冒险

+
主角:小明船长 · 更新于 2025/01/12
+
+
+
+

主角设定

+
小明是来自地球的探险家,勇敢且好奇。
+
+
+

常驻角色

+
机器人小七、外星猫咪星星
+
+
+

世界观

+
星际学院、彩虹星云、飞船港湾
+
+
+

成就

+
克服恐惧 · 结交朋友 · 学会独立
+
+
+
+ +
+
+
+ + diff --git a/.claude/specs/design/figma-html/theme-c/universes.html b/.claude/specs/design/figma-html/theme-c/universes.html new file mode 100644 index 0000000..b257b50 --- /dev/null +++ b/.claude/specs/design/figma-html/theme-c/universes.html @@ -0,0 +1,64 @@ + + + + + + 故事宇宙 + + + +
+ +
+
+

故事宇宙

+ +
+
+
+
星际冒险
+
主角:小明船长
+
+ 伙伴:机器人小七 + 成就:3 +
+
+
+
梦幻森林
+
主角:森林守护者
+
+ 伙伴:魔法猫咪 + 成就:1 +
+
+
+
空态示例:创建第一个宇宙
+
+
+
+
+ + diff --git a/.claude/specs/memory-intelligence/CHILD-PROFILE-MODEL.md b/.claude/specs/memory-intelligence/CHILD-PROFILE-MODEL.md new file mode 100644 index 0000000..494b30e --- /dev/null +++ b/.claude/specs/memory-intelligence/CHILD-PROFILE-MODEL.md @@ -0,0 +1,429 @@ +# 孩子档案数据模型 + +## 概述 + +孩子档案是记忆智能系统的核心,存储孩子的基础信息、兴趣偏好和阅读行为数据。 + +--- + +## 一、数据库模型 + +### 1.1 主表: child_profiles + +```sql +CREATE TABLE child_profiles ( + -- 主键 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- 外键: 所属用户 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- 基础信息 + name VARCHAR(50) NOT NULL, + avatar_url VARCHAR(500), + birth_date DATE, + gender VARCHAR(10) CHECK (gender IN ('male', 'female', 'other')), + + -- 显式偏好 (家长填写) + interests JSONB DEFAULT '[]', + -- 示例: ["恐龙", "太空", "公主", "动物"] + + growth_themes JSONB DEFAULT '[]', + -- 示例: ["勇气", "分享"] + + -- 隐式偏好 (系统学习) + reading_preferences JSONB DEFAULT '{}', + -- 示例: { + -- "preferred_length": "medium", -- short/medium/long + -- "preferred_style": "adventure", -- adventure/fairy/educational + -- "tag_weights": {"恐龙": 5, "公主": 2, "太空": 3} + -- } + + -- 统计数据 + stories_count INTEGER DEFAULT 0, + total_reading_time INTEGER DEFAULT 0, -- 秒 + + -- 时间戳 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + -- 约束 + CONSTRAINT unique_child_per_user UNIQUE (user_id, name) +); + +-- 索引 +CREATE INDEX idx_child_profiles_user_id ON child_profiles(user_id); +``` + +### 1.2 兴趣标签枚举 + +预定义的兴趣标签,前端展示用: + +```python +INTEREST_TAGS = { + "animals": { + "zh": "动物", + "icon": "🐾", + "subtags": ["恐龙", "猫咪", "狗狗", "兔子", "海洋动物"] + }, + "fantasy": { + "zh": "奇幻", + "icon": "✨", + "subtags": ["公主", "王子", "魔法", "精灵", "龙"] + }, + "adventure": { + "zh": "冒险", + "icon": "🗺️", + "subtags": ["太空", "海盗", "探险", "寻宝"] + }, + "vehicles": { + "zh": "交通工具", + "icon": "🚗", + "subtags": ["汽车", "火车", "飞机", "火箭"] + }, + "nature": { + "zh": "自然", + "icon": "🌳", + "subtags": ["森林", "海洋", "山川", "四季"] + } +} +``` + +### 1.3 成长主题枚举 + +```python +GROWTH_THEMES = [ + {"key": "courage", "zh": "勇气", "description": "克服恐惧,勇敢面对"}, + {"key": "sharing", "zh": "分享", "description": "学会与他人分享"}, + {"key": "friendship", "zh": "友谊", "description": "交朋友,珍惜友情"}, + {"key": "honesty", "zh": "诚实", "description": "说真话,不撒谎"}, + {"key": "independence", "zh": "独立", "description": "自己的事情自己做"}, + {"key": "kindness", "zh": "善良", "description": "帮助他人,关爱弱小"}, + {"key": "patience", "zh": "耐心", "description": "学会等待,不急躁"}, + {"key": "curiosity", "zh": "好奇", "description": "探索未知,爱问为什么"} +] +``` + +--- + +## 二、SQLAlchemy 模型 + +```python +# backend/app/db/models.py + +from sqlalchemy import Column, String, Date, Integer, ForeignKey, JSON +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +import uuid + +class ChildProfile(Base): + __tablename__ = "child_profiles" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + + # 基础信息 + name = Column(String(50), nullable=False) + avatar_url = Column(String(500)) + birth_date = Column(Date) + gender = Column(String(10)) + + # 偏好 + interests = Column(JSON, default=list) + growth_themes = Column(JSON, default=list) + reading_preferences = Column(JSON, default=dict) + + # 统计 + stories_count = Column(Integer, default=0) + total_reading_time = Column(Integer, default=0) + + # 时间戳 + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + # 关系 + user = relationship("User", back_populates="child_profiles") + story_universes = relationship("StoryUniverse", back_populates="child_profile", cascade="all, delete-orphan") + + @property + def age(self) -> int | None: + """计算年龄""" + if not self.birth_date: + return None + today = date.today() + return today.year - self.birth_date.year - ( + (today.month, today.day) < (self.birth_date.month, self.birth_date.day) + ) +``` + +--- + +## 三、Pydantic Schema + +```python +# backend/app/schemas/child_profile.py + +from pydantic import BaseModel, Field +from datetime import date +from uuid import UUID + +class ChildProfileCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=50) + birth_date: date | None = None + gender: str | None = Field(None, pattern="^(male|female|other)$") + interests: list[str] = Field(default_factory=list) + growth_themes: list[str] = Field(default_factory=list) + +class ChildProfileUpdate(BaseModel): + name: str | None = Field(None, min_length=1, max_length=50) + birth_date: date | None = None + gender: str | None = Field(None, pattern="^(male|female|other)$") + interests: list[str] | None = None + growth_themes: list[str] | None = None + avatar_url: str | None = None + +class ChildProfileResponse(BaseModel): + id: UUID + name: str + avatar_url: str | None + birth_date: date | None + gender: str | None + age: int | None + interests: list[str] + growth_themes: list[str] + stories_count: int + total_reading_time: int + + class Config: + from_attributes = True + +class ChildProfileListResponse(BaseModel): + profiles: list[ChildProfileResponse] + total: int +``` + +--- + +## 四、API 实现 + +```python +# backend/app/api/profiles.py + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from uuid import UUID + +router = APIRouter(prefix="/api/profiles", tags=["profiles"]) + +@router.get("", response_model=ChildProfileListResponse) +async def list_profiles( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取当前用户的所有孩子档案""" + profiles = await db.execute( + select(ChildProfile) + .where(ChildProfile.user_id == current_user.id) + .order_by(ChildProfile.created_at) + ) + profiles = profiles.scalars().all() + return ChildProfileListResponse(profiles=profiles, total=len(profiles)) + +@router.post("", response_model=ChildProfileResponse, status_code=201) +async def create_profile( + data: ChildProfileCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """创建孩子档案""" + # 检查是否超过限制 (每用户最多5个孩子档案) + count = await db.scalar( + select(func.count(ChildProfile.id)) + .where(ChildProfile.user_id == current_user.id) + ) + if count >= 5: + raise HTTPException(400, "最多只能创建5个孩子档案") + + profile = ChildProfile(user_id=current_user.id, **data.model_dump()) + db.add(profile) + await db.commit() + await db.refresh(profile) + return profile + +@router.get("/{profile_id}", response_model=ChildProfileResponse) +async def get_profile( + profile_id: UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取单个孩子档案""" + profile = await db.get(ChildProfile, profile_id) + if not profile or profile.user_id != current_user.id: + raise HTTPException(404, "档案不存在") + return profile + +@router.put("/{profile_id}", response_model=ChildProfileResponse) +async def update_profile( + profile_id: UUID, + data: ChildProfileUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """更新孩子档案""" + profile = await db.get(ChildProfile, profile_id) + if not profile or profile.user_id != current_user.id: + raise HTTPException(404, "档案不存在") + + for key, value in data.model_dump(exclude_unset=True).items(): + setattr(profile, key, value) + + await db.commit() + await db.refresh(profile) + return profile + +@router.delete("/{profile_id}", status_code=204) +async def delete_profile( + profile_id: UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """删除孩子档案""" + profile = await db.get(ChildProfile, profile_id) + if not profile or profile.user_id != current_user.id: + raise HTTPException(404, "档案不存在") + + await db.delete(profile) + await db.commit() +``` + +--- + +## 五、隐式偏好学习 + +### 5.1 行为事件 + +```python +class ReadingEvent(BaseModel): + """阅读行为事件""" + profile_id: UUID + story_id: UUID + event_type: Literal["started", "completed", "skipped", "replayed"] + reading_time: int # 秒 + timestamp: datetime +``` + +### 5.2 偏好更新算法 + +```python +async def update_reading_preferences( + db: AsyncSession, + profile_id: UUID, + story: Story, + event: ReadingEvent +): + """根据阅读行为更新隐式偏好""" + profile = await db.get(ChildProfile, profile_id) + prefs = profile.reading_preferences or {} + tag_weights = prefs.get("tag_weights", {}) + + # 权重调整 + weight_delta = { + "completed": 1.0, # 完整阅读,正向 + "replayed": 1.5, # 重复播放,强正向 + "skipped": -0.5, # 跳过,负向 + "started": 0.1 # 开始阅读,弱正向 + } + + delta = weight_delta.get(event.event_type, 0) + + for tag in story.tags: + current = tag_weights.get(tag, 0) + tag_weights[tag] = max(0, current + delta) # 不低于0 + + # 更新阅读长度偏好 + if event.event_type == "completed": + word_count = len(story.content) + if word_count < 300: + length_pref = "short" + elif word_count < 600: + length_pref = "medium" + else: + length_pref = "long" + + # 简单的移动平均 + prefs["preferred_length"] = length_pref + + prefs["tag_weights"] = tag_weights + profile.reading_preferences = prefs + await db.commit() +``` + +--- + +## 六、数据迁移 + +```python +# backend/alembic/versions/xxx_add_child_profiles.py + +def upgrade(): + op.create_table( + 'child_profiles', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('name', sa.String(50), nullable=False), + sa.Column('avatar_url', sa.String(500)), + sa.Column('birth_date', sa.Date()), + sa.Column('gender', sa.String(10)), + sa.Column('interests', sa.JSON(), server_default='[]'), + sa.Column('growth_themes', sa.JSON(), server_default='[]'), + sa.Column('reading_preferences', sa.JSON(), server_default='{}'), + sa.Column('stories_count', sa.Integer(), server_default='0'), + sa.Column('total_reading_time', sa.Integer(), server_default='0'), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_child_profiles_user_id', 'child_profiles', ['user_id']) + +def downgrade(): + op.drop_index('idx_child_profiles_user_id') + op.drop_table('child_profiles') +``` + +--- + +## 七、隐私与安全 + +### 7.1 数据加密 + +敏感字段(姓名、出生日期)在存储时加密: + +```python +from cryptography.fernet import Fernet + +class EncryptedChildProfile: + """加密存储的孩子档案""" + + @staticmethod + def encrypt_name(name: str, key: bytes) -> str: + f = Fernet(key) + return f.encrypt(name.encode()).decode() + + @staticmethod + def decrypt_name(encrypted: str, key: bytes) -> str: + f = Fernet(key) + return f.decrypt(encrypted.encode()).decode() +``` + +### 7.2 访问控制 + +- 孩子档案只能被创建者访问 +- 删除用户时级联删除所有孩子档案 +- API 层强制校验 `user_id` 归属 + +### 7.3 数据保留 + +- 用户可随时删除孩子档案 +- 删除后 30 天内可恢复(软删除) +- 30 天后永久删除 diff --git a/.claude/specs/memory-intelligence/MEMORY-INTELLIGENCE-PRD.md b/.claude/specs/memory-intelligence/MEMORY-INTELLIGENCE-PRD.md new file mode 100644 index 0000000..d9a28d7 --- /dev/null +++ b/.claude/specs/memory-intelligence/MEMORY-INTELLIGENCE-PRD.md @@ -0,0 +1,455 @@ +# 记忆智能系统 PRD + +## 概述 + +**功能名称**: 记忆智能 (Memory Intelligence) +**版本**: v1.0 +**优先级**: Phase 2.5 (体验增强后、社区化前) +**目标用户**: 家长 + 3-8 岁儿童 + +### 核心价值 + +让 DreamWeaver 从"故事生成工具"进化为"懂孩子的故事伙伴": +- **记住孩子**: 偏好、成长阶段、兴趣变化 +- **延续故事**: 角色、世界观跨故事延续 +- **主动关怀**: 适时推送个性化故事建议 + +--- + +## 一、功能模块 + +### 1.1 孩子档案系统 (Child Profile) + +| 字段 | 类型 | 说明 | +|------|------|------| +| 基础信息 | 显式 | 姓名、年龄、性别 | +| 兴趣标签 | 显式+隐式 | 恐龙、公主、太空、动物等 | +| 成长主题 | 显式 | 当前关注:勇气/分享/独立等 | +| 阅读偏好 | 隐式 | 故事长度、风格、复杂度 | +| 互动历史 | 隐式 | 喜欢的故事、跳过的故事 | + +**数据来源**: +- 显式: 家长主动填写 +- 隐式: 系统从使用行为中学习 + +### 1.2 故事宇宙记忆 (Story Universe) + +跨故事保持连续性的元素: + +| 元素 | 说明 | 示例 | +|------|------|------| +| 主角设定 | 孩子的故事化身 | "小明是个爱冒险的男孩" | +| 常驻角色 | 反复出现的配角 | 魔法猫咪"星星"、智慧老树 | +| 世界观 | 故事发生的宇宙 | 梦幻森林、星际学院 | +| 成就系统 | 主角的成长轨迹 | "学会了勇敢"、"交到新朋友" | + +**记忆结构字段**: +- `protagonist` / `recurring_characters` / `world_settings` / `achievements`(JSON 结构) +- “延续上一个故事”默认选最近更新的宇宙(按 `updated_at` 倒序) + +### 1.3 主动推送系统 (Proactive Push) + +| 触发类型 | 条件 | 推送内容 | +|----------|------|----------| +| 时间触发 | 睡前时段 (19:00-21:00) | "今晚想听什么故事?" | +| 事件触发 | 节日/生日 | 主题故事推荐 | +| 行为触发 | 3天未使用 | 召回提醒 | +| 成长触发 | 年龄变化 | 难度升级建议 | + +**优先级与抑制**: +- 优先级:事件 > 成长 > 行为 > 时间 +- 抑制:当天已推送不再触发;静默时段(21:00-09:00)延迟;用户关闭推送则不触发 + +--- + +## 二、用户故事 + +### US-1: 创建孩子档案 +``` +作为家长 +我想要创建孩子的专属档案 +以便系统生成更适合孩子的故事 +``` + +**验收标准**: +- [ ] 可填写孩子基础信息(姓名、年龄、性别) +- [ ] 可选择兴趣标签(多选) +- [ ] 可设置当前成长主题 +- [ ] 支持多个孩子档案切换 + +### US-2: 故事角色延续 +``` +作为家长 +我想要故事中的角色能在新故事中再次出现 +以便孩子感受到故事的连续性 +``` + +**验收标准**: +- [ ] 生成故事时可选择"延续上一个故事" +- [ ] 系统自动带入主角设定和常驻角色 +- [ ] 新故事引用之前的"成就" + +### US-3: 睡前故事提醒 +``` +作为家长 +我想要在睡前时段收到故事推荐 +以便养成固定的亲子阅读习惯 +``` + +**验收标准**: +- [ ] 可设置提醒时间 +- [ ] 推送包含个性化故事建议 +- [ ] 可一键进入故事生成 + +--- + +## 三、数据模型 + +### 3.1 孩子档案表 (child_profiles) + +```sql +CREATE TABLE child_profiles ( + id UUID PRIMARY KEY, + user_id UUID REFERENCES users(id), + name VARCHAR(50) NOT NULL, + birth_date DATE, + gender VARCHAR(10), + interests JSONB DEFAULT '[]', + growth_themes JSONB DEFAULT '[]', + reading_preferences JSONB DEFAULT '{}', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +``` + +### 3.2 故事宇宙表 (story_universes) + +```sql +CREATE TABLE story_universes ( + id UUID PRIMARY KEY, + child_profile_id UUID REFERENCES child_profiles(id), + name VARCHAR(100) NOT NULL, + protagonist JSONB NOT NULL, + recurring_characters JSONB DEFAULT '[]', + world_settings JSONB DEFAULT '{}', + achievements JSONB DEFAULT '[]', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +``` + +### 3.3 推送配置表 (push_configs) + +```sql +CREATE TABLE push_configs ( + id UUID PRIMARY KEY, + user_id UUID REFERENCES users(id), + child_profile_id UUID REFERENCES child_profiles(id), + push_time TIME, + push_days INTEGER[], -- 0-6 表示周日到周六 + enabled BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### 3.4 推送事件表 (push_events) + +```sql +CREATE TABLE push_events ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL, + child_profile_id UUID NOT NULL, + trigger_type VARCHAR(20) NOT NULL, -- time/event/behavior/growth + sent_at TIMESTAMP NOT NULL, + status VARCHAR(20) NOT NULL, -- sent/failed/suppressed + reason TEXT +); +``` + +### 3.5 记忆条目表 (memory_items) + +用于存储“可解释、可控”的记忆条目(兴趣偏好、成长主题、常驻角色、关键事件等),并支持时序衰减。 + +```sql +CREATE TABLE memory_items ( + id UUID PRIMARY KEY, + child_profile_id UUID NOT NULL, + universe_id UUID, + type VARCHAR(50) NOT NULL, -- interest/growth/character/event等 + value JSONB NOT NULL, -- 结构化内容 + base_weight FLOAT DEFAULT 1.0, -- 初始权重 + last_used_at TIMESTAMP, -- 最近使用时间 + created_at TIMESTAMP DEFAULT NOW(), + ttl_days INTEGER -- 可选:过期天数 +); +``` + +--- + +## 四、API 设计 + +### 4.1 孩子档案 API + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/profiles` | 获取当前用户的所有孩子档案 | +| POST | `/api/profiles` | 创建孩子档案 | +| GET | `/api/profiles/{id}` | 获取单个档案详情 | +| PUT | `/api/profiles/{id}` | 更新档案 | +| DELETE | `/api/profiles/{id}` | 删除档案 | + +### 4.2 故事宇宙 API + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/profiles/{id}/universes` | 获取孩子的故事宇宙列表 | +| POST | `/api/profiles/{id}/universes` | 创建新宇宙 | +| GET | `/api/universes/{id}` | 获取宇宙详情 | +| PUT | `/api/universes/{id}` | 更新宇宙设定 | +| POST | `/api/universes/{id}/achievements` | 添加成就 | + +### 4.3 推送配置 API + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/push-configs` | 获取推送配置 | +| PUT | `/api/push-configs` | 更新推送配置 | + +--- + +## 五、Prompt 工程 + +### 5.1 带记忆的故事生成 Prompt + +``` +你是一个专业的儿童故事作家。请为以下孩子创作一个故事: + +【孩子档案】 +- 姓名: {child_name} +- 年龄: {age}岁 +- 兴趣: {interests} +- 当前成长主题: {growth_theme} + +【故事宇宙】 +- 主角设定: {protagonist} +- 常驻角色: {recurring_characters} +- 世界观: {world_settings} +- 已获成就: {achievements} + +【本次创作要求】 +- 关键词: {keywords} +- 延续之前的故事世界观 +- 让主角在故事中有新的成长 + +请创作一个适合{age}岁儿童的故事,约{word_count}字。 +``` + +### 5.2 成就提取 Prompt + +``` +请分析以下故事,提取主角获得的成长/成就: + +【故事内容】 +{story_content} + +请以JSON格式返回: +{ + "achievements": [ + {"type": "勇气", "description": "克服了对黑暗的恐惧"}, + {"type": "友谊", "description": "帮助了迷路的小兔子"} + ] +} +``` + +--- + +## 六、前端设计 + +### 6.1 孩子档案页面 + +``` +┌─────────────────────────────────────┐ +│ 我的宝贝 [+添加] │ +├─────────────────────────────────────┤ +│ ┌─────┐ ┌─────┐ ┌─────┐ │ +│ │ 👦 │ │ 👧 │ │ + │ │ +│ │小明 │ │小红 │ │添加 │ │ +│ │5岁 │ │3岁 │ │ │ │ +│ └─────┘ └─────┘ └─────┘ │ +└─────────────────────────────────────┘ +``` + +### 6.2 档案详情页 + +``` +┌─────────────────────────────────────┐ +│ ← 小明的档案 [编辑] │ +├─────────────────────────────────────┤ +│ 基础信息 │ +│ 姓名: 小明 年龄: 5岁 性别: 男 │ +├─────────────────────────────────────┤ +│ 兴趣爱好 │ +│ [恐龙] [太空] [机器人] │ +├─────────────────────────────────────┤ +│ 成长主题 │ +│ ○ 勇气 ● 分享 ○ 独立 ○ 友谊 │ +├─────────────────────────────────────┤ +│ 故事宇宙 │ +│ ┌─────────────────────────────┐ │ +│ │ 🌟 星际冒险 │ │ +│ │ 主角: 小明船长 │ │ +│ │ 伙伴: 机器人小七、外星猫咪 │ │ +│ │ 成就: 3个 │ │ +│ └─────────────────────────────┘ │ +└─────────────────────────────────────┘ +``` + +### 6.3 故事生成时选择档案 + +``` +┌─────────────────────────────────────┐ +│ 为谁创作故事? │ +├─────────────────────────────────────┤ +│ ● 小明 (5岁) │ +│ ○ 小红 (3岁) │ +│ ○ 不使用档案 │ +├─────────────────────────────────────┤ +│ 选择故事宇宙 │ +│ ● 星际冒险 (延续上次) │ +│ ○ 创建新宇宙 │ +├─────────────────────────────────────┤ +│ [下一步: 输入关键词] │ +└─────────────────────────────────────┘ +``` + +--- + +## 七、技术实现要点 + +### 7.1 隐式偏好学习 + +```python +# 基于用户行为更新偏好 +async def update_implicit_preferences( + child_id: UUID, + story: Story, + interaction: Interaction # 完整阅读/跳过/重复播放 +): + profile = await get_child_profile(child_id) + + if interaction == "completed": + # 增加相关标签权重 + for tag in story.tags: + profile.reading_preferences[tag] = \ + profile.reading_preferences.get(tag, 0) + 1 + elif interaction == "skipped": + # 降低相关标签权重 + for tag in story.tags: + profile.reading_preferences[tag] = \ + profile.reading_preferences.get(tag, 0) - 0.5 +``` + +### 7.2 成就自动提取 + +故事生成完成后,异步调用 LLM 提取成就(以 `type + description` 去重): + +```python +@celery.task +async def extract_achievements(story_id: UUID, universe_id: UUID): + story = await get_story(story_id) + universe = await get_universe(universe_id) + + achievements = await llm.extract_achievements(story.content) + + universe.achievements.extend(achievements) + await save_universe(universe) +``` + +### 7.3 推送调度 + +使用 Celery Beat 定时检查推送: + +```python +@celery.task +def check_push_notifications(): + current_time = datetime.now().time() + current_day = datetime.now().weekday() + + configs = PushConfig.query.filter( + PushConfig.enabled == True, + PushConfig.push_time <= current_time, + current_day.in_(PushConfig.push_days) + ).all() + + for config in configs: + send_push_notification.delay(config.user_id, config.child_profile_id) +``` + +**执行约束**: +- 同一孩子每天最多 1 次推送 +- 推送前查询 `push_events` 去重,成功/抑制均需记录 + +### 7.4 时序衰减与记忆评分 + +**目标**:让“越新的记忆影响越大”,避免旧偏好长期干扰。 + +**默认实现(推荐)**:查询时动态计算分数,不直接修改数据库。 +- 记忆分数:`score = base_weight × decay(Δt)` +- 衰减示例(分段):0-7 天 1.0,8-30 天 0.7,31-90 天 0.4,90 天后 0.2 +- 读取时按 `score` 排序,选 Top N 进入 Prompt + +**可选实现**:定期批处理降权 +- 每日/每周批量更新 `base_weight` +- 适合数据量大、读多写少的场景 + +**RAG 场景的衰减用法**: +- 语义相似度分数 × 时间衰减 +- 可加时间窗口过滤(如仅取最近 90 天) + +**删除策略(默认不删)**: +- 默认只降权,不主动删除 +- 可选:对低权重且 180 天未使用的条目执行 TTL 清理 + +--- + +## 八、里程碑 + +### M1: 孩子档案基础 +- [ ] 数据库模型 +- [ ] CRUD API +- [ ] 前端档案管理页面 +- [ ] 故事生成时选择档案 + +### M2: 故事宇宙 +- [ ] 宇宙数据模型 +- [ ] Prompt 集成 +- [ ] 成就自动提取 +- [ ] 前端宇宙管理 + +### M3: 主动推送 +- [ ] 推送配置 API +- [ ] Celery Beat 调度 +- [ ] 推送通知集成 (Web Push / 微信) + +### M4: 隐式学习 +- [ ] 行为埋点 +- [ ] 偏好学习算法 +- [ ] 推荐优化 + +--- + +## 九、风险与应对 + +| 风险 | 影响 | 应对 | +|------|------|------| +| 隐私合规 | 高 | 儿童数据加密存储,家长授权机制 | +| 推送骚扰 | 中 | 默认关闭,用户主动开启 | +| 记忆膨胀 | 低 | 定期清理旧数据,限制宇宙数量 | + +--- + +## 十、相关文档 + +- [孩子档案数据模型](./CHILD-PROFILE-MODEL.md) +- [故事宇宙记忆结构](./STORY-UNIVERSE-MODEL.md) +- [主动推送触发规则](./PUSH-TRIGGER-RULES.md) diff --git a/.claude/specs/memory-intelligence/MEMORY-PERSONALIZATION-TECHNICAL-REPORT.md b/.claude/specs/memory-intelligence/MEMORY-PERSONALIZATION-TECHNICAL-REPORT.md new file mode 100644 index 0000000..26cc0b3 --- /dev/null +++ b/.claude/specs/memory-intelligence/MEMORY-PERSONALIZATION-TECHNICAL-REPORT.md @@ -0,0 +1,177 @@ +# 记忆与个性化技术方案建议(PRD 讨论稿) + +> 目标:给 DreamWeaver 的“记忆与个性化”提供可落地的技术路径与产品取舍依据,用于 PRD 细化。 + +--- + +## 1. 总体结论(推荐方案) + +**v1 推荐:混合方案(结构化 DB + 轻量语义检索)** + +- **DB** 作为权威事实与可解释记忆(孩子档案、宇宙设定、成就、偏好权重)。 +- **RAG** 用于非结构化内容(故事摘要、互动摘要、近期期望),辅助个性化提示词。 + +**原因** +- 纯 DB 可控但缺乏语义弹性;纯 RAG 难以稳定控制与审计。 +- 混合方案能在“可解释 + 个性化”之间取到最佳平衡。 + +--- + +## 2. DB vs RAG:技术与产品对比 + +### 2.1 DB(结构化记忆) + +**适用内容** +- 孩子档案(基础信息) +- 兴趣标签与成长主题 +- 故事宇宙设定(主角、世界观、常驻角色) +- 成就(可审核、可追溯) + +**优点** +- 高可解释性 +- 变更可追踪、可回滚 +- 便于用户管理(家长可编辑) + +**缺点** +- 灵活性不足 +- 难以覆盖“隐性偏好”(比如叙事风格喜好) + +### 2.2 RAG(语义记忆) + +**适用内容** +- 故事摘要 +- 互动摘要(“最近更喜欢冒险故事”) +- 非结构化日志 + +**优点** +- 具备语义召回能力 +- 适合挖掘“隐含偏好” + +**缺点** +- 可解释性弱 +- 成本与性能压力大 +- 隐私风险更高 + +--- + +## 3. 时序性与记忆衰减(建议必须有) + +**核心观点**:孩子兴趣会随时间变化,必须引入时间衰减。 + +**做法建议** +- 所有记忆项带 `created_at` / `last_used_at` +- 引入权重衰减模型: + - 近 7 天:高权重 + - 30 天:中权重 + - 90 天:低权重 + - 超过 90 天:降权或淘汰 + +**价值** +- 避免旧偏好过度影响新故事 +- 体现成长与兴趣演变 + +--- + +## 4. 分层记忆(建议引入) + +建议采用三层结构: + +### 4.1 短期记忆(Session) +- 当前生成上下文(关键词、选定档案/宇宙) +- 生命周期:仅本次请求有效 + +### 4.2 中期记忆(近期偏好) +- 最近 5-10 次故事生成/阅读偏好 +- 生命周期:30-60 天 + +### 4.3 长期记忆(稳定事实) +- 档案、宇宙、核心兴趣 +- 生命周期:长期可编辑 + +**价值** +- 既保留稳定设定,又能捕捉近期变化 + +--- + +## 5. Agent 动态判断是否写入记忆 + +**建议:规则优先 + 模型辅助** + +流程示例: +1. 命中规则(如完整阅读/重复播放)→ 进入候选 +2. LLM 抽取结构化信息 + 置信度 +3. 置信度不足 → 不写入 + +**优点** +- 避免模型“乱记忆” +- 降低噪声,提高记忆质量 + +--- + +## 6. 推荐的记忆数据结构 + +### 6.1 结构化表(DB) + +- `child_profiles`:基础信息、兴趣、成长主题 +- `story_universes`:主角、角色、世界观、成就 +- `reading_events`:阅读/跳过/重播行为日志 +- `memory_items`:抽象记忆表(type, value, confidence, ttl) + +### 6.2 语义检索(RAG) + +- 存储内容:故事摘要、成就摘要、行为总结 +- 向量库:**pgvector**(成本低、易部署) +- 检索过滤:`child_id` / `universe_id` / 时间窗口 + +--- + +## 7. 关键产品问题(需明确) + +1) **记忆是否可编辑** +- 家长是否能查看、修改、删除系统记忆? + +2) **跨孩子隔离** +- 同账号多孩子的记忆是否完全隔离(推荐隔离) + +3) **隐私与合规** +- 哪些数据进入记忆?是否脱敏?是否加密? + +4) **性能与成本** +- RAG 查询是否影响生成时延? +- 是否需要缓存与批量检索? + +5) **效果评估** +- 记忆是否提高故事满意度? +- 需要 A/B 或指标体系吗? + +--- + +## 8. 推荐实施路线 + +### v1(1-2 个月) +- DB 记忆为主,RAG 只做轻量补充 +- 引入时序衰减 +- 记忆来源:用户显式输入 + 行为日志 + +### v2(2-3 个月) +- 引入 Agent 记忆抽取与置信度 +- 记忆管理界面(家长可编辑) +- 更精细的个性化推荐 + +--- + +## 9. 需要确认的决定点 + +- 是否采用混合方案(DB + RAG) +- RAG 的检索范围(故事摘要 / 行为摘要 / 成就) +- 记忆分层与衰减规则 +- Agent 记忆写入规则与阈值 +- 家长可见/可控的记忆管理策略 + +--- + +如确认以上方向,我可以进一步输出: +- PRD 里的“记忆系统”完整章节 +- 数据模型(含字段 + 时序衰减) +- 交互与界面草案 +- 后端实现拆解(任务清单 + 里程碑) diff --git a/.claude/specs/memory-intelligence/PUSH-TRIGGER-RULES.md b/.claude/specs/memory-intelligence/PUSH-TRIGGER-RULES.md new file mode 100644 index 0000000..5e93320 --- /dev/null +++ b/.claude/specs/memory-intelligence/PUSH-TRIGGER-RULES.md @@ -0,0 +1,129 @@ +# 主动推送触发规则 + +## 概述 + +主动推送用于在合适的时间为家长提供个性化故事建议,提升使用频次与亲子阅读习惯。推送默认关闭,需家长开启并配置时间。 + +--- + +## 一、数据输入 + +- **孩子档案**: `child_profiles`(年龄、兴趣、成长主题) +- **故事数据**: `stories`(最近生成/阅读时间、主题标签) +- **推送配置**: `push_configs`(时间、周期、开关) +- **节日与生日**: 预置日历 + `birth_date` +- **行为事件**: 阅读/播放/跳过等行为埋点 + +--- + +## 二、触发类型与规则 + +### 2.1 时间触发(睡前) +- 条件:当前时间落在用户设定 `push_time` 附近(建议 ±30 分钟)。 +- 频率:同一孩子每天最多 1 次。 +- 示例:19:00-21:00 之间推送“今晚想听什么故事?” + +### 2.2 事件触发(节日/生日) +- 条件: + - 生日:`birth_date` 月日与当天一致。 + - 节日:命中节日清单(如儿童节、中秋节等)。 +- 频率:当天仅推送 1 次,优先级高于时间触发。 + +### 2.3 行为触发(召回) +- 条件:最近 3 天无故事生成或阅读行为。 +- 频率:每 3 天最多 1 次,避免频繁打扰。 + +### 2.4 成长触发(年龄变化) +- 条件:年龄跨越关键节点(如 4→5 岁)。 +- 频率:每次年龄变化仅触发一次。 +- 目的:推荐难度升级或新的成长主题。 + +--- + +## 三、优先级与抑制规则 + +**优先级顺序**(从高到低): +1. 事件触发 +2. 成长触发 +3. 行为触发 +4. 时间触发 + +**抑制规则**: +- 当天已推送则不再触发其他类型。 +- 若在静默时间(21:00-09:00)触发,则延迟至下一个允许窗口。 +- 用户关闭推送或未配置推送时间时,不触发。 + +--- + +## 四、个性化内容策略 + +- **兴趣标签**: 引用孩子的兴趣标签生成主题。 +- **成长主题**: 优先匹配当前成长主题。 +- **历史偏好**: 参考最近故事的标签与完成度。 + +**示例模板**: +- “今晚给{child_name}讲一个关于{interest}的故事,好吗?” +- “{child_name}最近在学习{growth_theme},我准备了一个新故事。” + +--- + +## 五、调度实现建议 + +使用 Celery Beat 每 5-10 分钟执行一次规则检查: + +```python +@celery.task +def check_push_notifications(): + now = datetime.now(local_tz) + configs = get_enabled_configs(now) + + for config in configs: + if has_sent_today(config.child_profile_id): + continue + + trigger = select_trigger(config, now) + if trigger: + send_push_notification(config.user_id, config.child_profile_id, trigger) +``` + +**关键点**: +- 需要记录每日推送日志用于去重。 +- 优先级触发时应立即标记已发送。 + +--- + +## 六、日志与度量 + +建议增加 `push_events` 事件表用于统计与去重: + +```sql +CREATE TABLE push_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + child_profile_id UUID NOT NULL, + trigger_type VARCHAR(20) NOT NULL, -- time/event/behavior/growth + sent_at TIMESTAMP WITH TIME ZONE NOT NULL, + status VARCHAR(20) NOT NULL, -- sent/failed/suppressed + reason TEXT +); +``` + +核心指标: +- Push 发送成功率 +- 打开率(CTA 点击) +- 触发分布占比 + +--- + +## 七、安全与合规 + +- **默认关闭**,需家长显式开启。 +- 支持一键关闭或设定免打扰时段。 +- 遵循儿童隐私合规要求,最小化推送内容敏感信息。 + +--- + +## 八、相关文档 + +- [记忆智能系统 PRD](./MEMORY-INTELLIGENCE-PRD.md) +- [孩子档案数据模型](./CHILD-PROFILE-MODEL.md) diff --git a/.claude/specs/memory-intelligence/STORY-UNIVERSE-MODEL.md b/.claude/specs/memory-intelligence/STORY-UNIVERSE-MODEL.md new file mode 100644 index 0000000..5ccf357 --- /dev/null +++ b/.claude/specs/memory-intelligence/STORY-UNIVERSE-MODEL.md @@ -0,0 +1,231 @@ +# 故事宇宙记忆结构 + +## 概述 + +故事宇宙用于在多次故事生成中保持角色、世界观与成长成就的连续性。每个孩子档案可以拥有多个宇宙,故事生成时可选择“延续上一个故事”,系统自动带入宇宙设定。 + +--- + +## 一、数据库模型 + +### 1.1 主表: story_universes + +```sql +CREATE TABLE story_universes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + child_profile_id UUID NOT NULL REFERENCES child_profiles(id) ON DELETE CASCADE, + + -- 宇宙基础 + name VARCHAR(100) NOT NULL, + + -- 记忆结构 + protagonist JSONB NOT NULL, -- 主角设定 + recurring_characters JSONB DEFAULT '[]', + world_settings JSONB DEFAULT '{}', + achievements JSONB DEFAULT '[]', + + -- 时间戳 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_story_universes_child_id ON story_universes(child_profile_id); +CREATE INDEX idx_story_universes_updated_at ON story_universes(updated_at); +``` + +### 1.2 JSON 结构示例 + +**protagonist** +```json +{ + "name": "小明", + "role": "星际船长", + "traits": ["勇敢", "好奇"], + "goal": "寻找失落的星球", + "backstory": "来自地球的探险家" +} +``` + +**recurring_characters** +```json +[ + { + "name": "星星", + "role": "魔法猫咪", + "traits": ["聪明", "调皮"], + "relation": "伙伴", + "first_story_id": "story-uuid" + } +] +``` + +**world_settings** +```json +{ + "world_name": "梦幻森林", + "era": "童话时代", + "locations": ["彩虹河", "月光山"], + "rules": ["动物会说话", "星星会指路"], + "tone": "温暖治愈" +} +``` + +**achievements** +```json +[ + { + "type": "勇气", + "description": "克服了对黑暗的恐惧", + "story_id": "story-uuid", + "achieved_at": "2025-01-10T12:00:00Z" + } +] +``` + +--- + +## 二、SQLAlchemy 模型 + +```python +# backend/app/db/models.py + +from sqlalchemy import Column, String, ForeignKey, JSON +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +import uuid + +class StoryUniverse(Base): + __tablename__ = "story_universes" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + child_profile_id = Column(UUID(as_uuid=True), ForeignKey("child_profiles.id", ondelete="CASCADE"), nullable=False) + + name = Column(String(100), nullable=False) + protagonist = Column(JSON, nullable=False) + recurring_characters = Column(JSON, default=list) + world_settings = Column(JSON, default=dict) + achievements = Column(JSON, default=list) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + child_profile = relationship("ChildProfile", back_populates="story_universes") +``` + +--- + +## 三、Pydantic Schema + +```python +# backend/app/schemas/story_universe.py + +from pydantic import BaseModel, Field +from typing import Any +from uuid import UUID + +class StoryUniverseCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + protagonist: dict[str, Any] + recurring_characters: list[dict[str, Any]] = Field(default_factory=list) + world_settings: dict[str, Any] = Field(default_factory=dict) + +class StoryUniverseUpdate(BaseModel): + name: str | None = Field(None, min_length=1, max_length=100) + protagonist: dict[str, Any] | None = None + recurring_characters: list[dict[str, Any]] | None = None + world_settings: dict[str, Any] | None = None + +class StoryUniverseResponse(BaseModel): + id: UUID + child_profile_id: UUID + name: str + protagonist: dict[str, Any] + recurring_characters: list[dict[str, Any]] + world_settings: dict[str, Any] + achievements: list[dict[str, Any]] + + class Config: + from_attributes = True +``` + +--- + +## 四、API 约定 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/profiles/{id}/universes` | 获取孩子的故事宇宙列表 | +| POST | `/api/profiles/{id}/universes` | 创建新宇宙 | +| GET | `/api/universes/{id}` | 获取宇宙详情 | +| PUT | `/api/universes/{id}` | 更新宇宙设定 | +| POST | `/api/universes/{id}/achievements` | 添加成就 | + +--- + +## 五、业务规则 + +- **延续故事**: “延续上一个故事”默认选最近更新的宇宙(按 `updated_at` 倒序)。 +- **成就追加**: 新成就追加到 `achievements`,以 `type + description` 去重。 +- **成长轨迹**: 成就保留顺序,优先展示最新项。 + +--- + +## 六、Prompt 集成 + +当选择宇宙时,生成 Prompt 需带入宇宙记忆: + +``` +【故事宇宙】 +- 主角设定: {protagonist} +- 常驻角色: {recurring_characters} +- 世界观: {world_settings} +- 已获成就: {achievements} +``` + +未选择宇宙时,提示词忽略该块,避免混淆。 + +--- + +## 七、数据迁移示例 + +```python +# backend/alembic/versions/xxx_add_story_universes.py + +def upgrade(): + op.create_table( + "story_universes", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("child_profile_id", sa.UUID(), nullable=False), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("protagonist", sa.JSON(), nullable=False), + sa.Column("recurring_characters", sa.JSON(), server_default='[]'), + sa.Column("world_settings", sa.JSON(), server_default='{}'), + sa.Column("achievements", sa.JSON(), server_default='[]'), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.ForeignKeyConstraint(["child_profile_id"], ["child_profiles.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("idx_story_universes_child_id", "story_universes", ["child_profile_id"]) + op.create_index("idx_story_universes_updated_at", "story_universes", ["updated_at"]) + + +def downgrade(): + op.drop_index("idx_story_universes_updated_at") + op.drop_index("idx_story_universes_child_id") + op.drop_table("story_universes") +``` + +--- + +## 八、权限与安全 + +- 宇宙数据必须通过 `child_profile_id` 归属校验,确保仅拥有者可访问。 +- 删除用户或档案时,级联删除所有宇宙数据。 + +--- + +## 九、相关文档 + +- [孩子档案数据模型](./CHILD-PROFILE-MODEL.md) +- [记忆智能系统 PRD](./MEMORY-INTELLIGENCE-PRD.md) diff --git a/.claude/specs/product-roadmap/PRODUCT-VISION.md b/.claude/specs/product-roadmap/PRODUCT-VISION.md new file mode 100644 index 0000000..17aa9c5 --- /dev/null +++ b/.claude/specs/product-roadmap/PRODUCT-VISION.md @@ -0,0 +1,130 @@ +# DreamWeaver 产品愿景与全流程规划 + +## 一、产品定位 + +### 1.1 愿景 +**梦语织机** - 为 3-8 岁儿童打造的 AI 故事创作平台,让每个孩子都能拥有专属的成长故事。 + +### 1.2 核心价值 +| 维度 | 价值主张 | +|------|----------| +| 个性化 | 基于关键词/主角定制,每个故事独一无二 | +| 教育性 | 融入成长主题(勇气、友谊、诚实等) | +| 沉浸感 | AI 封面 + 语音朗读,多感官体验 | +| 亲子互动 | 家长参与创作,增进亲子关系 | + +### 1.3 目标用户 +**主要用户:家长(25-40岁)** +- 需求:为孩子找到有教育意义的睡前故事 +- 痛点:市面故事千篇一律,缺乏个性化 +- 场景:睡前、旅途、周末亲子时光 + +**次要用户:幼儿园/早教机构** +- 需求:批量生成教学故事素材 +- 痛点:内容制作成本高 + +--- + +## 二、竞品分析 + +| 产品 | 优势 | 劣势 | 我们的差异化 | +|------|------|------|--------------| +| 凯叔讲故事 | 内容丰富、品牌知名 | 无个性化、订阅贵 | AI 个性化生成 | +| 喜马拉雅儿童 | 海量音频、多平台 | 内容同质化 | 用户参与创作 | +| ChatGPT | AI 能力强 | 非儿童专属、无配套 | 垂直场景优化 | +| Midjourney | 图像质量高 | 无故事整合 | 故事+图像+音频一体 | + +--- + +## 三、产品路线图 + +### Phase 1: MVP 完善 ✅ 已完成 +- [x] 关键词生成故事 +- [x] 故事润色增强 +- [x] AI 封面生成 +- [x] 语音朗读 +- [x] 故事收藏管理 +- [x] OAuth 登录 +- [x] 工程鲁棒性改进 + +### Phase 2: 体验增强 +| 功能 | 优先级 | 用户价值 | +|------|--------|----------| +| 故事编辑 | P0 | 用户可修改 AI 生成内容 | +| 角色定制 | P0 | 孩子成为故事主角 | +| 故事续写 | P1 | 形成系列故事 | +| 多语言支持 | P1 | 英文故事学习 | +| 故事分享 | P1 | 社交传播 | + +### Phase 3: 供应商平台化 +| 功能 | 优先级 | 技术价值 | +|------|--------|----------| +| 供应商管理后台 | P0 | 可视化配置 AI 供应商 | +| 适配器插件化 | P0 | 新供应商零代码接入 | +| 供应商健康监控 | P1 | 自动故障转移 | +| A/B 测试框架 | P1 | 供应商效果对比 | +| 成本分析面板 | P2 | API 调用成本追踪 | + +### Phase 4: 社区与增长 +| 功能 | 优先级 | 增长价值 | +|------|--------|----------| +| 故事广场 | P0 | 内容发现 | +| 点赞/收藏 | P0 | 社区互动 | +| 创作者主页 | P1 | 用户留存 | +| 故事模板 | P1 | 降低创作门槛 | + +### Phase 5: 商业化 +| 功能 | 优先级 | 商业价值 | +|------|--------|----------| +| 会员订阅 | P0 | 核心收入 | +| 故事导出 | P0 | 增值服务 | +| 实体书打印 | P1 | 高客单价 | +| API 开放 | P2 | B 端收入 | + +--- + +## 四、核心指标 (KPIs) + +### 4.1 用户指标 +| 指标 | 定义 | 目标 | +|------|------|------| +| DAU | 日活跃用户 | Phase 2: 1000+ | +| 留存率 | 次日/7日/30日 | 40%/25%/15% | +| 创作转化率 | 访问→创作 | 30%+ | + +### 4.2 业务指标 +| 指标 | 定义 | 目标 | +|------|------|------| +| 故事生成量 | 日均生成数 | 5000+ | +| 分享率 | 故事被分享比例 | 10%+ | +| 付费转化率 | 免费→付费 | 5%+ | + +### 4.3 技术指标 +| 指标 | 定义 | 目标 | +|------|------|------| +| API 成功率 | 供应商调用成功率 | 99%+ | +| 响应时间 | 故事生成 P95 | <30s | +| 成本/故事 | 单个故事 API 成本 | <$0.05 | + +--- + +## 五、风险与应对 + +| 风险 | 影响 | 概率 | 应对策略 | +|------|------|------|----------| +| AI 生成内容不当 | 高 | 中 | 内容审核 + 家长控制 + 敏感词过滤 | +| API 成本过高 | 高 | 中 | 多供应商比价 + 缓存优化 + 分级限流 | +| 供应商服务中断 | 高 | 低 | 多供应商冗余 + 自动故障转移 | +| 用户增长缓慢 | 中 | 中 | 社区运营 + 分享裂变 + SEO | +| 竞品模仿 | 低 | 高 | 快速迭代 + 深耕垂直 + 数据壁垒 | + +--- + +## 六、下一步讨论议题 + +1. **供应商平台化架构** - 如何设计插件化的适配器系统? +2. **Phase 2 功能优先级** - 先做哪个功能? +3. **技术选型** - nanobanana vs flux vs 其他图像供应商? +4. **商业模式** - 免费/付费边界在哪里? + +请确认以上产品愿景是否符合预期,我们再深入讨论供应商平台化的技术架构。 diff --git a/.claude/specs/product-roadmap/PROVIDER-PLATFORM-RFC.md b/.claude/specs/product-roadmap/PROVIDER-PLATFORM-RFC.md new file mode 100644 index 0000000..e2f633d --- /dev/null +++ b/.claude/specs/product-roadmap/PROVIDER-PLATFORM-RFC.md @@ -0,0 +1,677 @@ +# RFC: 供应商平台化架构设计 + +## 背景 + +### 当前问题 +1. **硬编码适配器**: `gemini`, `flux`, `minimax` 写死在代码中 +2. **新供应商需改代码**: 接入 nanobanana 等新供应商需要修改 `provider_router.py` +3. **无法动态切换**: 供应商故障时需要重启服务 +4. **缺乏监控**: 不知道哪个供应商更快、更便宜、更稳定 + +### 目标 +- **零代码接入**: 通过后台配置即可接入新供应商 +- **动态切换**: 运行时切换供应商,无需重启 +- **智能路由**: 基于成本、延迟、成功率自动选择最优供应商 +- **可观测性**: 供应商健康状态、成本、性能一目了然 + +--- + +## 架构设计 + +### 1. 整体架构 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Admin Dashboard │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ +│ │ 供应商管理 │ │ 健康监控 │ │ 成本分析 │ │ +│ └─────────────┘ └─────────────┘ └─────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Provider Router │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ 路由策略: Priority → Weight → Health → Cost │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌───────────┬───────────┬───────────┬───────────────────┐ │ +│ │ Adapter │ Adapter │ Adapter │ Adapter │ │ +│ │ Registry │ Factory │ Health │ Metrics │ │ +│ └───────────┴───────────┴───────────┴───────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + ┌─────────────────────┼─────────────────────┐ + ▼ ▼ ▼ +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ Text Adapters │ │ Image Adapters│ │ TTS Adapters │ +├───────────────┤ ├───────────────┤ ├───────────────┤ +│ • Gemini │ │ • Flux │ │ • Minimax │ +│ • OpenAI │ │ • Nanobanana │ │ • ElevenLabs │ +│ • Claude │ │ • DALL-E │ │ • Azure TTS │ +│ • Qwen │ │ • Midjourney │ │ • Google TTS │ +└───────────────┘ └───────────────┘ └───────────────┘ +``` + +### 2. 核心组件 + +#### 2.1 Adapter 接口定义 + +```python +# 统一适配器接口 +from abc import ABC, abstractmethod +from typing import TypeVar, Generic +from pydantic import BaseModel + +T = TypeVar("T") + +class AdapterConfig(BaseModel): + """适配器配置基类""" + api_key: str + api_base: str | None = None + model: str | None = None + timeout_ms: int = 60000 + max_retries: int = 3 + +class BaseAdapter(ABC, Generic[T]): + """适配器基类""" + + # 适配器元信息 + adapter_type: str # text / image / tts + adapter_name: str # gemini / flux / minimax + + def __init__(self, config: AdapterConfig): + self.config = config + + @abstractmethod + async def execute(self, **kwargs) -> T: + """执行适配器逻辑""" + pass + + @abstractmethod + async def health_check(self) -> bool: + """健康检查""" + pass + + @property + @abstractmethod + def estimated_cost(self) -> float: + """预估单次调用成本 (USD)""" + pass +``` + +#### 2.2 适配器注册表 + +```python +# 适配器注册表 - 支持动态注册 +class AdapterRegistry: + """适配器注册表""" + + _adapters: dict[str, type[BaseAdapter]] = {} + + @classmethod + def register(cls, adapter_type: str, adapter_name: str): + """装饰器: 注册适配器""" + def decorator(adapter_class: type[BaseAdapter]): + key = f"{adapter_type}:{adapter_name}" + cls._adapters[key] = adapter_class + return adapter_class + return decorator + + @classmethod + def get(cls, adapter_type: str, adapter_name: str) -> type[BaseAdapter] | None: + key = f"{adapter_type}:{adapter_name}" + return cls._adapters.get(key) + + @classmethod + def list_adapters(cls, adapter_type: str | None = None) -> list[str]: + """列出所有已注册的适配器""" + if adapter_type: + return [k for k in cls._adapters if k.startswith(f"{adapter_type}:")] + return list(cls._adapters.keys()) +``` + +#### 2.3 适配器实现示例 + +```python +# 图像适配器示例: Nanobanana +@AdapterRegistry.register("image", "nanobanana") +class NanobananapAdapter(BaseAdapter[str]): + adapter_type = "image" + adapter_name = "nanobanana" + + async def execute(self, prompt: str, **kwargs) -> str: + """生成图片,返回 URL""" + async with httpx.AsyncClient(timeout=self.config.timeout_ms / 1000) as client: + response = await client.post( + f"{self.config.api_base}/generate", + json={"prompt": prompt, "model": self.config.model}, + headers={"Authorization": f"Bearer {self.config.api_key}"}, + ) + response.raise_for_status() + return response.json()["image_url"] + + async def health_check(self) -> bool: + # 简单的健康检查 + try: + async with httpx.AsyncClient(timeout=5) as client: + response = await client.get(f"{self.config.api_base}/health") + return response.status_code == 200 + except Exception: + return False + + @property + def estimated_cost(self) -> float: + return 0.02 # $0.02 per image +``` + +#### 2.4 智能路由器 + +```python +class ProviderRouter: + """智能供应商路由器""" + + def __init__(self, db: AsyncSession): + self.db = db + self._health_cache: dict[str, tuple[bool, float]] = {} # adapter_key -> (healthy, last_check) + + async def route( + self, + provider_type: str, + strategy: str = "priority", # priority / cost / latency / round_robin + **kwargs + ): + """路由到最优供应商""" + providers = await self._get_enabled_providers(provider_type) + + if not providers: + raise ValueError(f"No {provider_type} providers configured") + + # 按策略排序 + sorted_providers = self._sort_by_strategy(providers, strategy) + + errors = [] + for provider in sorted_providers: + # 检查健康状态 + if not await self._is_healthy(provider): + continue + + try: + adapter = self._create_adapter(provider) + result = await adapter.execute(**kwargs) + + # 记录成功指标 + await self._record_metrics(provider, success=True) + return result + + except Exception as e: + errors.append(f"{provider.name}: {e}") + await self._record_metrics(provider, success=False, error=str(e)) + continue + + raise ValueError(f"All providers failed: {' | '.join(errors)}") + + def _sort_by_strategy(self, providers: list[Provider], strategy: str) -> list[Provider]: + if strategy == "priority": + return sorted(providers, key=lambda p: (-p.priority, -p.weight)) + elif strategy == "cost": + return sorted(providers, key=lambda p: self._get_estimated_cost(p)) + elif strategy == "latency": + return sorted(providers, key=lambda p: self._get_avg_latency(p)) + else: + return providers +``` + +### 3. 数据模型扩展 + +```sql +-- 供应商表 (已有,需扩展) +ALTER TABLE providers ADD COLUMN api_key_ref VARCHAR(100); -- 密钥引用 (从 secrets 表获取) +ALTER TABLE providers ADD COLUMN request_schema JSONB; -- 请求参数 schema +ALTER TABLE providers ADD COLUMN response_parser VARCHAR(200); -- 响应解析规则 + +-- 供应商指标表 (新增) +CREATE TABLE provider_metrics ( + id SERIAL PRIMARY KEY, + provider_id VARCHAR(36) REFERENCES providers(id), + timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + success BOOLEAN, + latency_ms INTEGER, + cost_usd DECIMAL(10, 6), + error_message TEXT, + request_id VARCHAR(100) +); + +-- 供应商健康状态表 (新增) +CREATE TABLE provider_health ( + provider_id VARCHAR(36) PRIMARY KEY REFERENCES providers(id), + is_healthy BOOLEAN DEFAULT TRUE, + last_check TIMESTAMP WITH TIME ZONE, + consecutive_failures INTEGER DEFAULT 0, + last_error TEXT +); + +-- 密钥管理表 (新增) +CREATE TABLE provider_secrets ( + id VARCHAR(36) PRIMARY KEY, + name VARCHAR(100) UNIQUE NOT NULL, + encrypted_value TEXT NOT NULL, -- 加密存储 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); +``` + +### 4. Admin Dashboard 功能 + +#### 4.1 供应商管理 +- 供应商列表 (启用/禁用/删除) +- 新增供应商 (选择适配器类型 + 配置参数) +- 编辑供应商 (修改优先级/权重/超时等) +- 测试连接 (验证 API Key 有效性) + +#### 4.2 健康监控 +- 实时健康状态 (绿/黄/红) +- 成功率趋势图 +- 延迟分布图 +- 故障告警配置 + +#### 4.3 成本分析 +- 按供应商统计调用量 +- 按供应商统计成本 +- 成本趋势图 +- 预算告警 + +#### 4.4 A/B 测试 +- 创建实验 (供应商 A vs B) +- 流量分配 (50/50 或自定义) +- 效果对比 (成功率/延迟/成本) + +--- + +## 实现路径 + +### 阶段 1: 适配器抽象 (基础) - ✅ 已完成 + +| 任务 | 状态 | 文件 | +|------|------|------| +| 定义 `BaseAdapter` 接口 | ✅ | `services/adapters/base.py` | +| 实现 `AdapterRegistry` 注册表 | ✅ | `services/adapters/registry.py` | +| 重构 GeminiAdapter | ✅ | `services/adapters/text/gemini.py` | +| 重构 FluxAdapter | ✅ | `services/adapters/image/flux.py` | +| 重构 MinimaxAdapter | ✅ | `services/adapters/tts/minimax.py` | +| 重构 `ProviderRouter` 使用新接口 | ✅ | `services/provider_router.py` | + +### 阶段 2: 新供应商接入 (扩展) - 待开始 +1. 实现 Nanobanana 适配器 +2. 实现 OpenAI/Claude 文本适配器 +3. 实现 ElevenLabs TTS 适配器 +4. 验证零代码接入流程 + +### 阶段 3: 监控与分析 (可观测) - 待开始 +1. 实现指标收集 +2. 实现健康检查 +3. 实现成本追踪 +4. Admin Dashboard 开发 + +### 阶段 4: 智能路由 (优化) - 待开始 +1. 实现多种路由策略 +2. 实现自动故障转移 +3. 实现 A/B 测试框架 + +--- + +## 并行执行与容错设计 + +### 问题 + +当前串行流程存在两个问题: +1. **等待时间长**: 故事(3-5s) → 封面(5-10s) → 音频(3-5s) = 总计 11-20s +2. **单点失败**: 某一步502/超时导致整个流程失败 + +### 方案 1: 并行执行 + +```python +async def generate_story_full(keywords: list[str]) -> StoryResult: + # Step 1: 故事生成(必须先完成,后续依赖它) + story = await generate_story_content(keywords) + + # Step 2: 图片和音频并行执行 + image_task = asyncio.create_task(generate_image(story.summary)) + audio_task = asyncio.create_task(text_to_speech(story.content)) + + # 等待两者完成,互不阻塞 + image_result, audio_result = await asyncio.gather( + image_task, audio_task, + return_exceptions=True # 一个���败不影响另一个 + ) + + return StoryResult( + story=story, + image_url=image_result if not isinstance(image_result, Exception) else None, + audio_url=audio_result if not isinstance(audio_result, Exception) else None, + errors={ + "image": str(image_result) if isinstance(image_result, Exception) else None, + "audio": str(audio_result) if isinstance(audio_result, Exception) else None, + } + ) +``` + +**时间对比:** +``` +串行: 3s + 8s + 4s = 15s +并行: 3s + max(8s, 4s) = 11s (节省 27%) +``` + +### 方案 2: 部分成功处理 + +**核心原则: 部分成功 > 全部失败** + +```python +@dataclass +class StoryResult: + story: Story # 核心,必须成功 + image_url: str | None = None # 增强,可降级 + audio_url: str | None = None # 增强,可降级 + errors: dict[str, str] = field(default_factory=dict) + + @property + def is_complete(self) -> bool: + return self.image_url is not None and self.audio_url is not None + + @property + def failed_components(self) -> list[str]: + return [k for k, v in self.errors.items() if v is not None] +``` + +**降级策略:** + +| 组件 | 失败时降级方案 | 用户体验 | +|------|---------------|---------| +| 故事 | 无降级,整体失败 | 显示错误,提示重试 | +| 封面 | 使用默认封面图 | 显示占位图 + "重新生成"按钮 | +| 音频 | 不生成音频 | 隐藏播放按钮 + "生成语音"按钮 | + +### 方案 3: 流式返回 (SSE) + +**为什么用 SSE:** +- 用户无需等待全部完成 +- 每完成一步立即展示 +- 比 WebSocket 简单,HTTP 兼容性好 + +**后端实现:** + +```python +from fastapi import APIRouter +from sse_starlette.sse import EventSourceResponse + +router = APIRouter() + +@router.post("/api/generate/stream") +async def generate_story_stream( + request: GenerateRequest, + current_user: User = Depends(get_current_user), +): + async def event_generator(): + # 1. 立即返回任务ID + story_id = str(uuid.uuid4()) + yield {"event": "started", "data": json.dumps({"story_id": story_id})} + + # 2. 生成故事 + try: + story = await generate_story_content(request.keywords) + yield {"event": "story_ready", "data": json.dumps({ + "title": story.title, + "content": story.content, + })} + except Exception as e: + yield {"event": "story_failed", "data": json.dumps({"error": str(e)})} + return + + # 3. 并行生成图片和音频 + async def gen_image(): + try: + url = await generate_image(story.summary) + yield {"event": "image_ready", "data": json.dumps({"image_url": url})} + except Exception as e: + yield {"event": "image_failed", "data": json.dumps({"error": str(e)})} + + async def gen_audio(): + try: + url = await text_to_speech(story.content) + yield {"event": "audio_ready", "data": json.dumps({"audio_url": url})} + except Exception as e: + yield {"event": "audio_failed", "data": json.dumps({"error": str(e)})} + + # 并行执行,逐个yield结果 + tasks = [gen_image(), gen_audio()] + for coro in asyncio.as_completed([t.__anext__() for t in tasks]): + result = await coro + yield result + + yield {"event": "complete", "data": json.dumps({"story_id": story_id})} + + return EventSourceResponse(event_generator()) +``` + +**前端实现:** + +```typescript +const eventSource = new EventSource('/api/generate/stream', { + method: 'POST', + body: JSON.stringify({ keywords }), +}); + +eventSource.addEventListener('started', (e) => { + const { story_id } = JSON.parse(e.data); + showLoading('正在创作故事...'); +}); + +eventSource.addEventListener('story_ready', (e) => { + const { title, content } = JSON.parse(e.data); + renderStory(title, content); + showLoading('正在生成封面和语音...'); +}); + +eventSource.addEventListener('image_ready', (e) => { + const { image_url } = JSON.parse(e.data); + renderCover(image_url); +}); + +eventSource.addEventListener('image_failed', (e) => { + showRetryButton('image'); +}); + +eventSource.addEventListener('audio_ready', (e) => { + const { audio_url } = JSON.parse(e.data); + enablePlayButton(audio_url); +}); + +eventSource.addEventListener('complete', () => { + eventSource.close(); + hideLoading(); +}); +``` + +**用户体验时间线:** +``` +0s → 显示"正在创作..." +3s → 故事文本渲染,显示"正在生成封面和语音..." +3-7s → 音频就绪,播放按钮可用 +3-11s → 封面就绪,图片显示 +11s → 完成 +``` + +### 方案 4: 断点续传 (可选) + +适用于网络不稳定场景,支持刷新页面后继续: + +```python +class StoryWorkflowState(Base): + __tablename__ = "story_workflow_states" + + story_id: Mapped[str] = mapped_column(String(36), primary_key=True) + status: Mapped[str] = mapped_column(String(20)) # pending/story_done/image_done/audio_done/complete + story_content: Mapped[str | None] = mapped_column(Text) + image_url: Mapped[str | None] = mapped_column(String(500)) + audio_url: Mapped[str | None] = mapped_column(String(500)) + last_error: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, onupdate=datetime.utcnow) + +async def resume_workflow(story_id: str) -> StoryResult: + state = await get_workflow_state(story_id) + + if state.status == "story_done": + # 从图片+音频生成继续 + return await generate_image_and_audio(state) + elif state.status == "image_done": + # 只需要生成音频 + return await generate_audio_only(state) + elif state.status == "audio_done": + # 只需要生成图片 + return await generate_image_only(state) + else: + return StoryResult.from_state(state) +``` + +### 推荐实现顺序 + +| 优先级 | 方案 | 收益 | 复杂度 | 状态 | +|--------|------|------|--------|------| +| P0 | 并行执行 | 节省 27% 时间 | 低 | ✅ 已完成 | +| P0 | 部分成功 | 提升容错性 | 低 | ✅ 已完成 | +| P1 | SSE 流式返回 | 体验大幅提升 | 中 | 待开始 | +| P2 | 断点续传 | 极端场景保障 | 高 | 待开始 | + +**P0 实现详情:** +- 新增 API: `POST /api/generate/full` +- 文件: `api/stories.py:113-189` +- 响应模型: `FullStoryResponse` (含 `errors` 字段标识失败组件) + +--- + +## 待决策清单 + +> **使用说明**: 在每个决策的 `[ ]` 中填入你的选择(如 `[x]` 或 `[B]`),确认后删除未选中的选项。 + +--- + +### 决策 1: 适配器配置存储 + +**问题**: 适配器的配置信息(API地址、模型名、超时等)存在哪里? + +| 选项 | 方案 | 优点 | 缺点 | +|------|------|------|------| +| [ ] A | 全部存数据库 | 完全动态,运行时可改 | 需要管理界面,初始化复杂 | +| [ ] B | 代码定义 + DB配置 | 平衡,核心逻辑在代码,参数可调 | 新适配器仍需改代码 | +| [ ] C | 配置文件 (YAML/JSON) | 简单,版本控制友好 | 改配置需重启 | + +**推荐**: B(代码定义适配器类,DB存储启用状态/优先级/API Key引用) + +--- + +### 决策 2: 密钥管理 + +**问题**: API Key 等敏感信息如何存储? + +| 选项 | 方案 | 优点 | 缺点 | +|------|------|------|------| +| [ ] A | 环境变量 | 简单,当前方式 | 多供应商时env膨胀,改key需重启 | +| [ ] B | 数据库加密存储 | 动态管理,支持多key | 需要加密方案,安全风险 | +| [ ] C | 外部密钥服务 (Vault/AWS Secrets) | 企业级安全 | 复杂,增加依赖 | + +**推荐**: A(当前阶段),后期可迁移到B + +--- + +### 决策 3: 图像供应商优先级 + +**问题**: 接入多个图像供应商后,默认使用哪个? + +| 选项 | 供应商 | 特点 | 预估成本 | +|------|--------|------|----------| +| [ ] 1 | Nanobanana | 新兴,据说效果好 | 待调研 | +| [ ] 2 | Flux (当前) | 稳定,已接入 | ~$0.03/张 | +| [ ] 3 | DALL-E 3 | OpenAI出品,质量高 | ~$0.04/张 | +| [ ] 4 | Midjourney | 艺术风格强 | API受限 | + +**推荐**: 先调研Nanobanana,效果好则替换Flux + +--- + +### 决策 4: 文本供应商优先级 + +**问题**: 故事生成使用哪个LLM? + +| 选项 | 供应商 | 特点 | 预估成本 | +|------|--------|------|----------| +| [ ] 1 | Gemini (当前) | 免费额度大,中文好 | 免费/低成本 | +| [ ] 2 | OpenAI GPT-4o | 质量稳定 | ~$0.01/1K tokens | +| [ ] 3 | Claude | 创意写作强 | ~$0.015/1K tokens | +| [ ] 4 | Qwen (通义千问) | 国内,中文优化 | 待调研 | + +**推荐**: Gemini为主,OpenAI备用 + +--- + +### 决策 5: TTS供应商优先级 + +**问题**: 语音合成使用哪个服务? + +| 选项 | 供应商 | 特点 | 预估成本 | +|------|--------|------|----------| +| [ ] 1 | Minimax (当前) | 中文效果好,已接入 | ~$0.01/1K字符 | +| [ ] 2 | ElevenLabs | 英文最佳,多语言 | ~$0.03/1K字符 | +| [ ] 3 | Azure TTS | 稳定,多语言 | ~$0.016/1K字符 | +| [ ] 4 | Google TTS | 便宜 | ~$0.004/1K字符 | + +**推荐**: Minimax为主(中文场景) + +--- + +### 决策 6: Admin Dashboard 技术栈 + +**问题**: 供应商管理后台用什么技术? + +| 选项 | 方案 | 优点 | 缺点 | +|------|------|------|------| +| [ ] A | 复用 Vue 前端 | 技术栈统一,复用组件 | 需要自己写UI | +| [ ] B | React Admin | 成熟的Admin框架 | 引入新技术栈 | +| [ ] C | 现成方案 (AdminJS/Retool) | 开发快 | 定制性差,可能收费 | + +**推荐**: A(在现有Vue项目中加 `/admin` 路由) + +--- + +### 决策 7: Phase 2 功能优先级 + +**问题**: 体验增强阶段先做哪个功能? + +| 选项 | 功能 | 用户价值 | 开发复杂度 | +|------|------|----------|------------| +| [ ] 1 | 故事编辑 | 高(用户可修改AI内容) | 中 | +| [ ] 2 | 角色定制 | 高(孩子成为主角) | 低 | +| [ ] 3 | 故事分享 | 高(增长引擎) | 中 | +| [ ] 4 | 故事续写 | 中(延长使用时长) | 中 | + +**推荐**: 2 → 1 → 3 → 4(角色定制最快出效果) + +--- + +### 决策 8: 并行与容错实现顺序 + +**问题**: 并行执行、部分成功、SSE、断点续传,先做哪些? + +| 选项 | 方案 | 说明 | +|------|------|------| +| [ ] A | P0先做 | 先实现并行+部分成功,快速见效 | +| [ ] B | P0+P1一起 | 并行+部分成功+SSE,体验完整 | +| [ ] C | 只做SSE | 跳过简单方案,直接上流式 | + +**推荐**: A(先P0,验证后再做SSE) + +--- + +## 确认后删除此区块 + +确认所有决策后,可以删除未选中的选项,保留最终方案作为实现依据。 diff --git a/.claude/specs/product-roadmap/ROADMAP.md b/.claude/specs/product-roadmap/ROADMAP.md new file mode 100644 index 0000000..b00ccec --- /dev/null +++ b/.claude/specs/product-roadmap/ROADMAP.md @@ -0,0 +1,169 @@ +# DreamWeaver 产品路线图 + +## 产品愿景 + +**梦语织机** - 为 3-8 岁儿童打造的 AI 故事创作平台,让每个孩子都能拥有专属的成长故事。 + +### 核心价值主张 +- **个性化**: 基于关键词生成独一无二的故事 +- **教育性**: 融入成长主题(勇气、友谊、诚实等) +- **沉浸感**: AI 封面 + 语音朗读,多感官体验 +- **亲子互动**: 家长参与创作,增进亲子关系 + +--- + +## 用户画像 + +### 主要用户:家长(25-40岁) +- **需求**: 为孩子找到有教育意义的睡前故事 +- **痛点**: 市面故事千篇一律,缺乏个性化 +- **场景**: 睡前、旅途、周末亲子时光 + +### 次要用户:幼儿园/早教机构 +- **需求**: 批量生成教学故事素材 +- **痛点**: 内容制作成本高 +- **场景**: 课堂教学、活动策划 + +--- + +## 功能规划 + +### Phase 1: MVP 完善(当前) +> 目标:核心体验闭环,用户可完整使用 + +| 功能 | 状态 | 说明 | +|------|------|------| +| 关键词生成故事 | ✅ 已完成 | 输入关键词,AI 生成故事 | +| 故事润色增强 | ✅ 已完成 | 用户提供草稿,AI 润色 | +| AI 封面生成 | ✅ 已完成 | 根据故事生成插画 | +| 语音朗读 | ✅ 已完成 | TTS 朗读故事 | +| 故事收藏管理 | ✅ 已完成 | 保存、查看、删除 | +| OAuth 登录 | ✅ 已完成 | GitHub/Google 登录 | + +### Phase 2: 体验增强 +> 目标:提升用户粘性,增加互动性 + +| 功能 | 优先级 | 说明 | +|------|--------|------| +| **故事编辑** | P0 | 用户可修改 AI 生成的故事内容 | +| **角色定制** | P0 | 输入孩子姓名/性别,成为故事主角 | +| **故事续写** | P1 | 基于已有故事继续创作下一章 | +| **多语言支持** | P1 | 英文故事生成(已有 i18n 基础) | +| **故事分享** | P1 | 生成分享图片/链接 | +| **收藏夹/标签** | P2 | 故事分类管理 | + +### Phase 3: 社区与增长 +> 目标:构建用户社区,实现自然增长 + +| 功能 | 优先级 | 说明 | +|------|--------|------| +| **故事广场** | P0 | 公开优质故事,用户可浏览 | +| **点赞/收藏** | P0 | 社区互动基础 | +| **故事模板** | P1 | 预设故事框架(冒险/友谊/成长) | +| **创作者主页** | P1 | 展示用户创作的故事集 | +| **评论系统** | P2 | 用户交流反馈 | + +### Phase 4: 商业化 +> 目标:建立可持续商业模式 + +| 功能 | 优先级 | 说明 | +|------|--------|------| +| **会员订阅** | P0 | 免费/基础/高级三档 | +| **故事导出** | P0 | PDF/电子书格式导出 | +| **实体书打印** | P1 | 对接印刷服务,生成实体绘本 | +| **API 开放** | P2 | 为 B 端客户提供 API | +| **企业版** | P2 | 幼儿园/早教机构定制 | + +--- + +## 技术架构演进 + +### 当前架构 (Phase 1) +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Vue 3 │────▶│ FastAPI │────▶│ PostgreSQL │ +│ Frontend │ │ Backend │ │ (Neon) │ +└─────────────┘ └──────┬──────┘ └─────────────┘ + │ + ┌────────────┼────────────┐ + ▼ ▼ ▼ + ┌────────┐ ┌─────────┐ ┌─────────┐ + │ Gemini │ │ Minimax │ │ Flux │ + │ (Text) │ │ (TTS) │ │ (Image) │ + └────────┘ └─────────┘ └─────────┘ +``` + +### Phase 2 架构演进 +``` +新增组件: +- Redis: 缓存 + 会话 + Rate Limit +- Celery: 异步任务队列(图片/音频生成) +- S3/OSS: 静态资源存储 +``` + +### Phase 3 架构演进 +``` +新增组件: +- Elasticsearch: 故事全文搜索 +- CDN: 静态资源加速 +- 消息队列: 社区通知推送 +``` + +--- + +## 里程碑规划 + +### M1: MVP 完善 ✅ +- [x] 核心功能闭环 +- [x] 工程鲁棒性改进 +- [x] 测试覆盖 + +### M2: 体验增强 +- [ ] 故事编辑功能 +- [ ] 角色定制(孩子成为主角) +- [ ] 故事续写 +- [ ] 多语言支持 +- [ ] 分享功能 + +### M3: 社区上线 +- [ ] 故事广场 +- [ ] 用户互动(点赞/收藏) +- [ ] 创作者主页 + +### M4: 商业化 +- [ ] 会员体系 +- [ ] 故事导出 +- [ ] 实体书打印 + +--- + +## 竞品分析 + +| 产品 | 优势 | 劣势 | 我们的差异化 | +|------|------|------|--------------| +| 凯叔讲故事 | 内容丰富、品牌知名 | 无个性化、订阅贵 | AI 个性化生成 | +| 喜马拉雅儿童 | 海量音频、多平台 | 内容同质化 | 用户参与创作 | +| ChatGPT | AI 能力强 | 非儿童专属、无配套 | 垂直场景优化 | + +--- + +## 风险与应对 + +| 风险 | 影响 | 应对策略 | +|------|------|----------| +| AI 生成内容不当 | 高 | 内容审核 + 家长控制 | +| API 成本过高 | 中 | 缓存优化 + 分级限流 | +| 用户增长缓慢 | 中 | 社区运营 + 分享裂变 | +| 竞品模仿 | 低 | 快速迭代 + 深耕垂直 | + +--- + +## 下一步行动 + +**Phase 2 优先实现功能:** + +1. **故事编辑** - 用户体验核心痛点 +2. **角色定制** - 差异化竞争力 +3. **故事分享** - 自然增长引擎 + +是否需要我为这些功能生成详细的技术规格文档? diff --git a/.claude/specs/robustness-improvement/dev-plan.md b/.claude/specs/robustness-improvement/dev-plan.md new file mode 100644 index 0000000..fcae8d2 --- /dev/null +++ b/.claude/specs/robustness-improvement/dev-plan.md @@ -0,0 +1,72 @@ +# DreamWeaver 工程鲁棒性改进计划 + +## 概述 +本计划旨在提升 DreamWeaver 项目的工程质量,包括测试覆盖、稳定性、可观测性等方面。 + +## 任务列表 + +### P0 - 关键问题修复 + +#### Task-1: 修复 Rate Limit 内存泄漏 +- **文件**: `backend/app/api/stories.py` +- **问题**: `_request_log` 全局字典无清理机制,长期运行内存无限增长 +- **方案**: 添加 TTL 自动清理机制,使用 `cachetools.TTLCache` +- **测试**: 验证过期条目自动清理 + +#### Task-2: 添加核心 API 测试 +- **文件**: `backend/tests/` (新建) +- **范围**: + - `test_auth.py`: OAuth 流程、session 验证 + - `test_stories.py`: 故事 CRUD、rate limit +- **目标**: 核心路径覆盖率 ≥80% + +### P1 - 稳定性提升 + +#### Task-3: 添加 API 重试机制 +- **文件**: `backend/app/services/gemini.py`, `minimax.py`, `drawing.py` +- **方案**: 使用 `tenacity` 库,指数退避重试 +- **配置**: 最多 3 次重试,初始间隔 1s + +#### Task-4: 添加结构化日志 +- **文件**: `backend/app/core/logging.py` (新建), 各 service 文件 +- **方案**: 使用 `structlog`,JSON 格式输出 +- **埋点**: API 调用、错误、性能指标 + +### P2 - 代码优化 + +#### Task-5: 重构 Provider Router +- **文件**: `backend/app/services/provider_router.py` +- **问题**: 三个函数重复代码 +- **方案**: 抽象通用 failover 函数 + +#### Task-6: 配置外部化 +- **文件**: `backend/app/core/config.py`, `backend/app/services/gemini.py` +- **问题**: 模型名硬编码 +- **方案**: 移至环境变量配置 + +#### Task-7: 修复脆弱的 URL 解析 +- **文件**: `backend/app/services/drawing.py` +- **问题**: 字符串切片解析 URL 不可靠 +- **方案**: 使用正则表达式 + +## 依赖关系 +``` +Task-1 (独立) +Task-2 (独立,但需要 Task-1 完成后验证) +Task-3 (独立) +Task-4 (独立) +Task-5 (独立) +Task-6 (独立) +Task-7 (独立) +``` + +## 新增依赖 +```toml +# pyproject.toml [project.dependencies] +cachetools>=5.0.0 # Task-1: TTL cache +tenacity>=8.0.0 # Task-3: 重试机制 +structlog>=24.0.0 # Task-4: 结构化日志 + +# [project.optional-dependencies.dev] +pytest-cov>=4.0.0 # Task-2: 覆盖率报告 +httpx[http2] # Task-2: 测试 mock diff --git a/.claude/ui-refactor-plan.md b/.claude/ui-refactor-plan.md new file mode 100644 index 0000000..ac709e5 --- /dev/null +++ b/.claude/ui-refactor-plan.md @@ -0,0 +1,303 @@ +DreamWeaver 前端 UI 重构任务列表 + +阶段一:基础设施(必须先完成) + +TASK-001 [x]: 安装图标库 + +文件: frontend/package.json +操作: 安装 @heroicons/vue 图标库 +命令: npm install @heroicons/vue +验收: 能在 Vue 组件中 import { SparklesIcon } from '@heroicons/vue/24/outline' + +TASK-002 [x]: 扩展 Tailwind 配置 + +文件: frontend/tailwind.config.js +操作: 添加完整的设计系统配置 +内容: + +- 扩展 fontFamily 添加 sans: ['Noto Sans SC', ...] +- 扩展 borderRadius 添加 '2xl': '1rem', '3xl': '1.5rem' +- 扩展 boxShadow 添加 'glass': '0 8px 32px rgba(0,0,0,0.08)' +- 扩展 animation 添加 'float': 'float 3s ease-in-out infinite' +- 扩展 keyframes 添加 float 动画定义 + 验收: Tailwind 类 font-sans, rounded-3xl, shadow-glass, animate-float 可用 + +TASK-003 [x]: 精简全局样式 + +文件: frontend/src/style.css +操作: + +1. 删除 .animate-float (移至 Tailwind) +2. 删除 .stars::before/after (移除 emoji 装饰) +3. 保留 .glass, .btn-magic, .input-magic, .card-hover, .gradient-text +4. 删除 --gradient-magic 变量(过于花哨) + 验收: 文件行数减少约 30%,无 emoji 相关 CSS + +--- + +阶段二:创建可复用组件 + +TASK-004 [x]: 创建 BaseButton 组件 + +文件: frontend/src/components/ui/BaseButton.vue +操作: 创建统一按钮组件 +Props: + +- variant: 'primary' | 'secondary' | 'danger' | 'ghost' +- size: 'sm' | 'md' | 'lg' +- loading: boolean +- disabled: boolean +- icon: Component (可选,Heroicon 组件) + 样式规范: +- primary: 使用 .btn-magic 渐变 +- secondary: bg-white border border-gray-200 +- danger: bg-red-500 text-white +- ghost: bg-transparent hover:bg-gray-100 + 验收: 导出组件,支持 slot 内容和所有 props + +TASK-005 [x]: 创建 BaseCard 组件 + +文件: frontend/src/components/ui/BaseCard.vue +操作: 创建统一卡片组件 +Props: + +- hover: boolean (是否启用悬浮效果) +- padding: 'none' | 'sm' | 'md' | 'lg' + 样式: 使用 .glass + rounded-2xl + 可选 .card-hover + 验收: 导出组件,支持默认 slot + +TASK-006 [x]: 创建 BaseInput 组件 + +文件: frontend/src/components/ui/BaseInput.vue +操作: 创建统一输入框组件 +Props: + +- modelValue: string +- type: 'text' | 'password' | 'email' | 'number' +- placeholder: string +- label: string (可选) +- error: string (可选) +- disabled: boolean + 样式: 使用 .input-magic + 错误状态红色边框 + 验收: 支持 v-model,显示 label 和 error + +TASK-007 [x]: 创建 BaseSelect 组件 + +文件: frontend/src/components/ui/BaseSelect.vue +操作: 创建统一下拉选择组件 +Props: + +- modelValue: string | number +- options: Array<{ value: string | number, label: string }> +- label: string (可选) +- placeholder: string +- disabled: boolean + 样式: 与 BaseInput 保持一致 + 验收: 支持 v-model,正确渲染 options + +TASK-008 [x]: 创建 BaseTextarea 组件 + +文件: frontend/src/components/ui/BaseTextarea.vue +操作: 创建统一文本域组件 +Props: + +- modelValue: string +- placeholder: string +- rows: number +- maxLength: number (可选,显示字数统计) +- label: string (可选) + 样式: 使用 .input-magic,右下角显示字数 + 验收: 支持 v-model,字数统计正确 + +TASK-009 [x]: 创建 LoadingSpinner 组件 + +文件: frontend/src/components/ui/LoadingSpinner.vue +操作: 创建统一加载动画组件 +Props: + +- size: 'sm' | 'md' | 'lg' +- text: string (可选,加载提示文字) + 样式: 紫色渐变圆环旋转动画,无 emoji + 验收: 三种尺寸正确渲染 + +TASK-010 [x]: 创建 EmptyState 组件 + +文件: frontend/src/components/ui/EmptyState.vue +操作: 创建统一空状态组件 +Props: + +- icon: Component (Heroicon) +- title: string +- description: string +- actionText: string (可选) +- actionTo: string (可选,路由路径) + 样式: 居中布局,图标使用 Heroicon 而非 emoji + 验收: 点击按钮正确跳转 + +TASK-011 [x]: 创建 ConfirmModal 组件 + +文件: frontend/src/components/ui/ConfirmModal.vue +操作: 创建统一确认弹窗组件 +Props: + +- show: boolean +- title: string +- message: string +- confirmText: string +- cancelText: string +- variant: 'danger' | 'warning' | 'info' + Emits: confirm, cancel + 样式: 使用 .glass 背景,Transition 动画 + 验收: 显示/隐藏动画流畅,事件正确触发 + +TASK-012 [x]: 创建组件导出索引 + +文件: frontend/src/components/ui/index.ts +操作: 统一导出所有 UI 组件 +内容: +export { default as BaseButton } from './BaseButton.vue' +export { default as BaseCard } from './BaseCard.vue' +// ... 其他组件 +验收: 可以 import { BaseButton, BaseCard } from '@/components/ui' + +--- + +阶段三:重构现有页面 + +TASK-013 [x]: 重构 NavBar 组件 + +文件: frontend/src/components/NavBar.vue +操作: + +1. 将 emoji ✨🌟📚🛠️🚪 替换为 Heroicons (SparklesIcon, StarIcon, BookOpenIcon, Cog6ToothIcon, ArrowRightOnRectangleIcon) +2. 将 ?? 占位符替换为正确图标 (UserGroupIcon, GlobeAltIcon) +3. 使用 BaseButton 替换登录按钮 +4. 移除 animate-float 和 animate-pulse 装饰动画 + 验收: 无 emoji,图标统一为 Heroicons,视觉更专业 + +TASK-014 [x]: 重构 Home.vue 页面 + +文件: frontend/src/views/Home.vue +操作: + +1. 删除 Hero 区域的浮动 emoji 装饰 (🌙⭐✨🌟) +2. 将模式切换按钮的 emoji (✨📝) 替换为 Heroicons +3. 将教育主题按钮的 emoji 替换为 Heroicons 或移除 +4. 使用 BaseButton 替换提交按钮 +5. 使用 BaseTextarea 替换文本输入区 +6. 使用 BaseSelect 替换档案/宇宙选择器 +7. 将 Features 区域的 emoji (🎨🔊📚) 替换为 Heroicons + 验收: 页面无 emoji,使用统一组件,视觉简洁专业 + +TASK-015 [x]: 重构 MyStories.vue 页面 + +文件: frontend/src/views/MyStories.vue +操作: + +1. 使用 BaseButton 替换"创作新故事"按钮 +2. 使用 LoadingSpinner 替换自定义加载动画 +3. 使用 EmptyState 替换空状态区域(移除 📚✨🪄 emoji) +4. 将错误状态的 😢 替换为 Heroicon ExclamationCircleIcon +5. 将统计区域的 📖 替换为 Heroicon +6. 使用 BaseCard 包装故事卡片 + 验收: 页面无 emoji,组件统一 + +TASK-016 [x]: 重构 StoryDetail.vue 页面 + +文件: frontend/src/views/StoryDetail.vue +操作: + +1. 使用 LoadingSpinner 替换加载动画 +2. 将 🎨 替换为 Heroicon PhotoIcon +3. 将 🔊 替换为 Heroicon SpeakerWaveIcon +4. 将 ✨ 替换为 Heroicon SparklesIcon +5. 将 🗑️ 替换为 Heroicon TrashIcon +6. 将 ⚠️ 替换为 Heroicon ExclamationTriangleIcon +7. 使用 BaseButton 替换所有按钮 +8. 使用 ConfirmModal 替换删除确认弹窗 + 验收: 页面无 emoji,弹窗使用统一组件 + +TASK-017 [x]: 重构 AdminProviders.vue 页面 + +文件: frontend/src/views/AdminProviders.vue +操作: + +1. 移除 + + +
+
+
+
+
+
+
+ + + + +
+
+
专为 3-8 岁儿童设计
+

为孩子编织
专属的童话梦境

+

输入几个关键词,AI 即刻为孩子创作独一无二的睡前故事。温暖的声音,精美的插画,让每个夜晚都充满想象。

+
+ + +
+
+
+
+
🎨
AI 生成插画
+
+
🐰
+

小兔子的勇气冒险

刚刚生成
+

在一片被晨露打湿的森林里,住着一只名叫棉花的小白兔。今天,她决定独自去森林深处...

+
勇气冒险友谊
+
+
+
🔊
温暖语音朗读
+
+
+
+ + +
+
+
0+
故事已创作
+
0+
家庭信赖
+
0%
满意度
+
+
+ + +
+
+

为什么选择梦语织机

+
+
✍️

智能创作

输入关键词或简单想法,AI 即刻创作充满想象力的原创故事

+
🧒

个性化记忆

系统记住孩子的喜好,故事越来越懂 TA

+
🎨

精美插画

为每个故事自动生成独特的封面插画

+
🔊

温暖朗读

专业配音,陪伴孩子进入甜美梦乡

+
📚

教育主题

勇气、友谊、分享...自然传递正向价值观

+
🌍

故事宇宙

创建专属世界观,角色可在不同故事中复用

+
+
+
+ + +
+
+

简单三步,创造专属故事

+
+
1

输入灵感

几个关键词或简单想法,比如"勇敢的小兔子在森林里冒险"

+
2

AI 创作

AI 即刻理解并创作充满想象力的原创故事,还能生成精美插画

+
3

温暖朗读

选择喜欢的声音,让温暖的朗读陪伴孩子进入甜美梦乡

+
+
+
+ + +
+
+

你可能想知道

+
+
梦语织机专为 3-8 岁儿童设计。我们的故事内容、语言难度和主题都经过精心调整,确保适合这个年龄段孩子的认知发展水平。
+
绝对安全。所有生成的故事都经过多层内容审核,确保不包含任何不适合儿童的内容。我们的 AI 模型经过专门训练,只会生成积极、正向、富有教育意义的故事内容。
+
当然可以!您可以输入孩子喜欢的角色名称、特征,甚至可以把孩子自己设定为故事主角。AI 会根据您的输入创作独一无二的个性化故事。
+
免费版每月可生成 5 个故事,包含基础的语音朗读功能。付费版提供无限故事生成、高级语音选择、AI 插画生成、故事导出等更多功能。
+
+
+
+ + +
+
+

准备好为孩子创造魔法了吗?

+

免费开始,无需信用卡

+ +
+
+ + +
+

© 2024 梦语织机 DreamWeaver. All rights reserved.

+
+ + + + + + + \ No newline at end of file diff --git a/admin-frontend/src/App.vue b/admin-frontend/src/App.vue new file mode 100644 index 0000000..0d40d78 --- /dev/null +++ b/admin-frontend/src/App.vue @@ -0,0 +1,53 @@ + + + diff --git a/admin-frontend/src/api/client.ts b/admin-frontend/src/api/client.ts new file mode 100644 index 0000000..66e7f2d --- /dev/null +++ b/admin-frontend/src/api/client.ts @@ -0,0 +1,45 @@ +const BASE_URL = '' + +class ApiClient { + async request(url: string, options: RequestInit = {}): Promise { + const response = await fetch(`${BASE_URL}${url}`, { + ...options, + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: '请求失败' })) + throw new Error(error.detail || '请求失败') + } + + return response.json() + } + + get(url: string): Promise { + return this.request(url) + } + + post(url: string, data?: unknown): Promise { + return this.request(url, { + method: 'POST', + body: data ? JSON.stringify(data) : undefined, + }) + } + + put(url: string, data?: unknown): Promise { + return this.request(url, { + method: 'PUT', + body: data ? JSON.stringify(data) : undefined, + }) + } + + delete(url: string): Promise { + return this.request(url, { method: 'DELETE' }) + } +} + +export const api = new ApiClient() diff --git a/admin-frontend/src/components/CreateStoryModal.vue b/admin-frontend/src/components/CreateStoryModal.vue new file mode 100644 index 0000000..3a4df6c --- /dev/null +++ b/admin-frontend/src/components/CreateStoryModal.vue @@ -0,0 +1,376 @@ + + + + + diff --git a/admin-frontend/src/components/NavBar.vue b/admin-frontend/src/components/NavBar.vue new file mode 100644 index 0000000..3f73b8c --- /dev/null +++ b/admin-frontend/src/components/NavBar.vue @@ -0,0 +1,112 @@ + + + diff --git a/admin-frontend/src/components/ui/BaseButton.vue b/admin-frontend/src/components/ui/BaseButton.vue new file mode 100644 index 0000000..cc996c7 --- /dev/null +++ b/admin-frontend/src/components/ui/BaseButton.vue @@ -0,0 +1,87 @@ + + + diff --git a/admin-frontend/src/components/ui/BaseCard.vue b/admin-frontend/src/components/ui/BaseCard.vue new file mode 100644 index 0000000..c096452 --- /dev/null +++ b/admin-frontend/src/components/ui/BaseCard.vue @@ -0,0 +1,43 @@ + + + diff --git a/admin-frontend/src/components/ui/BaseInput.vue b/admin-frontend/src/components/ui/BaseInput.vue new file mode 100644 index 0000000..01b1749 --- /dev/null +++ b/admin-frontend/src/components/ui/BaseInput.vue @@ -0,0 +1,71 @@ + + + diff --git a/admin-frontend/src/components/ui/BaseSelect.vue b/admin-frontend/src/components/ui/BaseSelect.vue new file mode 100644 index 0000000..55ad675 --- /dev/null +++ b/admin-frontend/src/components/ui/BaseSelect.vue @@ -0,0 +1,67 @@ + + + diff --git a/admin-frontend/src/components/ui/BaseTextarea.vue b/admin-frontend/src/components/ui/BaseTextarea.vue new file mode 100644 index 0000000..ca723f0 --- /dev/null +++ b/admin-frontend/src/components/ui/BaseTextarea.vue @@ -0,0 +1,62 @@ + + + diff --git a/admin-frontend/src/components/ui/ConfirmModal.vue b/admin-frontend/src/components/ui/ConfirmModal.vue new file mode 100644 index 0000000..cb840de --- /dev/null +++ b/admin-frontend/src/components/ui/ConfirmModal.vue @@ -0,0 +1,60 @@ + + + diff --git a/admin-frontend/src/components/ui/EmptyState.vue b/admin-frontend/src/components/ui/EmptyState.vue new file mode 100644 index 0000000..fb3eb67 --- /dev/null +++ b/admin-frontend/src/components/ui/EmptyState.vue @@ -0,0 +1,45 @@ + + + diff --git a/admin-frontend/src/components/ui/LoadingSpinner.vue b/admin-frontend/src/components/ui/LoadingSpinner.vue new file mode 100644 index 0000000..6f74cd5 --- /dev/null +++ b/admin-frontend/src/components/ui/LoadingSpinner.vue @@ -0,0 +1,36 @@ + + + diff --git a/admin-frontend/src/components/ui/LoginDialog.vue b/admin-frontend/src/components/ui/LoginDialog.vue new file mode 100644 index 0000000..60f5000 --- /dev/null +++ b/admin-frontend/src/components/ui/LoginDialog.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/admin-frontend/src/components/ui/index.ts b/admin-frontend/src/components/ui/index.ts new file mode 100644 index 0000000..bab7a3d --- /dev/null +++ b/admin-frontend/src/components/ui/index.ts @@ -0,0 +1,8 @@ +export { default as BaseButton } from './BaseButton.vue' +export { default as BaseCard } from './BaseCard.vue' +export { default as BaseInput } from './BaseInput.vue' +export { default as BaseSelect } from './BaseSelect.vue' +export { default as BaseTextarea } from './BaseTextarea.vue' +export { default as LoadingSpinner } from './LoadingSpinner.vue' +export { default as EmptyState } from './EmptyState.vue' +export { default as ConfirmModal } from './ConfirmModal.vue' diff --git a/admin-frontend/src/i18n.ts b/admin-frontend/src/i18n.ts new file mode 100644 index 0000000..c40691a --- /dev/null +++ b/admin-frontend/src/i18n.ts @@ -0,0 +1,27 @@ +import { createI18n } from 'vue-i18n' +import en from './locales/en.json' +import zh from './locales/zh.json' + +const messages = { en, zh } + +function detectLocale(): 'en' | 'zh' { + const saved = localStorage.getItem('locale') + if (saved === 'en' || saved === 'zh') return saved + const lang = navigator.language.toLowerCase() + if (lang.startsWith('zh')) return 'zh' + return 'en' +} + +const i18n = createI18n({ + legacy: false, + locale: detectLocale(), + fallbackLocale: 'en', + messages, +}) + +export function setLocale(locale: 'en' | 'zh') { + localStorage.setItem('locale', locale) + i18n.global.locale.value = locale +} + +export default i18n diff --git a/admin-frontend/src/locales/en.json b/admin-frontend/src/locales/en.json new file mode 100644 index 0000000..d7f61e6 --- /dev/null +++ b/admin-frontend/src/locales/en.json @@ -0,0 +1,154 @@ +{ + "app": { + "title": "DreamWeaver", + "navHome": "Home", + "navMyStories": "My Stories", + "navProfiles": "Profiles", + "navUniverses": "Universes", + "navAdmin": "Providers Admin" + }, + "home": { + "heroTitle": "Weave magical", + "heroTitleHighlight": "bedtime stories for your child", + "heroSubtitle": "AI-powered personalized stories for children aged 3-8, making every bedtime magical", + "heroCta": "Start Creating", + "heroCtaSecondary": "Learn More", + "heroPreviewTitle": "Bunny's Brave Adventure", + "heroPreviewText": "In a forest kissed by morning dew, there lived a little white bunny named Cotton...", + + "trustStoriesCreated": "Stories Created", + "trustFamilies": "Families Trust Us", + "trustSatisfaction": "Satisfaction", + + "featuresTitle": "Why Choose DreamWeaver", + "featuresSubtitle": "We combine AI technology with educational principles to create unique stories for every child", + "feature1Title": "AI-Powered Creation", + "feature1Desc": "Enter a few keywords, and AI instantly creates an imaginative original story for your child", + "feature2Title": "Personalized Memory", + "feature2Desc": "The system remembers your child's preferences and growth, making stories more tailored over time", + "feature3Title": "Beautiful AI Illustrations", + "feature3Desc": "Automatically generate unique cover illustrations for each story, bringing them to life", + "feature4Title": "Warm Voice Narration", + "feature4Desc": "Professional AI narration with a warm voice to accompany your child into sweet dreams", + "feature5Title": "Educational Themes", + "feature5Desc": "Courage, friendship, sharing, honesty... naturally weaving positive values into stories", + "feature6Title": "Story Universe", + "feature6Desc": "Create your own world where beloved characters continue their adventures across stories", + + "howItWorksTitle": "How It Works", + "howItWorksSubtitle": "Four steps to start your magical story journey", + "step1Title": "Enter Ideas", + "step1Desc": "Input keywords, characters, or simple ideas", + "step2Title": "AI Creates", + "step2Desc": "AI generates a unique story based on your input", + "step3Title": "Enrich Content", + "step3Desc": "Auto-generate beautiful illustrations and audio", + "step4Title": "Share Stories", + "step4Desc": "Save and tell stories to your child anytime", + + "showcaseTitle": "Designed for Parents", + "showcaseSubtitle": "Simple to use, powerful features", + "showcaseFeature1": "Intuitive interface, generate stories in seconds", + "showcaseFeature2": "Multi-child profile management with separate memories", + "showcaseFeature3": "Story history saved forever, revisit precious moments", + "showcaseFeature4": "Bilingual support to nurture language skills", + + "testimonialsTitle": "What Parents Say", + "testimonialsSubtitle": "Real feedback from our users", + "testimonial1Text": "Every night before bed, my daughter wants a new story. DreamWeaver saves me from making up stories, and the quality is amazing!", + "testimonial1Name": "Sarah M.", + "testimonial1Role": "Parent of 5-year-old girl", + "testimonial2Text": "The personalization is incredible! It remembers my son loves dinosaurs and space, and every story hits his interests perfectly.", + "testimonial2Name": "Michael T.", + "testimonial2Role": "Parent of 6-year-old boy", + "testimonial3Text": "The voice narration is fantastic! Even when traveling, I can tell stories remotely. The voice is warm and natural, my daughter loves it.", + "testimonial3Name": "Jennifer L.", + "testimonial3Role": "Parent of 4-year-old girl", + + "faqTitle": "Frequently Asked Questions", + "faq1Question": "What age is DreamWeaver suitable for?", + "faq1Answer": "We're designed for children aged 3-8. Story content, language difficulty, and educational themes are all optimized for this age group.", + "faq2Question": "Are the generated stories safe?", + "faq2Answer": "Absolutely safe. All stories go through content filtering to ensure they're appropriate for children and convey positive values.", + "faq3Question": "Can I customize story characters?", + "faq3Answer": "Yes! You can set preferences in your child's profile, or specify character names and traits when creating. AI will incorporate them into the story.", + "faq4Question": "Will stories repeat?", + "faq4Answer": "No. Every story is originally generated by AI in real-time. Even with the same keywords, you'll get different stories each time.", + "faq5Question": "What languages are supported?", + "faq5Answer": "Currently we support Chinese and English. You can switch interface language anytime, and stories will adjust accordingly.", + + "ctaTitle": "Ready to Create Magic for Your Child?", + "ctaSubtitle": "Start now and let AI weave unique stories for your child's growth", + "ctaButton": "Start Creating Free", + "ctaNote": "No credit card required", + + "createModalTitle": "Create New Story", + "inputTypeKeywords": "Keywords", + "inputTypeStory": "Polish Story", + "selectProfile": "Select Child Profile", + "selectProfileOptional": "(Optional)", + "selectUniverse": "Select Story Universe", + "noProfile": "No profile", + "noUniverse": "No universe", + "noUniverseHint": "No universe for this profile yet. Create one in Story Universe.", + "inputLabel": "Enter Keywords", + "inputLabelStory": "Enter Your Story", + "inputPlaceholder": "e.g., bunny, forest, courage, friendship...", + "inputPlaceholderStory": "Enter the story you want to polish...", + "themeLabel": "Select Educational Theme", + "themeOptional": "(Optional)", + "themeCourage": "Courage", + "themeFriendship": "Friendship", + "themeSharing": "Sharing", + "themeHonesty": "Honesty", + "themePersistence": "Persistence", + "themeTolerance": "Tolerance", + "themeCustom": "Or custom...", + "errorEmpty": "Please enter content", + "errorLogin": "Please login first", + "generating": "Weaving your story...", + "loginFirst": "Please Login", + "startCreate": "Create Magic Story" + }, + "stories": { + "myStories": "My Stories", + "view": "View", + "delete": "Delete", + "confirmDelete": "Are you sure to delete this story?", + "noStories": "No stories yet." + }, + "storyDetail": { + "back": "Back", + "generateImage": "Generate Cover", + "playAudio": "Play Audio", + "modeGenerated": "Generated", + "modeEnhanced": "Enhanced" + }, + "admin": { + "title": "Provider Management", + "reload": "Reload Cache", + "create": "Create", + "edit": "Edit", + "save": "Save", + "clear": "Clear", + "delete": "Delete", + "name": "Name", + "type": "Type", + "adapter": "Adapter", + "model": "Model", + "apiBase": "API Base", + "timeout": "Timeout (ms)", + "retries": "Max Retries", + "weight": "Weight", + "priority": "Priority", + "configRef": "Config Ref", + "enabled": "Enabled", + "actions": "Actions" + }, + "common": { + "enabled": "Enabled", + "disabled": "Disabled", + "confirm": "Confirm", + "cancel": "Cancel" + } +} diff --git a/admin-frontend/src/locales/zh.json b/admin-frontend/src/locales/zh.json new file mode 100644 index 0000000..b807a7f --- /dev/null +++ b/admin-frontend/src/locales/zh.json @@ -0,0 +1,154 @@ +{ + "app": { + "title": "梦语织机", + "navHome": "首页", + "navMyStories": "我的故事", + "navProfiles": "孩子档案", + "navUniverses": "故事宇宙", + "navAdmin": "供应商管理" + }, + "home": { + "heroTitle": "为孩子编织", + "heroTitleHighlight": "专属的童话梦境", + "heroSubtitle": "AI 智能创作个性化成长故事,陪伴 3-8 岁孩子的每一个美好夜晚", + "heroCta": "开始创作", + "heroCtaSecondary": "了解更多", + "heroPreviewTitle": "小兔子的勇气冒险", + "heroPreviewText": "在一片被晨露打湿的森林里,住着一只名叫棉花的小白兔...", + + "trustStoriesCreated": "故事已创作", + "trustFamilies": "家庭信赖", + "trustSatisfaction": "满意度", + + "featuresTitle": "为什么选择梦语织机", + "featuresSubtitle": "我们用 AI 技术和教育理念,为每个孩子打造独一无二的成长故事", + "feature1Title": "AI 智能创作", + "feature1Desc": "输入几个关键词,AI 即刻为您的孩子创作一个充满想象力的原创故事", + "feature2Title": "个性化记忆", + "feature2Desc": "系统记住孩子的喜好和成长轨迹,故事越来越懂 TA", + "feature3Title": "精美 AI 插画", + "feature3Desc": "为每个故事自动生成独特的精美封面插画,让故事更加生动", + "feature4Title": "温暖语音朗读", + "feature4Desc": "专业级 AI 配音,温暖的声音陪伴孩子进入甜美梦乡", + "feature5Title": "教育主题融入", + "feature5Desc": "勇气、友谊、分享、诚实...在故事中自然传递正向价值观", + "feature6Title": "故事宇宙", + "feature6Desc": "创建专属世界观,让喜爱的角色在不同故事中持续冒险", + + "howItWorksTitle": "如何使用", + "howItWorksSubtitle": "四步开启奇妙故事之旅", + "step1Title": "输入灵感", + "step1Desc": "输入关键词、角色或简单想法", + "step2Title": "AI 创作", + "step2Desc": "AI 根据输入生成专属故事", + "step3Title": "丰富内容", + "step3Desc": "自动生成精美插画和语音", + "step4Title": "分享故事", + "step4Desc": "保存收藏,随时为孩子讲述", + + "showcaseTitle": "专为家长设计", + "showcaseSubtitle": "简单易用,功能强大", + "showcaseFeature1": "直观的创作界面,几秒即可生成故事", + "showcaseFeature2": "多孩子档案管理,每个孩子独立记忆", + "showcaseFeature3": "故事历史永久保存,随时回顾美好时光", + "showcaseFeature4": "支持中英双语,培养语言能力", + + "testimonialsTitle": "家长们怎么说", + "testimonialsSubtitle": "来自真实用户的反馈", + "testimonial1Text": "每晚睡前,女儿都要听一个新故事。梦语织机让我不再为编故事发愁,而且故事质量真的很高!", + "testimonial1Name": "小雨妈妈", + "testimonial1Role": "5岁女孩家长", + "testimonial2Text": "最惊喜的是个性化功能,系统记住了儿子喜欢恐龙和太空,每个故事都能戳中他的兴趣点。", + "testimonial2Name": "航航爸爸", + "testimonial2Role": "6岁男孩家长", + "testimonial3Text": "语音朗读功能太棒了!出差时也能远程给孩子讲故事,声音温暖自然,孩子很喜欢。", + "testimonial3Name": "朵朵妈妈", + "testimonial3Role": "4岁女孩家长", + + "faqTitle": "常见问题", + "faq1Question": "梦语织机适合多大的孩子?", + "faq1Answer": "我们专为 3-8 岁儿童设计,故事内容、语言难度和教育主题都针对这个年龄段优化。", + "faq2Question": "生成的故事安全吗?", + "faq2Answer": "绝对安全。所有故事都经过内容过滤,确保适合儿童阅读,传递积极正向的价值观。", + "faq3Question": "可以自定义故事角色吗?", + "faq3Answer": "可以!您可以在孩子档案中设置喜好,或在创作时指定角色名称、特点,AI 会将其融入故事。", + "faq4Question": "故事会重复吗?", + "faq4Answer": "不会。每个故事都是 AI 实时原创生成的,即使使用相同关键词,也会产生不同的故事。", + "faq5Question": "支持哪些语言?", + "faq5Answer": "目前支持中文和英文,您可以随时切换界面语言,故事也会相应调整。", + + "ctaTitle": "准备好为孩子创造魔法了吗?", + "ctaSubtitle": "立即开始,让 AI 为您的孩子编织独一无二的成长故事", + "ctaButton": "免费开始创作", + "ctaNote": "无需信用卡,立即体验", + + "createModalTitle": "创作新故事", + "inputTypeKeywords": "关键词创作", + "inputTypeStory": "故事润色", + "selectProfile": "选择孩子档案", + "selectProfileOptional": "(可选)", + "selectUniverse": "选择故事宇宙", + "noProfile": "不使用档案", + "noUniverse": "不选择宇宙", + "noUniverseHint": "当前档案暂无宇宙,可在「故事宇宙」中创建", + "inputLabel": "输入关键词", + "inputLabelStory": "输入您的故事", + "inputPlaceholder": "例如:小兔子, 森林, 勇气, 友谊...", + "inputPlaceholderStory": "在这里输入您想要润色的故事...", + "themeLabel": "选择教育主题", + "themeOptional": "(可选)", + "themeCourage": "勇气", + "themeFriendship": "友谊", + "themeSharing": "分享", + "themeHonesty": "诚实", + "themePersistence": "坚持", + "themeTolerance": "包容", + "themeCustom": "或自定义...", + "errorEmpty": "请输入内容", + "errorLogin": "请先登录", + "generating": "正在编织故事...", + "loginFirst": "请先登录", + "startCreate": "开始创作魔法故事" + }, + "stories": { + "myStories": "我的故事", + "view": "查看", + "delete": "删除", + "confirmDelete": "确定删除这个故事吗?", + "noStories": "暂无故事。" + }, + "storyDetail": { + "back": "返回", + "generateImage": "生成封面", + "playAudio": "播放音频", + "modeGenerated": "生成", + "modeEnhanced": "润色" + }, + "admin": { + "title": "供应商管理", + "reload": "重载缓存", + "create": "创建", + "edit": "编辑", + "save": "保存", + "clear": "清空", + "delete": "删除", + "name": "名称", + "type": "类型", + "adapter": "适配器", + "model": "模型", + "apiBase": "API Base", + "timeout": "超时 (ms)", + "retries": "最大重试", + "weight": "权重", + "priority": "优先级", + "configRef": "Config Ref", + "enabled": "启用", + "actions": "操作" + }, + "common": { + "enabled": "启用", + "disabled": "停用", + "confirm": "确认", + "cancel": "取消" + } +} diff --git a/admin-frontend/src/main.ts b/admin-frontend/src/main.ts new file mode 100644 index 0000000..efc05bb --- /dev/null +++ b/admin-frontend/src/main.ts @@ -0,0 +1,14 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import router from './router' +import App from './App.vue' +import i18n from './i18n' +import './style.css' + +const app = createApp(App) + +app.use(createPinia()) +app.use(router) +app.use(i18n) + +app.mount('#app') diff --git a/admin-frontend/src/router.ts b/admin-frontend/src/router.ts new file mode 100644 index 0000000..fb0767e --- /dev/null +++ b/admin-frontend/src/router.ts @@ -0,0 +1,59 @@ +import { createRouter, createWebHistory } from 'vue-router' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/', + redirect: '/console/providers', + }, + { + path: '/my-stories', + name: 'my-stories', + component: () => import('./views/MyStories.vue'), + }, + { + path: '/profiles', + name: 'profiles', + component: () => import('./views/ChildProfiles.vue'), + }, + { + path: '/profiles/:id', + name: 'profile-detail', + component: () => import('./views/ChildProfileDetail.vue'), + }, + { + path: '/profiles/:id/timeline', + name: 'profile-timeline', + component: () => import('./views/ChildProfileTimeline.vue'), + }, + { + path: '/universes', + name: 'universes', + component: () => import('./views/Universes.vue'), + }, + { + path: '/universes/:id', + name: 'universe-detail', + component: () => import('./views/UniverseDetail.vue'), + }, + { + path: '/story/:id', + name: 'story-detail', + component: () => import('./views/StoryDetail.vue'), + }, + { + path: '/storybook/view', + name: 'storybook-viewer', + component: () => import('./views/StorybookViewer.vue'), + }, + { + path: '/console/providers', + name: 'admin-providers', + component: () => import('./views/AdminProviders.vue'), + meta: { requiresAdmin: true }, + }, + ], +}) + +export default router diff --git a/admin-frontend/src/stores/storybook.ts b/admin-frontend/src/stores/storybook.ts new file mode 100644 index 0000000..345c93a --- /dev/null +++ b/admin-frontend/src/stores/storybook.ts @@ -0,0 +1,38 @@ + +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export interface StorybookPage { + page_number: number + text: string + image_prompt: string + image_url?: string +} + +export interface Storybook { + id?: number // 新增 + title: string + main_character: string + art_style: string + pages: StorybookPage[] + cover_prompt: string + cover_url?: string +} + +export const useStorybookStore = defineStore('storybook', () => { + const currentStorybook = ref(null) + + function setStorybook(storybook: Storybook) { + currentStorybook.value = storybook + } + + function clearStorybook() { + currentStorybook.value = null + } + + return { + currentStorybook, + setStorybook, + clearStorybook, + } +}) diff --git a/admin-frontend/src/stores/user.ts b/admin-frontend/src/stores/user.ts new file mode 100644 index 0000000..feef998 --- /dev/null +++ b/admin-frontend/src/stores/user.ts @@ -0,0 +1,49 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { api } from '../api/client' + +interface User { + id: string + name: string + avatar_url: string | null + provider: string +} + +export const useUserStore = defineStore('user', () => { + const user = ref(null) + const loading = ref(false) + + async function fetchSession() { + loading.value = true + try { + const data = await api.get<{ user: User | null }>('/auth/session') + user.value = data.user + } catch { + user.value = null + } finally { + loading.value = false + } + } + + function loginWithGithub() { + window.location.href = '/auth/github/signin' + } + + function loginWithGoogle() { + window.location.href = '/auth/google/signin' + } + + async function logout() { + await api.post('/auth/signout') + user.value = null + } + + return { + user, + loading, + fetchSession, + loginWithGithub, + loginWithGoogle, + logout, + } +}) diff --git a/admin-frontend/src/style.css b/admin-frontend/src/style.css new file mode 100644 index 0000000..bbe49bc --- /dev/null +++ b/admin-frontend/src/style.css @@ -0,0 +1,243 @@ +/* 引入霞鹜文楷 */ +@import url('https://cdn.jsdelivr.net/npm/lxgw-wenkai-screen-web/style.css'); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* 全局基础样式 */ +body { + /* 优先使用文楷,营造书卷气 */ + font-family: 'LXGW WenKai Screen', 'Noto Sans SC', system-ui, sans-serif; + + /* 米色纸张背景 */ + background-color: #FDFBF7; + color: #292524; /* Stone-800 暖炭黑 */ + + /* 细微的纹理 (可选) */ + background-image: radial-gradient(#E5E7EB 1px, transparent 1px); + background-size: 32px 32px; +} + +/* 暗色模式适配 */ +.dark body { + background-color: #1C1917; /* Stone-900 */ + background-image: radial-gradient(#292524 1px, transparent 1px); + color: #E7E5E4; /* Stone-200 */ +} + +/* 自定义滚动条 - 更加柔和 */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: #D6D3D1; /* Stone-300 */ + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: #A8A29E; /* Stone-400 */ +} + +/* 卡片风格 - 实体书签感 */ +.glass { + background-color: #FFFFFF; + border: 1px solid #E7E5E4; /* Stone-200 */ + border-radius: 1rem; /* 16px */ + box-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.05), 0 1px 4px -1px rgba(0, 0, 0, 0.02); + transition: all 0.3s ease; +} + +.dark .glass { + background-color: #292524; + border-color: #44403C; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3); +} + +/* 标题风格 - 不再使用渐变,而是强调字重和颜色 */ +.gradient-text { + background: none; + -webkit-background-clip: unset; + -webkit-text-fill-color: initial; + color: #1F2937; /* Gray-800 */ + font-weight: 800; /* ExtraBold */ + letter-spacing: -0.025em; +} + +.dark .gradient-text { + color: #F3F4F6; +} + +/* 按钮 - 暖色调琥珀色 */ +.btn-magic { + background-color: #F59E0B; /* Amber-500 */ + color: #FFFFFF; + border-radius: 0.75rem; + font-weight: 600; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 2px 4px rgba(245, 158, 11, 0.2); +} + +.btn-magic:hover { + background-color: #D97706; /* Amber-600 */ + transform: translateY(-1px); + box-shadow: 0 4px 6px rgba(245, 158, 11, 0.3); +} + +.btn-magic:active { + transform: translateY(0); +} + +/* 卡片悬浮 */ +.card-hover:hover { + transform: translateY(-4px); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.05), 0 4px 6px -2px rgba(0, 0, 0, 0.025); + border-color: #F59E0B; /* 悬浮时边框变黄 */ +} + +/* 输入框 - 极简白卡纸风格 */ +.input-magic { + background: #FFFFFF; + border: 1px solid #E7E5E4; /* Stone-200 */ + color: #292524; + border-radius: 0.75rem; + transition: all 0.2s ease; +} + +.input-magic:focus { + outline: none; + border-color: #F59E0B; /* Amber-500 */ + box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.1); +} + +.dark .input-magic { + background: #292524; + border-color: #44403C; + color: #E7E5E4; +} + +/* 装饰背景 - 移除复杂的动画光斑,保持干净 */ +.bg-pattern { + background: none; +} + + +.dark body { + background: linear-gradient(135deg, #0f172a 0%, #111827 35%, #1f2937 70%, #111827 100%); +} + +/* 自定义滚动条 */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #f1f5f9; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb { + background: linear-gradient(135deg, #c084fc, #f472b6); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: linear-gradient(135deg, #a855f7, #ec4899); +} + +/* 玻璃态效果 */ +.glass { + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.3); +} + +.dark .glass { + background: rgba(15, 23, 42, 0.6); + border: 1px solid rgba(148, 163, 184, 0.25); +} + +/* 渐变文字 */ +.gradient-text { + background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* 按钮样式 */ +.btn-magic { + background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%); + background-size: 200% 200%; + transition: all 0.3s ease; +} + +.btn-magic:hover { + background-position: 100% 100%; + transform: translateY(-2px); + box-shadow: 0 10px 40px rgba(102, 126, 234, 0.4); +} + +.btn-magic:active { + transform: translateY(0); +} + +.dark .btn-magic { + background: linear-gradient(135deg, #7c3aed 0%, #4c1d95 50%, #a855f7 100%); +} + +/* 卡片悬浮效果 */ +.card-hover { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.card-hover:hover { + transform: translateY(-8px); + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15); +} + +/* 输入框聚焦效果 */ +.input-magic { + transition: all 0.3s ease; + border: 2px solid transparent; + background: linear-gradient(white, white) padding-box, + linear-gradient(135deg, #e9d5ff, #fbcfe8, #bfdbfe) border-box; +} + +.input-magic:focus { + background: linear-gradient(white, white) padding-box, + linear-gradient(135deg, #a855f7, #ec4899, #3b82f6) border-box; + box-shadow: 0 0 0 4px rgba(168, 85, 247, 0.1); +} + +.dark .input-magic { + background: linear-gradient(#0f172a, #0f172a) padding-box, + linear-gradient(135deg, #7c3aed, #db2777, #2563eb) border-box; +} + +/* 装饰性背景 */ +.bg-pattern { + background-image: + radial-gradient(circle at 20% 80%, rgba(168, 85, 247, 0.1) 0%, transparent 50%), + radial-gradient(circle at 80% 20%, rgba(236, 72, 153, 0.1) 0%, transparent 50%), + radial-gradient(circle at 40% 40%, rgba(59, 130, 246, 0.05) 0%, transparent 50%); +} + +.dark .bg-pattern { + background-image: + radial-gradient(circle at 20% 80%, rgba(124, 58, 237, 0.18) 0%, transparent 55%), + radial-gradient(circle at 80% 20%, rgba(236, 72, 153, 0.18) 0%, transparent 55%), + radial-gradient(circle at 40% 40%, rgba(59, 130, 246, 0.12) 0%, transparent 55%); +} + +@media (prefers-reduced-motion: reduce) { + .animate-float, .card-hover, .btn-magic { + animation: none; + transition: none; + } +} diff --git a/admin-frontend/src/views/AdminProviders.vue b/admin-frontend/src/views/AdminProviders.vue new file mode 100644 index 0000000..dca49aa --- /dev/null +++ b/admin-frontend/src/views/AdminProviders.vue @@ -0,0 +1,475 @@ + + + diff --git a/admin-frontend/src/views/ChildProfileDetail.vue b/admin-frontend/src/views/ChildProfileDetail.vue new file mode 100644 index 0000000..b3fe97a --- /dev/null +++ b/admin-frontend/src/views/ChildProfileDetail.vue @@ -0,0 +1,181 @@ + + + diff --git a/admin-frontend/src/views/ChildProfileTimeline.vue b/admin-frontend/src/views/ChildProfileTimeline.vue new file mode 100644 index 0000000..00d86cb --- /dev/null +++ b/admin-frontend/src/views/ChildProfileTimeline.vue @@ -0,0 +1,221 @@ + + + + + diff --git a/admin-frontend/src/views/ChildProfiles.vue b/admin-frontend/src/views/ChildProfiles.vue new file mode 100644 index 0000000..fa9945b --- /dev/null +++ b/admin-frontend/src/views/ChildProfiles.vue @@ -0,0 +1,174 @@ + + + diff --git a/admin-frontend/src/views/Home.vue b/admin-frontend/src/views/Home.vue new file mode 100644 index 0000000..ca2ac9d --- /dev/null +++ b/admin-frontend/src/views/Home.vue @@ -0,0 +1,476 @@ + + + + + diff --git a/admin-frontend/src/views/MyStories.vue b/admin-frontend/src/views/MyStories.vue new file mode 100644 index 0000000..3b46ebb --- /dev/null +++ b/admin-frontend/src/views/MyStories.vue @@ -0,0 +1,189 @@ + + + diff --git a/admin-frontend/src/views/StoryDetail.vue b/admin-frontend/src/views/StoryDetail.vue new file mode 100644 index 0000000..e9f2f97 --- /dev/null +++ b/admin-frontend/src/views/StoryDetail.vue @@ -0,0 +1,312 @@ + + + diff --git a/admin-frontend/src/views/StorybookViewer.vue b/admin-frontend/src/views/StorybookViewer.vue new file mode 100644 index 0000000..2e8fb3f --- /dev/null +++ b/admin-frontend/src/views/StorybookViewer.vue @@ -0,0 +1,197 @@ + + + + + + diff --git a/admin-frontend/src/views/UniverseDetail.vue b/admin-frontend/src/views/UniverseDetail.vue new file mode 100644 index 0000000..04946da --- /dev/null +++ b/admin-frontend/src/views/UniverseDetail.vue @@ -0,0 +1,208 @@ + + + diff --git a/admin-frontend/src/views/Universes.vue b/admin-frontend/src/views/Universes.vue new file mode 100644 index 0000000..edbcb9c --- /dev/null +++ b/admin-frontend/src/views/Universes.vue @@ -0,0 +1,203 @@ + + + diff --git a/admin-frontend/src/vite-env.d.ts b/admin-frontend/src/vite-env.d.ts new file mode 100644 index 0000000..323c78a --- /dev/null +++ b/admin-frontend/src/vite-env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/admin-frontend/tailwind.config.js b/admin-frontend/tailwind.config.js new file mode 100644 index 0000000..afcd725 --- /dev/null +++ b/admin-frontend/tailwind.config.js @@ -0,0 +1,46 @@ +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: 'class', + content: [ + "./index.html", + "./src/**/*.{vue,js,ts,jsx,tsx}", + ], + theme: { + extend: { + fontFamily: { + sans: ['Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', 'sans-serif'], + }, + borderRadius: { + '2xl': '1rem', + '3xl': '1.5rem', + }, + boxShadow: { + glass: '0 8px 32px rgba(0,0,0,0.08)', + }, + animation: { + float: 'float 3s ease-in-out infinite', + }, + keyframes: { + float: { + '0%, 100%': { transform: 'translateY(0px)' }, + '50%': { transform: 'translateY(-10px)' }, + }, + }, + colors: { + primary: { + 50: '#fdf4ff', + 100: '#fae8ff', + 200: '#f5d0fe', + 300: '#f0abfc', + 400: '#e879f9', + 500: '#d946ef', + 600: '#c026d3', + 700: '#a21caf', + 800: '#86198f', + 900: '#701a75', + }, + }, + }, + }, + plugins: [], +} diff --git a/admin-frontend/tsconfig.json b/admin-frontend/tsconfig.json new file mode 100644 index 0000000..4b6a33b --- /dev/null +++ b/admin-frontend/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/admin-frontend/tsconfig.node.json b/admin-frontend/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/admin-frontend/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/admin-frontend/vite.config.ts b/admin-frontend/vite.config.ts new file mode 100644 index 0000000..dce189e --- /dev/null +++ b/admin-frontend/vite.config.ts @@ -0,0 +1,29 @@ +import { fileURLToPath, URL } from 'node:url' +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + server: { + port: 5174, + proxy: { + '/api': { + target: 'http://localhost:52000', + changeOrigin: true, + }, + '/auth': { + target: 'http://localhost:52000', + changeOrigin: true, + }, + '/admin': { + target: 'http://localhost:52800', + changeOrigin: true, + }, + }, + }, +}) diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..cfb0d84 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,115 @@ +# ============================================== +# DREAMWEAVER 环境变量配置模板 +# ============================================== +# 使用说明: +# 1. 复制此文件为 .env +# 2. 填入您的 API Keys +# 3. 配合 docker-compose.yml 启动 +# ============================================== + +# ---------------------------------------------- +# 1. 基础设施 (Infrastructure) [必填] +# ---------------------------------------------- +# ⚠️ 在 Docker 启动时无需修改这部分,直接使用默认值即可 +# ⚠️ 仅当您想连接外部数据库时才修改这里 +POSTGRES_USER=dreamweaver +POSTGRES_PASSWORD=dreamweaver_password +POSTGRES_DB=dreamweaver_db +POSTGRES_PORT=5432 +REDIS_PORT=6379 + +DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} +CELERY_BROKER_URL=redis://redis:6379/0 +CELERY_RESULT_BACKEND=redis://redis:6379/0 + +# Web Security +SECRET_KEY=change-me-to-a-secure-random-string-in-production +DEBUG=true + + +# ---------------------------------------------- +# 2. AI 引擎配置 (AI Engines) [核心] +# ---------------------------------------------- +# [策略配置] +# 系统默认使用的供应商列表 (按优先级排序) +# 文本生成: 优先 Gemini,其次 OpenAI +TEXT_PROVIDERS=["gemini", "openai"] +# 图片生成: 优先 CQTAI (Flux/NanoBanana) +IMAGE_PROVIDERS=["cqtai"] +# 语音生成: 优先 MiniMax,其次 ElevenLabs,最后 EdgeTTS(免费) +TTS_PROVIDERS=["minimax", "elevenlabs", "edge_tts"] + +# [模型参数] +TEXT_MODEL=gemini-2.0-flash +IMAGE_MODEL=nano-banana +IMAGE_RESOLUTION=1K +# TTS_MODEL=speech-2.6-turbo (MiniMax) / zh-CN-XiaoxiaoNeural (Edge) + +# [API 密钥池] +# 请填入您拥有的 Key,没有的留空即可 +# ⚠️ 注意: 除非您使用国内中转(OneAPI)或企业私有版,否则无需填写 API_BASE (系统会自动使用官方地址) + +# Google Gemini +TEXT_API_KEY= +TEXT_API_BASE= + +# CQTAI / GoQuantum (Image) +CQTAI_API_KEY= +# CQTAI_API_BASE=https://api.cqtai.com/v1 + +# Antigravity (Image - OpenAI Compatible) +ANTIGRAVITY_API_KEY= +ANTIGRAVITY_API_BASE=http://127.0.0.1:8045/v1 +# 模型: gemini-3-pro-image, gemini-3-pro-image-16-9, etc. + +# MiniMax (TTS) +MINIMAX_API_KEY= +# MINIMAX_GROUP_ID 是 MiniMax v1/v2 接口必须的参数 (通常在 MiniMax 控制台可见) +MINIMAX_GROUP_ID= +MINIMAX_API_BASE= + +# ElevenLabs (TTS) +ELEVENLABS_API_KEY= +# ELEVENLABS_API_BASE=https://api.elevenlabs.io/v1 + +# OpenAI (如需使用) +OPENAI_API_KEY= +OPENAI_API_BASE= + +# ---------------------------------------------- +# 3. 第三方登录 (OAuth Config) [可选] +# ---------------------------------------------- +# 若留空,则无法使用该方式登录 +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + + +# ---------------------------------------------- +# 4. 管理后台 (Admin Console) +# ---------------------------------------------- +# 是否开启 /admin 路由与 API (生产环境建议 false) +ENABLE_ADMIN_CONSOLE=true + +# 管理员 Basic Auth 账号 +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin + + +# ---------------------------------------------- +# 5. 部署与网络 (Deployment & Network) +# ---------------------------------------------- +# [外部访问地址] +# 用于 OAuth 回调验证 (对应 docker-compose 的 52000 端口) +BASE_URL=http://localhost:52000 + +# [跨域白名单 CORS] +# 包含 User Frontend (52080), Admin Frontend (52888) 及本地开发端口 +CORS_ORIGINS=["http://localhost:52080", "http://localhost:52888", "http://localhost:5173", "http://localhost:5174"] + +# [本地开发覆盖 Local Dev Override] +# 如果您不使用 Docker,而是在本机直接运行 `python -m uvicorn ...` +# 请取消注释以下行以连接 localhost 数据库: +# DATABASE_URL=postgresql+asyncpg://dreamweaver:dreamweaver_password@localhost:52432/dreamweaver_db +# CELERY_BROKER_URL=redis://localhost:52379/0 diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..b6be84e --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,27 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +.venv/ +venv/ +ENV/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# 环境变量 +.env + +# 测试 +.pytest_cache/ +.coverage +htmlcov/ + +# 其他 +*.log +.DS_Store diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..23de17c --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.11-slim + +WORKDIR /app + +# 安装系统依赖 (如果需要) +# RUN apt-get update && apt-get install -y gcc libpq-dev && rm -rf /var/lib/apt/lists/* + +# 复制项目文件 +COPY pyproject.toml . +# 复制源码 +COPY app ./app +COPY alembic ./alembic +COPY alembic.ini . + +# 安装依赖 +# 使用 pip 安装当前目录 (.),会自动解析 pyproject.toml +RUN pip install --no-cache-dir . + +# 创建静态文件目录 (用于存放生成的图片) +RUN mkdir -p static/images + +# 暴露端口 +EXPOSE 8000 + +# 启动命令 +# 生产环境建议使用 gunicorn 或 uvicorn --workers +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..411135b --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = alembic +sqlalchemy.url = postgresql+asyncpg://user:password@localhost/db + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/backend/alembic/README.md b/backend/alembic/README.md new file mode 100644 index 0000000..7b3eb6e --- /dev/null +++ b/backend/alembic/README.md @@ -0,0 +1,20 @@ +# Alembic 使用说明 + +1. 安装依赖(在后端虚拟环境内) +``` +pip install alembic +``` + +2. 设置环境变量,确保 `DATABASE_URL` 指向目标数据库。 + +3. 运行迁移: +``` +alembic upgrade head +``` + +4. 生成新迁移(如有模型变更): +``` +alembic revision -m "message" --autogenerate +``` + +说明:`alembic/env.py` 会从 `app.core.config` 读取数据库 URL,并包含 admin/provider 模型。 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..8642c71 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,68 @@ +import asyncio +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context +from app.core.config import settings +from app.db import models, admin_models # ensure models are imported + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +fileConfig(config.config_file_name) + +# override sqlalchemy.url from settings +config.set_main_option("sqlalchemy.url", settings.database_url) + +target_metadata = models.Base.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode.""" + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection): + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online(): + """Run migrations in 'online' mode.""" + connectable = async_engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + connect_args={"statement_cache_size": 0}, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/backend/alembic/versions/0001_init_providers_and_story_mode.py b/backend/alembic/versions/0001_init_providers_and_story_mode.py new file mode 100644 index 0000000..391943f --- /dev/null +++ b/backend/alembic/versions/0001_init_providers_and_story_mode.py @@ -0,0 +1,45 @@ +"""init providers and story mode""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "0001_init_providers" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "providers", + sa.Column("id", sa.String(length=36), primary_key=True), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("type", sa.String(length=50), nullable=False), + sa.Column("adapter", sa.String(length=100), nullable=False), + sa.Column("model", sa.String(length=200), nullable=True), + sa.Column("api_base", sa.String(length=300), nullable=True), + sa.Column("timeout_ms", sa.Integer(), server_default="60000", nullable=False), + sa.Column("max_retries", sa.Integer(), server_default="1", nullable=False), + sa.Column("weight", sa.Integer(), server_default="1", nullable=False), + sa.Column("priority", sa.Integer(), server_default="0", nullable=False), + sa.Column("enabled", sa.Boolean(), server_default=sa.text("true"), nullable=False), + sa.Column("config_ref", sa.String(length=100), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_by", sa.String(length=100), nullable=True), + ) + + with op.batch_alter_table("stories", schema=None) as batch_op: + batch_op.add_column( + sa.Column("mode", sa.String(length=20), server_default="generated", nullable=False) + ) + batch_op.alter_column("mode", server_default=None) + + +def downgrade() -> None: + with op.batch_alter_table("stories", schema=None) as batch_op: + batch_op.drop_column("mode") + + op.drop_table("providers") diff --git a/backend/alembic/versions/0002_add_api_key_to_providers.py b/backend/alembic/versions/0002_add_api_key_to_providers.py new file mode 100644 index 0000000..17b04c3 --- /dev/null +++ b/backend/alembic/versions/0002_add_api_key_to_providers.py @@ -0,0 +1,29 @@ +"""add api_key to providers + +Revision ID: 0002_add_api_key_to_providers +Revises: 0001_init_providers_and_story_mode +Create Date: 2025-01-01 +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "0002_add_api_key" +down_revision = "0001_init_providers" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 添加 api_key 列,可为空,优先于 config_ref 使用 + with op.batch_alter_table("providers", schema=None) as batch_op: + batch_op.add_column( + sa.Column("api_key", sa.String(length=500), nullable=True) + ) + + +def downgrade() -> None: + with op.batch_alter_table("providers", schema=None) as batch_op: + batch_op.drop_column("api_key") diff --git a/backend/alembic/versions/0003_add_provider_monitoring_tables.py b/backend/alembic/versions/0003_add_provider_monitoring_tables.py new file mode 100644 index 0000000..3a2a091 --- /dev/null +++ b/backend/alembic/versions/0003_add_provider_monitoring_tables.py @@ -0,0 +1,100 @@ +"""add provider monitoring tables + +Revision ID: 0003_add_monitoring +Revises: 0002_add_api_key +Create Date: 2025-01-01 +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "0003_add_monitoring" +down_revision = "0002_add_api_key" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 创建 provider_metrics 表 + op.create_table( + "provider_metrics", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("provider_id", sa.String(length=36), nullable=False), + sa.Column( + "timestamp", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column("success", sa.Boolean(), nullable=False), + sa.Column("latency_ms", sa.Integer(), nullable=True), + sa.Column("cost_usd", sa.Numeric(precision=10, scale=6), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("request_id", sa.String(length=100), nullable=True), + sa.ForeignKeyConstraint( + ["provider_id"], + ["providers.id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_provider_metrics_provider_id", + "provider_metrics", + ["provider_id"], + unique=False, + ) + op.create_index( + "ix_provider_metrics_timestamp", + "provider_metrics", + ["timestamp"], + unique=False, + ) + + # 创建 provider_health 表 + op.create_table( + "provider_health", + sa.Column("provider_id", sa.String(length=36), nullable=False), + sa.Column("is_healthy", sa.Boolean(), server_default=sa.text("true"), nullable=True), + sa.Column("last_check", sa.DateTime(timezone=True), nullable=True), + sa.Column("consecutive_failures", sa.Integer(), server_default=sa.text("0"), nullable=True), + sa.Column("last_error", sa.Text(), nullable=True), + sa.ForeignKeyConstraint( + ["provider_id"], + ["providers.id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("provider_id"), + ) + + # 创建 provider_secrets 表 + op.create_table( + "provider_secrets", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("encrypted_value", sa.Text(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + + +def downgrade() -> None: + op.drop_table("provider_secrets") + op.drop_table("provider_health") + op.drop_index("ix_provider_metrics_timestamp", table_name="provider_metrics") + op.drop_index("ix_provider_metrics_provider_id", table_name="provider_metrics") + op.drop_table("provider_metrics") diff --git a/backend/alembic/versions/0004_add_child_profiles.py b/backend/alembic/versions/0004_add_child_profiles.py new file mode 100644 index 0000000..802baeb --- /dev/null +++ b/backend/alembic/versions/0004_add_child_profiles.py @@ -0,0 +1,42 @@ +"""add child profiles + +Revision ID: 0004_add_child_profiles +Revises: 0003_add_monitoring +Create Date: 2025-12-22 +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "0004_add_child_profiles" +down_revision = "0003_add_monitoring" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "child_profiles", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("user_id", sa.String(255), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("name", sa.String(50), nullable=False), + sa.Column("avatar_url", sa.String(500)), + sa.Column("birth_date", sa.Date()), + sa.Column("gender", sa.String(10)), + sa.Column("interests", sa.JSON(), server_default="[]", nullable=False), + sa.Column("growth_themes", sa.JSON(), server_default="[]", nullable=False), + sa.Column("reading_preferences", sa.JSON(), server_default="{}", nullable=False), + sa.Column("stories_count", sa.Integer(), server_default="0", nullable=False), + sa.Column("total_reading_time", sa.Integer(), server_default="0", nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.UniqueConstraint("user_id", "name", name="uq_child_profile_user_name"), + ) + op.create_index("idx_child_profiles_user_id", "child_profiles", ["user_id"]) + + +def downgrade(): + op.drop_index("idx_child_profiles_user_id", table_name="child_profiles") + op.drop_table("child_profiles") diff --git a/backend/alembic/versions/0005_add_story_universes_and_story_links.py b/backend/alembic/versions/0005_add_story_universes_and_story_links.py new file mode 100644 index 0000000..8902e95 --- /dev/null +++ b/backend/alembic/versions/0005_add_story_universes_and_story_links.py @@ -0,0 +1,67 @@ +"""add story universes and story links + +Revision ID: 0005_add_story_universes_and_story_links +Revises: 0004_add_child_profiles +Create Date: 2025-12-22 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "0005_add_story_universes_and_story_links" +down_revision = "0004_add_child_profiles" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "story_universes", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column( + "child_profile_id", + sa.String(36), + sa.ForeignKey("child_profiles.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("protagonist", sa.JSON(), nullable=False), + sa.Column("recurring_characters", sa.JSON(), server_default="[]", nullable=False), + sa.Column("world_settings", sa.JSON(), server_default="{}", nullable=False), + sa.Column("achievements", sa.JSON(), server_default="[]", nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index("idx_story_universes_child_id", "story_universes", ["child_profile_id"]) + op.create_index("idx_story_universes_updated_at", "story_universes", ["updated_at"]) + + op.add_column("stories", sa.Column("child_profile_id", sa.String(36), nullable=True)) + op.add_column("stories", sa.Column("universe_id", sa.String(36), nullable=True)) + op.create_foreign_key( + "fk_stories_child_profile", + "stories", + "child_profiles", + ["child_profile_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_foreign_key( + "fk_stories_universe", + "stories", + "story_universes", + ["universe_id"], + ["id"], + ondelete="SET NULL", + ) + + +def downgrade(): + op.drop_constraint("fk_stories_universe", "stories", type_="foreignkey") + op.drop_constraint("fk_stories_child_profile", "stories", type_="foreignkey") + op.drop_column("stories", "universe_id") + op.drop_column("stories", "child_profile_id") + + op.drop_index("idx_story_universes_updated_at", table_name="story_universes") + op.drop_index("idx_story_universes_child_id", table_name="story_universes") + op.drop_table("story_universes") diff --git a/backend/alembic/versions/0006_add_reading_events_and_memory_items.py b/backend/alembic/versions/0006_add_reading_events_and_memory_items.py new file mode 100644 index 0000000..c8a4ffc --- /dev/null +++ b/backend/alembic/versions/0006_add_reading_events_and_memory_items.py @@ -0,0 +1,78 @@ +"""add reading events and memory items + +Revision ID: 0006_add_reading_events_and_memory_items +Revises: 0005_add_story_universes_and_story_links +Create Date: 2025-12-22 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "0006_add_reading_events_and_memory_items" +down_revision = "0005_add_story_universes_and_story_links" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "reading_events", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column( + "child_profile_id", + sa.String(36), + sa.ForeignKey("child_profiles.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "story_id", + sa.Integer(), + sa.ForeignKey("stories.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("event_type", sa.String(20), nullable=False), + sa.Column("reading_time", sa.Integer(), server_default="0", nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index("idx_reading_events_profile", "reading_events", ["child_profile_id"]) + op.create_index("idx_reading_events_story", "reading_events", ["story_id"]) + op.create_index("idx_reading_events_created", "reading_events", ["created_at"]) + + op.create_table( + "memory_items", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column( + "child_profile_id", + sa.String(36), + sa.ForeignKey("child_profiles.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "universe_id", + sa.String(36), + sa.ForeignKey("story_universes.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("type", sa.String(50), nullable=False), + sa.Column("value", sa.JSON(), nullable=False), + sa.Column("base_weight", sa.Float(), server_default="1.0", nullable=False), + sa.Column("last_used_at", sa.DateTime(timezone=True)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("ttl_days", sa.Integer()), + ) + op.create_index("idx_memory_items_profile", "memory_items", ["child_profile_id"]) + op.create_index("idx_memory_items_universe", "memory_items", ["universe_id"]) + op.create_index("idx_memory_items_last_used", "memory_items", ["last_used_at"]) + + +def downgrade(): + op.drop_index("idx_memory_items_last_used", table_name="memory_items") + op.drop_index("idx_memory_items_universe", table_name="memory_items") + op.drop_index("idx_memory_items_profile", table_name="memory_items") + op.drop_table("memory_items") + + op.drop_index("idx_reading_events_created", table_name="reading_events") + op.drop_index("idx_reading_events_story", table_name="reading_events") + op.drop_index("idx_reading_events_profile", table_name="reading_events") + op.drop_table("reading_events") diff --git a/backend/alembic/versions/0007_add_push_configs_and_events.py b/backend/alembic/versions/0007_add_push_configs_and_events.py new file mode 100644 index 0000000..aa564f4 --- /dev/null +++ b/backend/alembic/versions/0007_add_push_configs_and_events.py @@ -0,0 +1,68 @@ +"""Add push configs and events. + +Revision ID: 0007_add_push_configs_and_events +Revises: 0006_add_reading_events_and_memory_items +Create Date: 2025-12-24 16:40:00.000000 +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "0007_add_push_configs_and_events" +down_revision = "0006_add_reading_events_and_memory_items" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "push_configs", + sa.Column("id", sa.String(length=36), primary_key=True), + sa.Column("user_id", sa.String(length=255), nullable=False), + sa.Column("child_profile_id", sa.String(length=36), nullable=False), + sa.Column("push_time", sa.Time(), nullable=True), + sa.Column("push_days", sa.JSON(), nullable=False, server_default="[]"), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + onupdate=sa.func.now(), + ), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["child_profile_id"], ["child_profiles.id"], ondelete="CASCADE"), + sa.UniqueConstraint("child_profile_id", name="uq_push_config_child"), + ) + op.create_index("ix_push_configs_user_id", "push_configs", ["user_id"]) + op.create_index("ix_push_configs_child_profile_id", "push_configs", ["child_profile_id"]) + + op.create_table( + "push_events", + sa.Column("id", sa.String(length=36), primary_key=True), + sa.Column("user_id", sa.String(length=255), nullable=False), + sa.Column("child_profile_id", sa.String(length=36), nullable=False), + sa.Column("trigger_type", sa.String(length=20), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("reason", sa.Text(), nullable=True), + sa.Column("sent_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["child_profile_id"], ["child_profiles.id"], ondelete="CASCADE"), + ) + op.create_index("ix_push_events_user_id", "push_events", ["user_id"]) + op.create_index("ix_push_events_child_profile_id", "push_events", ["child_profile_id"]) + op.create_index("ix_push_events_sent_at", "push_events", ["sent_at"]) + + +def downgrade() -> None: + op.drop_index("ix_push_events_sent_at", table_name="push_events") + op.drop_index("ix_push_events_child_profile_id", table_name="push_events") + op.drop_index("ix_push_events_user_id", table_name="push_events") + op.drop_table("push_events") + + op.drop_index("ix_push_configs_child_profile_id", table_name="push_configs") + op.drop_index("ix_push_configs_user_id", table_name="push_configs") + op.drop_table("push_configs") diff --git a/backend/alembic/versions/0008_add_pages_to_stories.py b/backend/alembic/versions/0008_add_pages_to_stories.py new file mode 100644 index 0000000..d706141 --- /dev/null +++ b/backend/alembic/versions/0008_add_pages_to_stories.py @@ -0,0 +1,25 @@ +"""add pages column to stories + +Revision ID: 0008_add_pages_to_stories +Revises: 0007_add_push_configs_and_events +Create Date: 2026-01-20 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision = '0008_add_pages_to_stories' +down_revision = '0007_add_push_configs_and_events' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column('stories', sa.Column('pages', postgresql.JSON(), nullable=True)) + + +def downgrade() -> None: + op.drop_column('stories', 'pages') diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/admin_main.py b/backend/app/admin_main.py new file mode 100644 index 0000000..51e9599 --- /dev/null +++ b/backend/app/admin_main.py @@ -0,0 +1,61 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api import admin_providers, admin_reload +from app.core.config import settings +from app.core.logging import get_logger, setup_logging +from app.db.database import init_db + +setup_logging() +logger = get_logger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Admin App lifespan manager.""" + logger.info("admin_app_starting") + await init_db() + + # 可以在这里加载特定的 Admin 缓存或预热 + + yield + logger.info("admin_app_shutdown") + + +app = FastAPI( + title=f"{settings.app_name} Admin Console", + description="Administrative Control Plane for DreamWeaver.", + version="0.1.0", + lifespan=lifespan, +) + +# Admin 后台通常允许更宽松的 CORS,或者特定的管理域名 +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, # 或者专门的 ADMIN_CORS_ORIGINS + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 根据配置开关挂载路由 +if settings.enable_admin_console: + app.include_router(admin_providers.router, prefix="/admin", tags=["admin-providers"]) + app.include_router(admin_reload.router, prefix="/admin", tags=["admin-reload"]) +else: + @app.get("/admin/{path:path}") + @app.post("/admin/{path:path}") + @app.put("/admin/{path:path}") + @app.delete("/admin/{path:path}") + async def admin_disabled(path: str): + from fastapi import HTTPException + raise HTTPException( + status_code=403, + detail="Admin console is disabled in environment configuration." + ) + +@app.get("/health") +async def health_check(): + return {"status": "ok", "service": "admin-backend"} diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/admin_providers.py b/backend/app/api/admin_providers.py new file mode 100644 index 0000000..d66e829 --- /dev/null +++ b/backend/app/api/admin_providers.py @@ -0,0 +1,307 @@ +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.admin_auth import admin_guard +from app.db.admin_models import Provider +from app.db.database import get_db +from app.services.cost_tracker import cost_tracker +from app.services.secret_service import SecretService + +router = APIRouter(dependencies=[Depends(admin_guard)]) + + +class ProviderCreate(BaseModel): + name: str + type: str = Field(..., pattern="^(text|image|tts|storybook)$") + adapter: str + model: str | None = None + api_base: str | None = None + api_key: str | None = None # 可选,优先于 config_ref + timeout_ms: int = 60000 + max_retries: int = 1 + weight: int = 1 + priority: int = 0 + enabled: bool = True + config_json: dict | None = None + config_ref: str | None = None # 环境变量 key 名称(回退) + updated_by: str | None = None + + +class ProviderUpdate(ProviderCreate): + enabled: bool | None = None + api_key: str | None = None + config_json: dict | None = None + + +class ProviderResponse(BaseModel): + """Provider 响应模型,隐藏敏感字段。""" + + id: str + name: str + type: str + adapter: str + model: str | None = None + api_base: str | None = None + has_api_key: bool = False # 仅标识是否配置了 api_key,不返回明文 + timeout_ms: int = 60000 + max_retries: int = 1 + weight: int = 1 + priority: int = 0 + enabled: bool = True + config_ref: str | None = None + + model_config = ConfigDict(from_attributes=True) + + +from app.services.adapters.registry import AdapterRegistry +from app.services.provider_router import DEFAULT_PROVIDERS + + +@router.get("/providers/adapters") +async def list_available_adapters(): + """获取所有可用的适配器类型 (定义的类)。""" + return AdapterRegistry.list_adapters() + + +@router.get("/providers/defaults") +async def get_env_defaults(): + """获取当前环境变量定义的默认策略 (Read-Only)。""" + return DEFAULT_PROVIDERS + + +@router.get("/providers", response_model=list[ProviderResponse]) +async def list_providers(db: AsyncSession = Depends(get_db)): + result = await db.execute(select(Provider)) + providers = result.scalars().all() + # 转换为响应模型,隐藏 api_key 明文 + return [ + ProviderResponse( + id=p.id, + name=p.name, + type=p.type, + adapter=p.adapter, + model=p.model, + api_base=p.api_base, + has_api_key=bool(p.api_key), # 仅标识是否有 key + timeout_ms=p.timeout_ms, + max_retries=p.max_retries, + weight=p.weight, + priority=p.priority, + enabled=p.enabled, + config_ref=p.config_ref, + ) + for p in providers + ] + + +def _to_response(provider: Provider) -> ProviderResponse: + """将 Provider 转换为响应模型,隐藏敏感字段。""" + return ProviderResponse( + id=provider.id, + name=provider.name, + type=provider.type, + adapter=provider.adapter, + model=provider.model, + api_base=provider.api_base, + has_api_key=bool(provider.api_key), + timeout_ms=provider.timeout_ms, + max_retries=provider.max_retries, + weight=provider.weight, + priority=provider.priority, + enabled=provider.enabled, + config_ref=provider.config_ref, + ) + + +@router.post("/providers", response_model=ProviderResponse) +async def create_provider(payload: ProviderCreate, db: AsyncSession = Depends(get_db)): + data = payload.model_dump() + # 加密 API Key + if data.get("api_key"): + data["api_key"] = SecretService.encrypt(data["api_key"]) + provider = Provider(**data) + db.add(provider) + await db.commit() + await db.refresh(provider) + return _to_response(provider) + + +@router.put("/providers/{provider_id}", response_model=ProviderResponse) +async def update_provider( + provider_id: str, payload: ProviderUpdate, db: AsyncSession = Depends(get_db) +): + result = await db.execute(select(Provider).where(Provider.id == provider_id)) + provider = result.scalar_one_or_none() + if not provider: + raise HTTPException(status_code=404, detail="Provider not found") + + data = payload.model_dump(exclude_unset=True) + # 加密 API Key + if "api_key" in data and data["api_key"]: + data["api_key"] = SecretService.encrypt(data["api_key"]) + for k, v in data.items(): + setattr(provider, k, v) + await db.commit() + await db.refresh(provider) + return _to_response(provider) + + +@router.delete("/providers/{provider_id}") +async def delete_provider(provider_id: str, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(Provider).where(Provider.id == provider_id)) + provider = result.scalar_one_or_none() + if not provider: + raise HTTPException(status_code=404, detail="Provider not found") + await db.delete(provider) + await db.commit() + return {"message": "deleted"} + + +# ==================== 密钥管理 API ==================== + + +class SecretCreate(BaseModel): + """密钥创建请求。""" + + name: str = Field(..., description="密钥名称,如 CQTAI_API_KEY") + value: str = Field(..., description="密钥明文值") + + +class SecretResponse(BaseModel): + """密钥响应,不返回明文。""" + + name: str + created_at: str | None = None + updated_at: str | None = None + + +@router.get("/secrets", response_model=list[str]) +async def list_secrets(db: AsyncSession = Depends(get_db)): + """列出所有密钥名称(不返回值)。""" + return await SecretService.list_secrets(db) + + +@router.post("/secrets", response_model=SecretResponse) +async def create_or_update_secret(payload: SecretCreate, db: AsyncSession = Depends(get_db)): + """创建或更新密钥。""" + secret = await SecretService.set_secret(db, payload.name, payload.value) + return SecretResponse( + name=secret.name, + created_at=secret.created_at.isoformat() if secret.created_at else None, + updated_at=secret.updated_at.isoformat() if secret.updated_at else None, + ) + + +@router.delete("/secrets/{name}") +async def delete_secret(name: str, db: AsyncSession = Depends(get_db)): + """删除密钥。""" + deleted = await SecretService.delete_secret(db, name) + if not deleted: + raise HTTPException(status_code=404, detail="Secret not found") + return {"message": "deleted"} + + +@router.get("/secrets/{name}/verify") +async def verify_secret(name: str, db: AsyncSession = Depends(get_db)): + """验证密钥是否存在且可解密(不返回明文)。""" + value = await SecretService.get_secret(db, name) + if value is None: + raise HTTPException(status_code=404, detail="Secret not found") + return {"name": name, "valid": True, "length": len(value)} + + +# ==================== 成本追踪 API ==================== + + +class BudgetUpdate(BaseModel): + """预算更新请求。""" + + daily_limit_usd: float | None = None + monthly_limit_usd: float | None = None + alert_threshold: float | None = Field(default=None, ge=0, le=1) + enabled: bool | None = None + + +@router.get("/costs/summary/{user_id}") +async def get_user_cost_summary(user_id: str, db: AsyncSession = Depends(get_db)): + """获取用户成本摘要。""" + return await cost_tracker.get_cost_summary(db, user_id) + + +@router.get("/costs/all") +async def get_all_costs_summary(db: AsyncSession = Depends(get_db)): + """获取所有用户成本汇总(管理员)。""" + from sqlalchemy import func + + from app.db.admin_models import CostRecord + + # 按用户汇总 + result = await db.execute( + select( + CostRecord.user_id, + func.sum(CostRecord.estimated_cost).label("total_cost"), + func.count().label("call_count"), + ).group_by(CostRecord.user_id) + ) + users = [ + {"user_id": row[0], "total_cost_usd": float(row[1]), "call_count": row[2]} + for row in result.all() + ] + + # 按能力汇总 + result = await db.execute( + select( + CostRecord.capability, + func.sum(CostRecord.estimated_cost).label("total_cost"), + func.count().label("call_count"), + ).group_by(CostRecord.capability) + ) + capabilities = [ + {"capability": row[0], "total_cost_usd": float(row[1]), "call_count": row[2]} + for row in result.all() + ] + + return {"by_user": users, "by_capability": capabilities} + + +@router.get("/budgets/{user_id}") +async def get_user_budget(user_id: str, db: AsyncSession = Depends(get_db)): + """获取用户预算配置。""" + budget = await cost_tracker.get_user_budget(db, user_id) + if not budget: + return {"user_id": user_id, "budget": None} + return { + "user_id": user_id, + "budget": { + "daily_limit_usd": float(budget.daily_limit_usd), + "monthly_limit_usd": float(budget.monthly_limit_usd), + "alert_threshold": float(budget.alert_threshold), + "enabled": budget.enabled, + }, + } + + +@router.post("/budgets/{user_id}") +async def set_user_budget( + user_id: str, payload: BudgetUpdate, db: AsyncSession = Depends(get_db) +): + """设置用户预算。""" + budget = await cost_tracker.set_user_budget( + db, + user_id, + daily_limit=payload.daily_limit_usd, + monthly_limit=payload.monthly_limit_usd, + alert_threshold=payload.alert_threshold, + enabled=payload.enabled, + ) + return { + "user_id": user_id, + "budget": { + "daily_limit_usd": float(budget.daily_limit_usd), + "monthly_limit_usd": float(budget.monthly_limit_usd), + "alert_threshold": float(budget.alert_threshold), + "enabled": budget.enabled, + }, + } diff --git a/backend/app/api/admin_reload.py b/backend/app/api/admin_reload.py new file mode 100644 index 0000000..8da4018 --- /dev/null +++ b/backend/app/api/admin_reload.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.admin_auth import admin_guard +from app.db.database import get_db +from app.services.provider_cache import reload_providers + +router = APIRouter(dependencies=[Depends(admin_guard)]) + + +@router.post("/providers/reload") +async def reload(db: AsyncSession = Depends(get_db)): + cache = await reload_providers(db) + return {k: len(v) for k, v in cache.items()} diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..c84d882 --- /dev/null +++ b/backend/app/api/auth.py @@ -0,0 +1,272 @@ +import secrets +from urllib.parse import urlencode + +import httpx +from fastapi import APIRouter, Cookie, Depends, HTTPException, Query +from fastapi.responses import RedirectResponse +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.deps import get_current_user +from app.core.security import create_access_token +from app.db.database import get_db +from app.db.models import User + +router = APIRouter() + +# OAuth endpoints +GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize" +GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token" +GITHUB_USER_URL = "https://api.github.com/user" + +GOOGLE_AUTHORIZE_URL = "https://accounts.google.com/o/oauth2/v2/auth" +GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" +GOOGLE_USER_URL = "https://www.googleapis.com/oauth2/v2/userinfo" + +STATE_COOKIE = "oauth_state" +STATE_MAX_AGE = 600 # 10 minutes + + +def _set_state_cookie(response: RedirectResponse, provider: str, state: str) -> None: + response.set_cookie( + key=STATE_COOKIE, + value=f"{provider}:{state}", + httponly=True, + secure=not settings.debug, + samesite="lax", + max_age=STATE_MAX_AGE, + ) + + +def _validate_state(state_from_query: str | None, state_cookie: str | None, provider: str): + if not state_from_query or not state_cookie: + raise HTTPException(status_code=400, detail="Missing OAuth state") + expected_prefix = f"{provider}:" + if not state_cookie.startswith(expected_prefix): + raise HTTPException(status_code=400, detail="OAuth state mismatch") + expected_state = state_cookie.removeprefix(expected_prefix) + if not secrets.compare_digest(state_from_query, expected_state): + raise HTTPException(status_code=400, detail="OAuth state mismatch") + + +@router.get("/github/signin") +async def github_signin(): + """Start GitHub OAuth with state protection.""" + state = secrets.token_urlsafe(16) + params = { + "client_id": settings.github_client_id, + "redirect_uri": f"{settings.base_url}/auth/github/callback", + "scope": "read:user user:email", + "state": state, + } + url = f"{GITHUB_AUTHORIZE_URL}?{urlencode(params)}" + response = RedirectResponse(url=url) + _set_state_cookie(response, "github", state) + return response + + +@router.get("/github/callback") +async def github_callback( + code: str, + state: str | None = Query(default=None), + state_cookie: str | None = Cookie(default=None, alias=STATE_COOKIE), + db: AsyncSession = Depends(get_db), +): + """Handle GitHub OAuth callback.""" + _validate_state(state, state_cookie, "github") + + try: + async with httpx.AsyncClient() as client: + token_resp = await client.post( + GITHUB_TOKEN_URL, + data={ + "client_id": settings.github_client_id, + "client_secret": settings.github_client_secret, + "code": code, + }, + headers={"Accept": "application/json"}, + ) + token_resp.raise_for_status() + token_data = token_resp.json() + access_token = token_data.get("access_token") + if not access_token: + raise HTTPException(status_code=502, detail="GitHub login failed") + + user_resp = await client.get( + GITHUB_USER_URL, + headers={"Authorization": f"Bearer {access_token}"}, + ) + user_resp.raise_for_status() + user_data = user_resp.json() + except httpx.HTTPStatusError: + raise HTTPException(status_code=502, detail="GitHub login failed") + + github_id = user_data.get("id") + if github_id is None: + raise HTTPException(status_code=502, detail="GitHub login failed") + + return await _handle_oauth_user( + db=db, + provider="github", + user_id=str(github_id), + name=user_data.get("name") or user_data.get("login") or "GitHub User", + avatar_url=user_data.get("avatar_url"), + ) + + +@router.get("/google/signin") +async def google_signin(): + """Start Google OAuth with state protection.""" + state = secrets.token_urlsafe(16) + params = { + "client_id": settings.google_client_id, + "redirect_uri": f"{settings.base_url}/auth/google/callback", + "response_type": "code", + "scope": "openid email profile", + "state": state, + } + url = f"{GOOGLE_AUTHORIZE_URL}?{urlencode(params)}" + response = RedirectResponse(url=url) + _set_state_cookie(response, "google", state) + return response + + +@router.get("/google/callback") +async def google_callback( + code: str, + state: str | None = Query(default=None), + state_cookie: str | None = Cookie(default=None, alias=STATE_COOKIE), + db: AsyncSession = Depends(get_db), +): + """Handle Google OAuth callback.""" + _validate_state(state, state_cookie, "google") + + try: + async with httpx.AsyncClient() as client: + token_resp = await client.post( + GOOGLE_TOKEN_URL, + data={ + "client_id": settings.google_client_id, + "client_secret": settings.google_client_secret, + "code": code, + "grant_type": "authorization_code", + "redirect_uri": f"{settings.base_url}/auth/google/callback", + }, + ) + token_resp.raise_for_status() + token_data = token_resp.json() + access_token = token_data.get("access_token") + if not access_token: + raise HTTPException(status_code=502, detail="Google login failed") + + user_resp = await client.get( + GOOGLE_USER_URL, + headers={"Authorization": f"Bearer {access_token}"}, + ) + user_resp.raise_for_status() + user_data = user_resp.json() + except httpx.HTTPStatusError: + raise HTTPException(status_code=502, detail="Google login failed") + + google_id = user_data.get("id") + if google_id is None: + raise HTTPException(status_code=502, detail="Google login failed") + + return await _handle_oauth_user( + db=db, + provider="google", + user_id=str(google_id), + name=user_data.get("name") or user_data.get("email") or "Google User", + avatar_url=user_data.get("picture"), + ) + + +async def _handle_oauth_user( + db: AsyncSession, + provider: str, + user_id: str, + name: str, + avatar_url: str | None, +) -> RedirectResponse: + """Create/update user and issue session cookie.""" + full_id = f"{provider}:{user_id}" + + result = await db.execute(select(User).where(User.id == full_id)) + user = result.scalar_one_or_none() + + if not user: + user = User( + id=full_id, + name=name, + avatar_url=avatar_url, + provider=provider, + ) + db.add(user) + else: + user.name = name + user.avatar_url = avatar_url + + await db.commit() + + token = create_access_token({"sub": user.id}) + + frontend_url = "http://localhost:5173" + if settings.cors_origins and len(settings.cors_origins) > 0: + frontend_url = settings.cors_origins[0] + + response = RedirectResponse(url=f"{frontend_url}/my-stories", status_code=302) + response.set_cookie( + key="access_token", + value=token, + httponly=True, + secure=not settings.debug, + samesite="lax", + max_age=60 * 60 * 24 * 7, # align with ACCESS_TOKEN_EXPIRE_DAYS + ) + response.delete_cookie(STATE_COOKIE) + return response + + +@router.post("/signout") +async def signout(): + """Sign out and clear cookies.""" + response = RedirectResponse(url=settings.cors_origins[0], status_code=302) + response.delete_cookie("access_token", samesite="lax", secure=not settings.debug) + response.delete_cookie(STATE_COOKIE, samesite="lax", secure=not settings.debug) + return response + + +@router.get("/session") +async def get_session(user: User | None = Depends(get_current_user)): + """Fetch current session info.""" + if not user: + return {"user": None} + return { + "user": { + "id": user.id, + "name": user.name, + "avatar_url": user.avatar_url, + "provider": user.provider, + } + } + + +@router.get("/dev/signin") +async def dev_signin(db: AsyncSession = Depends(get_db)): + """Developer backdoor login. Only works in DEBUG mode.""" + # if not settings.debug: + # raise HTTPException(status_code=403, detail="Developer login disabled") + + try: + return await _handle_oauth_user( + db=db, + provider="github", + user_id="dev_user_001", + name="Developer", + avatar_url="https://api.dicebear.com/7.x/avataaars/svg?seed=Developer" + ) + except Exception as e: + import traceback + traceback.print_exc() + raise HTTPException(status_code=500, detail=f"Dev login failed: {str(e)}") diff --git a/backend/app/api/memories.py b/backend/app/api/memories.py new file mode 100644 index 0000000..67e4717 --- /dev/null +++ b/backend/app/api/memories.py @@ -0,0 +1,268 @@ +"""Memory management APIs.""" + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import require_user +from app.db.database import get_db +from app.db.models import ChildProfile, User +from app.services import memory_service +from app.services.memory_service import MemoryType + +router = APIRouter() + + +class MemoryItemResponse(BaseModel): + """Memory item response.""" + + id: str + type: str + value: dict + base_weight: float + ttl_days: int | None + created_at: str + last_used_at: str | None + + class Config: + from_attributes = True + + +class MemoryListResponse(BaseModel): + """Memory list response.""" + + memories: list[MemoryItemResponse] + total: int + + +class CreateMemoryRequest(BaseModel): + """Create memory request.""" + + type: str = Field(..., description="记忆类型") + value: dict = Field(..., description="记忆内容") + universe_id: str | None = Field(default=None, description="关联的故事宇宙 ID") + weight: float | None = Field(default=None, description="权重") + ttl_days: int | None = Field(default=None, description="过期天数") + + +class CreateCharacterMemoryRequest(BaseModel): + """Create character memory request.""" + + name: str = Field(..., description="角色名称") + description: str | None = Field(default=None, description="角色描述") + source_story_id: int | None = Field(default=None, description="来源故事 ID") + affinity_score: float = Field(default=1.0, ge=0.0, le=1.0, description="喜爱程度") + universe_id: str | None = Field(default=None, description="关联的故事宇宙 ID") + + +class CreateScaryElementRequest(BaseModel): + """Create scary element memory request.""" + + keyword: str = Field(..., description="回避的关键词") + category: str = Field(default="other", description="分类") + source_story_id: int | None = Field(default=None, description="来源故事 ID") + + +async def _verify_profile_ownership( + profile_id: str, user: User, db: AsyncSession +) -> ChildProfile: + """验证档案所有权。""" + from sqlalchemy import select + + result = await db.execute( + select(ChildProfile).where( + ChildProfile.id == profile_id, + ChildProfile.user_id == user.id, + ) + ) + profile = result.scalar_one_or_none() + if not profile: + raise HTTPException(status_code=404, detail="档案不存在") + return profile + + +@router.get("/profiles/{profile_id}/memories", response_model=MemoryListResponse) +async def list_memories( + profile_id: str, + memory_type: str | None = None, + universe_id: str | None = None, + limit: int = 50, + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """获取档案的记忆列表。""" + await _verify_profile_ownership(profile_id, user, db) + + memories = await memory_service.get_profile_memories( + db=db, + profile_id=profile_id, + memory_type=memory_type, + universe_id=universe_id, + limit=limit, + ) + + return MemoryListResponse( + memories=[ + MemoryItemResponse( + id=m.id, + type=m.type, + value=m.value, + base_weight=m.base_weight, + ttl_days=m.ttl_days, + created_at=m.created_at.isoformat() if m.created_at else "", + last_used_at=m.last_used_at.isoformat() if m.last_used_at else None, + ) + for m in memories + ], + total=len(memories), + ) + + +@router.post("/profiles/{profile_id}/memories", response_model=MemoryItemResponse) +async def create_memory( + profile_id: str, + payload: CreateMemoryRequest, + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """创建新的记忆项。""" + await _verify_profile_ownership(profile_id, user, db) + + # 验证类型 + valid_types = [ + MemoryType.RECENT_STORY, + MemoryType.FAVORITE_CHARACTER, + MemoryType.SCARY_ELEMENT, + MemoryType.VOCABULARY_GROWTH, + MemoryType.EMOTIONAL_HIGHLIGHT, + MemoryType.READING_PREFERENCE, + MemoryType.MILESTONE, + MemoryType.SKILL_MASTERED, + ] + if payload.type not in valid_types: + raise HTTPException(status_code=400, detail=f"无效的记忆类型: {payload.type}") + + memory = await memory_service.create_memory( + db=db, + profile_id=profile_id, + memory_type=payload.type, + value=payload.value, + universe_id=payload.universe_id, + weight=payload.weight, + ttl_days=payload.ttl_days, + ) + + return MemoryItemResponse( + id=memory.id, + type=memory.type, + value=memory.value, + base_weight=memory.base_weight, + ttl_days=memory.ttl_days, + created_at=memory.created_at.isoformat() if memory.created_at else "", + last_used_at=memory.last_used_at.isoformat() if memory.last_used_at else None, + ) + + +@router.post("/profiles/{profile_id}/memories/character", response_model=MemoryItemResponse) +async def create_character_memory( + profile_id: str, + payload: CreateCharacterMemoryRequest, + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """添加喜欢的角色。""" + await _verify_profile_ownership(profile_id, user, db) + + memory = await memory_service.create_character_memory( + db=db, + profile_id=profile_id, + name=payload.name, + description=payload.description, + source_story_id=payload.source_story_id, + affinity_score=payload.affinity_score, + universe_id=payload.universe_id, + ) + + return MemoryItemResponse( + id=memory.id, + type=memory.type, + value=memory.value, + base_weight=memory.base_weight, + ttl_days=memory.ttl_days, + created_at=memory.created_at.isoformat() if memory.created_at else "", + last_used_at=memory.last_used_at.isoformat() if memory.last_used_at else None, + ) + + +@router.post("/profiles/{profile_id}/memories/scary", response_model=MemoryItemResponse) +async def create_scary_element_memory( + profile_id: str, + payload: CreateScaryElementRequest, + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """添加回避元素。""" + await _verify_profile_ownership(profile_id, user, db) + + memory = await memory_service.create_scary_element_memory( + db=db, + profile_id=profile_id, + keyword=payload.keyword, + category=payload.category, + source_story_id=payload.source_story_id, + ) + + return MemoryItemResponse( + id=memory.id, + type=memory.type, + value=memory.value, + base_weight=memory.base_weight, + ttl_days=memory.ttl_days, + created_at=memory.created_at.isoformat() if memory.created_at else "", + last_used_at=memory.last_used_at.isoformat() if memory.last_used_at else None, + ) + + +@router.delete("/profiles/{profile_id}/memories/{memory_id}") +async def delete_memory( + profile_id: str, + memory_id: str, + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """删除记忆项。""" + from sqlalchemy import select + + from app.db.models import MemoryItem + + await _verify_profile_ownership(profile_id, user, db) + + result = await db.execute( + select(MemoryItem).where( + MemoryItem.id == memory_id, + MemoryItem.child_profile_id == profile_id, + ) + ) + memory = result.scalar_one_or_none() + + if not memory: + raise HTTPException(status_code=404, detail="记忆不存在") + + await db.delete(memory) + await db.commit() + + return {"message": "Deleted"} + + +@router.get("/memory-types") +async def list_memory_types(): + """获取所有可用的记忆类型及其配置。""" + types = [] + for type_name, config in MemoryType.CONFIG.items(): + types.append({ + "type": type_name, + "default_weight": config[0], + "default_ttl_days": config[1], + "description": config[2], + }) + return {"types": types} diff --git a/backend/app/api/profiles.py b/backend/app/api/profiles.py new file mode 100644 index 0000000..7ccd25d --- /dev/null +++ b/backend/app/api/profiles.py @@ -0,0 +1,280 @@ +"""Child profile APIs.""" + +from datetime import date +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import require_user +from app.db.database import get_db +from app.db.models import ChildProfile, Story, StoryUniverse, User + +router = APIRouter() + +MAX_PROFILES_PER_USER = 5 + + +class ChildProfileCreate(BaseModel): + """Create profile payload.""" + + name: str = Field(..., min_length=1, max_length=50) + birth_date: date | None = None + gender: str | None = Field(default=None, pattern="^(male|female|other)$") + interests: list[str] = Field(default_factory=list) + growth_themes: list[str] = Field(default_factory=list) + avatar_url: str | None = None + + +class ChildProfileUpdate(BaseModel): + """Update profile payload.""" + + name: str | None = Field(default=None, min_length=1, max_length=50) + birth_date: date | None = None + gender: str | None = Field(default=None, pattern="^(male|female|other)$") + interests: list[str] | None = None + growth_themes: list[str] | None = None + avatar_url: str | None = None + + +class ChildProfileResponse(BaseModel): + """Profile response.""" + + id: str + name: str + avatar_url: str | None + birth_date: date | None + gender: str | None + age: int | None + interests: list[str] + growth_themes: list[str] + stories_count: int + total_reading_time: int + + class Config: + from_attributes = True + + +class ChildProfileListResponse(BaseModel): + """Profile list response.""" + + profiles: list[ChildProfileResponse] + total: int + + +class TimelineEvent(BaseModel): + """Timeline event item.""" + + date: str + type: Literal["story", "achievement", "milestone"] + title: str + description: str | None = None + image_url: str | None = None + metadata: dict | None = None + + +class TimelineResponse(BaseModel): + """Timeline response.""" + + events: list[TimelineEvent] + + +@router.get("/profiles", response_model=ChildProfileListResponse) +async def list_profiles( + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """List child profiles for current user.""" + result = await db.execute( + select(ChildProfile) + .where(ChildProfile.user_id == user.id) + .order_by(ChildProfile.created_at.desc()) + ) + profiles = result.scalars().all() + + return ChildProfileListResponse(profiles=profiles, total=len(profiles)) + + +@router.post("/profiles", response_model=ChildProfileResponse, status_code=status.HTTP_201_CREATED) +async def create_profile( + payload: ChildProfileCreate, + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """Create a new child profile.""" + count = await db.scalar( + select(func.count(ChildProfile.id)).where(ChildProfile.user_id == user.id) + ) + if count and count >= MAX_PROFILES_PER_USER: + raise HTTPException(status_code=400, detail="最多只能创建 5 个孩子档案") + + existing = await db.scalar( + select(ChildProfile.id).where( + ChildProfile.user_id == user.id, + ChildProfile.name == payload.name, + ) + ) + if existing: + raise HTTPException(status_code=409, detail="该档案名称已存在") + + profile = ChildProfile(user_id=user.id, **payload.model_dump()) + db.add(profile) + await db.commit() + await db.refresh(profile) + + return profile + + +@router.get("/profiles/{profile_id}", response_model=ChildProfileResponse) +async def get_profile( + profile_id: str, + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """Get one child profile.""" + result = await db.execute( + select(ChildProfile).where( + ChildProfile.id == profile_id, + ChildProfile.user_id == user.id, + ) + ) + profile = result.scalar_one_or_none() + + if not profile: + raise HTTPException(status_code=404, detail="档案不存在") + + return profile + + +@router.put("/profiles/{profile_id}", response_model=ChildProfileResponse) +async def update_profile( + profile_id: str, + payload: ChildProfileUpdate, + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """Update a child profile.""" + result = await db.execute( + select(ChildProfile).where( + ChildProfile.id == profile_id, + ChildProfile.user_id == user.id, + ) + ) + profile = result.scalar_one_or_none() + + if not profile: + raise HTTPException(status_code=404, detail="档案不存在") + + updates = payload.model_dump(exclude_unset=True) + if "name" in updates: + existing = await db.scalar( + select(ChildProfile.id).where( + ChildProfile.user_id == user.id, + ChildProfile.name == updates["name"], + ChildProfile.id != profile_id, + ) + ) + if existing: + raise HTTPException(status_code=409, detail="该档案名称已存在") + + for key, value in updates.items(): + setattr(profile, key, value) + + await db.commit() + await db.refresh(profile) + + return profile + + +@router.delete("/profiles/{profile_id}") +async def delete_profile( + profile_id: str, + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """Delete a child profile.""" + result = await db.execute( + select(ChildProfile).where( + ChildProfile.id == profile_id, + ChildProfile.user_id == user.id, + ) + ) + profile = result.scalar_one_or_none() + + if not profile: + raise HTTPException(status_code=404, detail="档案不存在") + + await db.delete(profile) + await db.commit() + + return {"message": "Deleted"} + + +@router.get("/profiles/{profile_id}/timeline", response_model=TimelineResponse) +async def get_profile_timeline( + profile_id: str, + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """Get profile growth timeline.""" + result = await db.execute( + select(ChildProfile).where( + ChildProfile.id == profile_id, + ChildProfile.user_id == user.id, + ) + ) + profile = result.scalar_one_or_none() + + if not profile: + raise HTTPException(status_code=404, detail="档案不存在") + + events: list[TimelineEvent] = [] + + # 1. Milestone: Profile Created + events.append(TimelineEvent( + date=profile.created_at.isoformat(), + type="milestone", + title="初次相遇", + description=f"创建了档案 {profile.name}" + )) + + # 2. Stories + stories_result = await db.execute( + select(Story).where(Story.child_profile_id == profile_id) + ) + for s in stories_result.scalars(): + events.append(TimelineEvent( + date=s.created_at.isoformat(), + type="story", + title=s.title, + image_url=s.image_url, + metadata={"story_id": s.id, "mode": s.mode} + )) + + # 3. Achievements (from Universe) + universes_result = await db.execute( + select(StoryUniverse).where(StoryUniverse.child_profile_id == profile_id) + ) + for u in universes_result.scalars(): + if u.achievements: + for ach in u.achievements: + if isinstance(ach, dict): + obt_at = ach.get("obtained_at") + # Fallback + if not obt_at: + obt_at = u.updated_at.isoformat() + + events.append(TimelineEvent( + date=obt_at, + type="achievement", + title=f"获得成就:{ach.get('type')}", + description=ach.get('description'), + metadata={"universe_id": u.id, "source_story_id": ach.get("source_story_id")} + )) + + # Sort by date desc + events.sort(key=lambda x: x.date, reverse=True) + + return TimelineResponse(events=events) diff --git a/backend/app/api/push_configs.py b/backend/app/api/push_configs.py new file mode 100644 index 0000000..a852203 --- /dev/null +++ b/backend/app/api/push_configs.py @@ -0,0 +1,120 @@ +"""Push configuration APIs.""" + +from datetime import time + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import require_user +from app.db.database import get_db +from app.db.models import ChildProfile, PushConfig, User + +router = APIRouter() + + +class PushConfigUpsert(BaseModel): + """Upsert push config payload.""" + + child_profile_id: str + push_time: time | None = None + push_days: list[int] | None = None + enabled: bool | None = None + + +class PushConfigResponse(BaseModel): + """Push config response.""" + + id: str + child_profile_id: str + push_time: time | None + push_days: list[int] + enabled: bool + + class Config: + from_attributes = True + + +class PushConfigListResponse(BaseModel): + """Push config list response.""" + + configs: list[PushConfigResponse] + total: int + + +def _validate_push_days(push_days: list[int]) -> list[int]: + invalid = [day for day in push_days if day < 0 or day > 6] + if invalid: + raise HTTPException(status_code=400, detail="推送日期必须在 0-6 之间") + return list(dict.fromkeys(push_days)) + + +@router.get("/push-configs", response_model=PushConfigListResponse) +async def list_push_configs( + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """List push configs for current user.""" + result = await db.execute( + select(PushConfig).where(PushConfig.user_id == user.id) + ) + configs = result.scalars().all() + return PushConfigListResponse(configs=configs, total=len(configs)) + + +@router.put("/push-configs", response_model=PushConfigResponse) +async def upsert_push_config( + payload: PushConfigUpsert, + response: Response, + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """Create or update push config for a child profile.""" + result = await db.execute( + select(ChildProfile).where( + ChildProfile.id == payload.child_profile_id, + ChildProfile.user_id == user.id, + ) + ) + profile = result.scalar_one_or_none() + if not profile: + raise HTTPException(status_code=404, detail="孩子档案不存在") + + result = await db.execute( + select(PushConfig).where(PushConfig.child_profile_id == payload.child_profile_id) + ) + config = result.scalar_one_or_none() + + if config is None: + if payload.push_time is None or payload.push_days is None: + raise HTTPException(status_code=400, detail="创建配置需要提供推送时间和日期") + push_days = _validate_push_days(payload.push_days) + config = PushConfig( + user_id=user.id, + child_profile_id=payload.child_profile_id, + push_time=payload.push_time, + push_days=push_days, + enabled=True if payload.enabled is None else payload.enabled, + ) + db.add(config) + await db.commit() + await db.refresh(config) + response.status_code = status.HTTP_201_CREATED + return config + + updates = payload.model_dump(exclude_unset=True) + if "push_days" in updates and updates["push_days"] is not None: + updates["push_days"] = _validate_push_days(updates["push_days"]) + if "push_time" in updates and updates["push_time"] is None: + raise HTTPException(status_code=400, detail="推送时间不能为空") + + for key, value in updates.items(): + if key == "child_profile_id": + continue + if value is not None: + setattr(config, key, value) + + await db.commit() + await db.refresh(config) + return config diff --git a/backend/app/api/reading_events.py b/backend/app/api/reading_events.py new file mode 100644 index 0000000..dd4a4af --- /dev/null +++ b/backend/app/api/reading_events.py @@ -0,0 +1,120 @@ +"""Reading event APIs.""" + +from datetime import datetime, timezone +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import require_user +from app.db.database import get_db +from app.db.models import ChildProfile, MemoryItem, ReadingEvent, Story, User + +router = APIRouter() + +EVENT_WEIGHTS: dict[str, float] = { + "completed": 1.0, + "replayed": 1.5, + "started": 0.1, + "skipped": -0.5, +} + + +class ReadingEventCreate(BaseModel): + """Reading event payload.""" + + child_profile_id: str + story_id: int | None = None + event_type: Literal["started", "completed", "skipped", "replayed"] + reading_time: int = Field(default=0, ge=0) + + +class ReadingEventResponse(BaseModel): + """Reading event response.""" + + id: int + child_profile_id: str + story_id: int | None + event_type: str + reading_time: int + created_at: datetime + + class Config: + from_attributes = True + + +@router.post("/reading-events", response_model=ReadingEventResponse, status_code=status.HTTP_201_CREATED) +async def create_reading_event( + payload: ReadingEventCreate, + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """Create a reading event and update profile stats/memory.""" + result = await db.execute( + select(ChildProfile).where( + ChildProfile.id == payload.child_profile_id, + ChildProfile.user_id == user.id, + ) + ) + profile = result.scalar_one_or_none() + if not profile: + raise HTTPException(status_code=404, detail="孩子档案不存在") + + story = None + if payload.story_id is not None: + result = await db.execute( + select(Story).where( + Story.id == payload.story_id, + Story.user_id == user.id, + ) + ) + story = result.scalar_one_or_none() + if not story: + raise HTTPException(status_code=404, detail="故事不存在") + + if payload.reading_time: + profile.total_reading_time = (profile.total_reading_time or 0) + payload.reading_time + + if payload.event_type in {"completed", "replayed"} and payload.story_id is not None: + existing = await db.scalar( + select(ReadingEvent.id).where( + ReadingEvent.child_profile_id == payload.child_profile_id, + ReadingEvent.story_id == payload.story_id, + ReadingEvent.event_type.in_(["completed", "replayed"]), + ) + ) + if existing is None: + profile.stories_count = (profile.stories_count or 0) + 1 + + event = ReadingEvent( + child_profile_id=payload.child_profile_id, + story_id=payload.story_id, + event_type=payload.event_type, + reading_time=payload.reading_time, + ) + db.add(event) + + weight = EVENT_WEIGHTS.get(payload.event_type, 0.0) + if story and weight > 0: + db.add( + MemoryItem( + child_profile_id=payload.child_profile_id, + universe_id=story.universe_id, + type="recent_story", + value={ + "story_id": story.id, + "title": story.title, + "event_type": payload.event_type, + }, + base_weight=weight, + last_used_at=datetime.now(timezone.utc), + ttl_days=90, + ) + ) + + await db.commit() + await db.refresh(event) + + return event diff --git a/backend/app/api/stories.py b/backend/app/api/stories.py new file mode 100644 index 0000000..d9fbe49 --- /dev/null +++ b/backend/app/api/stories.py @@ -0,0 +1,605 @@ +"""Story related APIs.""" + +import asyncio +import json +import time +import uuid +from typing import AsyncGenerator, Literal + +from cachetools import TTLCache +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import Response +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import joinedload +from sse_starlette.sse import EventSourceResponse + +from app.core.deps import require_user +from app.core.logging import get_logger +from app.db.database import get_db +from app.db.models import ChildProfile, Story, StoryUniverse, User +from app.services.provider_router import ( + generate_image, + generate_story_content, + generate_storybook, + text_to_speech, +) +from app.tasks.achievements import extract_story_achievements + +logger = get_logger(__name__) + +router = APIRouter() + +MAX_DATA_LENGTH = 2000 +MAX_EDU_THEME_LENGTH = 200 +MAX_TTS_LENGTH = 4000 + +RATE_LIMIT_WINDOW = 60 # seconds +RATE_LIMIT_REQUESTS = 10 +RATE_LIMIT_CACHE_SIZE = 10000 # 最大跟踪用户数 + +_request_log: TTLCache[str, list[float]] = TTLCache( + maxsize=RATE_LIMIT_CACHE_SIZE, ttl=RATE_LIMIT_WINDOW * 2 +) + + +def _check_rate_limit(user_id: str): + now = time.time() + timestamps = _request_log.get(user_id, []) + timestamps = [t for t in timestamps if now - t <= RATE_LIMIT_WINDOW] + if len(timestamps) >= RATE_LIMIT_REQUESTS: + raise HTTPException(status_code=429, detail="Too many requests, please slow down.") + timestamps.append(now) + _request_log[user_id] = timestamps + + +class GenerateRequest(BaseModel): + """Story generation request.""" + + type: Literal["keywords", "full_story"] + data: str = Field(..., min_length=1, max_length=MAX_DATA_LENGTH) + education_theme: str | None = Field(default=None, max_length=MAX_EDU_THEME_LENGTH) + child_profile_id: str | None = None + universe_id: str | None = None + + +class StoryResponse(BaseModel): + """Story response.""" + + id: int + title: str + story_text: str + cover_prompt: str | None + image_url: str | None + mode: str + child_profile_id: str | None = None + universe_id: str | None = None + + +class StoryListItem(BaseModel): + """Story list item.""" + + id: int + title: str + image_url: str | None + created_at: str + mode: str + + +class FullStoryResponse(BaseModel): + """完整故事响应(含图片和音频状态)。""" + + id: int + title: str + story_text: str + cover_prompt: str | None + image_url: str | None + audio_ready: bool + mode: str + errors: dict[str, str | None] = Field(default_factory=dict) + child_profile_id: str | None = None + universe_id: str | None = None + + +from app.services.memory_service import build_enhanced_memory_context + + +async def _validate_profile_and_universe( + request: GenerateRequest, + user: User, + db: AsyncSession, +) -> tuple[str | None, str | None]: + if not request.child_profile_id and not request.universe_id: + return None, None + + profile_id = request.child_profile_id + universe_id = request.universe_id + + if profile_id: + result = await db.execute( + select(ChildProfile).where( + ChildProfile.id == profile_id, + ChildProfile.user_id == user.id, + ) + ) + profile = result.scalar_one_or_none() + if not profile: + raise HTTPException(status_code=404, detail="孩子档案不存在") + + if universe_id: + result = await db.execute( + select(StoryUniverse) + .join(ChildProfile, StoryUniverse.child_profile_id == ChildProfile.id) + .where( + StoryUniverse.id == universe_id, + ChildProfile.user_id == user.id, + ) + ) + universe = result.scalar_one_or_none() + if not universe: + raise HTTPException(status_code=404, detail="故事宇宙不存在") + if profile_id and universe.child_profile_id != profile_id: + raise HTTPException(status_code=400, detail="故事宇宙与孩子档案不匹配") + if not profile_id: + profile_id = universe.child_profile_id + + return profile_id, universe_id + + +@router.post("/stories/generate", response_model=StoryResponse) +async def generate_story( + request: GenerateRequest, + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """Generate or enhance a story.""" + _check_rate_limit(user.id) + profile_id, universe_id = await _validate_profile_and_universe(request, user, db) + memory_context = await build_enhanced_memory_context(profile_id, universe_id, db) + + try: + result = await generate_story_content( + input_type=request.type, + data=request.data, + education_theme=request.education_theme, + memory_context=memory_context, + ) + except HTTPException: + raise + except Exception: + raise HTTPException(status_code=502, detail="Story generation failed, please try again.") + + story = Story( + user_id=user.id, + child_profile_id=profile_id, + universe_id=universe_id, + title=result.title, + story_text=result.story_text, + cover_prompt=result.cover_prompt_suggestion, + mode=result.mode, + ) + db.add(story) + await db.commit() + await db.refresh(story) + + if universe_id: + extract_story_achievements.delay(story.id, universe_id) + + return StoryResponse( + id=story.id, + title=story.title, + story_text=story.story_text, + cover_prompt=story.cover_prompt, + image_url=story.image_url, + mode=story.mode, + child_profile_id=story.child_profile_id, + universe_id=story.universe_id, + ) + + +@router.post("/stories/generate/full", response_model=FullStoryResponse) +async def generate_story_full( + request: GenerateRequest, + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """生成完整故事(故事 + 并行生成图片和音频)。 + + 部分成功策略:故事必须成功,图片/音频失败不影响整体。 + """ + _check_rate_limit(user.id) + profile_id, universe_id = await _validate_profile_and_universe(request, user, db) + memory_context = await build_enhanced_memory_context(profile_id, universe_id, db) + + # Step 1: 故事生成(必须成功) + try: + result = await generate_story_content( + input_type=request.type, + data=request.data, + education_theme=request.education_theme, + memory_context=memory_context, + ) + except Exception as exc: + logger.error("story_generation_failed", error=str(exc)) + raise HTTPException(status_code=502, detail="Story generation failed, please try again.") + + # 保存故事 + story = Story( + user_id=user.id, + child_profile_id=profile_id, + universe_id=universe_id, + title=result.title, + story_text=result.story_text, + cover_prompt=result.cover_prompt_suggestion, + mode=result.mode, + ) + db.add(story) + await db.commit() + await db.refresh(story) + + if universe_id: + extract_story_achievements.delay(story.id, universe_id) + + # Step 2: 生成封面图片(音频按需生成,避免浪费) + errors: dict[str, str | None] = {} + image_url: str | None = None + + if story.cover_prompt: + try: + image_url = await generate_image(story.cover_prompt) + story.image_url = image_url + await db.commit() + except Exception as exc: + errors["image"] = str(exc) + logger.warning("image_generation_failed", story_id=story.id, error=str(exc)) + + # 注意:音频不在此处预生成,用户通过 /api/audio/{id} 按需获取 + # 这样避免生成后丢弃造成的成本浪费 + + return FullStoryResponse( + id=story.id, + title=story.title, + story_text=story.story_text, + cover_prompt=story.cover_prompt, + image_url=image_url, + audio_ready=False, # 音频需要用户主动请求 + mode=story.mode, + errors=errors, + child_profile_id=story.child_profile_id, + universe_id=story.universe_id, + ) + + +@router.post("/stories/generate/stream") +async def generate_story_stream( + request: GenerateRequest, + req: Request, + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """流式生成故事(SSE)。 + + 事件流程: + - started: 返回 story_id + - story_ready: 返回 title, content + - story_failed: 返回 error + - image_ready: 返回 image_url + - image_failed: 返回 error + - complete: 结束流 + """ + _check_rate_limit(user.id) + profile_id, universe_id = await _validate_profile_and_universe(request, user, db) + memory_context = await build_enhanced_memory_context(profile_id, universe_id, db) + + async def event_generator() -> AsyncGenerator[dict, None]: + story_id = str(uuid.uuid4()) + yield {"event": "started", "data": json.dumps({"story_id": story_id})} + + # Step 1: 生成故事 + try: + result = await generate_story_content( + input_type=request.type, + data=request.data, + education_theme=request.education_theme, + memory_context=memory_context, + ) + except Exception as e: + logger.error("sse_story_generation_failed", error=str(e)) + yield {"event": "story_failed", "data": json.dumps({"error": str(e)})} + return + + # 保存故事 + story = Story( + user_id=user.id, + child_profile_id=profile_id, + universe_id=universe_id, + title=result.title, + story_text=result.story_text, + cover_prompt=result.cover_prompt_suggestion, + mode=result.mode, + ) + db.add(story) + await db.commit() + await db.refresh(story) + + if universe_id: + extract_story_achievements.delay(story.id, universe_id) + + yield { + "event": "story_ready", + "data": json.dumps({ + "id": story.id, + "title": story.title, + "content": story.story_text, + "cover_prompt": story.cover_prompt, + "mode": story.mode, + "child_profile_id": story.child_profile_id, + "universe_id": story.universe_id, + }), + } + + # Step 2: 并行生成图片(音频按需) + if story.cover_prompt: + try: + image_url = await generate_image(story.cover_prompt) + story.image_url = image_url + await db.commit() + yield {"event": "image_ready", "data": json.dumps({"image_url": image_url})} + except Exception as e: + logger.warning("sse_image_generation_failed", story_id=story.id, error=str(e)) + yield {"event": "image_failed", "data": json.dumps({"error": str(e)})} + + yield {"event": "complete", "data": json.dumps({"story_id": story.id})} + + return EventSourceResponse(event_generator()) + + +# ==================== Storybook API ==================== + + +class StorybookRequest(BaseModel): + """Storybook 生成请求。""" + + keywords: str = Field(..., min_length=1, max_length=200) + page_count: int = Field(default=6, ge=4, le=12) + education_theme: str | None = Field(default=None, max_length=MAX_EDU_THEME_LENGTH) + generate_images: bool = Field(default=False, description="是否同时生成插图") + child_profile_id: str | None = None + universe_id: str | None = None + + +class StorybookPageResponse(BaseModel): + """故事书单页响应。""" + + page_number: int + text: str + image_prompt: str + image_url: str | None = None + + +class StorybookResponse(BaseModel): + """故事书响应。""" + + id: int | None = None + title: str + main_character: str + art_style: str + pages: list[StorybookPageResponse] + cover_prompt: str + cover_url: str | None = None + + +@router.post("/storybook/generate", response_model=StorybookResponse) +async def generate_storybook_api( + request: StorybookRequest, + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """生成分页故事书并保存。 + + 返回故事书结构,包含每页文字和图像提示词。 + """ + _check_rate_limit(user.id) + + # 验证档案和宇宙 + # 复用 _validate_profile_and_universe 需要将 request 转换为 GenerateRequest 或稍微修改验证函数 + # 这里我们直接手动验证,或重构验证函数。为了简单,手动调用部分逻辑。 + + # 构建临时的 GenerateRequest 用于验证验证函数签名(或者直接手动查库更好) + profile_id = request.child_profile_id + universe_id = request.universe_id + + if profile_id: + result = await db.execute( + select(ChildProfile).where( + ChildProfile.id == profile_id, + ChildProfile.user_id == user.id, + ) + ) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="孩子档案不存在") + + if universe_id: + result = await db.execute( + select(StoryUniverse) + .join(ChildProfile, StoryUniverse.child_profile_id == ChildProfile.id) + .where( + StoryUniverse.id == universe_id, + ChildProfile.user_id == user.id, + ) + ) + universe = result.scalar_one_or_none() + if not universe: + raise HTTPException(status_code=404, detail="故事宇宙不存在") + if profile_id and universe.child_profile_id != profile_id: + raise HTTPException(status_code=400, detail="故事宇宙与孩子档案不匹配") + if not profile_id: + profile_id = universe.child_profile_id + + logger.info( + "storybook_request", + user_id=user.id, + keywords=request.keywords, + page_count=request.page_count, + profile_id=profile_id, + universe_id=universe_id, + ) + + memory_context = await build_enhanced_memory_context(profile_id, universe_id, db) + + try: + # 注意:generate_storybook 目前可能不支持记忆上下文注入 + # 我们需要看看 generate_storybook 的签名 + # 如果不支持,记忆功能在绘本模式下暂不可用,但基本参数传递是支持的 + storybook = await generate_storybook( + keywords=request.keywords, + page_count=request.page_count, + education_theme=request.education_theme, + memory_context=memory_context, + db=db, + ) + except Exception as e: + logger.error("storybook_generation_failed", error=str(e)) + raise HTTPException(status_code=500, detail=f"故事书生成失败: {e}") + + # ============================================================================== + # 核心升级: 并行全量生成 (Parallel Full Rendering) + # ============================================================================== + final_cover_url = storybook.cover_url + + if request.generate_images: + logger.info("storybook_parallel_generation_start", page_count=len(storybook.pages)) + + # 1. 准备所有生图任务 (封面 + 所有内页) + tasks = [] + + # 封面任务 + async def _gen_cover(): + if storybook.cover_prompt and not storybook.cover_url: + try: + return await generate_image(storybook.cover_prompt, db=db) + except Exception as e: + logger.warning("cover_gen_failed", error=str(e)) + return storybook.cover_url + + tasks.append(_gen_cover()) + + # 内页任务 + async def _gen_page(page): + if page.image_prompt and not page.image_url: + try: + url = await generate_image(page.image_prompt, db=db) + page.image_url = url + except Exception as e: + logger.warning("page_gen_failed", page=page.page_number, error=str(e)) + + for page in storybook.pages: + tasks.append(_gen_page(page)) + + # 2. 并发执行所有任务 + # 使用 return_exceptions=True 防止单张失败影响整体 + results = await asyncio.gather(*tasks, return_exceptions=True) + + # 3. 更新封面结果 (results[0] 是封面任务的返回值) + cover_res = results[0] + if isinstance(cover_res, str): + final_cover_url = cover_res + + logger.info("storybook_parallel_generation_complete") + + # ============================================================================== + + # 构建并保存 Story 对象 + # 将 pages 对象转换为字典列表以存入 JSON 字段 + pages_data = [ + { + "page_number": p.page_number, + "text": p.text, + "image_prompt": p.image_prompt, + "image_url": p.image_url, + } + for p in storybook.pages + ] + + story = Story( + user_id=user.id, + child_profile_id=profile_id, + universe_id=universe_id, + title=storybook.title, + mode="storybook", + pages=pages_data, # 存入 JSON 字段 + story_text=None, # 绘本模式下,主文本可为空,或者可以存个摘要 + cover_prompt=storybook.cover_prompt, + image_url=final_cover_url, + ) + db.add(story) + await db.commit() + await db.refresh(story) + + if universe_id: + extract_story_achievements.delay(story.id, universe_id) + + # 构建响应 (使用更新后的 pages_data) + response_pages = [ + StorybookPageResponse( + page_number=p["page_number"], + text=p["text"], + image_prompt=p["image_prompt"], + image_url=p.get("image_url"), + ) + for p in pages_data + ] + + return StorybookResponse( + id=story.id, + title=storybook.title, + main_character=storybook.main_character, + art_style=storybook.art_style, + pages=response_pages, + cover_prompt=storybook.cover_prompt, + cover_url=final_cover_url, + ) + + +class AchievementItem(BaseModel): + type: str + description: str + obtained_at: str | None = None + + +@router.get("/stories/{story_id}/achievements", response_model=list[AchievementItem]) +async def get_story_achievements( + story_id: int, + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """Get achievements unlocked by a specific story.""" + # 使用 joinedload 避免 N+1 查询 + result = await db.execute( + select(Story) + .options(joinedload(Story.story_universe)) + .where(Story.id == story_id, Story.user_id == user.id) + ) + story = result.scalar_one_or_none() + + if not story: + raise HTTPException(status_code=404, detail="Story not found") + + if not story.universe_id or not story.story_universe: + return [] + + universe = story.story_universe + if not universe.achievements: + return [] + + results = [] + for ach in universe.achievements: + if isinstance(ach, dict) and ach.get("source_story_id") == story_id: + results.append(AchievementItem( + type=ach.get("type", "Unknown"), + description=ach.get("description", ""), + obtained_at=ach.get("obtained_at") + )) + + return results diff --git a/backend/app/api/universes.py b/backend/app/api/universes.py new file mode 100644 index 0000000..d5ab681 --- /dev/null +++ b/backend/app/api/universes.py @@ -0,0 +1,201 @@ +"""Story universe APIs.""" + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import require_user +from app.db.database import get_db +from app.db.models import ChildProfile, StoryUniverse, User + +router = APIRouter() + + +class StoryUniverseCreate(BaseModel): + """Create universe payload.""" + + name: str = Field(..., min_length=1, max_length=100) + protagonist: dict[str, Any] + recurring_characters: list[dict[str, Any]] = Field(default_factory=list) + world_settings: dict[str, Any] = Field(default_factory=dict) + + +class StoryUniverseUpdate(BaseModel): + """Update universe payload.""" + + name: str | None = Field(default=None, min_length=1, max_length=100) + protagonist: dict[str, Any] | None = None + recurring_characters: list[dict[str, Any]] | None = None + world_settings: dict[str, Any] | None = None + + +class AchievementCreate(BaseModel): + """Achievement payload.""" + + type: str = Field(..., min_length=1, max_length=50) + description: str = Field(..., min_length=1, max_length=200) + + +class StoryUniverseResponse(BaseModel): + """Universe response.""" + + id: str + child_profile_id: str + name: str + protagonist: dict[str, Any] + recurring_characters: list[dict[str, Any]] + world_settings: dict[str, Any] + achievements: list[dict[str, Any]] + + class Config: + from_attributes = True + + +class StoryUniverseListResponse(BaseModel): + """Universe list response.""" + + universes: list[StoryUniverseResponse] + total: int + + +async def _get_profile_or_404( + profile_id: str, + user: User, + db: AsyncSession, +) -> ChildProfile: + result = await db.execute( + select(ChildProfile).where( + ChildProfile.id == profile_id, + ChildProfile.user_id == user.id, + ) + ) + profile = result.scalar_one_or_none() + if not profile: + raise HTTPException(status_code=404, detail="档案不存在") + return profile + + +async def _get_universe_or_404( + universe_id: str, + user: User, + db: AsyncSession, +) -> StoryUniverse: + result = await db.execute( + select(StoryUniverse) + .join(ChildProfile, StoryUniverse.child_profile_id == ChildProfile.id) + .where( + StoryUniverse.id == universe_id, + ChildProfile.user_id == user.id, + ) + ) + universe = result.scalar_one_or_none() + if not universe: + raise HTTPException(status_code=404, detail="宇宙不存在") + return universe + + +@router.get("/profiles/{profile_id}/universes", response_model=StoryUniverseListResponse) +async def list_universes( + profile_id: str, + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """List universes for a child profile.""" + await _get_profile_or_404(profile_id, user, db) + result = await db.execute( + select(StoryUniverse) + .where(StoryUniverse.child_profile_id == profile_id) + .order_by(StoryUniverse.updated_at.desc()) + ) + universes = result.scalars().all() + return StoryUniverseListResponse(universes=universes, total=len(universes)) + + +@router.post( + "/profiles/{profile_id}/universes", + response_model=StoryUniverseResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_universe( + profile_id: str, + payload: StoryUniverseCreate, + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """Create a story universe.""" + await _get_profile_or_404(profile_id, user, db) + universe = StoryUniverse(child_profile_id=profile_id, **payload.model_dump()) + db.add(universe) + await db.commit() + await db.refresh(universe) + return universe + + +@router.get("/universes/{universe_id}", response_model=StoryUniverseResponse) +async def get_universe( + universe_id: str, + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """Get one universe.""" + universe = await _get_universe_or_404(universe_id, user, db) + return universe + + +@router.put("/universes/{universe_id}", response_model=StoryUniverseResponse) +async def update_universe( + universe_id: str, + payload: StoryUniverseUpdate, + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """Update a story universe.""" + universe = await _get_universe_or_404(universe_id, user, db) + updates = payload.model_dump(exclude_unset=True) + for key, value in updates.items(): + setattr(universe, key, value) + await db.commit() + await db.refresh(universe) + return universe + + +@router.delete("/universes/{universe_id}") +async def delete_universe( + universe_id: str, + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """Delete a story universe.""" + universe = await _get_universe_or_404(universe_id, user, db) + await db.delete(universe) + await db.commit() + return {"message": "Deleted"} + + +@router.post("/universes/{universe_id}/achievements", response_model=StoryUniverseResponse) +async def add_achievement( + universe_id: str, + payload: AchievementCreate, + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """Add an achievement to a universe.""" + universe = await _get_universe_or_404(universe_id, user, db) + + achievements = list(universe.achievements or []) + key = (payload.type.strip(), payload.description.strip()) + existing = { + (str(item.get("type", "")).strip(), str(item.get("description", "")).strip()) + for item in achievements + if isinstance(item, dict) + } + if key not in existing: + achievements.append({"type": key[0], "description": key[1]}) + universe.achievements = achievements + await db.commit() + await db.refresh(universe) + + return universe diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/admin_auth.py b/backend/app/core/admin_auth.py new file mode 100644 index 0000000..1c48c7e --- /dev/null +++ b/backend/app/core/admin_auth.py @@ -0,0 +1,72 @@ +import secrets +import time + +from cachetools import TTLCache +from fastapi import Depends, HTTPException, Request, status +from fastapi.security import HTTPBasic, HTTPBasicCredentials + +from app.core.config import settings + +security = HTTPBasic() + +# 登录失败记录:IP -> (失败次数, 首次失败时间) +_failed_attempts: TTLCache[str, tuple[int, float]] = TTLCache(maxsize=1000, ttl=900) # 15分钟 + +MAX_ATTEMPTS = 5 +LOCKOUT_SECONDS = 900 # 15分钟 + + +def _get_client_ip(request: Request) -> str: + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return forwarded.split(",")[0].strip() + if request.client and request.client.host: + return request.client.host + return "unknown" + + +def admin_guard( + request: Request, + credentials: HTTPBasicCredentials = Depends(security), +): + client_ip = _get_client_ip(request) + + # 检查是否被锁定 + if client_ip in _failed_attempts: + attempts, first_fail = _failed_attempts[client_ip] + if attempts >= MAX_ATTEMPTS: + remaining = int(LOCKOUT_SECONDS - (time.time() - first_fail)) + if remaining > 0: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=f"登录尝试过多,请 {remaining} 秒后重试", + ) + else: + del _failed_attempts[client_ip] + + # 使用 secrets.compare_digest 防止时序攻击 + username_ok = secrets.compare_digest( + credentials.username.encode(), settings.admin_username.encode() + ) + password_ok = secrets.compare_digest( + credentials.password.encode(), settings.admin_password.encode() + ) + + if not (username_ok and password_ok): + # 记录失败 + if client_ip in _failed_attempts: + attempts, first_fail = _failed_attempts[client_ip] + _failed_attempts[client_ip] = (attempts + 1, first_fail) + else: + _failed_attempts[client_ip] = (1, time.time()) + + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户名或密码错误", + ) + + # 登录成功,清除失败记录 + if client_ip in _failed_attempts: + del _failed_attempts[client_ip] + + return True diff --git a/backend/app/core/celery_app.py b/backend/app/core/celery_app.py new file mode 100644 index 0000000..5debd5c --- /dev/null +++ b/backend/app/core/celery_app.py @@ -0,0 +1,33 @@ +"""Celery application setup.""" + +from celery import Celery +from celery.schedules import crontab + +from app.core.config import settings + +celery_app = Celery( + "dreamweaver", + broker=settings.celery_broker_url, + backend=settings.celery_result_backend, +) + +celery_app.conf.update( + task_track_started=True, + task_serializer="json", + accept_content=["json"], + result_serializer="json", + timezone="Asia/Shanghai", + enable_utc=True, + beat_schedule={ + "check_push_notifications": { + "task": "app.tasks.push_notifications.check_push_notifications", + "schedule": crontab(minute="*/15"), + }, + "prune_expired_memories": { + "task": "app.tasks.memory.prune_memories_task", + "schedule": crontab(minute="0", hour="3"), # Daily at 03:00 + }, + }, +) + +celery_app.autodiscover_tasks(["app.tasks"]) diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..ca038b9 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,76 @@ +from pydantic import Field, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """应用全局配置""" + + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") + + # 应用基础配置 + app_name: str = "DreamWeaver" + debug: bool = False + secret_key: str = Field(..., description="JWT 签名密钥") + base_url: str = Field("http://localhost:8000", description="后端对外回调地址") + + # 数据库 + database_url: str = Field(..., description="SQLAlchemy async URL") + + # OAuth - GitHub + github_client_id: str = "" + github_client_secret: str = "" + + # OAuth - Google + google_client_id: str = "" + google_client_secret: str = "" + + # AI Capability Keys + text_api_key: str = "" + tts_api_base: str = "" + tts_api_key: str = "" + image_api_key: str = "" + + # Additional Provider API Keys + openai_api_key: str = "" + elevenlabs_api_key: str = "" + cqtai_api_key: str = "" + minimax_api_key: str = "" + minimax_group_id: str = "" + antigravity_api_key: str = "" + antigravity_api_base: str = "" + + # AI Model Configuration + text_model: str = "gemini-2.0-flash" + tts_model: str = "" + image_model: str = "" + + # Provider routing (ordered lists) + text_providers: list[str] = Field(default_factory=lambda: ["gemini"]) + image_providers: list[str] = Field(default_factory=lambda: ["cqtai"]) + tts_providers: list[str] = Field(default_factory=lambda: ["minimax", "elevenlabs", "edge_tts"]) + + # Celery (Redis) + celery_broker_url: str = Field("redis://localhost:6379/0") + celery_result_backend: str = Field("redis://localhost:6379/0") + + # Admin console + enable_admin_console: bool = False + admin_username: str = "admin" + admin_password: str = "admin123" # 建议通过环境变量覆盖 + + # CORS + cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:5173"]) + + @model_validator(mode="after") + def _require_core_settings(self) -> "Settings": # type: ignore[override] + missing = [] + if not self.secret_key or self.secret_key == "change-me-in-production": + missing.append("SECRET_KEY") + if not self.database_url: + missing.append("DATABASE_URL") + if missing: + raise ValueError(f"Missing required settings: {', '.join(missing)}") + return self + + +settings = Settings() diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py new file mode 100644 index 0000000..d2a9664 --- /dev/null +++ b/backend/app/core/deps.py @@ -0,0 +1,39 @@ +from fastapi import Cookie, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import decode_access_token +from app.db.database import get_db +from app.db.models import User + + +async def get_current_user( + access_token: str | None = Cookie(default=None), + db: AsyncSession = Depends(get_db), +) -> User | None: + """获取当前用户(可选)。""" + if not access_token: + return None + + payload = decode_access_token(access_token) + if not payload: + return None + + user_id = payload.get("sub") + if not user_id: + return None + + result = await db.execute(select(User).where(User.id == user_id)) + return result.scalar_one_or_none() + + +async def require_user( + user: User | None = Depends(get_current_user), +) -> User: + """要求用户登录,否则抛 401。""" + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="未登录", + ) + return user diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py new file mode 100644 index 0000000..893a175 --- /dev/null +++ b/backend/app/core/logging.py @@ -0,0 +1,48 @@ +"""结构化日志配置。""" + +import logging +import sys + +import structlog + +from app.core.config import settings + + +def setup_logging(): + """配置 structlog 结构化日志。""" + shared_processors = [ + structlog.contextvars.merge_contextvars, + structlog.stdlib.add_log_level, + structlog.stdlib.add_logger_name, + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + ] + + if settings.debug: + processors = shared_processors + [ + structlog.dev.ConsoleRenderer(colors=True), + ] + else: + processors = shared_processors + [ + structlog.processors.format_exc_info, + structlog.processors.JSONRenderer(), + ] + + structlog.configure( + processors=processors, + wrapper_class=structlog.stdlib.BoundLogger, + context_class=dict, + logger_factory=structlog.stdlib.LoggerFactory(), + cache_logger_on_first_use=True, + ) + + logging.basicConfig( + format="%(message)s", + stream=sys.stdout, + level=logging.DEBUG if settings.debug else logging.INFO, + ) + + +def get_logger(name: str) -> structlog.stdlib.BoundLogger: + """获取结构化日志器。""" + return structlog.get_logger(name) diff --git a/backend/app/core/prompts.py b/backend/app/core/prompts.py new file mode 100644 index 0000000..cc07ff5 --- /dev/null +++ b/backend/app/core/prompts.py @@ -0,0 +1,190 @@ +# ruff: noqa: E501 +"""AI 提示词模板 (Modernized)""" + +# 随机元素列表:为故事注入不可预测的魔法 +RANDOM_ELEMENTS = [ + "一个会打喷嚏的云朵", + "一本地图上找不到的神秘图书馆", + "一只能实现小愿望的彩色蜗牛", + "一扇通往颠倒世界的门", + "一顶能听懂动物说话的旧帽子", + "一个装着星星的玻璃罐", + "一棵结满笑声果实的树", + "一只能在水上画画的画笔", + "一个怕黑的影子", + "一只收集回声的瓶子", + "一双会自己跳舞的红鞋子", + "一个只能在月光下看见的邮筒", + "一张会改变模样的全家福", + "一把可以打开梦境的钥匙", + "一个喜欢讲冷笑话的冰箱", + "一条通往星期八的秘密小径" +] + +# ============================================================================== +# Model A: 故事生成 (Story Generation) +# ============================================================================== + +SYSTEM_INSTRUCTION_STORYTELLER = """ +# Role +You are "**Dream Weaver**", a world-class children's storyteller with the imagination of Pixar and the warmth of Miyazaki. +Your mission is to create engaging, safe, and educational stories for children (ages 3-8). + +# Core Philosophy +1. **Show, Don't Tell**: Don't preach the lesson. Let the character's actions and the plot demonstrate the theme. +2. **Safety First**: No violence, horror, or scary elements. Conflict should be emotional or situational, not physical. +3. **Vivid Imagery**: Use sensory details (colors, sounds, smells) that appeal to children. +4. **Empowerment**: The child protagonist should solve the problem using wit, kindness, or courage, not just luck. + +# Continuity & Memory (CRITICAL) +- **Universal Context**: The story takes place in the child's established "Story Universe". Respect existing world rules. +- **Character Consistency**: If "Child Profile" or "Sidekicks" are provided, you MUST use their specific names and traits. Do NOT invent new main characters unless asked. +- **Callback**: If "Past Memories" are provided, try to make a natural, one-sentence reference to a past adventure to build a sense of continuity (e.g., "Just like when we found the lost star..."). + +# Output Format +You MUST return a pure JSON object with NO markdown formatting (no ```json code blocks). +The JSON object must have the following schema: +{ + "mode": "generated", + "title": "A catchy, imaginative title", + "story_text": "The full story text. Use \\n\\n for paragraph breaks.", + "cover_prompt_suggestion": "A detailed English image generation prompt for the story cover. Style: whimsical, children's book illustration, soft lighting, vibrant colors." +} +""" + +USER_PROMPT_GENERATION = """ +# Task: Write a Children's Story + +## Contextual Memory (Use these if provided) +{memory_context} + +## Inputs +- **Keywords/Topic**: {keywords} +- **Educational Theme**: {education_theme} +- **Magic Element (Must Incorporate)**: {random_element} + +## Constraints +- Length: 300-600 words. +- Structure: Beginning (Hook) -> Middle (Challenge) -> End (Resolution & Growth). +""" + +# ============================================================================== +# Model B: 故事润色 (Story Enhancement) +# ============================================================================== + +SYSTEM_INSTRUCTION_ENHANCER = """ +# Role +You are "**Dream Weaver Editor**", an expert children's book editor who turns rough drafts into polished gems. + +# Mission +Analyze the user's input story and rewrite it to be: +1. **More Engaging**: Enhance the plot with a "Magic Element" to add surprise. +2. **More Educational**: Weave the "Educational Theme" deeper into the narrative arc. +3. **Better Written**: Polish the sentences for rhythm and flow (suitable for reading aloud). +4. **Safe**: Remove any inappropriate content (violence, scary interaction) and replace it with constructive solutions. + +# Output Format +You MUST return a pure JSON object with NO markdown formatting (no ```json code blocks). +The JSON object must have the following schema: +{ + "mode": "enhanced", + "title": "An improved title (or the original if perfect)", + "story_text": "The rewritten story text. Use \\n\\n for paragraph breaks.", + "cover_prompt_suggestion": "A detailed English image generation prompt for the cover." +} +""" + +USER_PROMPT_ENHANCEMENT = """ +# Task: Enhance This Story + +## Contextual Memory +{memory_context} + +## Inputs +- **Original Story**: {full_story} +- **Target Theme**: {education_theme} +- **Magic Element to Add**: {random_element} + +## Constraints +- Length: 300-600 words. +- Keep the original character names if possible, but feel free to upgrade the plot. +""" + +# ============================================================================== +# Model C: 成就提取 (Achievement Extraction) +# ============================================================================== + +# 保持简单,暂不使用 System Instruction,沿用单次提示 +ACHIEVEMENT_EXTRACTION_PROMPT = """ +Analyze the story and extract key growth moments or achievements for the child protagonist. + +# Story +{story_text} + +# Target Categories (Examples) +- **Courage**: Overcoming fear, trying something new. +- **Kindness**: Helping others, sharing, empathy. +- **Curiosity**: Asking questions, exploring, learning. +- **Resilience**: Not giving up, handling failure. +- **Wisdom**: Problem-solving, honesty, patience. + +# Output Format +Return a pure JSON object (no markdown): +{{ + "achievements": [ + {{ + "type": "Category Name", + "description": "Brief reason (max 10 words)", + "score": 8 // 1-10 intensity + }} + ] +}} +""" + +# ============================================================================== +# Model D: 绘本生成 (Storybook Generation) +# ============================================================================== + +SYSTEM_INSTRUCTION_STORYBOOK = """ +# Role +You are "**Dream Weaver Illustrator**", a creative children's book author and visual director. +Your mission is to create a paginated picture book for children (ages 3-8), where each page has text and a matching illustration prompt. + +# Core Philosophy +1. **Pacing**: The story must flow logically across the specified number of pages. +2. **Visual Consistency**: Define the "Main Character" and "Art Style" once, and ensure all image prompts adhere to them. +3. **Language**: The story text MUST be in **Chinese (Simplified)**. The image prompts MUST be in **English**. +4. **Memory**: If a memory context is provided, incorporate known characters or references naturally. + +# Output Format +You MUST return a pure JSON object using the following schema (no markdown): +{ + "title": "Story Title (Chinese)", + "main_character": "Description of the protagonist (e.g., 'A small blue robot with rusty gears')", + "art_style": "Visual style description (e.g., 'Watercolor, soft pastel colors, whimsical')", + "pages": [ + { + "page_number": 1, + "text": "Page text in Chinese (30-60 chars).", + "image_prompt": "Detailed English image prompt describing the scene. Include 'main_character' reference." + } + ], + "cover_prompt": "English image prompt for the book cover." +} +""" + +USER_PROMPT_STORYBOOK = """ +# Task: Create a {page_count}-Page Storybook + +## Contextual Memory +{memory_context} + +## Inputs +- **Keywords/Topic**: {keywords} +- **Educational Theme**: {education_theme} +- **Magic Element**: {random_element} + +## Constraints +- Pages: Exactly {page_count} pages. +- Structure: Intro -> Development -> Climax -> Resolution. +""" diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..d5ccb98 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,25 @@ +from datetime import datetime, timedelta, timezone + +from jose import JWTError, jwt + +from app.core.config import settings + +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_DAYS = 7 + + +def create_access_token(data: dict) -> str: + """创建 JWT token""" + to_encode = data.copy() + expire = datetime.now(timezone.utc) + timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, settings.secret_key, algorithm=ALGORITHM) + + +def decode_access_token(token: str) -> dict | None: + """解码 JWT token""" + try: + payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM]) + return payload + except JWTError: + return None diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/db/admin_models.py b/backend/app/db/admin_models.py new file mode 100644 index 0000000..639ac68 --- /dev/null +++ b/backend/app/db/admin_models.py @@ -0,0 +1,119 @@ +from datetime import datetime +from decimal import Decimal +from uuid import uuid4 + +from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, Numeric, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.models import Base + + +def _uuid() -> str: + return str(uuid4()) + + +class Provider(Base): + """Model provider registry.""" + + __tablename__ = "providers" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) + name: Mapped[str] = mapped_column(String(100), nullable=False) + type: Mapped[str] = mapped_column(String(50), nullable=False) # text/image/tts/storybook + adapter: Mapped[str] = mapped_column(String(100), nullable=False) + model: Mapped[str] = mapped_column(String(200), nullable=True) + api_base: Mapped[str] = mapped_column(String(300), nullable=True) + api_key: Mapped[str] = mapped_column(String(500), nullable=True) # 可选,优先于 config_ref + timeout_ms: Mapped[int] = mapped_column(Integer, default=60000) + max_retries: Mapped[int] = mapped_column(Integer, default=1) + weight: Mapped[int] = mapped_column(Integer, default=1) + priority: Mapped[int] = mapped_column(Integer, default=0) + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + config_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) # 存储额外配置(speed, vol, etc) + config_ref: Mapped[str] = mapped_column(String(100), nullable=True) # 环境变量 key 名称(回退) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow + ) + updated_by: Mapped[str] = mapped_column(String(100), nullable=True) + + +class ProviderMetrics(Base): + """供应商调用指标记录。""" + + __tablename__ = "provider_metrics" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + provider_id: Mapped[str] = mapped_column( + String(36), ForeignKey("providers.id", ondelete="CASCADE"), nullable=False, index=True + ) + timestamp: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=datetime.utcnow, index=True + ) + success: Mapped[bool] = mapped_column(Boolean, nullable=False) + latency_ms: Mapped[int] = mapped_column(Integer, nullable=True) + cost_usd: Mapped[Decimal] = mapped_column(Numeric(10, 6), nullable=True) + error_message: Mapped[str] = mapped_column(Text, nullable=True) + request_id: Mapped[str] = mapped_column(String(100), nullable=True) + + +class ProviderHealth(Base): + """供应商健康状态。""" + + __tablename__ = "provider_health" + + provider_id: Mapped[str] = mapped_column( + String(36), ForeignKey("providers.id", ondelete="CASCADE"), primary_key=True + ) + is_healthy: Mapped[bool] = mapped_column(Boolean, default=True) + last_check: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True) + consecutive_failures: Mapped[int] = mapped_column(Integer, default=0) + last_error: Mapped[str] = mapped_column(Text, nullable=True) + + +class ProviderSecret(Base): + """供应商密钥加密存储。""" + + __tablename__ = "provider_secrets" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) + name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + encrypted_value: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow + ) + + +class CostRecord(Base): + """成本记录表 - 记录每次 API 调用的成本。""" + + __tablename__ = "cost_records" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + provider_id: Mapped[str] = mapped_column(String(36), nullable=True) # 可能是环境变量配置 + provider_name: Mapped[str] = mapped_column(String(100), nullable=False) + capability: Mapped[str] = mapped_column(String(50), nullable=False) # text/image/tts/storybook + estimated_cost: Mapped[Decimal] = mapped_column(Numeric(10, 6), nullable=False) + timestamp: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=datetime.utcnow, index=True + ) + + +class UserBudget(Base): + """用户预算配置。""" + + __tablename__ = "user_budgets" + + user_id: Mapped[str] = mapped_column(String(36), primary_key=True) + daily_limit_usd: Mapped[Decimal] = mapped_column(Numeric(10, 4), default=Decimal("1.0")) + monthly_limit_usd: Mapped[Decimal] = mapped_column(Numeric(10, 4), default=Decimal("10.0")) + alert_threshold: Mapped[Decimal] = mapped_column( + Numeric(3, 2), default=Decimal("0.8") + ) # 80% 时告警 + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow + ) diff --git a/backend/app/db/database.py b/backend/app/db/database.py new file mode 100644 index 0000000..32bad04 --- /dev/null +++ b/backend/app/db/database.py @@ -0,0 +1,50 @@ +import threading + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.core.config import settings + +_engine = None +_session_factory: async_sessionmaker[AsyncSession] | None = None +_lock = threading.Lock() + + +def _get_engine(): + global _engine + if _engine is None: + with _lock: + if _engine is None: + _engine = create_async_engine( + settings.database_url, + echo=settings.debug, + pool_pre_ping=True, + pool_recycle=300, + ) + return _engine + + +def _get_session_factory(): + global _session_factory + if _session_factory is None: + with _lock: + if _session_factory is None: + _session_factory = async_sessionmaker( + _get_engine(), class_=AsyncSession, expire_on_commit=False + ) + return _session_factory + + +async def init_db(): + """Create tables if they do not exist.""" + from app.db.models import Base # main models + + engine = _get_engine() + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +async def get_db(): + """Yield a DB session with proper cleanup.""" + session_factory = _get_session_factory() + async with session_factory() as session: + yield session diff --git a/backend/app/db/models.py b/backend/app/db/models.py new file mode 100644 index 0000000..394d325 --- /dev/null +++ b/backend/app/db/models.py @@ -0,0 +1,232 @@ +from datetime import date, datetime, time +from uuid import uuid4 + +from sqlalchemy import ( + JSON, + Boolean, + Date, + DateTime, + Float, + ForeignKey, + Integer, + String, + Text, + Time, + UniqueConstraint, + func, +) +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + """Declarative base.""" + + +class User(Base): + """User entity.""" + + __tablename__ = "users" + + id: Mapped[str] = mapped_column(String(255), primary_key=True) # OAuth provider user ID + name: Mapped[str] = mapped_column(String(255), nullable=False) + avatar_url: Mapped[str | None] = mapped_column(String(500)) + provider: Mapped[str] = mapped_column(String(50), nullable=False) # github / google + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + stories: Mapped[list["Story"]] = relationship( + "Story", back_populates="user", cascade="all, delete-orphan" + ) + child_profiles: Mapped[list["ChildProfile"]] = relationship( + "ChildProfile", back_populates="user", cascade="all, delete-orphan" + ) + + +class Story(Base): + """Story entity.""" + + __tablename__ = "stories" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[str] = mapped_column( + String(255), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + child_profile_id: Mapped[str | None] = mapped_column( + String(36), ForeignKey("child_profiles.id", ondelete="SET NULL"), nullable=True + ) + universe_id: Mapped[str | None] = mapped_column( + String(36), ForeignKey("story_universes.id", ondelete="SET NULL"), nullable=True + ) + title: Mapped[str] = mapped_column(String(255), nullable=False) + story_text: Mapped[str] = mapped_column(Text, nullable=True) # 允许为空(绘本模式下) + pages: Mapped[list[dict] | None] = mapped_column(JSON, default=list) # 绘本分页数据 + cover_prompt: Mapped[str | None] = mapped_column(Text) + image_url: Mapped[str | None] = mapped_column(String(500)) + mode: Mapped[str] = mapped_column(String(20), nullable=False, default="generated") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + user: Mapped["User"] = relationship("User", back_populates="stories") + child_profile: Mapped["ChildProfile | None"] = relationship("ChildProfile") + story_universe: Mapped["StoryUniverse | None"] = relationship("StoryUniverse") + + +def _uuid() -> str: + return str(uuid4()) + + +class ChildProfile(Base): + """Child profile entity.""" + + __tablename__ = "child_profiles" + __table_args__ = (UniqueConstraint("user_id", "name", name="uq_child_profile_user_name"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) + user_id: Mapped[str] = mapped_column( + String(255), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + name: Mapped[str] = mapped_column(String(50), nullable=False) + avatar_url: Mapped[str | None] = mapped_column(String(500)) + birth_date: Mapped[date | None] = mapped_column(Date) + gender: Mapped[str | None] = mapped_column(String(10)) + + interests: Mapped[list[str]] = mapped_column(JSON, default=list) + growth_themes: Mapped[list[str]] = mapped_column(JSON, default=list) + reading_preferences: Mapped[dict] = mapped_column(JSON, default=dict) + + stories_count: Mapped[int] = mapped_column(Integer, default=0) + total_reading_time: Mapped[int] = mapped_column(Integer, default=0) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + user: Mapped["User"] = relationship("User", back_populates="child_profiles") + story_universes: Mapped[list["StoryUniverse"]] = relationship( + "StoryUniverse", back_populates="child_profile", cascade="all, delete-orphan" + ) + + @property + def age(self) -> int | None: + if not self.birth_date: + return None + today = date.today() + return today.year - self.birth_date.year - ( + (today.month, today.day) < (self.birth_date.month, self.birth_date.day) + ) + + +class StoryUniverse(Base): + """Story universe entity.""" + __tablename__ = "story_universes" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) + child_profile_id: Mapped[str] = mapped_column( + String(36), ForeignKey("child_profiles.id", ondelete="CASCADE"), nullable=False, index=True + ) + name: Mapped[str] = mapped_column(String(100), nullable=False) + protagonist: Mapped[dict] = mapped_column(JSON, nullable=False) + recurring_characters: Mapped[list] = mapped_column(JSON, default=list) + world_settings: Mapped[dict] = mapped_column(JSON, default=dict) + achievements: Mapped[list] = mapped_column(JSON, default=list) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + child_profile: Mapped["ChildProfile"] = relationship("ChildProfile", back_populates="story_universes") + + +class ReadingEvent(Base): + """Reading event entity.""" + + __tablename__ = "reading_events" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + child_profile_id: Mapped[str] = mapped_column( + String(36), ForeignKey("child_profiles.id", ondelete="CASCADE"), nullable=False, index=True + ) + story_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("stories.id", ondelete="SET NULL"), nullable=True, index=True + ) + event_type: Mapped[str] = mapped_column(String(20), nullable=False) + reading_time: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True + ) + +class PushConfig(Base): + """Push configuration entity.""" + + __tablename__ = "push_configs" + __table_args__ = ( + UniqueConstraint("child_profile_id", name="uq_push_config_child"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) + user_id: Mapped[str] = mapped_column( + String(255), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + child_profile_id: Mapped[str] = mapped_column( + String(36), ForeignKey("child_profiles.id", ondelete="CASCADE"), nullable=False, index=True + ) + push_time: Mapped[time | None] = mapped_column(Time) + push_days: Mapped[list[int]] = mapped_column(JSON, default=list) + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + +class PushEvent(Base): + """Push event entity.""" + + __tablename__ = "push_events" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) + user_id: Mapped[str] = mapped_column( + String(255), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + child_profile_id: Mapped[str] = mapped_column( + String(36), ForeignKey("child_profiles.id", ondelete="CASCADE"), nullable=False, index=True + ) + trigger_type: Mapped[str] = mapped_column(String(20), nullable=False) + status: Mapped[str] = mapped_column(String(20), nullable=False) + reason: Mapped[str | None] = mapped_column(Text) + sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + +class MemoryItem(Base): + """Memory item entity with time decay metadata.""" + + __tablename__ = "memory_items" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) + child_profile_id: Mapped[str] = mapped_column( + String(36), ForeignKey("child_profiles.id", ondelete="CASCADE"), nullable=False, index=True + ) + universe_id: Mapped[str | None] = mapped_column( + String(36), ForeignKey("story_universes.id", ondelete="SET NULL"), nullable=True, index=True + ) + type: Mapped[str] = mapped_column(String(50), nullable=False) + value: Mapped[dict] = mapped_column(JSON, nullable=False) + base_weight: Mapped[float] = mapped_column(Float, default=1.0) + last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + ttl_days: Mapped[int | None] = mapped_column(Integer) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..09ec39c --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,80 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api import ( + auth, + memories, + profiles, + push_configs, + reading_events, + stories, + universes, +) +from app.core.config import settings +from app.core.logging import get_logger, setup_logging +from app.db.database import init_db + +setup_logging() +logger = get_logger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """App lifespan manager.""" + logger.info("app_starting", app_name=settings.app_name) + await init_db() + logger.info("database_initialized") + + # 加载 provider 缓存 + await _load_provider_cache() + + yield + logger.info("app_shutdown") + + +async def _load_provider_cache(): + """启动时加载 provider 缓存。""" + from app.db.database import _get_session_factory + from app.services.provider_cache import reload_providers + + try: + session_factory = _get_session_factory() + async with session_factory() as session: + cache = await reload_providers(session) + provider_count = sum(len(v) for v in cache.values()) + logger.info("provider_cache_loaded", provider_count=provider_count) + except Exception as e: + logger.warning("provider_cache_load_failed", error=str(e)) + # 不阻止启动,使用 settings 中的默认配置 + + +app = FastAPI( + title=settings.app_name, + description="AI-driven story generator for kids.", + version="0.1.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth.router, prefix="/auth", tags=["auth"]) +app.include_router(stories.router, prefix="/api", tags=["stories"]) +app.include_router(profiles.router, prefix="/api", tags=["profiles"]) +app.include_router(universes.router, prefix="/api", tags=["universes"]) +app.include_router(push_configs.router, prefix="/api", tags=["push-configs"]) +app.include_router(reading_events.router, prefix="/api", tags=["reading-events"]) +app.include_router(memories.router, prefix="/api", tags=["memories"]) + + +@app.get("/health") +async def health_check(): + """Simple liveness check.""" + return {"status": "ok"} diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/achievement_extractor.py b/backend/app/services/achievement_extractor.py new file mode 100644 index 0000000..50f1f5e --- /dev/null +++ b/backend/app/services/achievement_extractor.py @@ -0,0 +1,85 @@ +"""Achievement extraction service.""" + +import json +import re + +import httpx + +from app.core.config import settings +from app.core.logging import get_logger +from app.core.prompts import ACHIEVEMENT_EXTRACTION_PROMPT + +logger = get_logger(__name__) + +TEXT_API_BASE = "https://generativelanguage.googleapis.com/v1beta/models" + + +async def extract_achievements(story_text: str) -> list[dict]: + """Extract achievements from story text using LLM.""" + if not settings.text_api_key: + logger.warning("achievement_extraction_skipped", reason="missing_text_api_key") + return [] + + model = settings.text_model or "gemini-2.0-flash" + url = f"{TEXT_API_BASE}/{model}:generateContent" + + prompt = ACHIEVEMENT_EXTRACTION_PROMPT.format(story_text=story_text) + payload = { + "contents": [{"parts": [{"text": prompt}]}], + "generationConfig": { + "responseMimeType": "application/json", + "temperature": 0.2, + "topP": 0.9, + }, + } + + async with httpx.AsyncClient(timeout=30) as client: + response = await client.post( + url, + json=payload, + headers={"x-goog-api-key": settings.text_api_key}, + ) + response.raise_for_status() + result = response.json() + + candidates = result.get("candidates") or [] + if not candidates: + logger.warning("achievement_extraction_empty") + return [] + + parts = candidates[0].get("content", {}).get("parts") or [] + if not parts or "text" not in parts[0]: + logger.warning("achievement_extraction_missing_text") + return [] + + response_text = parts[0]["text"] + clean_json = response_text + if response_text.startswith("```json"): + clean_json = re.sub(r"^```json\n|```$", "", response_text) + + try: + parsed = json.loads(clean_json) + except json.JSONDecodeError: + logger.warning("achievement_extraction_parse_failed") + return [] + + achievements = parsed.get("achievements") + if not isinstance(achievements, list): + return [] + + normalized: list[dict] = [] + for item in achievements: + if not isinstance(item, dict): + continue + a_type = str(item.get("type", "")).strip() + description = str(item.get("description", "")).strip() + score = item.get("score", 0) + if not a_type or not description: + continue + normalized.append({ + "type": a_type, + "description": description, + "score": score + }) + + return normalized diff --git a/backend/app/services/adapters/__init__.py b/backend/app/services/adapters/__init__.py new file mode 100644 index 0000000..aed7b69 --- /dev/null +++ b/backend/app/services/adapters/__init__.py @@ -0,0 +1,21 @@ +"""适配器模块 - 供应商平台化架构核心。""" + +from app.services.adapters.base import AdapterConfig, BaseAdapter + +# Image adapters +from app.services.adapters.image import cqtai as _image_cqtai_adapter # noqa: F401 +from app.services.adapters.registry import AdapterRegistry + +# Storybook adapters +from app.services.adapters.storybook import primary as _storybook_primary # noqa: F401 +from app.services.adapters.text import gemini as _text_gemini_adapter # noqa: F401 + +# 导入所有适配器以触发注册 +# Text adapters +from app.services.adapters.text import openai as _text_openai_adapter # noqa: F401 + +# TTS adapters +from app.services.adapters.tts import elevenlabs as _tts_elevenlabs_adapter # noqa: F401 +from app.services.adapters.tts import minimax as _tts_minimax_adapter # noqa: F401 + +__all__ = ["AdapterConfig", "BaseAdapter", "AdapterRegistry"] diff --git a/backend/app/services/adapters/base.py b/backend/app/services/adapters/base.py new file mode 100644 index 0000000..5afb80c --- /dev/null +++ b/backend/app/services/adapters/base.py @@ -0,0 +1,46 @@ +"""适配器基类定义。""" + +from abc import ABC, abstractmethod +from typing import Generic, TypeVar + +from pydantic import BaseModel + +T = TypeVar("T") + + +class AdapterConfig(BaseModel): + """适配器配置基类。""" + + api_key: str + api_base: str | None = None + model: str | None = None + timeout_ms: int = 60000 + max_retries: int = 3 + extra_config: dict = {} + + +class BaseAdapter(ABC, Generic[T]): + """适配器基类,所有供应商适配器必须继承此类。""" + + # 子类必须定义 + adapter_type: str # text / image / tts + adapter_name: str # text_primary / image_primary / tts_primary + + def __init__(self, config: AdapterConfig): + self.config = config + + @abstractmethod + async def execute(self, **kwargs) -> T: + """执行适配器逻辑,返回结果。""" + pass + + @abstractmethod + async def health_check(self) -> bool: + """健康检查,返回是否可用。""" + pass + + @property + @abstractmethod + def estimated_cost(self) -> float: + """预估单次调用成本 (USD)。""" + pass diff --git a/backend/app/services/adapters/image/__init__.py b/backend/app/services/adapters/image/__init__.py new file mode 100644 index 0000000..1f0fbb7 --- /dev/null +++ b/backend/app/services/adapters/image/__init__.py @@ -0,0 +1,3 @@ +"""图像生成适配器。"""# Image adapters +from app.services.adapters.image import cqtai as _image_cqtai_adapter # noqa: F401 +from app.services.adapters.image import antigravity as _image_antigravity_adapter # noqa: F401 diff --git a/backend/app/services/adapters/image/antigravity.py b/backend/app/services/adapters/image/antigravity.py new file mode 100644 index 0000000..9ca48ea --- /dev/null +++ b/backend/app/services/adapters/image/antigravity.py @@ -0,0 +1,214 @@ +"""Antigravity 图像生成适配器。 + +使用 OpenAI 兼容 API 生成图像。 +支持 gemini-3-pro-image 等模型。 +""" + +import base64 +import time +from typing import Any + +from openai import AsyncOpenAI +from tenacity import ( + retry, + retry_if_exception_type, + stop_after_attempt, + wait_exponential, +) + +from app.core.logging import get_logger +from app.services.adapters.base import AdapterConfig, BaseAdapter +from app.services.adapters.registry import AdapterRegistry + +logger = get_logger(__name__) + +# 默认配置 +DEFAULT_API_BASE = "http://127.0.0.1:8045/v1" +DEFAULT_MODEL = "gemini-3-pro-image" +DEFAULT_SIZE = "1024x1024" + +# 支持的尺寸映射 +SUPPORTED_SIZES = { + "1024x1024": "1:1", + "1280x720": "16:9", + "720x1280": "9:16", + "1216x896": "4:3", +} + + +@AdapterRegistry.register("image", "antigravity") +class AntigravityImageAdapter(BaseAdapter[str]): + """Antigravity 图像生成适配器 (OpenAI 兼容 API)。 + + 特点: + - 使用 OpenAI 兼容的 chat.completions 端点 + - 通过 extra_body.size 指定图像尺寸 + - 支持 gemini-3-pro-image 等模型 + - 返回图片 URL 或 base64 + """ + + adapter_type = "image" + adapter_name = "antigravity" + + def __init__(self, config: AdapterConfig): + super().__init__(config) + self.api_base = config.api_base or DEFAULT_API_BASE + self.client = AsyncOpenAI( + base_url=self.api_base, + api_key=config.api_key, + timeout=config.timeout_ms / 1000, + ) + + async def execute( + self, + prompt: str, + model: str | None = None, + size: str | None = None, + num_images: int = 1, + **kwargs, + ) -> str | list[str]: + """根据提示词生成图片,返回 URL 或 base64。 + + Args: + prompt: 图片描述提示词 + model: 模型名称 (gemini-3-pro-image / gemini-3-pro-image-16-9 等) + size: 图像尺寸 (1024x1024, 1280x720, 720x1280, 1216x896) + num_images: 生成图片数量 (暂只支持 1) + + Returns: + 图片 URL 或 base64 字符串 + """ + # 优先使用传入参数,其次使用 Adapter 配置,最后使用默认值 + model = model or self.config.model or DEFAULT_MODEL + + cfg = self.config.extra_config or {} + size = size or cfg.get("size") or DEFAULT_SIZE + + start_time = time.time() + logger.info( + "antigravity_generate_start", + prompt_length=len(prompt), + model=model, + size=size, + ) + + # 调用 API + image_url = await self._generate_image(prompt, model, size) + + elapsed = time.time() - start_time + logger.info( + "antigravity_generate_success", + elapsed_seconds=round(elapsed, 2), + model=model, + ) + + return image_url + + async def health_check(self) -> bool: + """检查 Antigravity API 是否可用。""" + try: + # 简单测试连通性 + response = await self.client.chat.completions.create( + model=self.config.model or DEFAULT_MODEL, + messages=[{"role": "user", "content": "test"}], + max_tokens=1, + ) + return True + except Exception as e: + logger.warning("antigravity_health_check_failed", error=str(e)) + return False + + @property + def estimated_cost(self) -> float: + """预估每张图片成本 (USD)。 + + Antigravity 使用 Gemini 模型,成本约 $0.02/张。 + """ + return 0.02 + + @retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=1, max=10), + retry=retry_if_exception_type((Exception,)), + reraise=True, + ) + async def _generate_image( + self, + prompt: str, + model: str, + size: str, + ) -> str: + """调用 Antigravity API 生成图像。 + + Returns: + 图片 URL 或 base64 data URI + """ + try: + response = await self.client.chat.completions.create( + model=model, + messages=[{"role": "user", "content": prompt}], + extra_body={"size": size}, + ) + + # 解析响应 + content = response.choices[0].message.content + if not content: + raise ValueError("Antigravity 未返回内容") + + # 尝试解析为图片 URL 或 base64 + # 响应可能是纯 URL、base64 或 markdown 格式的图片 + image_url = self._extract_image_url(content) + if image_url: + return image_url + + raise ValueError(f"Antigravity 响应无法解析为图片: {content[:200]}") + + except Exception as e: + logger.error( + "antigravity_generate_error", + error=str(e), + model=model, + ) + raise + + def _extract_image_url(self, content: str) -> str | None: + """从响应内容中提取图片 URL。 + + 支持多种格式: + - 纯 URL: https://... + - Markdown: ![...](https://...) + - Base64 data URI: data:image/... + - 纯 base64 字符串 + """ + content = content.strip() + + # 1. 检查是否为 data URI + if content.startswith("data:image/"): + return content + + # 2. 检查是否为纯 URL + if content.startswith("http://") or content.startswith("https://"): + # 可能有多行,取第一行 + return content.split("\n")[0].strip() + + # 3. 检查 Markdown 图片格式 ![...](url) + import re + md_match = re.search(r"!\[.*?\]\((https?://[^\)]+)\)", content) + if md_match: + return md_match.group(1) + + # 4. 检查是否像 base64 编码的图片数据 + if self._looks_like_base64(content): + # 假设是 PNG + return f"data:image/png;base64,{content}" + + return None + + def _looks_like_base64(self, s: str) -> bool: + """判断字符串是否看起来像 base64 编码。""" + # Base64 只包含 A-Z, a-z, 0-9, +, /, = + # 且长度通常较长 + if len(s) < 100: + return False + import re + return bool(re.match(r"^[A-Za-z0-9+/=]+$", s.replace("\n", ""))) diff --git a/backend/app/services/adapters/image/cqtai.py b/backend/app/services/adapters/image/cqtai.py new file mode 100644 index 0000000..89e465b --- /dev/null +++ b/backend/app/services/adapters/image/cqtai.py @@ -0,0 +1,252 @@ +"""CQTAI nano 图像生成适配器。 + +支持异步生成 + 轮询获取结果。 +API 文档: https://api.cqtai.com +""" + +import asyncio +import time + +import httpx +from tenacity import ( + retry, + retry_if_exception_type, + stop_after_attempt, + wait_exponential, +) + +from app.core.logging import get_logger +from app.services.adapters.base import AdapterConfig, BaseAdapter +from app.services.adapters.registry import AdapterRegistry + +logger = get_logger(__name__) + +# 默认配置 +DEFAULT_API_BASE = "https://api.cqtai.com" +DEFAULT_MODEL = "nano-banana" +DEFAULT_RESOLUTION = "2K" +DEFAULT_ASPECT_RATIO = "1:1" +POLL_INTERVAL_SECONDS = 2 +MAX_POLL_ATTEMPTS = 60 # 最多轮询 2 分钟 + + +@AdapterRegistry.register("image", "cqtai") +class CQTAIImageAdapter(BaseAdapter[str]): + """CQTAI nano 图像生成适配器,返回图片 URL。 + + 特点: + - 异步生成 + 轮询获取结果 + - 支持 nano-banana (标准) 和 nano-banana-pro (高画质) + - 支持多种分辨率和画面比例 + - 支持图生图 (filesUrl) + """ + + adapter_type = "image" + adapter_name = "cqtai" + + def __init__(self, config: AdapterConfig): + super().__init__(config) + self.api_base = config.api_base or DEFAULT_API_BASE + + async def execute( + self, + prompt: str, + model: str | None = None, + resolution: str | None = None, + aspect_ratio: str | None = None, + num_images: int = 1, + files_url: list[str] | None = None, + **kwargs, + ) -> str | list[str]: + """根据提示词生成图片,返回 URL 或 URL 列表。 + + Args: + prompt: 图片描述提示词 + model: 模型名称 (nano-banana / nano-banana-pro) + resolution: 分辨率 (1K / 2K / 4K) + aspect_ratio: 画面比例 (1:1, 16:9, 9:16, 4:3, 3:4 等) + num_images: 生成图片数量 (1-4) + files_url: 输入图片 URL 列表 (图生图) + + Returns: + 单张图片返回 str,多张返回 list[str] + """ + # 1. 优先使用传入参数 + # 2. 其次使用 Adapter 配置里的 default (extra_config) + # 3. 最后使用系统默认值 + model = model or self.config.model or DEFAULT_MODEL + + cfg = self.config.extra_config or {} + resolution = resolution or cfg.get("resolution") or DEFAULT_RESOLUTION + aspect_ratio = aspect_ratio or cfg.get("aspect_ratio") or DEFAULT_ASPECT_RATIO + num_images = min(max(num_images, 1), 4) # 限制 1-4 + + start_time = time.time() + logger.info( + "cqtai_generate_start", + prompt_length=len(prompt), + model=model, + resolution=resolution, + aspect_ratio=aspect_ratio, + num_images=num_images, + ) + + # 1. 提交生成任务 + task_id = await self._submit_task( + prompt=prompt, + model=model, + resolution=resolution, + aspect_ratio=aspect_ratio, + num_images=num_images, + files_url=files_url or [], + ) + + logger.info("cqtai_task_submitted", task_id=task_id) + + # 2. 轮询获取结果 + result = await self._poll_result(task_id) + + elapsed = time.time() - start_time + logger.info( + "cqtai_generate_success", + task_id=task_id, + elapsed_seconds=round(elapsed, 2), + image_count=len(result) if isinstance(result, list) else 1, + ) + + # 单张图片返回字符串,多张返回列表 + if num_images == 1 and isinstance(result, list) and len(result) == 1: + return result[0] + return result + + async def health_check(self) -> bool: + """检查 CQTAI API 是否可用。""" + try: + async with httpx.AsyncClient(timeout=10) as client: + # 简单的连通性测试 + response = await client.get( + f"{self.api_base}/api/cqt/info/nano", + params={"id": "health_check_test"}, + headers={"Authorization": self.config.api_key}, + ) + # 即使返回错误也说明服务可达 + return response.status_code in (200, 400, 401, 403, 404) + except Exception: + return False + + @property + def estimated_cost(self) -> float: + """预估每张图片成本 (USD)。 + + nano-banana: ¥0.1 ≈ $0.014 + nano-banana-pro: ¥0.2 ≈ $0.028 + """ + model = self.config.model or DEFAULT_MODEL + if model == "nano-banana-pro": + return 0.028 + return 0.014 + + @retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=1, max=10), + retry=retry_if_exception_type((httpx.HTTPError, httpx.TimeoutException)), + reraise=True, + ) + async def _submit_task( + self, + prompt: str, + model: str, + resolution: str, + aspect_ratio: str, + num_images: int, + files_url: list[str], + ) -> str: + """提交图像生成任务,返回任务 ID。""" + timeout = self.config.timeout_ms / 1000 + + payload = { + "prompt": prompt, + "numImages": num_images, + "aspectRatio": aspect_ratio, + "filesUrl": files_url, + } + + # 可选参数,不传则使用默认值 + if model != DEFAULT_MODEL: + payload["model"] = model + if resolution != DEFAULT_RESOLUTION: + payload["resolution"] = resolution + + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.post( + f"{self.api_base}/api/cqt/generator/nano", + json=payload, + headers={ + "Authorization": self.config.api_key, + "Content-Type": "application/json", + }, + ) + response.raise_for_status() + data = response.json() + + if data.get("code") != 200: + raise ValueError(f"CQTAI 任务提交失败: {data.get('msg', '未知错误')}") + + task_id = data.get("data") + if not task_id: + raise ValueError("CQTAI 未返回任务 ID") + + return task_id + + async def _poll_result(self, task_id: str) -> list[str]: + """轮询获取生成结果。 + + Returns: + 图片 URL 列表 + """ + timeout = self.config.timeout_ms / 1000 + + for attempt in range(MAX_POLL_ATTEMPTS): + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.get( + f"{self.api_base}/api/cqt/info/nano", + params={"id": task_id}, + headers={"Authorization": self.config.api_key}, + ) + response.raise_for_status() + data = response.json() + + if data.get("code") != 200: + raise ValueError(f"CQTAI 查询失败: {data.get('msg', '未知错误')}") + + result_data = data.get("data", {}) + status = result_data.get("status") + + if status == "completed": + # 提取图片 URL + images = result_data.get("images", []) + if not images: + # 兼容不同返回格式 + image_url = result_data.get("imageUrl") or result_data.get("url") + if image_url: + images = [image_url] + + if not images: + raise ValueError("CQTAI 未返回图片 URL") + + return images + + elif status == "failed": + error_msg = result_data.get("error", "生成失败") + raise ValueError(f"CQTAI 图像生成失败: {error_msg}") + + # 继续等待 + logger.debug( + "cqtai_poll_waiting", + task_id=task_id, + attempt=attempt + 1, + status=status, + ) + await asyncio.sleep(POLL_INTERVAL_SECONDS) + + raise TimeoutError(f"CQTAI 任务超时: {task_id}") diff --git a/backend/app/services/adapters/registry.py b/backend/app/services/adapters/registry.py new file mode 100644 index 0000000..60a5cbc --- /dev/null +++ b/backend/app/services/adapters/registry.py @@ -0,0 +1,73 @@ +"""适配器注册表 - 支持动态注册和工厂创建。""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from app.services.adapters.base import AdapterConfig, BaseAdapter + + +class AdapterRegistry: + """适配器注册表,管理所有已注册的适配器类。""" + + _adapters: dict[str, type["BaseAdapter"]] = {} + + @classmethod + def register(cls, adapter_type: str, adapter_name: str): + """装饰器:注册适配器类。 + + 用法: + @AdapterRegistry.register("text", "text_primary") + class TextPrimaryAdapter(BaseAdapter[StoryOutput]): + ... + """ + + def decorator(adapter_class: type["BaseAdapter"]): + key = f"{adapter_type}:{adapter_name}" + cls._adapters[key] = adapter_class + # 自动设置类属性 + adapter_class.adapter_type = adapter_type + adapter_class.adapter_name = adapter_name + return adapter_class + + return decorator + + @classmethod + def get(cls, adapter_type: str, adapter_name: str) -> type["BaseAdapter"] | None: + """获取已注册的适配器类。""" + key = f"{adapter_type}:{adapter_name}" + return cls._adapters.get(key) + + @classmethod + def list_adapters(cls, adapter_type: str | None = None) -> list[str]: + """列出所有已注册的适配器。 + + Args: + adapter_type: 可选,筛选特定类型 (text/image/tts) + + Returns: + 适配器键列表,格式为 "type:name" + """ + if adapter_type: + return [k for k in cls._adapters if k.startswith(f"{adapter_type}:")] + return list(cls._adapters.keys()) + + @classmethod + def create( + cls, + adapter_type: str, + adapter_name: str, + config: "AdapterConfig", + ) -> "BaseAdapter": + """工厂方法:创建适配器实例。 + + Raises: + ValueError: 适配器未注册 + """ + adapter_class = cls.get(adapter_type, adapter_name) + if not adapter_class: + available = cls.list_adapters(adapter_type) + raise ValueError( + f"适配器 '{adapter_type}:{adapter_name}' 未注册。" + f"可用: {available}" + ) + return adapter_class(config) diff --git a/backend/app/services/adapters/storybook/__init__.py b/backend/app/services/adapters/storybook/__init__.py new file mode 100644 index 0000000..dc26763 --- /dev/null +++ b/backend/app/services/adapters/storybook/__init__.py @@ -0,0 +1 @@ +"""Storybook 适配器模块。""" diff --git a/backend/app/services/adapters/storybook/primary.py b/backend/app/services/adapters/storybook/primary.py new file mode 100644 index 0000000..8987889 --- /dev/null +++ b/backend/app/services/adapters/storybook/primary.py @@ -0,0 +1,195 @@ +"""Storybook 适配器 - 生成可翻页的分页故事书。""" + +import json +import random +import re +import time +from dataclasses import dataclass, field + +import httpx +from tenacity import ( + retry, + retry_if_exception_type, + stop_after_attempt, + wait_exponential, +) + +from app.core.logging import get_logger +from app.core.prompts import ( + RANDOM_ELEMENTS, + SYSTEM_INSTRUCTION_STORYBOOK, + USER_PROMPT_STORYBOOK, +) +from app.services.adapters.base import BaseAdapter +from app.services.adapters.registry import AdapterRegistry + +logger = get_logger(__name__) + +TEXT_API_BASE = "https://generativelanguage.googleapis.com/v1beta/models" + + +@dataclass +class StorybookPage: + """故事书单页。""" + + page_number: int + text: str + image_prompt: str + image_url: str | None = None + + +@dataclass +class Storybook: + """故事书输出。""" + + title: str + main_character: str + art_style: str + pages: list[StorybookPage] = field(default_factory=list) + cover_prompt: str = "" + cover_url: str | None = None + + +@AdapterRegistry.register("storybook", "storybook_primary") +class StorybookPrimaryAdapter(BaseAdapter[Storybook]): + """Storybook 生成适配器(默认)。 + + 生成分页故事书结构,包含每页文字和图像提示词。 + 图像生成需要单独调用 image adapter。 + """ + + adapter_type = "storybook" + adapter_name = "storybook_primary" + + async def execute( + self, + keywords: str, + page_count: int = 6, + education_theme: str | None = None, + memory_context: str | None = None, + **kwargs, + ) -> Storybook: + """生成分页故事书。 + + Args: + keywords: 故事关键词 + page_count: 页数 (4-12) + education_theme: 教育主题 + memory_context: 记忆上下文 + + Returns: + Storybook 对象,包含标题、页面列表和封面提示词 + """ + start_time = time.time() + page_count = max(4, min(page_count, 12)) # 限制 4-12 页 + + logger.info( + "storybook_generate_start", + keywords=keywords, + page_count=page_count, + has_memory=bool(memory_context), + ) + + theme = education_theme or "成长" + random_element = random.choice(RANDOM_ELEMENTS) + + prompt = USER_PROMPT_STORYBOOK.format( + keywords=keywords, + education_theme=theme, + random_element=random_element, + page_count=page_count, + memory_context=memory_context or "", + ) + + payload = { + "system_instruction": {"parts": [{"text": SYSTEM_INSTRUCTION_STORYBOOK}]}, + "contents": [{"parts": [{"text": prompt}]}], + "generationConfig": { + "responseMimeType": "application/json", + "temperature": 0.95, + "topP": 0.9, + }, + } + + result = await self._call_api(payload) + + candidates = result.get("candidates") or [] + if not candidates: + raise ValueError("Storybook 服务未返回内容") + + parts = candidates[0].get("content", {}).get("parts") or [] + if not parts or "text" not in parts[0]: + raise ValueError("Storybook 服务响应缺少文本") + + response_text = parts[0]["text"] + clean_json = response_text + if response_text.startswith("```json"): + clean_json = re.sub(r"^```json\n|```$", "", response_text) + + try: + parsed = json.loads(clean_json) + except json.JSONDecodeError as exc: + raise ValueError(f"Storybook JSON 解析失败: {exc}") + + # 构建 Storybook 对象 + pages = [ + StorybookPage( + page_number=p.get("page_number", i + 1), + text=p.get("text", ""), + image_prompt=p.get("image_prompt", ""), + ) + for i, p in enumerate(parsed.get("pages", [])) + ] + + storybook = Storybook( + title=parsed.get("title", "未命名故事"), + main_character=parsed.get("main_character", ""), + art_style=parsed.get("art_style", ""), + pages=pages, + cover_prompt=parsed.get("cover_prompt", ""), + ) + + elapsed = time.time() - start_time + logger.info( + "storybook_generate_success", + elapsed_seconds=round(elapsed, 2), + title=storybook.title, + page_count=len(pages), + ) + + return storybook + + + async def health_check(self) -> bool: + """检查 API 是否可用。""" + try: + payload = { + "contents": [{"parts": [{"text": "Hi"}]}], + "generationConfig": {"maxOutputTokens": 10}, + } + await self._call_api(payload) + return True + except Exception: + return False + + @property + def estimated_cost(self) -> float: + """预估成本(仅文本生成,不含图像)。""" + return 0.002 # 比普通故事稍贵,因为输出更长 + + @retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=1, max=10), + retry=retry_if_exception_type((httpx.HTTPError, httpx.TimeoutException)), + reraise=True, + ) + async def _call_api(self, payload: dict) -> dict: + """调用 API,带重试机制。""" + model = self.config.model or "gemini-2.0-flash" + url = f"{TEXT_API_BASE}/{model}:generateContent?key={self.config.api_key}" + timeout = self.config.timeout_ms / 1000 + + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.post(url, json=payload) + response.raise_for_status() + return response.json() diff --git a/backend/app/services/adapters/text/__init__.py b/backend/app/services/adapters/text/__init__.py new file mode 100644 index 0000000..b42c816 --- /dev/null +++ b/backend/app/services/adapters/text/__init__.py @@ -0,0 +1 @@ +"""文本生成适配器。""" diff --git a/backend/app/services/adapters/text/gemini.py b/backend/app/services/adapters/text/gemini.py new file mode 100644 index 0000000..6be9d52 --- /dev/null +++ b/backend/app/services/adapters/text/gemini.py @@ -0,0 +1,164 @@ +"""文本生成适配器 (Google Gemini)。""" + +import json +import random +import re +import time +from typing import Literal + +import httpx +from tenacity import ( + retry, + retry_if_exception_type, + stop_after_attempt, + wait_exponential, +) + +from app.core.logging import get_logger +from app.core.prompts import ( + RANDOM_ELEMENTS, + SYSTEM_INSTRUCTION_ENHANCER, + SYSTEM_INSTRUCTION_STORYTELLER, + USER_PROMPT_ENHANCEMENT, + USER_PROMPT_GENERATION, +) +from app.services.adapters.base import BaseAdapter +from app.services.adapters.registry import AdapterRegistry +from app.services.adapters.text.models import StoryOutput + +logger = get_logger(__name__) + +TEXT_API_BASE = "https://generativelanguage.googleapis.com/v1beta/models" + + +@AdapterRegistry.register("text", "gemini") +class GeminiTextAdapter(BaseAdapter[StoryOutput]): + """Google Gemini 文本生成适配器。""" + + adapter_type = "text" + adapter_name = "gemini" + + async def execute( + self, + input_type: Literal["keywords", "full_story"], + data: str, + education_theme: str | None = None, + memory_context: str | None = None, + **kwargs, + ) -> StoryOutput: + """生成或润色故事。""" + start_time = time.time() + logger.info("request_start", adapter="gemini", input_type=input_type, data_length=len(data)) + + theme = education_theme or "成长" + random_element = random.choice(RANDOM_ELEMENTS) + + if input_type == "keywords": + system_instruction = SYSTEM_INSTRUCTION_STORYTELLER + prompt = USER_PROMPT_GENERATION.format( + keywords=data, + education_theme=theme, + random_element=random_element, + memory_context=memory_context or "", + ) + else: + system_instruction = SYSTEM_INSTRUCTION_ENHANCER + prompt = USER_PROMPT_ENHANCEMENT.format( + full_story=data, + education_theme=theme, + random_element=random_element, + memory_context=memory_context or "", + ) + + # Gemini API Payload supports 'system_instruction' + payload = { + "system_instruction": {"parts": [{"text": system_instruction}]}, + "contents": [{"parts": [{"text": prompt}]}], + "generationConfig": { + "responseMimeType": "application/json", + "temperature": 0.95, + "topP": 0.9, + }, + } + + result = await self._call_api(payload) + + candidates = result.get("candidates") or [] + if not candidates: + raise ValueError("Gemini 未返回内容") + + parts = candidates[0].get("content", {}).get("parts") or [] + if not parts or "text" not in parts[0]: + raise ValueError("Gemini 响应缺少文本") + + response_text = parts[0]["text"] + clean_json = response_text + if response_text.startswith("```json"): + clean_json = re.sub(r"^```json\n|```$", "", response_text) + + try: + parsed = json.loads(clean_json) + except json.JSONDecodeError as exc: + raise ValueError(f"Gemini 输出 JSON 解析失败: {exc}") + + required_fields = ["mode", "title", "story_text", "cover_prompt_suggestion"] + if any(field not in parsed for field in required_fields): + raise ValueError("Gemini 输出缺少必要字段") + + elapsed = time.time() - start_time + logger.info( + "request_success", + adapter="gemini", + elapsed_seconds=round(elapsed, 2), + title=parsed["title"], + ) + + return StoryOutput( + mode=parsed["mode"], + title=parsed["title"], + story_text=parsed["story_text"], + cover_prompt_suggestion=parsed["cover_prompt_suggestion"], + ) + + async def health_check(self) -> bool: + """检查 Gemini API 是否可用。""" + try: + payload = { + "contents": [{"parts": [{"text": "Hi"}]}], + "generationConfig": {"maxOutputTokens": 10}, + } + await self._call_api(payload) + return True + except Exception: + return False + + @property + def estimated_cost(self) -> float: + return 0.001 + + @retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=1, max=10), + retry=retry_if_exception_type((httpx.HTTPError, httpx.TimeoutException)), + reraise=True, + ) + async def _call_api(self, payload: dict) -> dict: + """调用 Gemini API。""" + model = self.config.model or "gemini-2.0-flash" + base_url = self.config.api_base or TEXT_API_BASE + + # 智能补全: + # 1. 如果用户填了完整路径 (以 /models 结尾),就直接用 (支持 v1 或 v1beta) + if self.config.api_base and base_url.rstrip("/").endswith("/models"): + pass + # 2. 如果没填路径 (只是域名),默认补全代码适配的 /v1beta/models + elif self.config.api_base: + base_url = f"{base_url.rstrip('/')}/v1beta/models" + + url = f"{base_url}/{model}:generateContent?key={self.config.api_key}" + timeout = self.config.timeout_ms / 1000 + + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.post(url, json=payload) + response.raise_for_status() + return response.json() diff --git a/backend/app/services/adapters/text/models.py b/backend/app/services/adapters/text/models.py new file mode 100644 index 0000000..01ba09c --- /dev/null +++ b/backend/app/services/adapters/text/models.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass +from typing import Literal + + +@dataclass +class StoryOutput: + """故事生成输出。""" + mode: Literal["generated", "enhanced"] + title: str + story_text: str + cover_prompt_suggestion: str diff --git a/backend/app/services/adapters/text/openai.py b/backend/app/services/adapters/text/openai.py new file mode 100644 index 0000000..0c1d654 --- /dev/null +++ b/backend/app/services/adapters/text/openai.py @@ -0,0 +1,172 @@ +"""OpenAI 文本生成适配器。""" + +import json +import random +import re +import time +from typing import Literal + +import httpx +from tenacity import ( + retry, + retry_if_exception_type, + stop_after_attempt, + wait_exponential, +) + +from app.core.logging import get_logger +from app.core.prompts import ( + RANDOM_ELEMENTS, + SYSTEM_INSTRUCTION_ENHANCER, + SYSTEM_INSTRUCTION_STORYTELLER, + USER_PROMPT_ENHANCEMENT, + USER_PROMPT_GENERATION, +) +from app.services.adapters.base import BaseAdapter +from app.services.adapters.registry import AdapterRegistry +from app.services.adapters.text.models import StoryOutput + +logger = get_logger(__name__) + +OPENAI_API_BASE = "https://api.openai.com/v1/chat/completions" + + + + +@AdapterRegistry.register("text", "openai") +class OpenAITextAdapter(BaseAdapter[StoryOutput]): + """OpenAI 文本生成适配器。""" + + adapter_type = "text" + adapter_name = "openai" + + async def execute( + self, + input_type: Literal["keywords", "full_story"], + data: str, + education_theme: str | None = None, + memory_context: str | None = None, + **kwargs, + ) -> StoryOutput: + """生成或润色故事。""" + start_time = time.time() + logger.info("openai_text_request_start", input_type=input_type, data_length=len(data)) + + theme = education_theme or "成长" + random_element = random.choice(RANDOM_ELEMENTS) + + if input_type == "keywords": + system_instruction = SYSTEM_INSTRUCTION_STORYTELLER + prompt = USER_PROMPT_GENERATION.format( + keywords=data, + education_theme=theme, + random_element=random_element, + memory_context=memory_context or "", + ) + else: + system_instruction = SYSTEM_INSTRUCTION_ENHANCER + prompt = USER_PROMPT_ENHANCEMENT.format( + full_story=data, + education_theme=theme, + random_element=random_element, + memory_context=memory_context or "", + ) + + model = self.config.model or "gpt-4o-mini" + payload = { + "model": model, + "messages": [ + { + "role": "system", + "content": system_instruction, + }, + {"role": "user", "content": prompt}, + ], + "response_format": {"type": "json_object"}, + "temperature": 0.95, + "top_p": 0.9, + } + + result = await self._call_api(payload) + + choices = result.get("choices") or [] + if not choices: + raise ValueError("OpenAI 未返回内容") + + response_text = choices[0].get("message", {}).get("content", "") + if not response_text: + raise ValueError("OpenAI 响应缺少文本") + + clean_json = response_text + if response_text.startswith("```json"): + clean_json = re.sub(r"^```json\n|```$", "", response_text) + + try: + parsed = json.loads(clean_json) + except json.JSONDecodeError as exc: + raise ValueError(f"OpenAI 输出 JSON 解析失败: {exc}") + + required_fields = ["mode", "title", "story_text", "cover_prompt_suggestion"] + if any(field not in parsed for field in required_fields): + raise ValueError("OpenAI 输出缺少必要字段") + + elapsed = time.time() - start_time + logger.info( + "openai_text_request_success", + elapsed_seconds=round(elapsed, 2), + title=parsed["title"], + mode=parsed["mode"], + ) + + return StoryOutput( + mode=parsed["mode"], + title=parsed["title"], + story_text=parsed["story_text"], + cover_prompt_suggestion=parsed["cover_prompt_suggestion"], + ) + + @retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=4, max=10), + retry=retry_if_exception_type(httpx.HTTPStatusError), + ) + async def _call_api(self, payload: dict) -> dict: + """调用 OpenAI API,带重试机制。""" + url = self.config.api_base or OPENAI_API_BASE + + # 智能补全: 如果用户只填了 Base URL,自动补全路径 + if self.config.api_base and not url.endswith("/chat/completions"): + base = url.rstrip("/") + url = f"{base}/chat/completions" + + timeout = self.config.timeout_ms / 1000 + + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.post( + url, + json=payload, + headers={ + "Authorization": f"Bearer {self.config.api_key}", + "Content-Type": "application/json", + }, + ) + response.raise_for_status() + return response.json() + + async def health_check(self) -> bool: + """检查 OpenAI API 是否可用。""" + try: + payload = { + "model": self.config.model or "gpt-4o-mini", + "messages": [{"role": "user", "content": "Hi"}], + "max_tokens": 5, + } + await self._call_api(payload) + return True + except Exception: + return False + + @property + def estimated_cost(self) -> float: + """预估文本生成成本 (USD)。""" + return 0.01 diff --git a/backend/app/services/adapters/tts/__init__.py b/backend/app/services/adapters/tts/__init__.py new file mode 100644 index 0000000..4b3b162 --- /dev/null +++ b/backend/app/services/adapters/tts/__init__.py @@ -0,0 +1,5 @@ +"""TTS 语音合成适配器。""" + +from app.services.adapters.tts import edge_tts as _tts_edge_tts_adapter # noqa: F401 +from app.services.adapters.tts import elevenlabs as _tts_elevenlabs_adapter # noqa: F401 +from app.services.adapters.tts import minimax as _tts_minimax_adapter # noqa: F401 diff --git a/backend/app/services/adapters/tts/edge_tts.py b/backend/app/services/adapters/tts/edge_tts.py new file mode 100644 index 0000000..4d68bbb --- /dev/null +++ b/backend/app/services/adapters/tts/edge_tts.py @@ -0,0 +1,66 @@ +"""EdgeTTS 免费语音生成适配器。""" + +import time + +import edge_tts + +from app.core.logging import get_logger +from app.services.adapters.base import BaseAdapter +from app.services.adapters.registry import AdapterRegistry + +logger = get_logger(__name__) + +# 默认中文女声 (晓晓) +DEFAULT_VOICE = "zh-CN-XiaoxiaoNeural" + + +@AdapterRegistry.register("tts", "edge_tts") +class EdgeTTSAdapter(BaseAdapter[bytes]): + """EdgeTTS 语音生成适配器 (Free)。 + + 不需要 API Key。 + """ + + adapter_type = "tts" + adapter_name = "edge_tts" + + async def execute(self, text: str, **kwargs) -> bytes: + """生成语音。""" + # 支持动态指定音色 + voice = kwargs.get("voice") or self.config.model or DEFAULT_VOICE + + start_time = time.time() + logger.info("edge_tts_generate_start", text_length=len(text), voice=voice) + + # EdgeTTS 只能输出到文件,我们需要用临时文件周转一下 + # 或者直接 capture stream (communicate) 但 edge-tts 库主要面向文件 + + # 优化: 使用 communicate 直接获取 bytes,无需磁盘IO + communicate = edge_tts.Communicate(text, voice) + + audio_data = b"" + async for chunk in communicate.stream(): + if chunk["type"] == "audio": + audio_data += chunk["data"] + + elapsed = time.time() - start_time + logger.info( + "edge_tts_generate_success", + elapsed_seconds=round(elapsed, 2), + audio_size_bytes=len(audio_data), + ) + + return audio_data + + async def health_check(self) -> bool: + """检查 EdgeTTS 是否可用 (网络连通性)。""" + try: + # 简单生成一个词 + await self.execute("Hi") + return True + except Exception: + return False + + @property + def estimated_cost(self) -> float: + return 0.0 # Free! diff --git a/backend/app/services/adapters/tts/elevenlabs.py b/backend/app/services/adapters/tts/elevenlabs.py new file mode 100644 index 0000000..9b96ad7 --- /dev/null +++ b/backend/app/services/adapters/tts/elevenlabs.py @@ -0,0 +1,104 @@ +"""ElevenLabs TTS 语音合成适配器。""" + +import time + +import httpx +from tenacity import ( + retry, + retry_if_exception_type, + stop_after_attempt, + wait_exponential, +) + +from app.core.logging import get_logger +from app.services.adapters.base import AdapterConfig, BaseAdapter +from app.services.adapters.registry import AdapterRegistry + +logger = get_logger(__name__) + +ELEVENLABS_API_BASE = "https://api.elevenlabs.io/v1" +DEFAULT_VOICE_ID = "21m00Tcm4TlvDq8ikWAM" # Rachel + + +@AdapterRegistry.register("tts", "elevenlabs") +class ElevenLabsTtsAdapter(BaseAdapter[bytes]): + """ElevenLabs TTS 语音合成适配器,返回 MP3 bytes。""" + + adapter_type = "tts" + adapter_name = "elevenlabs" + + def __init__(self, config: AdapterConfig): + super().__init__(config) + self.api_base = config.api_base or ELEVENLABS_API_BASE + + async def execute(self, text: str, **kwargs) -> bytes: + """将文本转换为语音 MP3 bytes。""" + start_time = time.time() + logger.info("elevenlabs_tts_start", text_length=len(text)) + + voice_id = kwargs.get("voice_id") or DEFAULT_VOICE_ID + model_id = kwargs.get("model") or self.config.model or "eleven_multilingual_v2" + stability = kwargs.get("stability", 0.5) + similarity_boost = kwargs.get("similarity_boost", 0.75) + + url = f"{self.api_base}/text-to-speech/{voice_id}" + + payload = { + "text": text, + "model_id": model_id, + "voice_settings": { + "stability": stability, + "similarity_boost": similarity_boost, + }, + } + + audio_bytes = await self._call_api(url, payload) + + elapsed = time.time() - start_time + logger.info( + "elevenlabs_tts_success", + elapsed_seconds=round(elapsed, 2), + audio_size_bytes=len(audio_bytes), + ) + + return audio_bytes + + async def health_check(self) -> bool: + """检查 ElevenLabs API 是否可用。""" + try: + async with httpx.AsyncClient(timeout=10) as client: + response = await client.get( + f"{self.api_base}/voices", + headers={"xi-api-key": self.config.api_key}, + ) + return response.status_code == 200 + except Exception: + return False + + @property + def estimated_cost(self) -> float: + """预估每千字符成本 (USD)。""" + return 0.03 + + @retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=1, max=10), + retry=retry_if_exception_type((httpx.HTTPError, httpx.TimeoutException)), + reraise=True, + ) + async def _call_api(self, url: str, payload: dict) -> bytes: + """调用 ElevenLabs API,带重试机制。""" + timeout = self.config.timeout_ms / 1000 + + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.post( + url, + json=payload, + headers={ + "xi-api-key": self.config.api_key, + "Content-Type": "application/json", + "Accept": "audio/mpeg", + }, + ) + response.raise_for_status() + return response.content diff --git a/backend/app/services/adapters/tts/minimax.py b/backend/app/services/adapters/tts/minimax.py new file mode 100644 index 0000000..cdacc83 --- /dev/null +++ b/backend/app/services/adapters/tts/minimax.py @@ -0,0 +1,149 @@ +"""MiniMax 语音生成适配器 (T2A V2)。""" + +import time + +import httpx +from tenacity import ( + retry, + retry_if_exception_type, + stop_after_attempt, + wait_exponential, +) + +from app.core.config import settings +from app.core.logging import get_logger +from app.services.adapters.base import AdapterConfig, BaseAdapter +from app.services.adapters.registry import AdapterRegistry + +logger = get_logger(__name__) + +# MiniMax API 配置 +DEFAULT_API_URL = "https://api.minimaxi.com/v1/t2a_v2" +DEFAULT_MODEL = "speech-2.6-turbo" + +@AdapterRegistry.register("tts", "minimax") +class MiniMaxTTSAdapter(BaseAdapter[bytes]): + """MiniMax 语音生成适配器。 + + 需要配置: + - api_key: MiniMax API Key + - minimax_group_id: 可选 (取决于使用的模型/账户类型) + """ + + adapter_type = "tts" + adapter_name = "minimax" + + def __init__(self, config: AdapterConfig): + super().__init__(config) + self.api_url = DEFAULT_API_URL + + async def execute( + self, + text: str, + voice_id: str | None = None, + model: str | None = None, + speed: float | None = None, + vol: float | None = None, + pitch: int | None = None, + emotion: str | None = None, + **kwargs, + ) -> bytes: + """生成语音。""" + # 1. 优先使用传入参数 + # 2. 其次使用 Adapter 配置里的 default + # 3. 最后使用系统默认值 + model = model or self.config.model or DEFAULT_MODEL + + cfg = self.config.extra_config or {} + + voice_id = voice_id or cfg.get("voice_id") or "male-qn-qingse" + speed = speed if speed is not None else (cfg.get("speed") or 1.0) + vol = vol if vol is not None else (cfg.get("vol") or 1.0) + pitch = pitch if pitch is not None else (cfg.get("pitch") or 0) + emotion = emotion or cfg.get("emotion") + group_id = kwargs.get("group_id") or settings.minimax_group_id + + url = self.api_url + if group_id: + url = f"{self.api_url}?GroupId={group_id}" + + payload = { + "model": model, + "text": text, + "stream": False, + "voice_setting": { + "voice_id": voice_id, + "speed": speed, + "vol": vol, + "pitch": pitch, + }, + "audio_setting": { + "sample_rate": 32000, + "bitrate": 128000, + "format": "mp3", + "channel": 1 + } + } + + if emotion: + payload["voice_setting"]["emotion"] = emotion + + start_time = time.time() + logger.info("minimax_generate_start", text_length=len(text), model=model) + + result = await self._call_api(url, payload) + + # 错误处理 + if result.get("base_resp", {}).get("status_code") != 0: + error_msg = result.get("base_resp", {}).get("status_msg", "未知错误") + raise ValueError(f"MiniMax API 错误: {error_msg}") + + # Hex 解码 (关键逻辑,从 primary.py 迁移) + hex_audio = result.get("data", {}).get("audio") + if not hex_audio: + raise ValueError("API 响应中未找到音频数据 (data.audio)") + + try: + audio_bytes = bytes.fromhex(hex_audio) + except ValueError: + raise ValueError("MiniMax 返回的音频数据不是有效的 Hex 字符串") + + elapsed = time.time() - start_time + logger.info( + "minimax_generate_success", + elapsed_seconds=round(elapsed, 2), + audio_size_bytes=len(audio_bytes), + ) + + return audio_bytes + + async def health_check(self) -> bool: + """检查 Minimax API 是否可用。""" + try: + # 尝试生成极短文本 + await self.execute("Hi") + return True + except Exception: + return False + + @retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=1, max=10), + retry=retry_if_exception_type((httpx.HTTPError, httpx.TimeoutException)), + reraise=True, + ) + async def _call_api(self, url: str, payload: dict) -> dict: + """调用 API,带重试机制。""" + timeout = self.config.timeout_ms / 1000 + + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.post( + url, + json=payload, + headers={ + "Authorization": f"Bearer {self.config.api_key}", + "Content-Type": "application/json", + }, + ) + response.raise_for_status() + return response.json() diff --git a/backend/app/services/cost_tracker.py b/backend/app/services/cost_tracker.py new file mode 100644 index 0000000..021250f --- /dev/null +++ b/backend/app/services/cost_tracker.py @@ -0,0 +1,196 @@ +"""成本追踪服务。 + +记录 API 调用成本,支持预算控制。 +""" + +from datetime import datetime, timedelta +from decimal import Decimal + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.logging import get_logger +from app.db.admin_models import CostRecord, UserBudget + +logger = get_logger(__name__) + + +class BudgetExceededError(Exception): + """预算超限错误。""" + + def __init__(self, limit_type: str, used: Decimal, limit: Decimal): + self.limit_type = limit_type + self.used = used + self.limit = limit + super().__init__(f"{limit_type} 预算已超限: {used}/{limit} USD") + + +class CostTracker: + """成本追踪器。""" + + async def record_cost( + self, + db: AsyncSession, + user_id: str, + provider_name: str, + capability: str, + estimated_cost: float, + provider_id: str | None = None, + ) -> CostRecord: + """记录一次 API 调用成本。""" + record = CostRecord( + user_id=user_id, + provider_id=provider_id, + provider_name=provider_name, + capability=capability, + estimated_cost=Decimal(str(estimated_cost)), + ) + db.add(record) + await db.commit() + + logger.debug( + "cost_recorded", + user_id=user_id, + provider=provider_name, + capability=capability, + cost=estimated_cost, + ) + return record + + async def get_daily_cost(self, db: AsyncSession, user_id: str) -> Decimal: + """获取用户今日成本。""" + today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + + result = await db.execute( + select(func.sum(CostRecord.estimated_cost)).where( + CostRecord.user_id == user_id, + CostRecord.timestamp >= today_start, + ) + ) + total = result.scalar() + return Decimal(str(total)) if total else Decimal("0") + + async def get_monthly_cost(self, db: AsyncSession, user_id: str) -> Decimal: + """获取用户本月成本。""" + now = datetime.utcnow() + month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + result = await db.execute( + select(func.sum(CostRecord.estimated_cost)).where( + CostRecord.user_id == user_id, + CostRecord.timestamp >= month_start, + ) + ) + total = result.scalar() + return Decimal(str(total)) if total else Decimal("0") + + async def get_cost_by_capability( + self, + db: AsyncSession, + user_id: str, + days: int = 30, + ) -> dict[str, Decimal]: + """按能力类型统计成本。""" + since = datetime.utcnow() - timedelta(days=days) + + result = await db.execute( + select(CostRecord.capability, func.sum(CostRecord.estimated_cost)) + .where(CostRecord.user_id == user_id, CostRecord.timestamp >= since) + .group_by(CostRecord.capability) + ) + return {row[0]: Decimal(str(row[1])) for row in result.all()} + + async def check_budget( + self, + db: AsyncSession, + user_id: str, + estimated_cost: float, + ) -> bool: + """检查预算是否允许此次调用。 + + Returns: + True 如果允许,否则抛出 BudgetExceededError + """ + budget = await self.get_user_budget(db, user_id) + if not budget or not budget.enabled: + return True + + # 检查日预算 + daily_cost = await self.get_daily_cost(db, user_id) + if daily_cost + Decimal(str(estimated_cost)) > budget.daily_limit_usd: + raise BudgetExceededError("日", daily_cost, budget.daily_limit_usd) + + # 检查月预算 + monthly_cost = await self.get_monthly_cost(db, user_id) + if monthly_cost + Decimal(str(estimated_cost)) > budget.monthly_limit_usd: + raise BudgetExceededError("月", monthly_cost, budget.monthly_limit_usd) + + return True + + async def get_user_budget(self, db: AsyncSession, user_id: str) -> UserBudget | None: + """获取用户预算配置。""" + result = await db.execute( + select(UserBudget).where(UserBudget.user_id == user_id) + ) + return result.scalar_one_or_none() + + async def set_user_budget( + self, + db: AsyncSession, + user_id: str, + daily_limit: float | None = None, + monthly_limit: float | None = None, + alert_threshold: float | None = None, + enabled: bool | None = None, + ) -> UserBudget: + """设置用户预算。""" + budget = await self.get_user_budget(db, user_id) + + if budget is None: + budget = UserBudget(user_id=user_id) + db.add(budget) + + if daily_limit is not None: + budget.daily_limit_usd = Decimal(str(daily_limit)) + if monthly_limit is not None: + budget.monthly_limit_usd = Decimal(str(monthly_limit)) + if alert_threshold is not None: + budget.alert_threshold = Decimal(str(alert_threshold)) + if enabled is not None: + budget.enabled = enabled + + await db.commit() + await db.refresh(budget) + return budget + + async def get_cost_summary( + self, + db: AsyncSession, + user_id: str, + ) -> dict: + """获取用户成本摘要。""" + daily = await self.get_daily_cost(db, user_id) + monthly = await self.get_monthly_cost(db, user_id) + by_capability = await self.get_cost_by_capability(db, user_id) + budget = await self.get_user_budget(db, user_id) + + return { + "daily_cost_usd": float(daily), + "monthly_cost_usd": float(monthly), + "by_capability": {k: float(v) for k, v in by_capability.items()}, + "budget": { + "daily_limit_usd": float(budget.daily_limit_usd) if budget else None, + "monthly_limit_usd": float(budget.monthly_limit_usd) if budget else None, + "daily_usage_percent": float(daily / budget.daily_limit_usd * 100) + if budget and budget.daily_limit_usd + else None, + "monthly_usage_percent": float(monthly / budget.monthly_limit_usd * 100) + if budget and budget.monthly_limit_usd + else None, + "enabled": budget.enabled if budget else False, + }, + } + + +# 全局单例 +cost_tracker = CostTracker() diff --git a/backend/app/services/memory_service.py b/backend/app/services/memory_service.py new file mode 100644 index 0000000..d047f3c --- /dev/null +++ b/backend/app/services/memory_service.py @@ -0,0 +1,471 @@ +"""Memory service handles memory retrieval, scoring, and prompt injection.""" + +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.logging import get_logger +from app.db.models import ChildProfile, MemoryItem, StoryUniverse + +logger = get_logger(__name__) + + +class MemoryType: + """记忆类型常量及配置。""" + + # 基础类型 + RECENT_STORY = "recent_story" + FAVORITE_CHARACTER = "favorite_character" + SCARY_ELEMENT = "scary_element" + VOCABULARY_GROWTH = "vocabulary_growth" + EMOTIONAL_HIGHLIGHT = "emotional_highlight" + + # Phase 1 新增类型 + READING_PREFERENCE = "reading_preference" # 阅读偏好 + MILESTONE = "milestone" # 里程碑事件 + SKILL_MASTERED = "skill_mastered" # 掌握的技能 + + # 类型配置: (默认权重, 默认TTL天数, 描述) + CONFIG = { + RECENT_STORY: (1.0, 30, "最近阅读的故事"), + FAVORITE_CHARACTER: (1.5, None, "喜欢的角色"), # None = 永久 + SCARY_ELEMENT: (2.0, None, "回避的元素"), # 高权重,永久有效 + VOCABULARY_GROWTH: (0.8, 90, "词汇积累"), + EMOTIONAL_HIGHLIGHT: (1.2, 60, "情感高光"), + READING_PREFERENCE: (1.0, None, "阅读偏好"), + MILESTONE: (1.5, None, "里程碑事件"), + SKILL_MASTERED: (1.0, 180, "掌握的技能"), + } + + @classmethod + def get_default_weight(cls, memory_type: str) -> float: + """获取类型的默认权重。""" + config = cls.CONFIG.get(memory_type) + return config[0] if config else 1.0 + + @classmethod + def get_default_ttl(cls, memory_type: str) -> int | None: + """获取类型的默认 TTL 天数。""" + config = cls.CONFIG.get(memory_type) + return config[1] if config else None + + +def _decay_factor(days: float) -> float: + """计算时间衰减因子。""" + if days <= 7: + return 1.0 + if days <= 30: + return 0.7 + if days <= 90: + return 0.4 + return 0.2 + + +async def build_enhanced_memory_context( + profile_id: str | None, + universe_id: str | None, + db: AsyncSession, +) -> str | None: + """构建增强版记忆上下文(自然语言 Prompt)。""" + if not profile_id and not universe_id: + return None + + context_parts: list[str] = [] + + # 1. 基础档案 (Identity Layer) + if profile_id: + profile = await db.scalar(select(ChildProfile).where(ChildProfile.id == profile_id)) + if profile: + context_parts.append(f"【目标读者】\n姓名:{profile.name}") + if profile.age: + context_parts.append(f"年龄:{profile.age}岁") + if profile.interests: + context_parts.append(f"兴趣爱好:{'、'.join(profile.interests)}") + if profile.growth_themes: + context_parts.append(f"当前成长关注点:{'、'.join(profile.growth_themes)}") + context_parts.append("") # 空行 + + # 2. 故事宇宙 (Universe Layer) + if universe_id: + universe = await db.scalar(select(StoryUniverse).where(StoryUniverse.id == universe_id)) + if universe: + context_parts.append("【故事宇宙设定】") + context_parts.append(f"世界观:{universe.name}") + + # 主角 + protagonist = universe.protagonist or {} + p_desc = f"{protagonist.get('name', '主角')} ({protagonist.get('personality', '')})" + context_parts.append(f"主角设定:{p_desc}") + + # 常驻角色 + if universe.recurring_characters: + chars = [f"{c.get('name')} ({c.get('type')})" for c in universe.recurring_characters if isinstance(c, dict)] + context_parts.append(f"已知伙伴:{'、'.join(chars)}") + + # 成就 + if universe.achievements: + badges = [str(a.get('type')) for a in universe.achievements if isinstance(a, dict)] + if badges: + context_parts.append(f"已获荣誉:{'、'.join(badges[:5])}") + + context_parts.append("") + + # 3. 动态记忆 (Working Memory) + if profile_id: + memories = await _fetch_scored_memories(profile_id, universe_id, db) + if memories: + memory_text = _format_memories_to_prompt(memories) + if memory_text: + context_parts.append("【关键记忆回忆】(请在故事中自然地融入或致敬以下元素)") + context_parts.append(memory_text) + + return "\n".join(context_parts) + + +async def _fetch_scored_memories( + profile_id: str, + universe_id: str | None, + db: AsyncSession, + limit: int = 8 +) -> list[MemoryItem]: + """获取并评分记忆项,返回 Top N。""" + query = select(MemoryItem).where(MemoryItem.child_profile_id == profile_id) + if universe_id: + query = query.where( + (MemoryItem.universe_id == universe_id) | (MemoryItem.universe_id.is_(None)) + ) + # 取最近 50 条进行评分 + query = query.order_by(MemoryItem.last_used_at.desc(), MemoryItem.created_at.desc()).limit(50) + + result = await db.execute(query) + items = result.scalars().all() + + scored: list[tuple[float, MemoryItem]] = [] + now = datetime.now(timezone.utc) + + for item in items: + reference = item.last_used_at or item.created_at or now + delta_days = max((now - reference).total_seconds() / 86400, 0) + + if item.ttl_days and delta_days > item.ttl_days: + continue + + score = (item.base_weight or 1.0) * _decay_factor(delta_days) + if score <= 0.1: # 忽略低权重 + continue + + scored.append((score, item)) + + scored.sort(key=lambda x: x[0], reverse=True) + return [item for _, item in scored[:limit]] + + +def _format_memories_to_prompt(memories: list[MemoryItem]) -> str: + """将记忆项转换为自然语言指令。""" + lines = [] + + # 分类处理 + recent_stories = [] + favorites = [] + scary = [] + vocab = [] + + for m in memories: + if m.type == MemoryType.RECENT_STORY: + recent_stories.append(m) + elif m.type == MemoryType.FAVORITE_CHARACTER: + favorites.append(m) + elif m.type == MemoryType.SCARY_ELEMENT: + scary.append(m) + elif m.type == MemoryType.VOCABULARY_GROWTH: + vocab.append(m) + + # 1. 喜欢的角色 + if favorites: + names = [] + for m in favorites: + val = m.value + if isinstance(val, dict): + names.append(f"{val.get('name')} ({val.get('description', '')})") + if names: + lines.append(f"- 孩子特别喜欢这些角色,可以让他们客串出场:{', '.join(names)}") + + # 2. 避雷区 + if scary: + items = [] + for m in scary: + val = m.value + if isinstance(val, dict): + items.append(val.get('keyword', '')) + elif isinstance(val, str): + items.append(val) + if items: + lines.append(f"- 【注意禁止】不要出现以下让孩子害怕的元素:{', '.join(items)}") + + # 3. 近期故事 (取最近 2 个) + if recent_stories: + lines.append("- 近期经历(可作为彩蛋提及):") + for m in recent_stories[:2]: + val = m.value + if isinstance(val, dict): + title = val.get('title', '未知故事') + lines.append(f" * 之前读过《{title}》") + + # 4. 词汇积累 + if vocab: + words = [] + for m in vocab: + val = m.value + if isinstance(val, dict): + words.append(val.get('word')) + if words: + lines.append(f"- 已掌握词汇(可适当复现以巩固):{', '.join([w for w in words if w])}") + + return "\n".join(lines) + + +async def prune_expired_memories(db: AsyncSession) -> int: + """清理过期的记忆项。 + + Returns: + 删除的记录数量 + """ + from sqlalchemy import delete + + now = datetime.now(timezone.utc) + + # 查找所有设置了 TTL 的项目 + stmt = select(MemoryItem).where(MemoryItem.ttl_days.is_not(None)) + result = await db.execute(stmt) + candidates = result.scalars().all() + + to_delete_ids = [] + for item in candidates: + if not item.ttl_days: + continue + + reference = item.last_used_at or item.created_at or now + delta_days = (now - reference).total_seconds() / 86400 + + if delta_days > item.ttl_days: + to_delete_ids.append(item.id) + + if not to_delete_ids: + return 0 + + delete_stmt = delete(MemoryItem).where(MemoryItem.id.in_(to_delete_ids)) + await db.execute(delete_stmt) + await db.commit() + + logger.info("memory_pruned", count=len(to_delete_ids)) + return len(to_delete_ids) + + +async def create_memory( + db: AsyncSession, + profile_id: str, + memory_type: str, + value: dict, + universe_id: str | None = None, + weight: float | None = None, + ttl_days: int | None = None, +) -> MemoryItem: + """创建新的记忆项。 + + Args: + db: 数据库会话 + profile_id: 孩子档案 ID + memory_type: 记忆类型 (使用 MemoryType 常量) + value: 记忆内容 (JSON 格式) + universe_id: 可选,关联的故事宇宙 ID + weight: 可选,权重 (默认使用类型配置) + ttl_days: 可选,过期天数 (默认使用类型配置) + + Returns: + 创建的 MemoryItem + """ + memory = MemoryItem( + child_profile_id=profile_id, + universe_id=universe_id, + type=memory_type, + value=value, + base_weight=weight or MemoryType.get_default_weight(memory_type), + ttl_days=ttl_days if ttl_days is not None else MemoryType.get_default_ttl(memory_type), + ) + db.add(memory) + await db.commit() + await db.refresh(memory) + + logger.info( + "memory_created", + memory_id=memory.id, + profile_id=profile_id, + type=memory_type, + ) + return memory + + +async def update_memory_usage(db: AsyncSession, memory_id: str) -> None: + """更新记忆的最后使用时间。 + + Args: + db: 数据库会话 + memory_id: 记忆项 ID + """ + result = await db.execute(select(MemoryItem).where(MemoryItem.id == memory_id)) + memory = result.scalar_one_or_none() + + if memory: + memory.last_used_at = datetime.now(timezone.utc) + await db.commit() + logger.debug("memory_usage_updated", memory_id=memory_id) + + +async def get_profile_memories( + db: AsyncSession, + profile_id: str, + memory_type: str | None = None, + universe_id: str | None = None, + limit: int = 50, +) -> list[MemoryItem]: + """获取档案的记忆列表。 + + Args: + db: 数据库会话 + profile_id: 孩子档案 ID + memory_type: 可选,按类型筛选 + universe_id: 可选,按宇宙筛选 + limit: 返回数量限制 + + Returns: + MemoryItem 列表 + """ + query = select(MemoryItem).where(MemoryItem.child_profile_id == profile_id) + + if memory_type: + query = query.where(MemoryItem.type == memory_type) + + if universe_id: + query = query.where( + (MemoryItem.universe_id == universe_id) | (MemoryItem.universe_id.is_(None)) + ) + + query = query.order_by(MemoryItem.created_at.desc()).limit(limit) + + result = await db.execute(query) + return list(result.scalars().all()) + + +async def create_story_memory( + db: AsyncSession, + profile_id: str, + story_id: int, + title: str, + summary: str | None = None, + keywords: list[str] | None = None, + universe_id: str | None = None, +) -> MemoryItem: + """为故事创建记忆项。 + + 这是一个便捷函数,专门用于在故事阅读后创建 recent_story 类型的记忆。 + + Args: + db: 数据库会话 + profile_id: 孩子档案 ID + story_id: 故事 ID + title: 故事标题 + summary: 故事梗概 + keywords: 关键词列表 + universe_id: 可选,关联的故事宇宙 ID + + Returns: + 创建的 MemoryItem + """ + value = { + "story_id": story_id, + "title": title, + "summary": summary or "", + "keywords": keywords or [], + } + + return await create_memory( + db=db, + profile_id=profile_id, + memory_type=MemoryType.RECENT_STORY, + value=value, + universe_id=universe_id, + ) + + +async def create_character_memory( + db: AsyncSession, + profile_id: str, + name: str, + description: str | None = None, + source_story_id: int | None = None, + affinity_score: float = 1.0, + universe_id: str | None = None, +) -> MemoryItem: + """为喜欢的角色创建记忆项。 + + Args: + db: 数据库会话 + profile_id: 孩子档案 ID + name: 角色名称 + description: 角色描述 + source_story_id: 来源故事 ID + affinity_score: 喜爱程度 (0.0-1.0) + universe_id: 可选,关联的故事宇宙 ID + + Returns: + 创建的 MemoryItem + """ + value = { + "name": name, + "description": description or "", + "source_story_id": source_story_id, + "affinity_score": min(1.0, max(0.0, affinity_score)), + } + + return await create_memory( + db=db, + profile_id=profile_id, + memory_type=MemoryType.FAVORITE_CHARACTER, + value=value, + universe_id=universe_id, + ) + + +async def create_scary_element_memory( + db: AsyncSession, + profile_id: str, + keyword: str, + category: str = "other", + source_story_id: int | None = None, +) -> MemoryItem: + """为回避元素创建记忆项。 + + Args: + db: 数据库会话 + profile_id: 孩子档案 ID + keyword: 回避的关键词 + category: 分类 (creature/scene/action/other) + source_story_id: 来源故事 ID + + Returns: + 创建的 MemoryItem + """ + value = { + "keyword": keyword, + "category": category, + "source_story_id": source_story_id, + } + + return await create_memory( + db=db, + profile_id=profile_id, + memory_type=MemoryType.SCARY_ELEMENT, + value=value, + ) + diff --git a/backend/app/services/provider_cache.py b/backend/app/services/provider_cache.py new file mode 100644 index 0000000..bd59404 --- /dev/null +++ b/backend/app/services/provider_cache.py @@ -0,0 +1,31 @@ +"""In-memory cache for providers loaded from DB.""" + +from collections import defaultdict +from typing import Literal + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.admin_models import Provider + +ProviderType = Literal["text", "image", "tts", "storybook"] + +_cache: dict[ProviderType, list[Provider]] = defaultdict(list) + + +async def reload_providers(db: AsyncSession): + result = await db.execute(select(Provider).where(Provider.enabled == True)) # noqa: E712 + providers = result.scalars().all() + grouped: dict[ProviderType, list[Provider]] = defaultdict(list) + for p in providers: + grouped[p.type].append(p) + # sort by priority desc, then weight desc + for k in grouped: + grouped[k].sort(key=lambda x: (x.priority, x.weight), reverse=True) + _cache.clear() + _cache.update(grouped) + return _cache + + +def get_providers(provider_type: ProviderType) -> list[Provider]: + return _cache.get(provider_type, []) diff --git a/backend/app/services/provider_metrics.py b/backend/app/services/provider_metrics.py new file mode 100644 index 0000000..8aac415 --- /dev/null +++ b/backend/app/services/provider_metrics.py @@ -0,0 +1,248 @@ +"""供应商指标收集和健康检查服务。""" + +from datetime import datetime, timedelta +from decimal import Decimal +from typing import TYPE_CHECKING + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.logging import get_logger +from app.db.admin_models import ProviderHealth, ProviderMetrics + +if TYPE_CHECKING: + from app.services.adapters.base import BaseAdapter + +logger = get_logger(__name__) + +# 熔断阈值:连续失败次数 +CIRCUIT_BREAKER_THRESHOLD = 3 +# 熔断恢复时间(秒) +CIRCUIT_BREAKER_RECOVERY_SECONDS = 60 + + +class MetricsCollector: + """供应商调用指标收集器。""" + + async def record_call( + self, + db: AsyncSession, + provider_id: str, + success: bool, + latency_ms: int | None = None, + cost_usd: float | None = None, + error_message: str | None = None, + request_id: str | None = None, + ) -> None: + """记录一次 API 调用。""" + metric = ProviderMetrics( + provider_id=provider_id, + success=success, + latency_ms=latency_ms, + cost_usd=Decimal(str(cost_usd)) if cost_usd else None, + error_message=error_message, + request_id=request_id, + ) + db.add(metric) + await db.commit() + + logger.debug( + "metrics_recorded", + provider_id=provider_id, + success=success, + latency_ms=latency_ms, + ) + + async def get_success_rate( + self, + db: AsyncSession, + provider_id: str, + window_minutes: int = 60, + ) -> float: + """获取指定时间窗口内的成功率。""" + since = datetime.utcnow() - timedelta(minutes=window_minutes) + + result = await db.execute( + select( + func.count().filter(ProviderMetrics.success.is_(True)).label("success_count"), + func.count().label("total_count"), + ).where( + ProviderMetrics.provider_id == provider_id, + ProviderMetrics.timestamp >= since, + ) + ) + row = result.one() + success_count, total_count = row.success_count, row.total_count + + if total_count == 0: + return 1.0 # 无数据时假设健康 + + return success_count / total_count + + async def get_avg_latency( + self, + db: AsyncSession, + provider_id: str, + window_minutes: int = 60, + ) -> float: + """获取指定时间窗口内的平均延迟(毫秒)。""" + since = datetime.utcnow() - timedelta(minutes=window_minutes) + + result = await db.execute( + select(func.avg(ProviderMetrics.latency_ms)).where( + ProviderMetrics.provider_id == provider_id, + ProviderMetrics.timestamp >= since, + ProviderMetrics.latency_ms.isnot(None), + ) + ) + avg = result.scalar() + return float(avg) if avg else 0.0 + + async def get_total_cost( + self, + db: AsyncSession, + provider_id: str, + window_minutes: int = 60, + ) -> float: + """获取指定时间窗口内的总成本(USD)。""" + since = datetime.utcnow() - timedelta(minutes=window_minutes) + + result = await db.execute( + select(func.sum(ProviderMetrics.cost_usd)).where( + ProviderMetrics.provider_id == provider_id, + ProviderMetrics.timestamp >= since, + ) + ) + total = result.scalar() + return float(total) if total else 0.0 + + +class HealthChecker: + """供应商健康检查器。""" + + async def check_provider( + self, + db: AsyncSession, + provider_id: str, + adapter: "BaseAdapter", + ) -> bool: + """执行健康检查并更新状态。""" + try: + is_healthy = await adapter.health_check() + except Exception as e: + logger.warning("health_check_failed", provider_id=provider_id, error=str(e)) + is_healthy = False + + await self.update_health_status( + db, + provider_id, + is_healthy, + error=None if is_healthy else "Health check failed", + ) + return is_healthy + + async def update_health_status( + self, + db: AsyncSession, + provider_id: str, + is_healthy: bool, + error: str | None = None, + ) -> None: + """更新供应商健康状态(含熔断逻辑)。""" + result = await db.execute( + select(ProviderHealth).where(ProviderHealth.provider_id == provider_id) + ) + health = result.scalar_one_or_none() + + now = datetime.utcnow() + + if health is None: + health = ProviderHealth( + provider_id=provider_id, + is_healthy=is_healthy, + last_check=now, + consecutive_failures=0 if is_healthy else 1, + last_error=error, + ) + db.add(health) + else: + health.last_check = now + + if is_healthy: + health.is_healthy = True + health.consecutive_failures = 0 + health.last_error = None + else: + health.consecutive_failures += 1 + health.last_error = error + + # 熔断逻辑 + if health.consecutive_failures >= CIRCUIT_BREAKER_THRESHOLD: + health.is_healthy = False + logger.warning( + "circuit_breaker_triggered", + provider_id=provider_id, + consecutive_failures=health.consecutive_failures, + ) + + await db.commit() + + async def record_call_result( + self, + db: AsyncSession, + provider_id: str, + success: bool, + error: str | None = None, + ) -> None: + """根据调用结果更新健康状态。""" + await self.update_health_status(db, provider_id, success, error) + + async def get_healthy_providers( + self, + db: AsyncSession, + provider_ids: list[str], + ) -> list[str]: + """获取健康的供应商列表。""" + if not provider_ids: + return [] + + # 查询所有已记录的健康状态 + result = await db.execute( + select(ProviderHealth.provider_id, ProviderHealth.is_healthy).where( + ProviderHealth.provider_id.in_(provider_ids), + ) + ) + health_map = {row[0]: row[1] for row in result.all()} + + # 未记录的供应商默认健康,已记录但不健康的排除 + return [ + pid for pid in provider_ids + if pid not in health_map or health_map[pid] + ] + + async def is_healthy( + self, + db: AsyncSession, + provider_id: str, + ) -> bool: + """检查供应商是否健康。""" + result = await db.execute( + select(ProviderHealth).where(ProviderHealth.provider_id == provider_id) + ) + health = result.scalar_one_or_none() + + if health is None: + return True # 未记录默认健康 + + # 检查是否可以恢复 + if not health.is_healthy and health.last_check: + recovery_time = health.last_check + timedelta(seconds=CIRCUIT_BREAKER_RECOVERY_SECONDS) + if datetime.utcnow() >= recovery_time: + return True # 允许重试 + + return health.is_healthy + + +# 全局单例 +metrics_collector = MetricsCollector() +health_checker = HealthChecker() diff --git a/backend/app/services/provider_router.py b/backend/app/services/provider_router.py new file mode 100644 index 0000000..259a152 --- /dev/null +++ b/backend/app/services/provider_router.py @@ -0,0 +1,432 @@ +"""Provider routing with failover - 基于适配器注册表的智能路由。""" + +import time +from enum import Enum +from typing import TYPE_CHECKING, Literal, TypeVar + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.logging import get_logger +from app.services.adapters import AdapterConfig, AdapterRegistry +from app.services.adapters.text.models import StoryOutput +from app.services.cost_tracker import cost_tracker +from app.services.provider_cache import get_providers +from app.services.provider_metrics import health_checker, metrics_collector + +if TYPE_CHECKING: + from app.db.admin_models import Provider + +logger = get_logger(__name__) + +T = TypeVar("T") + +ProviderType = Literal["text", "image", "tts", "storybook"] + + +class RoutingStrategy(str, Enum): + """路由策略枚举。""" + + PRIORITY = "priority" # 按优先级排序(默认) + COST = "cost" # 按成本排序 + LATENCY = "latency" # 按延迟排序 + ROUND_ROBIN = "round_robin" # 轮询 + + +# 默认配置映射(当 DB 无配置时使用) +# 默认配置映射(当 DB 无配置时使用) +# 这是“代码级”的默认策略,对应 .env 为空的情况 +DEFAULT_PROVIDERS: dict[ProviderType, list[str]] = { + "text": ["gemini", "openai"], + "image": ["cqtai"], + "tts": ["minimax", "elevenlabs", "edge_tts"], + "storybook": ["gemini"], +} + +# API Key 映射:adapter_name -> settings 属性名 +API_KEY_MAP: dict[str, str] = { + # Text + "gemini": "text_api_key", # Gemini 还是复用 text_api_key 字段 + "text_primary": "text_api_key", # 兼容旧别名 + "openai": "openai_api_key", + + # Image + "cqtai": "cqtai_api_key", + "image_primary": "image_api_key", # 兼容旧别名 + + # TTS + "minimax": "minimax_api_key", + "elevenlabs": "elevenlabs_api_key", + "edge_tts": "tts_api_key", # EdgeTTS 复用 tts_api_key (通常为空) + "tts_primary": "tts_api_key", # 兼容旧别名 +} + +# 轮询计数器 +_round_robin_counters: dict[ProviderType, int] = { + "text": 0, + "image": 0, + "tts": 0, +} + +# 延迟缓存(内存中,简化实现) +_latency_cache: dict[str, float] = {} + + +def _get_api_key(config_ref: str | None, adapter_name: str) -> str: + """根据 config_ref 或适配器名称获取 API Key。""" + # 优先使用 config_ref + key_attr = API_KEY_MAP.get(config_ref or adapter_name, None) + if key_attr: + return getattr(settings, key_attr, "") + # 回退到适配器名称 + key_attr = API_KEY_MAP.get(adapter_name, None) + if key_attr: + return getattr(settings, key_attr, "") + return "" + + +def _get_default_config(adapter_name: str) -> AdapterConfig | None: + """获取适配器的默认配置(无 DB 记录时使用)。返回 None 表示未知适配器。""" + + # --- Text Defaults --- + if adapter_name in ("gemini", "text_primary"): + return AdapterConfig( + api_key=settings.text_api_key, + model=settings.text_model or "gemini-2.0-flash", + timeout_ms=60000, + ) + if adapter_name == "openai": + return AdapterConfig( + api_key=getattr(settings, "openai_api_key", ""), + model="gpt-4o-mini", # 这里可以从 settings 读取,看需求 + timeout_ms=60000, + ) + + # --- Image Defaults --- + if adapter_name in ("cqtai"): + return AdapterConfig( + api_key=getattr(settings, "cqtai_api_key", ""), + model="nano-banana-pro", # 默认使用 Pro + timeout_ms=120000, + ) + if adapter_name == "image_primary": + # 如果还有地方在用 image_primary,暂时映射到快或者其他 + # 但既然我们全面整改,最好也删了。这里暂时保留一个空的 fallback 以防报错 + return AdapterConfig( + api_key=settings.image_api_key, + timeout_ms=120000 + ) + + # --- TTS Defaults --- + if adapter_name == "minimax": + # 传递 group_id 到 Adapter + # 目前 AdapterConfig 没有 group_id 字段,我们暂时不改 Base, + # 而是假设 Adapter 会从 config (通过 kwargs 或其他方式) 拿。 + # 实际上我们的 MiniMaxTTSAdapter 还没有处理 group_id。 + # 最简单的方法:把 group_id 藏在 api_base 里或者让 Adapter 自己去 settings 拿。 + # 鉴于 _build_config_from_provider 里我们无法传递额外参数给 Adapter.__init__, + # 我们这里暂时返回基础配置。 + return AdapterConfig( + api_key=getattr(settings, "minimax_api_key", ""), + model="speech-2.6-turbo", + timeout_ms=60000, + ) + + if adapter_name == "elevenlabs": + return AdapterConfig( + api_key=getattr(settings, "elevenlabs_api_key", ""), + timeout_ms=120000, + ) + if adapter_name in ("edge_tts", "tts_primary"): + return AdapterConfig( + api_key=settings.tts_api_key, + api_base=settings.tts_api_base, + model=settings.tts_model or "zh-CN-XiaoxiaoNeural", + timeout_ms=120000, + ) + + # --- Others --- + if adapter_name in ("storybook_primary", "storybook_gemini"): + return AdapterConfig( + api_key=settings.text_api_key, # 复用 Gemini key + model=settings.text_model, + timeout_ms=120000, + ) + + # 未知适配器返回 None + return None + + +def _build_config_from_provider(provider: "Provider") -> AdapterConfig: + """从 DB Provider 记录构建 AdapterConfig。""" + api_key = getattr(provider, "api_key", None) or "" + if not api_key: + api_key = _get_api_key(provider.config_ref, provider.adapter) + + default = _get_default_config(provider.adapter) + if default is None: + default = AdapterConfig(api_key="", timeout_ms=60000) + + return AdapterConfig( + api_key=api_key or default.api_key, + api_base=provider.api_base or default.api_base, + model=provider.model or default.model, + timeout_ms=provider.timeout_ms or default.timeout_ms, + max_retries=provider.max_retries or default.max_retries, + extra_config=provider.config_json or {}, + ) + + +def _get_providers_with_config( + provider_type: ProviderType, +) -> list[tuple[str, AdapterConfig, "Provider | None"]]: + """获取供应商列表及其配置。 + + Returns: + [(adapter_name, config, provider_or_none), ...] 按优先级排序 + """ + db_providers = get_providers(provider_type) + + if db_providers: + return [(p.adapter, _build_config_from_provider(p), p) for p in db_providers] + + settings_map = { + "text": settings.text_providers, + "image": settings.image_providers, + "tts": settings.tts_providers, + } + names = settings_map.get(provider_type) or DEFAULT_PROVIDERS[provider_type] + result = [] + for name in names: + config = _get_default_config(name) + if config is None: + logger.warning("unknown_adapter_skipped", adapter=name, provider_type=provider_type) + continue + result.append((name, config, None)) + return result + + +def _sort_by_strategy( + providers: list[tuple[str, AdapterConfig, "Provider | None"]], + strategy: RoutingStrategy, + provider_type: ProviderType, +) -> list[tuple[str, AdapterConfig, "Provider | None"]]: + """按策略排序供应商列表。""" + if strategy == RoutingStrategy.PRIORITY: + # 按 priority 降序, weight 降序 + return sorted( + providers, + key=lambda x: (-(x[2].priority if x[2] else 0), -(x[2].weight if x[2] else 1)), + ) + + if strategy == RoutingStrategy.COST: + # 按预估成本升序 + def get_cost(item: tuple[str, AdapterConfig, "Provider | None"]) -> float: + adapter_class = AdapterRegistry.get(provider_type, item[0]) + if adapter_class: + try: + adapter = adapter_class(item[1]) + return adapter.estimated_cost + except Exception: + pass + return float("inf") + + return sorted(providers, key=get_cost) + + if strategy == RoutingStrategy.LATENCY: + # 按历史延迟升序 + def get_latency(item: tuple[str, AdapterConfig, "Provider | None"]) -> float: + return _latency_cache.get(item[0], float("inf")) + + return sorted(providers, key=get_latency) + + if strategy == RoutingStrategy.ROUND_ROBIN: + # 轮询:旋转列表 + counter = _round_robin_counters[provider_type] + _round_robin_counters[provider_type] = (counter + 1) % max(len(providers), 1) + return providers[counter:] + providers[:counter] + + return providers + + +async def _route_with_failover( + provider_type: ProviderType, + strategy: RoutingStrategy = RoutingStrategy.PRIORITY, + db: AsyncSession | None = None, + user_id: str | None = None, + **kwargs, +) -> T: + """通用 provider failover 路由。 + + Args: + provider_type: 供应商类型 (text/image/tts/storybook) + strategy: 路由策略 + db: 数据库会话(可选,用于指标收集和熔断检查) + user_id: 用户 ID(可选,用于成本追踪和预算检查) + **kwargs: 传递给适配器的参数 + """ + providers = _get_providers_with_config(provider_type) + + if not providers: + raise ValueError(f"No {provider_type} providers configured.") + + # 按策略排序 + sorted_providers = _sort_by_strategy(providers, strategy, provider_type) + + # 如果有 db 会话,过滤掉熔断的供应商 + if db: + healthy_providers = [] + for item in sorted_providers: + name, config, db_provider = item + provider_id = db_provider.id if db_provider else name + if await health_checker.is_healthy(db, provider_id): + healthy_providers.append(item) + else: + logger.debug("provider_circuit_open", adapter=name, provider_id=provider_id) + # 如果所有供应商都熔断,仍然尝试第一个(允许恢复) + if not healthy_providers: + healthy_providers = sorted_providers[:1] + sorted_providers = healthy_providers + + errors: list[str] = [] + for name, config, db_provider in sorted_providers: + adapter_class = AdapterRegistry.get(provider_type, name) + if not adapter_class: + errors.append(f"{name}: 适配器未注册") + continue + + provider_id = db_provider.id if db_provider else name + + try: + logger.debug( + "provider_attempt", + provider_type=provider_type, + adapter=name, + strategy=strategy.value, + ) + + adapter = adapter_class(config) + + # 执行并计时 + start_time = time.time() + result = await adapter.execute(**kwargs) + latency_ms = int((time.time() - start_time) * 1000) + + # 更新延迟缓存 + _latency_cache[name] = latency_ms + + # 记录成功指标 + if db: + await metrics_collector.record_call( + db, + provider_id=provider_id, + success=True, + latency_ms=latency_ms, + cost_usd=adapter.estimated_cost, + ) + await health_checker.record_call_result(db, provider_id, success=True) + + # 记录用户成本 + if user_id: + await cost_tracker.record_cost( + db, + user_id=user_id, + provider_name=name, + capability=provider_type, + estimated_cost=adapter.estimated_cost, + provider_id=provider_id if db_provider else None, + ) + + logger.info( + "provider_success", + provider_type=provider_type, + adapter=name, + latency_ms=latency_ms, + ) + return result + + except Exception as exc: + error_msg = str(exc) + logger.warning( + "provider_failed", + provider_type=provider_type, + adapter=name, + error=error_msg, + ) + errors.append(f"{name}: {exc}") + + # 记录失败指标 + if db: + await metrics_collector.record_call( + db, + provider_id=provider_id, + success=False, + error_message=error_msg, + ) + await health_checker.record_call_result( + db, provider_id, success=False, error=error_msg + ) + + raise ValueError(f"No {provider_type} provider succeeded. Errors: {' | '.join(errors)}") + + +async def generate_story_content( + input_type: Literal["keywords", "full_story"], + data: str, + education_theme: str | None = None, + memory_context: str | None = None, + strategy: RoutingStrategy = RoutingStrategy.PRIORITY, + db: AsyncSession | None = None, +) -> StoryOutput: + """生成或润色故事,支持 failover。""" + return await _route_with_failover( + "text", + strategy=strategy, + db=db, + input_type=input_type, + data=data, + education_theme=education_theme, + memory_context=memory_context, + ) + + +async def generate_image( + prompt: str, + strategy: RoutingStrategy = RoutingStrategy.PRIORITY, + db: AsyncSession | None = None, + **kwargs, +) -> str: + """生成图片,返回 URL,支持 failover。""" + return await _route_with_failover("image", strategy=strategy, db=db, prompt=prompt, **kwargs) + + +async def text_to_speech( + text: str, + strategy: RoutingStrategy = RoutingStrategy.PRIORITY, + db: AsyncSession | None = None, +) -> bytes: + """文本转语音,返回 MP3 bytes,支持 failover。""" + return await _route_with_failover("tts", strategy=strategy, db=db, text=text) + + +async def generate_storybook( + keywords: str, + page_count: int = 6, + education_theme: str | None = None, + memory_context: str | None = None, + strategy: RoutingStrategy = RoutingStrategy.PRIORITY, + db: AsyncSession | None = None, +): + """生成分页故事书,支持 failover。""" + from app.services.adapters.storybook.primary import Storybook + + result: Storybook = await _route_with_failover( + "storybook", + strategy=strategy, + db=db, + keywords=keywords, + page_count=page_count, + education_theme=education_theme, + memory_context=memory_context, + ) + return result diff --git a/backend/app/services/secret_service.py b/backend/app/services/secret_service.py new file mode 100644 index 0000000..5934d77 --- /dev/null +++ b/backend/app/services/secret_service.py @@ -0,0 +1,207 @@ +"""供应商密钥加密存储服务。 + +使用 Fernet 对称加密,密钥从 SECRET_KEY 派生。 +""" + +import base64 +import hashlib +from typing import TYPE_CHECKING + +from cryptography.fernet import Fernet, InvalidToken +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.logging import get_logger +from app.db.admin_models import ProviderSecret + +if TYPE_CHECKING: + pass + +logger = get_logger(__name__) + + +class SecretEncryptionError(Exception): + """密钥加密/解密错误。""" + + pass + + +class SecretService: + """供应商密钥加密存储服务。""" + + _fernet: Fernet | None = None + + @classmethod + def _get_fernet(cls) -> Fernet: + """获取 Fernet 实例,从 SECRET_KEY 派生加密密钥。""" + if cls._fernet is None: + # 从 SECRET_KEY 派生 32 字节密钥 + key_bytes = hashlib.sha256(settings.secret_key.encode()).digest() + fernet_key = base64.urlsafe_b64encode(key_bytes) + cls._fernet = Fernet(fernet_key) + return cls._fernet + + @classmethod + def encrypt(cls, plaintext: str) -> str: + """加密明文,返回 base64 编码的密文。 + + Args: + plaintext: 要加密的明文 + + Returns: + base64 编码的密文 + """ + if not plaintext: + return "" + fernet = cls._get_fernet() + encrypted = fernet.encrypt(plaintext.encode()) + return encrypted.decode() + + @classmethod + def decrypt(cls, ciphertext: str) -> str: + """解密密文,返回明文。 + + Args: + ciphertext: base64 编码的密文 + + Returns: + 解密后的明文 + + Raises: + SecretEncryptionError: 解密失败 + """ + if not ciphertext: + return "" + try: + fernet = cls._get_fernet() + decrypted = fernet.decrypt(ciphertext.encode()) + return decrypted.decode() + except InvalidToken as e: + logger.error("secret_decrypt_failed", error=str(e)) + raise SecretEncryptionError("密钥解密失败,可能是 SECRET_KEY 已更改") from e + + @classmethod + async def get_secret(cls, db: AsyncSession, name: str) -> str | None: + """从数据库获取并解密密钥。 + + Args: + db: 数据库会话 + name: 密钥名称 + + Returns: + 解密后的密钥值,不存在返回 None + """ + result = await db.execute(select(ProviderSecret).where(ProviderSecret.name == name)) + secret = result.scalar_one_or_none() + if secret is None: + return None + return cls.decrypt(secret.encrypted_value) + + @classmethod + async def set_secret(cls, db: AsyncSession, name: str, value: str) -> ProviderSecret: + """存储或更新加密密钥。 + + Args: + db: 数据库会话 + name: 密钥名称 + value: 密钥明文值 + + Returns: + ProviderSecret 实例 + """ + encrypted = cls.encrypt(value) + + result = await db.execute(select(ProviderSecret).where(ProviderSecret.name == name)) + secret = result.scalar_one_or_none() + + if secret is None: + secret = ProviderSecret(name=name, encrypted_value=encrypted) + db.add(secret) + else: + secret.encrypted_value = encrypted + + await db.commit() + await db.refresh(secret) + logger.info("secret_stored", name=name) + return secret + + @classmethod + async def delete_secret(cls, db: AsyncSession, name: str) -> bool: + """删除密钥。 + + Args: + db: 数据库会话 + name: 密钥名称 + + Returns: + 是否删除成功 + """ + result = await db.execute(select(ProviderSecret).where(ProviderSecret.name == name)) + secret = result.scalar_one_or_none() + if secret is None: + return False + + await db.delete(secret) + await db.commit() + logger.info("secret_deleted", name=name) + return True + + @classmethod + async def list_secrets(cls, db: AsyncSession) -> list[str]: + """列出所有密钥名称(不返回值)。 + + Args: + db: 数据库会话 + + Returns: + 密钥名称列表 + """ + result = await db.execute(select(ProviderSecret.name)) + return [row[0] for row in result.fetchall()] + + @classmethod + async def get_api_key( + cls, + db: AsyncSession, + provider_api_key: str | None, + config_ref: str | None, + ) -> str | None: + """获取 Provider 的 API Key,按优先级查找。 + + 优先级: + 1. provider.api_key (数据库明文/加密) + 2. provider.config_ref 指向的 ProviderSecret + 3. 环境变量 (config_ref 作为变量名) + + Args: + db: 数据库会话 + provider_api_key: Provider 表中的 api_key 字段 + config_ref: Provider 表中的 config_ref 字段 + + Returns: + API Key 或 None + """ + # 1. 直接使用 provider.api_key + if provider_api_key: + # 尝试解密,如果失败则当作明文 + try: + decrypted = cls.decrypt(provider_api_key) + if decrypted: + return decrypted + except SecretEncryptionError: + pass + return provider_api_key + + # 2. 从 ProviderSecret 表查找 + if config_ref: + secret_value = await cls.get_secret(db, config_ref) + if secret_value: + return secret_value + + # 3. 从环境变量查找 + env_value = getattr(settings, config_ref.lower(), None) + if env_value: + return env_value + + return None diff --git a/backend/app/tasks/__init__.py b/backend/app/tasks/__init__.py new file mode 100644 index 0000000..9b5d36f --- /dev/null +++ b/backend/app/tasks/__init__.py @@ -0,0 +1,3 @@ +"""Celery tasks package.""" + +from . import achievements, memory, push_notifications # noqa: F401 diff --git a/backend/app/tasks/achievements.py b/backend/app/tasks/achievements.py new file mode 100644 index 0000000..06b071e --- /dev/null +++ b/backend/app/tasks/achievements.py @@ -0,0 +1,82 @@ +"""Celery tasks for achievements.""" + +import asyncio +from datetime import datetime + +from sqlalchemy import select + +from app.core.celery_app import celery_app +from app.core.logging import get_logger +from app.db.database import _get_session_factory +from app.db.models import Story, StoryUniverse +from app.services.achievement_extractor import extract_achievements + +logger = get_logger(__name__) + + +@celery_app.task +def extract_story_achievements(story_id: int, universe_id: str) -> None: + """Extract achievements and update universe.""" + asyncio.run(_extract_story_achievements(story_id, universe_id)) + + +async def _extract_story_achievements(story_id: int, universe_id: str) -> None: + session_factory = _get_session_factory() + async with session_factory() as session: + result = await session.execute(select(Story).where(Story.id == story_id)) + story = result.scalar_one_or_none() + if not story: + logger.warning("achievement_task_story_missing", story_id=story_id) + return + + result = await session.execute( + select(StoryUniverse).where(StoryUniverse.id == universe_id) + ) + universe = result.scalar_one_or_none() + if not universe: + logger.warning("achievement_task_universe_missing", universe_id=universe_id) + return + + text_content = story.story_text + if not text_content and story.pages: + # 如果是绘本,拼接每页文本 + text_content = "\n".join([str(p.get("text", "")) for p in story.pages]) + + if not text_content: + logger.warning("achievement_task_empty_content", story_id=story_id) + return + + achievements = await extract_achievements(text_content) + if not achievements: + logger.info("achievement_task_no_new", story_id=story_id) + return + + existing = { + (str(item.get("type", "")).strip(), str(item.get("description", "")).strip()) + for item in (universe.achievements or []) + if isinstance(item, dict) + } + merged = list(universe.achievements or []) + added_count = 0 + + for item in achievements: + key = (item.get("type", "").strip(), item.get("description", "").strip()) + if key in existing: + continue + merged.append({ + "type": key[0], + "description": key[1], + "obtained_at": datetime.now().isoformat(), + "source_story_id": story_id, + }) + existing.add(key) + added_count += 1 + + universe.achievements = merged + await session.commit() + logger.info( + "achievement_task_success", + story_id=story_id, + universe_id=universe_id, + added=added_count, + ) diff --git a/backend/app/tasks/memory.py b/backend/app/tasks/memory.py new file mode 100644 index 0000000..dbb1130 --- /dev/null +++ b/backend/app/tasks/memory.py @@ -0,0 +1,29 @@ + +import asyncio + +from app.core.celery_app import celery_app +from app.core.logging import get_logger +from app.db.database import _get_session_factory +from app.services.memory_service import prune_expired_memories + +logger = get_logger(__name__) + +@celery_app.task +def prune_memories_task(): + """Daily task to prune expired memories.""" + logger.info("prune_memories_task_started") + + async def _run(): + # Ensure engine is initialized in this process + session_factory = _get_session_factory() + async with session_factory() as session: + return await prune_expired_memories(session) + + try: + # Create a new event loop for this task execution + count = asyncio.run(_run()) + logger.info("prune_memories_task_completed", deleted_count=count) + return f"Deleted {count} expired memories" + except Exception as exc: + logger.error("prune_memories_task_failed", error=str(exc)) + raise diff --git a/backend/app/tasks/push_notifications.py b/backend/app/tasks/push_notifications.py new file mode 100644 index 0000000..f7fe943 --- /dev/null +++ b/backend/app/tasks/push_notifications.py @@ -0,0 +1,108 @@ +"""Celery tasks for push notifications.""" + +import asyncio +from datetime import datetime, time +from zoneinfo import ZoneInfo + +from sqlalchemy import select + +from app.core.celery_app import celery_app +from app.core.logging import get_logger +from app.db.database import _get_session_factory +from app.db.models import PushConfig, PushEvent + +logger = get_logger(__name__) + +LOCAL_TZ = ZoneInfo("Asia/Shanghai") +QUIET_HOURS_START = time(21, 0) +QUIET_HOURS_END = time(9, 0) +TRIGGER_WINDOW_MINUTES = 30 + + +@celery_app.task +def check_push_notifications() -> None: + """Check push configs and create push events.""" + asyncio.run(_check_push_notifications()) + + +def _is_quiet_hours(current: time) -> bool: + if QUIET_HOURS_START < QUIET_HOURS_END: + return QUIET_HOURS_START <= current < QUIET_HOURS_END + return current >= QUIET_HOURS_START or current < QUIET_HOURS_END + + +def _within_window(current: time, target: time) -> bool: + current_minutes = current.hour * 60 + current.minute + target_minutes = target.hour * 60 + target.minute + return 0 <= current_minutes - target_minutes < TRIGGER_WINDOW_MINUTES + + +async def _already_sent_today( + session, + child_profile_id: str, + now: datetime, +) -> bool: + start = now.replace(hour=0, minute=0, second=0, microsecond=0) + end = now.replace(hour=23, minute=59, second=59, microsecond=999999) + result = await session.execute( + select(PushEvent.id).where( + PushEvent.child_profile_id == child_profile_id, + PushEvent.status == "sent", + PushEvent.sent_at >= start, + PushEvent.sent_at <= end, + ) + ) + return result.scalar_one_or_none() is not None + + +async def _check_push_notifications() -> None: + session_factory = _get_session_factory() + now = datetime.now(LOCAL_TZ) + current_day = now.weekday() + current_time = now.time() + + async with session_factory() as session: + result = await session.execute( + select(PushConfig).where(PushConfig.enabled.is_(True)) + ) + configs = result.scalars().all() + + for config in configs: + if not config.push_time: + continue + if config.push_days and current_day not in config.push_days: + continue + if not _within_window(current_time, config.push_time): + continue + if _is_quiet_hours(current_time): + session.add( + PushEvent( + user_id=config.user_id, + child_profile_id=config.child_profile_id, + trigger_type="time", + status="suppressed", + reason="quiet_hours", + sent_at=now, + ) + ) + continue + if await _already_sent_today(session, config.child_profile_id, now): + continue + + session.add( + PushEvent( + user_id=config.user_id, + child_profile_id=config.child_profile_id, + trigger_type="time", + status="sent", + reason=None, + sent_at=now, + ) + ) + logger.info( + "push_event_sent", + child_profile_id=config.child_profile_id, + user_id=config.user_id, + ) + + await session.commit() diff --git a/backend/docs/code_review_report.md b/backend/docs/code_review_report.md new file mode 100644 index 0000000..54d9082 --- /dev/null +++ b/backend/docs/code_review_report.md @@ -0,0 +1,14 @@ +# Code Review Report (2nd follow-up) + +## Whats fixed +- Provider cache now loads on startup via lifespan (`app/main.py`), so DB providers are honored without manual reload. +- Providers support DB-stored `api_key` precedence (`provider_router.py:77-104`) and Provider model added `api_key` column (`db/admin_models.py:25`). +- Frontend uses `/api/generate/full` and propagates image-failure warning to detail via query flag; StoryDetail displays banner when image generation failed. +- Tests added for full generation, provider failover, config-from-DB, and startup cache load. + +## Remaining issue +1) **Missing DB migration for new Provider.api_key column** + - Files updated model (`backend/app/db/admin_models.py:25`) but `backend/alembic/versions/0001_init_providers_and_story_mode.py` lacks this column. Existing databases will not have `api_key`, causing runtime errors when accessing or inserting. Add an Alembic migration to add/drop `api_key` to `providers` table and update sample data if needed. + +## Suggested action +- Create and apply an Alembic migration adding `api_key` (String, nullable) to `providers`. After migration, verify admin CRUD works with the new field. diff --git a/backend/docs/memory_system_dev.md b/backend/docs/memory_system_dev.md new file mode 100644 index 0000000..3d86d7c --- /dev/null +++ b/backend/docs/memory_system_dev.md @@ -0,0 +1,147 @@ +# 记忆系统开发指南 (Development Guide) + +本文档详细说明了 PRD 中定义的记忆系统的技术实现细节。 + +## 1. 数据库架构变更 (Schema Changes) + +目前的 `MemoryItem` 表结构尚可,但需要增强字段以支持丰富的情感和元数据。 + +### 1.1 `MemoryItem` 表优化 +建议使用 Alembic 进行迁移,增加以下字段或在 `value` JSON 中规范化以下结构: + +```python +# 建议在 models.py 中明确这些字段,或者严格定义 value 字段的 Schema +class MemoryItem(Base): + # ... 现有字段 ... + + # 新增/规范化字段建议 + # value 字段的 JSON 结构规范: + # { + # "content": "小兔子战胜了大灰狼", # 记忆的核心文本 + # "keywords": ["勇敢", "森林"], # 用于检索的关键词 + # "emotion": "positive", # 情感倾向: positive/negative/neutral + # "source_story_id": 123, # 来源故事 ID + # "confidence": 0.85 # 记忆置信度 (如果是 AI 自动提取) + # } +``` + +### 1.2 `StoryUniverse` 表优化 (成就系统) +目前的成就存储在 `achievements` JSON 字段中。为了支持更复杂的查询(如"获得勇气勋章的所有用户"),建议将其重构为独立关联表,或保持 JSON 但规范化结构。 + +**当前 JSON 结构规范**: +```json +[ + { + "id": "badge_courage_01", + "type": "勇气", + "name": "小小勇士", + "description": "第一次在故事中独自面对困难", + "icon_url": "badges/courage.png", + "obtained_at": "2023-10-27T10:00:00Z", + "source_story_id": 45 + } +] +``` + +--- + +## 2. 核心逻辑实现 + +### 2.1 记忆注入逻辑 (Prompt Engineering) + +修改 `backend/app/api/stories.py` 中的 `_build_memory_context` 函数。 + +**目标**: 生成一段自然的、不仅是罗列数据的 Prompt。 + +**伪代码逻辑**: +```python +def format_memory_for_prompt(memories: list[MemoryItem]) -> str: + """ + 将记忆项转换为自然语言 Prompt 片段。 + """ + context_parts = [] + + # 1. 角色记忆 + chars = [m for m in memories if m.type == 'favorite_character'] + if chars: + names = ", ".join([c.value['name'] for c in chars]) + context_parts.append(f"孩子特别喜欢的角色有:{names}。请尝试让他们客串出场。") + + # 2. 近期情节 + recent_stories = [m for m in memories if m.type == 'recent_story'][:2] + if recent_stories: + for story in recent_stories: + context_parts.append(f"最近发生过:{story.value['summary']}。可以在对话中不经意地提及此事。") + + # 3. 避雷区 (负面记忆) + scary = [m for m in memories if m.type == 'scary_element'] + if scary: + items = ", ".join([s.value['keyword'] for s in scary]) + context_parts.append(f"【绝对禁止】不要出现以下让孩子害怕的元素:{items}。") + + return "\n".join(context_parts) +``` + +### 2.2 成就提取与通知流程 + +当前流程在 `app/tasks/achievements.py`。需要完善闭环。 + +**改进后的流程**: +1. **Story Generation**: 故事生成成功,存入数据库。 +2. **Async Task**: 触发 Celery 任务 `extract_story_achievements`。 +3. **LLM Analysis**: 调用 Gemini 分析故事,提取成就。 +4. **Update DB**: 更新 `StoryUniverse.achievements`。 +5. **Notification (新增)**: + * 创建一个 `Notification` 或 `UserMessage` 记录(需要新建表或使用 Redis Pub/Sub)。 + * 前端轮询或通过 SSE (Server-Sent Events) 接收通知:"🎉 恭喜!在这个故事里,小明获得了[诚实勋章]!" + +### 2.3 记忆清理与衰减 (Maintenance) + +需要一个后台定时任务(Cron Job),清理无效记忆,避免 Prompt 过长。 + +* **频率**: 每天一次。 +* **逻辑**: + * 删除 `ttl_days` 已过期的记录。 + * 对 `recent_story` 类型的 `base_weight` 进行每日衰减 update(或者只在读取时计算,数据库存静态值,推荐读取时动态计算以减少写操作)。 + * 当 `MemoryItem` 总数超过 100 条时,触发"记忆总结"任务,将多条旧记忆合并为一条"长期印象" (Long-term Impression)。 + +--- + +## 3. API 接口规划 + +### 3.1 获取成长时间轴 +`GET /api/profiles/{id}/timeline` + +**Response**: +```json +{ + "events": [ + { + "date": "2023-10-01", + "type": "milestone", + "title": "初次相遇", + "description": "创建了角色 [小明]" + }, + { + "date": "2023-10-05", + "type": "story", + "title": "小明与魔法树", + "image_url": "..." + }, + { + "date": "2023-10-05", + "type": "achievement", + "badge": { + "name": "好奇宝宝", + "icon": "..." + } + } + ] +} +``` + +### 3.2 记忆反馈 (人工干预) +`POST /api/memories/{id}/feedback` + +允许家长手动删除或修正错误的记忆。 +* **Action**: `delete` | `reinforce` (强化,增加权重) diff --git a/backend/docs/memory_system_prd.md b/backend/docs/memory_system_prd.md new file mode 100644 index 0000000..4076ad8 --- /dev/null +++ b/backend/docs/memory_system_prd.md @@ -0,0 +1,93 @@ +# 梦语织机 (DreamWeaver) 记忆系统升级 PRD +> 版本: v1.0 | 状态: 规划中 | 优先级: High + +## 1. 核心愿景 (Vision) + +将当前的"数据存储"升级为有温度的**"情感连接系统"**。 +我们不只是在记住数据,而是在**维护孩子与故事世界的关系**。让每一个故事不再是孤立的碎片,而是构建孩子专属"故事宇宙"的砖瓦。 + +--- + +## 2. 产品痛点与解决方案 + +| 用户角色 | 核心痛点 | 解决方案 | 预期价值 | +|---------|---------|---------|---------| +| **孩子** | "上次的小兔子怎么不认识我了?"
故事之间缺乏连续性,只有单次体验。 | **角色一致性与记忆注入**
故事开头主动提及往事,角色性格延续。 | 建立情感依恋,提升沉浸感。 | +| **家长** | "这App除了生成故事还能干嘛?"
无法感知产品的长期教育价值。 | **显性化成长轨迹**
词汇量统计、主题变化、成就徽章可视化。 | 提高付费意愿,提供社交货币。 | +| **平台** | 用户用完即走,缺乏留存壁垒。 | **沉没成本与情感资产**
积累的记忆越多,越舍不得离开。 | 提升长期留存率 (LTV)。 | + +--- + +## 3. 功能架构:记忆分层模型 + +### 3.1 层级 1: 核心档案 (Identity Layer) +*性质:永久、静态、显性* +* **数据**: 姓名、年龄、性别。 +* **输入**: 家长在 Onboarding 阶段手动输入。 +* **作用**: 决定故事的基础适龄性和称呼。 + +### 3.2 层级 2: 故事宇宙 (Universe Layer) +*性质:长期、动态积累、半显性* +* **主角设定**: 姓名、性格特征(勇敢/害羞)、外貌特征(戴眼镜/卷发)。 +* **常驻配角**: 从随机故事中涌现出的固定伙伴(如"爱吃胡萝卜的松鼠奇奇")。 +* **世界观**: 故事发生的背景(魔法森林、未来城市、海底世界)。 +* **成就系统**: 孩子获得的虚拟奖励(勇气勋章、小小探险家)。 + +### 3.3 层级 3: 工作记忆 (Working Memory) +*性质:短期、自动衰减、隐性* +* **关键情节**: 最近 3 个故事的结局和核心冲突。 +* **情感标记**: 孩子对特定内容的反应(根据“重播”、“跳过”推断)。 +* **新学词汇**: 故事中出现的高级词汇。 + +--- + +## 4. 关键功能特性 (Feature Specs) + +### 4.1 智能开场白 (Memory Injection) +在生成新故事时,Prompt 必须包含一段"记忆唤醒"指令。 +* **示例**: "小明,还记得上周我们帮小松鼠找回了松果吗?今天,小松鼠带来了一位新朋友..." +* **策略**: 提取权重最高的 Top 3 记忆注入 Prompt。 + +### 4.2 成长时间轴 (Growth Timeline) +一个可视化的 H5 页面或 App 模块,以时间轴形式展示里程碑。 +* **节点类型**: + * 🌟 **初次相遇**: 创建角色的第一天。 + * 📖 **阅读打卡**: 累计阅读 10/50/100 本。 + * 🏅 **获得成就**: 获得"诚实勋章"。 + * 🧠 **能力解锁**: 第一次阅读"科幻"题材。 + +### 4.3 成就仪式感 (Achievement Ceremony) +* **触发**: 故事生成并分析后,如果获得新成就。 +* **表现**: 弹窗动画 + 音效 + "恭喜获得 [勇气] 徽章"。 +* **分享**:允许生成带二维码的成就海报。 + +--- + +## 5. 记忆类型扩展 (Memory Types) + +| 类型 Key | 描述 | 来源 | 过期策略 | +|---------|------|------|---------| +| `recent_story` | 最近读过的故事梗概 | 阅读事件 | 30天衰减 | +| `favorite_character` | 孩子喜欢的角色 | 重播/高评分 | 长期有效 | +| `scary_element` | 孩子害怕/不喜欢的元素 | 跳过/负反馈 | 长期有效 (避雷) | +| `vocabulary_growth` | 新掌握的词汇 | 故事分析 | 90天衰减 | +| `emotional_highlight` | 高光时刻 (如: 特别开心的情节) | 互动数据 | 60天衰减 | + +--- + +## 6. 实施路线图 (Roadmap) + +### Phase 1: 基础建设 (v0.3.0) +* [x] 数据库 `MemoryItem` 表 (已存在)。 +* [ ] 扩展 `MemoryItem` 类型字段,支持更多维度。 +* [ ] 优化 `_build_memory_context`,支持更自然的 Prompt 注入。 +* [ ] 前端:简单的"近期回忆"展示列表。 + +### Phase 2: 可视化与成就 (v0.4.0) +* [ ] 实现"成就提取器" (Achievement Extractor) 的闭环通知。 +* [ ] 前端:开发"我的成就"和"成长时间轴"页面。 +* [ ] 增加故事开场白的动态生成逻辑。 + +### Phase 3: 深度智能 (v0.5.0+) +* [ ] 引入向量数据库,实现基于语义的记忆检索 (不仅是时间最近)。 +* [ ] 情感分析模型:分析用户行为推断情感倾向。 diff --git a/backend/docs/provider_system.md b/backend/docs/provider_system.md new file mode 100644 index 0000000..551a84f --- /dev/null +++ b/backend/docs/provider_system.md @@ -0,0 +1,246 @@ +# Provider 系统开发文档 + +## 当前版本功能 (v0.2.0) + +### 已完成功能 + +1. **CQTAI nano 图像适配器** (`app/services/adapters/image/cqtai.py`) + - 异步生成 + 轮询获取结果 + - 支持 nano-banana / nano-banana-pro 模型 + - 支持多种分辨率和画面比例 + - 支持图生图 (filesUrl) + +2. **密钥加密存储** (`app/services/secret_service.py`) + - Fernet 对称加密,密钥从 SECRET_KEY 派生 + - Provider API Key 自动加密存储 + - 密钥管理 API (CRUD) + +3. **指标收集系统** (`app/services/provider_metrics.py`) + - 调用成功率、延迟、成本统计 + - 时间窗口聚合查询 + - 已集成到 provider_router + +4. **熔断器功能** (`app/services/provider_metrics.py`) + - 连续失败 3 次触发熔断 + - 60 秒后自动恢复尝试 + - 健康状态持久化到数据库 + +5. **管理后台前端** (`app/admin_app.py`) + - 独立端口部署 (8001) + - Vue 3 + Tailwind CSS 单页应用 + - Provider CRUD 管理 + - 密钥管理界面 + - Basic Auth 认证 + +### 配置说明 + +```bash +# 启动主应用 +uvicorn app.main:app --port 8000 + +# 启动管理后台 (独立端口) +uvicorn app.admin_app:app --port 8001 +``` + +环境变量: +``` +CQTAI_API_KEY=your-cqtai-api-key +ENABLE_ADMIN_CONSOLE=true +ADMIN_USERNAME=admin +ADMIN_PASSWORD=your-secure-password +``` + +--- + +## 下一版本优化计划 (v0.3.0) + +### 高优先级 + +#### 1. 智能负载分流 (方案 B) +**目标**: 主渠道压力大时自动分流到后备渠道 + +**实现方案**: +- 监控指标: 并发数、响应延迟、错误率 +- 分流阈值配置: + ```python + class LoadBalanceConfig: + max_concurrent: int = 10 # 并发超过此值时分流 + max_latency_ms: int = 5000 # 延迟超过此值时分流 + max_error_rate: float = 0.1 # 错误率超过 10% 时分流 + ``` +- 分流策略: 加权轮询,根据健康度动态调整权重 + +**涉及文件**: +- `app/services/provider_router.py` - 添加负载均衡逻辑 +- `app/services/provider_metrics.py` - 添加并发计数器 +- `app/db/admin_models.py` - 添加 LoadBalanceConfig 模型 + +#### 2. Storybook 适配器 +**目标**: 生成可翻页的分页故事书 + +**实现方案**: +- 参考 Gemini AI Story Generator 格式 +- 输出结构: + ```python + class StorybookPage: + page_number: int + text: str + image_prompt: str + image_url: str | None + + class Storybook: + title: str + pages: list[StorybookPage] + cover_url: str | None + ``` +- 集成文本 + 图像生成流水线 + +**涉及文件**: +- `app/services/adapters/storybook/` - 新建目录 +- `app/api/stories.py` - 添加 storybook 生成端点 + +### 中优先级 + +#### 3. 成本追踪系统 +**目标**: 记录实际消费,支持预算控制 + +**实现方案**: +- 成本记录表: + ```python + class CostRecord: + user_id: str + provider_id: str + capability: str # text/image/tts + estimated_cost: Decimal + actual_cost: Decimal | None + timestamp: datetime + ``` +- 预算配置: + ```python + class BudgetConfig: + user_id: str + daily_limit: Decimal + monthly_limit: Decimal + alert_threshold: float = 0.8 # 80% 时告警 + ``` +- 超预算处理: 拒绝请求 / 降级到低成本 provider + +**涉及文件**: +- `app/db/admin_models.py` - 添加 CostRecord, BudgetConfig +- `app/services/cost_tracker.py` - 新建 +- `app/api/admin_providers.py` - 添加成本查询 API + +#### 4. 指标可视化 +**目标**: 管理后台展示供应商指标图表 + +**实现方案**: +- 添加指标查询 API: + - GET /admin/metrics/summary - 汇总统计 + - GET /admin/metrics/timeline - 时间线数据 + - GET /admin/metrics/providers/{id} - 单个供应商详情 +- 前端使用 Chart.js 或 ECharts 展示 + +### 低优先级 + +#### 5. 多租户 Provider 配置 +**目标**: 每个租户可配置独立 provider 列表和 API Key + +**实现方案**: +- 租户配置表: + ```python + class TenantProviderConfig: + tenant_id: str + provider_type: str + provider_ids: list[str] # 按优先级排序 + api_key_override: str | None # 加密存储 + ``` +- 路由时优先使用租户配置,回退到全局配置 + +#### 6. Provider 健康检查调度器 +**目标**: 定期主动检查 provider 健康状态 + +**实现方案**: +- Celery Beat 定时任务 +- 每 5 分钟检查一次所有启用的 provider +- 更新 ProviderHealth 表 + +#### 7. 适配器热加载 +**目标**: 支持运行时动态加载新适配器 + +**实现方案**: +- 适配器插件目录: `app/services/adapters/plugins/` +- 启动时扫描并注册 +- 提供 API 触发重新扫描 + +--- + +## API 变更记录 + +### v0.2.0 新增 + +| Method | Route | Description | +|--------|-------|-------------| +| GET | `/admin/secrets` | 列出所有密钥名称 | +| POST | `/admin/secrets` | 创建/更新密钥 | +| DELETE | `/admin/secrets/{name}` | 删除密钥 | +| GET | `/admin/secrets/{name}/verify` | 验证密钥有效性 | + +### 计划中 (v0.3.0) + +| Method | Route | Description | +|--------|-------|-------------| +| GET | `/admin/metrics/summary` | 指标汇总 | +| GET | `/admin/metrics/timeline` | 时间线数据 | +| POST | `/api/storybook/generate` | 生成分页故事书 | +| GET | `/admin/costs` | 成本统计 | +| POST | `/admin/budgets` | 设置预算 | + +--- + +## 适配器开发指南 + +### 添加新适配器 + +1. 创建适配器文件: +```python +# app/services/adapters/image/new_provider.py +from app.services.adapters.base import AdapterConfig, BaseAdapter +from app.services.adapters.registry import AdapterRegistry + +@AdapterRegistry.register("image", "new_provider") +class NewProviderAdapter(BaseAdapter[str]): + adapter_type = "image" + adapter_name = "new_provider" + + async def execute(self, prompt: str, **kwargs) -> str: + # 实现生成逻辑 + pass + + async def health_check(self) -> bool: + # 实现健康检查 + pass + + @property + def estimated_cost(self) -> float: + return 0.01 # USD +``` + +2. 在 `__init__.py` 中导入: +```python +# app/services/adapters/__init__.py +from app.services.adapters.image import new_provider as _new_provider # noqa: F401 +``` + +3. 添加配置: +```python +# app/core/config.py +new_provider_api_key: str = "" + +# app/services/provider_router.py +API_KEY_MAP["new_provider"] = "new_provider_api_key" +``` + +4. 更新 `.env.example`: +``` +NEW_PROVIDER_API_KEY= +``` diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..f9f8d05 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,46 @@ +[project] +name = "dreamweaver" +version = "0.1.0" +description = "AI 驱动的儿童故事生成应用" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.32.0", + "sqlalchemy[asyncio]>=2.0.0", + "asyncpg>=0.30.0", + "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", + "python-jose[cryptography]>=3.3.0", + "cryptography>=43.0.0", + "httpx>=0.28.0", + "alembic>=1.13.0", + "cachetools>=5.0.0", + "tenacity>=8.0.0", + "structlog>=24.0.0", + "sse-starlette>=2.0.0", + "celery>=5.4.0", + "redis>=5.0.0", + "openai>=1.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.24.0", + "pytest-cov>=4.0.0", + "aiosqlite>=0.20.0", + "ruff>=0.8.0", +] + +[tool.setuptools.packages.find] +include = ["app*"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/backend/scripts/add_config_column.py b/backend/scripts/add_config_column.py new file mode 100644 index 0000000..ec12436 --- /dev/null +++ b/backend/scripts/add_config_column.py @@ -0,0 +1,27 @@ +import asyncio +import os +import sys + +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.db.database import async_engine +from sqlalchemy import text + +async def upgrade_db(): + print("🚀 Checking database schema...") + async with async_engine.begin() as conn: + # Check if column exists + result = await conn.execute(text( + "SELECT column_name FROM information_schema.columns WHERE table_name='providers' AND column_name='config_json';" + )) + if result.scalar(): + print("✅ Column 'config_json' already exists.") + else: + print("⚠️ Column 'config_json' missing. Adding it now...") + await conn.execute(text("ALTER TABLE providers ADD COLUMN config_json JSON;")) + print("✅ Column 'config_json' added successfully.") + +if __name__ == "__main__": + if sys.platform == 'win32': + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + asyncio.run(upgrade_db()) diff --git a/backend/scripts/fix_db_schema.py b/backend/scripts/fix_db_schema.py new file mode 100644 index 0000000..d8092cd --- /dev/null +++ b/backend/scripts/fix_db_schema.py @@ -0,0 +1,29 @@ +import asyncio +import os +import sys + +# Add backend to path +sys.path.append(os.path.join(os.getcwd())) + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from app.core.config import settings + +async def add_column(): + engine = create_async_engine(settings.database_url) + async_session = async_sessionmaker(engine, expire_on_commit=False) + async with async_session() as session: + try: + print("Adding config_json column to providers table...") + await session.execute(text("ALTER TABLE providers ADD COLUMN IF NOT EXISTS config_json JSONB DEFAULT '{}'::jsonb")) + await session.commit() + print("Successfully added config_json column.") + except Exception as e: + print(f"Error adding column: {e}") + await session.rollback() + +if __name__ == "__main__": + import asyncio + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + asyncio.run(add_column()) diff --git a/backend/scripts/manual_init_db.py b/backend/scripts/manual_init_db.py new file mode 100644 index 0000000..edef70b --- /dev/null +++ b/backend/scripts/manual_init_db.py @@ -0,0 +1,21 @@ +import asyncio +import sys +import os + +# Add backend to path +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.db.database import init_db +from app.core.logging import setup_logging + +async def main(): + setup_logging() + print("Initializing database...") + try: + await init_db() + print("Database initialized successfully.") + except Exception as e: + print(f"Error initializing database: {e}") + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..d4839a6 --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +# Tests package diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..ee340b8 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,146 @@ +"""测试配置和 fixtures。""" + +import os +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing") +os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///:memory:") + +from app.core.security import create_access_token +from app.api.stories import _request_log +from app.db.database import get_db +from app.db.models import Base, Story, User +from app.main import app + + +@pytest.fixture +async def async_engine(): + """创建内存数据库引擎。""" + engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield engine + await engine.dispose() + + +@pytest.fixture +async def db_session(async_engine) -> AsyncGenerator[AsyncSession, None]: + """创建数据库会话。""" + session_factory = async_sessionmaker( + async_engine, class_=AsyncSession, expire_on_commit=False + ) + async with session_factory() as session: + yield session + + +@pytest.fixture +async def test_user(db_session: AsyncSession) -> User: + """创建测试用户。""" + user = User( + id="github:12345", + name="Test User", + avatar_url="https://example.com/avatar.png", + provider="github", + ) + db_session.add(user) + await db_session.commit() + await db_session.refresh(user) + return user + + +@pytest.fixture +async def test_story(db_session: AsyncSession, test_user: User) -> Story: + """创建测试故事。""" + story = Story( + user_id=test_user.id, + title="测试故事", + story_text="从前有一只小兔子...", + cover_prompt="A cute rabbit in a forest", + mode="generated", + ) + db_session.add(story) + await db_session.commit() + await db_session.refresh(story) + return story + + +@pytest.fixture +def auth_token(test_user: User) -> str: + """生成测试用户的 JWT token。""" + return create_access_token({"sub": test_user.id}) + + +@pytest.fixture +def client(db_session: AsyncSession) -> TestClient: + """创建测试客户端。""" + + async def override_get_db(): + yield db_session + + app.dependency_overrides[get_db] = override_get_db + with TestClient(app) as c: + yield c + app.dependency_overrides.clear() + + +@pytest.fixture +def auth_client(client: TestClient, auth_token: str) -> TestClient: + """带认证的测试客户端。""" + client.cookies.set("access_token", auth_token) + return client + + +@pytest.fixture(autouse=True) +def clear_rate_limit_cache(): + """确保每个测试用例的限流缓存互不影响。""" + _request_log.clear() + yield + _request_log.clear() + + +@pytest.fixture +def mock_text_provider(): + """Mock 文本生成适配器 API 调用。""" + from app.services.adapters.text.models import StoryOutput + + mock_result = StoryOutput( + mode="generated", + title="小兔子的冒险", + story_text="从前有一只小兔子...", + cover_prompt_suggestion="A cute rabbit", + ) + + with patch("app.api.stories.generate_story_content", new_callable=AsyncMock) as mock: + mock.return_value = mock_result + yield mock + + +@pytest.fixture +def mock_image_provider(): + """Mock 图像生成。""" + with patch("app.api.stories.generate_image", new_callable=AsyncMock) as mock: + mock.return_value = "https://example.com/image.png" + yield mock + + +@pytest.fixture +def mock_tts_provider(): + """Mock TTS。""" + with patch("app.api.stories.text_to_speech", new_callable=AsyncMock) as mock: + mock.return_value = b"fake-audio-bytes" + yield mock + + +@pytest.fixture +def mock_all_providers(mock_text_provider, mock_image_provider, mock_tts_provider): + """Mock 所有 AI 供应商。""" + return { + "text_primary": mock_text_provider, + "image_primary": mock_image_provider, + "tts_primary": mock_tts_provider, + } diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..382a11f --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,65 @@ +"""认证相关测试。""" + +import pytest +from fastapi.testclient import TestClient + +from app.core.security import create_access_token, decode_access_token + + +class TestJWT: + """JWT token 测试。""" + + def test_create_and_decode_token(self): + """测试 token 创建和解码。""" + payload = {"sub": "github:12345"} + token = create_access_token(payload) + decoded = decode_access_token(token) + assert decoded is not None + assert decoded["sub"] == "github:12345" + + def test_decode_invalid_token(self): + """测试无效 token 解码。""" + result = decode_access_token("invalid-token") + assert result is None + + def test_decode_empty_token(self): + """测试空 token 解码。""" + result = decode_access_token("") + assert result is None + + +class TestSession: + """Session 端点测试。""" + + def test_session_without_auth(self, client: TestClient): + """未登录时获取 session。""" + response = client.get("/auth/session") + assert response.status_code == 200 + data = response.json() + assert data["user"] is None + + def test_session_with_auth(self, auth_client: TestClient, test_user): + """已登录时获取 session。""" + response = auth_client.get("/auth/session") + assert response.status_code == 200 + data = response.json() + assert data["user"] is not None + assert data["user"]["id"] == test_user.id + assert data["user"]["name"] == test_user.name + + def test_session_with_invalid_token(self, client: TestClient): + """无效 token 获取 session。""" + client.cookies.set("access_token", "invalid-token") + response = client.get("/auth/session") + assert response.status_code == 200 + data = response.json() + assert data["user"] is None + + +class TestSignout: + """登出测试。""" + + def test_signout(self, auth_client: TestClient): + """测试登出。""" + response = auth_client.post("/auth/signout", follow_redirects=False) + assert response.status_code == 302 diff --git a/backend/tests/test_profiles.py b/backend/tests/test_profiles.py new file mode 100644 index 0000000..b51eba6 --- /dev/null +++ b/backend/tests/test_profiles.py @@ -0,0 +1,78 @@ +"""Child profile API tests.""" + +from datetime import date + + +def _calc_age(birth_date: date) -> int: + today = date.today() + return today.year - birth_date.year - ( + (today.month, today.day) < (birth_date.month, birth_date.day) + ) + + +def test_list_profiles_empty(auth_client): + response = auth_client.get("/api/profiles") + assert response.status_code == 200 + data = response.json() + assert data["profiles"] == [] + assert data["total"] == 0 + + +def test_create_update_delete_profile(auth_client): + payload = { + "name": "小明", + "birth_date": "2020-05-12", + "gender": "male", + "interests": ["太空", "机器人"], + "growth_themes": ["勇气"], + } + response = auth_client.post("/api/profiles", json=payload) + assert response.status_code == 201 + data = response.json() + assert data["name"] == payload["name"] + assert data["gender"] == payload["gender"] + assert data["interests"] == payload["interests"] + assert data["growth_themes"] == payload["growth_themes"] + assert data["age"] == _calc_age(date.fromisoformat(payload["birth_date"])) + + profile_id = data["id"] + + update_payload = {"growth_themes": ["分享", "独立"]} + response = auth_client.put(f"/api/profiles/{profile_id}", json=update_payload) + assert response.status_code == 200 + data = response.json() + assert data["growth_themes"] == update_payload["growth_themes"] + + response = auth_client.delete(f"/api/profiles/{profile_id}") + assert response.status_code == 200 + assert response.json()["message"] == "Deleted" + + +def test_profile_limit_and_duplicate(auth_client): + # 先测试重复名称(在达到限制前) + response = auth_client.post( + "/api/profiles", + json={"name": "孩子1", "gender": "female"}, + ) + assert response.status_code == 201 + + response = auth_client.post( + "/api/profiles", + json={"name": "孩子1", "gender": "female"}, + ) + assert response.status_code == 409 # 重复名称 + + # 继续创建到上限 + for i in range(2, 6): + response = auth_client.post( + "/api/profiles", + json={"name": f"孩子{i}", "gender": "female"}, + ) + assert response.status_code == 201 + + # 测试数量限制 + response = auth_client.post( + "/api/profiles", + json={"name": "孩子6", "gender": "female"}, + ) + assert response.status_code == 400 # 超过5个限制 diff --git a/backend/tests/test_provider_router.py b/backend/tests/test_provider_router.py new file mode 100644 index 0000000..23ef4c5 --- /dev/null +++ b/backend/tests/test_provider_router.py @@ -0,0 +1,195 @@ +"""Provider router 测试 - failover 和配置加载。""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.services.adapters import AdapterConfig +from app.services.adapters.text.models import StoryOutput + + +class TestProviderFailover: + """Provider failover 测试。""" + + @pytest.mark.asyncio + async def test_failover_to_second_provider(self): + """第一个 provider 失败时切换到第二个。""" + from app.services import provider_router + + # Mock 两个 provider - 使用 spec=False 并显式设置所有属性 + mock_provider_1 = MagicMock() + mock_provider_1.configure_mock( + id="provider-1", + type="text", + adapter="text_primary", + api_key="key1", + api_base=None, + model=None, + timeout_ms=60000, + max_retries=3, + config_ref=None, + config_json={}, + priority=10, + weight=1.0, + enabled=True, + ) + + mock_provider_2 = MagicMock() + mock_provider_2.configure_mock( + id="provider-2", + type="text", + adapter="text_primary", + api_key="key2", + api_base=None, + model=None, + timeout_ms=60000, + max_retries=3, + config_ref=None, + config_json={}, + priority=5, + weight=1.0, + enabled=True, + ) + + mock_providers = [mock_provider_1, mock_provider_2] + + mock_result = StoryOutput( + mode="generated", + title="测试故事", + story_text="内容", + cover_prompt_suggestion="prompt", + ) + + call_count = 0 + + async def mock_execute(**kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise Exception("First provider failed") + return mock_result + + with patch.object(provider_router, "get_providers", return_value=mock_providers): + with patch("app.services.adapters.AdapterRegistry.get") as mock_get: + mock_adapter_class = MagicMock() + mock_adapter_instance = MagicMock() + mock_adapter_instance.execute = mock_execute + mock_adapter_class.return_value = mock_adapter_instance + mock_get.return_value = mock_adapter_class + + result = await provider_router.generate_story_content( + input_type="keywords", + data="测试", + ) + + assert result == mock_result + assert call_count == 2 # 第一个失败,第二个成功 + + @pytest.mark.asyncio + async def test_all_providers_fail(self): + """所有 provider 都失败时抛出异常。""" + from app.services import provider_router + + mock_provider = MagicMock() + mock_provider.configure_mock( + id="provider-1", + type="text", + adapter="text_primary", + api_key="key1", + api_base=None, + model=None, + timeout_ms=60000, + max_retries=3, + config_ref=None, + config_json={}, + priority=10, + weight=1.0, + enabled=True, + ) + mock_providers = [mock_provider] + + async def mock_execute(**kwargs): + raise Exception("Provider failed") + + with patch.object(provider_router, "get_providers", return_value=mock_providers): + with patch("app.services.adapters.AdapterRegistry.get") as mock_get: + mock_adapter_class = MagicMock() + mock_adapter_instance = MagicMock() + mock_adapter_instance.execute = mock_execute + mock_adapter_class.return_value = mock_adapter_instance + mock_get.return_value = mock_adapter_class + + with pytest.raises(ValueError, match="No text provider succeeded"): + await provider_router.generate_story_content( + input_type="keywords", + data="测试", + ) + + +class TestProviderConfigFromDB: + """从 DB 加载 provider 配置测试。""" + + def test_build_config_from_provider_with_api_key(self): + """Provider 有 api_key 时优先使用。""" + from app.services.provider_router import _build_config_from_provider + + mock_provider = MagicMock() + mock_provider.adapter = "text_primary" + mock_provider.api_key = "db-api-key" + mock_provider.api_base = "https://custom.api.com" + mock_provider.model = "custom-model" + mock_provider.timeout_ms = 30000 + mock_provider.max_retries = 5 + mock_provider.config_ref = None + mock_provider.config_json = {} + + config = _build_config_from_provider(mock_provider) + + assert config.api_key == "db-api-key" + assert config.api_base == "https://custom.api.com" + assert config.model == "custom-model" + assert config.timeout_ms == 30000 + assert config.max_retries == 5 + + def test_build_config_fallback_to_settings(self): + """Provider 无 api_key 时回退到 settings。""" + from app.services.provider_router import _build_config_from_provider + + mock_provider = MagicMock() + mock_provider.adapter = "text_primary" + mock_provider.api_key = None + mock_provider.api_base = None + mock_provider.model = None + mock_provider.timeout_ms = None + mock_provider.max_retries = None + mock_provider.config_ref = "text_api_key" + mock_provider.config_json = {} + + with patch("app.services.provider_router.settings") as mock_settings: + mock_settings.text_api_key = "settings-api-key" + mock_settings.text_model = "gemini-2.0-flash" + + config = _build_config_from_provider(mock_provider) + + assert config.api_key == "settings-api-key" + + +class TestProviderCacheStartup: + """Provider cache 启动加载测试。""" + + @pytest.mark.asyncio + async def test_cache_loaded_on_startup(self): + """启动时加载 provider cache。""" + from app.main import _load_provider_cache + + with patch("app.db.database._get_session_factory") as mock_factory: + mock_session = AsyncMock() + mock_factory.return_value.__aenter__ = AsyncMock(return_value=mock_session) + mock_factory.return_value.__aexit__ = AsyncMock() + + with patch("app.services.provider_cache.reload_providers", new_callable=AsyncMock) as mock_reload: + mock_reload.return_value = {"text": [], "image": [], "tts": []} + + await _load_provider_cache() + + mock_reload.assert_called_once() diff --git a/backend/tests/test_push_configs.py b/backend/tests/test_push_configs.py new file mode 100644 index 0000000..a2b3006 --- /dev/null +++ b/backend/tests/test_push_configs.py @@ -0,0 +1,77 @@ +"""Push config API tests.""" + + +def _create_profile(auth_client) -> str: + response = auth_client.post( + "/api/profiles", + json={"name": "小明", "gender": "male"}, + ) + assert response.status_code == 201 + return response.json()["id"] + + +def test_create_list_update_push_config(auth_client): + profile_id = _create_profile(auth_client) + + response = auth_client.put( + "/api/push-configs", + json={ + "child_profile_id": profile_id, + "push_time": "20:30", + "push_days": [1, 3, 5], + "enabled": True, + }, + ) + assert response.status_code == 201 + data = response.json() + assert data["child_profile_id"] == profile_id + assert data["push_time"].startswith("20:30") + assert data["push_days"] == [1, 3, 5] + assert data["enabled"] is True + + response = auth_client.get("/api/push-configs") + assert response.status_code == 200 + payload = response.json() + assert payload["total"] == 1 + + response = auth_client.put( + "/api/push-configs", + json={ + "child_profile_id": profile_id, + "enabled": False, + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["enabled"] is False + assert data["push_time"].startswith("20:30") + assert data["push_days"] == [1, 3, 5] + + +def test_push_config_validation(auth_client): + profile_id = _create_profile(auth_client) + + response = auth_client.put( + "/api/push-configs", + json={"child_profile_id": profile_id}, + ) + assert response.status_code == 400 + + response = auth_client.put( + "/api/push-configs", + json={ + "child_profile_id": profile_id, + "push_time": "19:00", + "push_days": [7], + }, + ) + assert response.status_code == 400 + + response = auth_client.put( + "/api/push-configs", + json={ + "child_profile_id": profile_id, + "push_time": None, + }, + ) + assert response.status_code == 400 diff --git a/backend/tests/test_reading_events.py b/backend/tests/test_reading_events.py new file mode 100644 index 0000000..65a94ca --- /dev/null +++ b/backend/tests/test_reading_events.py @@ -0,0 +1,143 @@ +"""Reading event API tests.""" + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import select + +from app.db.database import get_db +from app.db.models import MemoryItem +from app.main import app + +pytestmark = pytest.mark.asyncio + + +async def _create_profile(client: AsyncClient) -> str: + response = await client.post( + "/api/profiles", + json={"name": "小明", "gender": "male"}, + ) + assert response.status_code == 201 + return response.json()["id"] + + +async def test_create_reading_event_updates_stats_and_memory( + db_session, + test_user, + auth_token, + test_story, +): + async def override_get_db(): + yield db_session + + app.dependency_overrides[get_db] = override_get_db + transport = ASGITransport(app=app) + + try: + async with AsyncClient(transport=transport, base_url="http://test") as client: + client.cookies.set("access_token", auth_token) + profile_id = await _create_profile(client) + + response = await client.post( + "/api/reading-events", + json={ + "child_profile_id": profile_id, + "story_id": test_story.id, + "event_type": "completed", + "reading_time": 120, + }, + ) + assert response.status_code == 201 + data = response.json() + assert data["child_profile_id"] == profile_id + assert data["story_id"] == test_story.id + assert data["event_type"] == "completed" + + response = await client.get(f"/api/profiles/{profile_id}") + assert response.status_code == 200 + profile = response.json() + assert profile["stories_count"] == 1 + assert profile["total_reading_time"] == 120 + + result = await db_session.execute( + select(MemoryItem).where(MemoryItem.child_profile_id == profile_id) + ) + items = result.scalars().all() + assert len(items) == 1 + assert items[0].type == "recent_story" + assert items[0].value["story_id"] == test_story.id + + response = await client.post( + "/api/reading-events", + json={ + "child_profile_id": profile_id, + "story_id": test_story.id, + "event_type": "skipped", + "reading_time": 0, + }, + ) + assert response.status_code == 201 + + result = await db_session.execute( + select(MemoryItem).where(MemoryItem.child_profile_id == profile_id) + ) + assert len(result.scalars().all()) == 1 + + response = await client.post( + "/api/reading-events", + json={ + "child_profile_id": profile_id, + "story_id": test_story.id, + "event_type": "completed", + "reading_time": 0, + }, + ) + assert response.status_code == 201 + + response = await client.get(f"/api/profiles/{profile_id}") + assert response.status_code == 200 + profile = response.json() + assert profile["stories_count"] == 1 + assert profile["total_reading_time"] == 120 + finally: + app.dependency_overrides.clear() + + +async def test_reading_event_validation_errors( + db_session, + test_user, + auth_token, +): + async def override_get_db(): + yield db_session + + app.dependency_overrides[get_db] = override_get_db + transport = ASGITransport(app=app) + + try: + async with AsyncClient(transport=transport, base_url="http://test") as client: + client.cookies.set("access_token", auth_token) + + response = await client.post( + "/api/reading-events", + json={ + "child_profile_id": "not-exist", + "event_type": "started", + "reading_time": 0, + }, + ) + assert response.status_code == 404 + + profile_id = await _create_profile(client) + + response = await client.post( + "/api/reading-events", + json={ + "child_profile_id": profile_id, + "story_id": 999999, + "event_type": "completed", + "reading_time": 0, + }, + ) + assert response.status_code == 404 + finally: + app.dependency_overrides.clear() diff --git a/backend/tests/test_stories.py b/backend/tests/test_stories.py new file mode 100644 index 0000000..9d0f568 --- /dev/null +++ b/backend/tests/test_stories.py @@ -0,0 +1,257 @@ +"""故事 API 测试。""" + +import time +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.testclient import TestClient + +from app.api.stories import _request_log, RATE_LIMIT_REQUESTS + + +class TestStoryGenerate: + """故事生成测试。""" + + def test_generate_without_auth(self, client: TestClient): + """未登录时生成故事。""" + response = client.post( + "/api/generate", + json={"type": "keywords", "data": "小兔子, 森林"}, + ) + assert response.status_code == 401 + + def test_generate_with_empty_data(self, auth_client: TestClient): + """空数据生成故事。""" + response = auth_client.post( + "/api/generate", + json={"type": "keywords", "data": ""}, + ) + assert response.status_code == 422 + + def test_generate_with_invalid_type(self, auth_client: TestClient): + """无效类型生成故事。""" + response = auth_client.post( + "/api/generate", + json={"type": "invalid", "data": "test"}, + ) + assert response.status_code == 422 + + def test_generate_story_success(self, auth_client: TestClient, mock_text_provider): + """成功生成故事。""" + response = auth_client.post( + "/api/generate", + json={"type": "keywords", "data": "小兔子, 森林, 勇气"}, + ) + assert response.status_code == 200 + data = response.json() + assert "id" in data + assert "title" in data + assert "story_text" in data + assert data["mode"] == "generated" + + +class TestStoryList: + """故事列表测试。""" + + def test_list_without_auth(self, client: TestClient): + """未登录时获取列表。""" + response = client.get("/api/stories") + assert response.status_code == 401 + + def test_list_empty(self, auth_client: TestClient): + """空列表。""" + response = auth_client.get("/api/stories") + assert response.status_code == 200 + assert response.json() == [] + + def test_list_with_stories(self, auth_client: TestClient, test_story): + """有故事时获取列表。""" + response = auth_client.get("/api/stories") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["id"] == test_story.id + assert data[0]["title"] == test_story.title + + def test_list_pagination(self, auth_client: TestClient, test_story): + """分页测试。""" + response = auth_client.get("/api/stories?limit=1&offset=0") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + + response = auth_client.get("/api/stories?limit=1&offset=1") + assert response.status_code == 200 + data = response.json() + assert len(data) == 0 + + +class TestStoryDetail: + """故事详情测试。""" + + def test_get_story_without_auth(self, client: TestClient, test_story): + """未登录时获取详情。""" + response = client.get(f"/api/stories/{test_story.id}") + assert response.status_code == 401 + + def test_get_story_not_found(self, auth_client: TestClient): + """故事不存在。""" + response = auth_client.get("/api/stories/99999") + assert response.status_code == 404 + + def test_get_story_success(self, auth_client: TestClient, test_story): + """成功获取详情。""" + response = auth_client.get(f"/api/stories/{test_story.id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == test_story.id + assert data["title"] == test_story.title + assert data["story_text"] == test_story.story_text + + +class TestStoryDelete: + """故事删除测试。""" + + def test_delete_without_auth(self, client: TestClient, test_story): + """未登录时删除。""" + response = client.delete(f"/api/stories/{test_story.id}") + assert response.status_code == 401 + + def test_delete_not_found(self, auth_client: TestClient): + """删除不存在的故事。""" + response = auth_client.delete("/api/stories/99999") + assert response.status_code == 404 + + def test_delete_success(self, auth_client: TestClient, test_story): + """成功删除故事。""" + response = auth_client.delete(f"/api/stories/{test_story.id}") + assert response.status_code == 200 + assert response.json()["message"] == "Deleted" + + response = auth_client.get(f"/api/stories/{test_story.id}") + assert response.status_code == 404 + + +class TestRateLimit: + """Rate limit 测试。""" + + def setup_method(self): + """每个测试前清理 rate limit 缓存。""" + _request_log.clear() + + def test_rate_limit_allows_normal_requests(self, auth_client: TestClient, test_story): + """正常请求不触发限流。""" + for _ in range(RATE_LIMIT_REQUESTS - 1): + response = auth_client.get(f"/api/stories/{test_story.id}") + assert response.status_code == 200 + + def test_rate_limit_blocks_excess_requests(self, auth_client: TestClient, test_story): + """超限请求被阻止。""" + for _ in range(RATE_LIMIT_REQUESTS): + auth_client.get(f"/api/stories/{test_story.id}") + + response = auth_client.get(f"/api/stories/{test_story.id}") + assert response.status_code == 429 + assert "Too many requests" in response.json()["detail"] + + +class TestImageGenerate: + """封面图片生成测试。""" + + def test_generate_image_without_auth(self, client: TestClient, test_story): + """未登录时生成图片。""" + response = client.post(f"/api/image/generate/{test_story.id}") + assert response.status_code == 401 + + def test_generate_image_not_found(self, auth_client: TestClient): + """故事不存在。""" + response = auth_client.post("/api/image/generate/99999") + assert response.status_code == 404 + + +class TestAudio: + """语音朗读测试。""" + + def test_get_audio_without_auth(self, client: TestClient, test_story): + """未登录时获取音频。""" + response = client.get(f"/api/audio/{test_story.id}") + assert response.status_code == 401 + + def test_get_audio_not_found(self, auth_client: TestClient): + """故事不存在。""" + response = auth_client.get("/api/audio/99999") + assert response.status_code == 404 + + def test_get_audio_success(self, auth_client: TestClient, test_story, mock_tts_provider): + """成功获取音频。""" + response = auth_client.get(f"/api/audio/{test_story.id}") + assert response.status_code == 200 + assert response.headers["content-type"] == "audio/mpeg" + + +class TestGenerateFull: + """完整故事生成测试(/api/generate/full)。""" + + def test_generate_full_without_auth(self, client: TestClient): + """未登录时生成完整故事。""" + response = client.post( + "/api/generate/full", + json={"type": "keywords", "data": "小兔子, 森林"}, + ) + assert response.status_code == 401 + + def test_generate_full_success(self, auth_client: TestClient, mock_text_provider, mock_image_provider): + """成功生成完整故事(含图片)。""" + response = auth_client.post( + "/api/generate/full", + json={"type": "keywords", "data": "小兔子, 森林, 勇气"}, + ) + assert response.status_code == 200 + data = response.json() + assert "id" in data + assert "title" in data + assert "story_text" in data + assert data["mode"] == "generated" + assert data["image_url"] == "https://example.com/image.png" + assert data["audio_ready"] is False # 音频按需生成 + assert data["errors"] == {} + + def test_generate_full_image_failure(self, auth_client: TestClient, mock_text_provider): + """图片生成失败时返回部分成功。""" + with patch("app.api.stories.generate_image", new_callable=AsyncMock) as mock_img: + mock_img.side_effect = Exception("Image API error") + response = auth_client.post( + "/api/generate/full", + json={"type": "keywords", "data": "小兔子, 森林"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["image_url"] is None + assert "image" in data["errors"] + assert "Image API error" in data["errors"]["image"] + + def test_generate_full_with_education_theme(self, auth_client: TestClient, mock_text_provider, mock_image_provider): + """带教育主题生成故事。""" + response = auth_client.post( + "/api/generate/full", + json={ + "type": "keywords", + "data": "小兔子, 森林", + "education_theme": "勇气与友谊", + }, + ) + assert response.status_code == 200 + mock_text_provider.assert_called_once() + call_kwargs = mock_text_provider.call_args.kwargs + assert call_kwargs["education_theme"] == "勇气与友谊" + + +class TestImageGenerateSuccess: + """封面图片生成成功测试。""" + + def test_generate_image_success(self, auth_client: TestClient, test_story, mock_image_provider): + """成功生成图片。""" + response = auth_client.post(f"/api/image/generate/{test_story.id}") + assert response.status_code == 200 + data = response.json() + assert data["image_url"] == "https://example.com/image.png" diff --git a/backend/tests/test_universes.py b/backend/tests/test_universes.py new file mode 100644 index 0000000..1604175 --- /dev/null +++ b/backend/tests/test_universes.py @@ -0,0 +1,68 @@ +"""Story universe API tests.""" + + +def _create_profile(auth_client): + response = auth_client.post( + "/api/profiles", + json={ + "name": "小明", + "gender": "male", + "interests": ["太空"], + "growth_themes": ["勇气"], + }, + ) + assert response.status_code == 201 + return response.json()["id"] + + +def test_create_list_update_universe(auth_client): + profile_id = _create_profile(auth_client) + + payload = { + "name": "星际冒险", + "protagonist": {"name": "小明", "role": "船长"}, + "recurring_characters": [{"name": "小七", "role": "机器人"}], + "world_settings": {"world_name": "星际学院"}, + } + + response = auth_client.post(f"/api/profiles/{profile_id}/universes", json=payload) + assert response.status_code == 201 + data = response.json() + assert data["name"] == payload["name"] + + universe_id = data["id"] + + response = auth_client.get(f"/api/profiles/{profile_id}/universes") + assert response.status_code == 200 + list_data = response.json() + assert list_data["total"] == 1 + + response = auth_client.put( + f"/api/universes/{universe_id}", + json={"name": "星际冒险·第二季"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["name"] == "星际冒险·第二季" + + +def test_add_achievement(auth_client): + profile_id = _create_profile(auth_client) + + response = auth_client.post( + f"/api/profiles/{profile_id}/universes", + json={ + "name": "梦幻森林", + "protagonist": {"name": "小红"}, + }, + ) + assert response.status_code == 201 + universe_id = response.json()["id"] + + response = auth_client.post( + f"/api/universes/{universe_id}/achievements", + json={"type": "勇气", "description": "克服黑暗"}, + ) + assert response.status_code == 200 + data = response.json() + assert {"type": "勇气", "description": "克服黑暗"} in data["achievements"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..814d2ee --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,174 @@ +version: '3.8' + +services: + # ============================================== + # 前端服务 (C端用户 App) + # ============================================== + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: dreamweaver_frontend + restart: always + ports: + - "52080:80" # User App UI + depends_on: + - backend + + # ============================================== + # 管理后台前端 (Admin Console) + # ============================================== + frontend-admin: + build: + context: ./admin-frontend + dockerfile: Dockerfile + container_name: dreamweaver_frontend_admin + restart: always + ports: + - "52888:80" # Admin Console UI + depends_on: + - backend-admin + + # ============================================== + # 后端服务 (FastAPI) + # ============================================== + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: dreamweaver_backend + restart: always + ports: + - "52000:8000" # User App API + env_file: + - ./backend/.env + environment: + - DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-dreamweaver}:${POSTGRES_PASSWORD:-dreamweaver_password}@db:5432/${POSTGRES_DB:-dreamweaver_db} + - CELERY_BROKER_URL=redis://redis:6379/0 + - CELERY_RESULT_BACKEND=redis://redis:6379/0 + volumes: + - backend_static:/app/static + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + + # ============================================== + # 管理后台后端 (Admin Backend) + # ============================================== + backend-admin: + build: + context: ./backend + dockerfile: Dockerfile + container_name: dreamweaver_backend_admin + restart: always + ports: + - "52800:8001" # Admin API + command: ["uvicorn", "app.admin_main:app", "--host", "0.0.0.0", "--port", "8001"] + env_file: + - ./backend/.env + environment: + # 复用相同的 DB/Redis 连接 + - DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-dreamweaver}:${POSTGRES_PASSWORD:-dreamweaver_password}@db:5432/${POSTGRES_DB:-dreamweaver_db} + - CELERY_BROKER_URL=redis://redis:6379/0 + - CELERY_RESULT_BACKEND=redis://redis:6379/0 + volumes: + - backend_static:/app/static + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + + # ============================================== + + # ============================================== + # 工作节点 (Celery Worker) + # ============================================== + # ============================================== + # 工作节点 (Celery Worker) + # ============================================== + worker: + build: + context: ./backend + container_name: dreamweaver_worker + command: celery -A app.core.celery_app worker --loglevel=info + restart: always + env_file: ./backend/.env + environment: + - DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-dreamweaver}:${POSTGRES_PASSWORD:-dreamweaver_password}@db:5432/${POSTGRES_DB:-dreamweaver_db} + - CELERY_BROKER_URL=redis://redis:6379/0 + - CELERY_RESULT_BACKEND=redis://redis:6379/0 + depends_on: + - backend + - redis + + # ============================================== + # 调度节点 (Celery Beat) + # ============================================== + celery-beat: + build: + context: ./backend + container_name: dreamweaver_beat + command: celery -A app.core.celery_app beat --loglevel=info + restart: always + env_file: ./backend/.env + environment: + - DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-dreamweaver}:${POSTGRES_PASSWORD:-dreamweaver_password}@db:5432/${POSTGRES_DB:-dreamweaver_db} + - CELERY_BROKER_URL=redis://redis:6379/0 + - CELERY_RESULT_BACKEND=redis://redis:6379/0 + depends_on: + - backend + - redis + + # ============================================== + # 数据库 (PostgreSQL) + # ============================================== + db: + image: postgres:15-alpine + container_name: dreamweaver_db + restart: always + environment: + POSTGRES_USER: ${POSTGRES_USER:-dreamweaver} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dreamweaver_password} + POSTGRES_DB: ${POSTGRES_DB:-dreamweaver_db} + ports: + - "52432:5432" # DB Host Port + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dreamweaver}"] + interval: 10s + timeout: 5s + retries: 5 + + # ============================================== + # 缓存 (Redis) + # ============================================== + redis: + image: redis:7-alpine + container_name: dreamweaver_redis + restart: always + ports: + - "52379:6379" # Redis Host Port + volumes: + - redis_data:/data + command: redis-server --appendonly yes + + # ============================================== + # 数据库管理 (Adminer) + # ============================================== + adminer: + image: adminer + container_name: dreamweaver_adminer + restart: always + ports: + - "52999:8080" # Adminer UI + depends_on: + - db + +volumes: + postgres_data: + redis_data: + backend_static: diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..d735f80 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,17 @@ +# Dependencies +node_modules/ + +# Build +dist/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store + +# Logs +*.log diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..cbd1703 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,23 @@ +# Build Stage +FROM node:18-alpine as build-stage + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . +RUN npm run build + +# Production Stage +FROM nginx:alpine as production-stage + +# 复制构建产物到 Nginx +COPY --from=build-stage /app/dist /usr/share/nginx/html + +# 复制自定义 Nginx 配置 (处理 SPA 路由) +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/docs/landing-page-refactor-spec.md b/frontend/docs/landing-page-refactor-spec.md new file mode 100644 index 0000000..df702ac --- /dev/null +++ b/frontend/docs/landing-page-refactor-spec.md @@ -0,0 +1,639 @@ +# DreamWeaver 落地页重构规范文档 + +## 1. 项目概述 + +### 1.1 目标 +将当前简单的 Home.vue 落地页重构为专业级 SaaS 产品落地页,提升品牌形象和用户转化率。 + +### 1.2 当前状态 +- 文件位置: `frontend/src/views/Home.vue` +- 问题: 页面结构单一,仅有一个故事生成表单,缺少产品介绍、功能展示、用户信任背书等专业落地页必备元素 + +### 1.3 技术栈 +- Vue 3 + Composition API + TypeScript +- Tailwind CSS +- vue-i18n 国际化 +- @heroicons/vue/24/outline 图标库 +- 现有 UI 组件: BaseButton, BaseCard, BaseInput, BaseSelect, BaseTextarea + +--- + +## 2. 页面结构规范 + +页面从上到下包含 8 个主要区块(Section),每个区块独立且可复用。 + +### 2.1 Hero Section(主视觉区) + +**布局**: 两栏布局,左 60% 右 40%,移动端堆叠 + +**左侧内容**: +``` +- 主标题: 使用 gradient-text 样式 + - 第一行: "为孩子编织" (普通渐变) + - 第二行: "专属的童话梦境" (加粗强调) +- 副标题: 灰色次要文字,说明产品价值 +- CTA 按钮组: + - 主按钮: "开始创作" (btn-magic 样式,点击打开创作模态框) + - 次按钮: "了解更多" (outline 样式,滚动到 Features 区块) +``` + +**右侧内容**: +``` +- 故事卡片预览 (模拟产品效果) + - 卡片使用 glass 样式 + 阴影 + - 顶部: 模拟封面图区域 (渐变色块 + 星星图标) + - 标题: "小兔子的勇气冒险" + - 内容预览: 故事开头文字 (截断显示) + - 底部: 模拟的播放按钮和图片生成按钮图标 + - 添加浮动动画 (animate-float) +``` + +**背景装饰**: +``` +- 左上角: 浮动星星 SVG (absolute, opacity-20) +- 右下角: 浮动云朵 SVG (absolute, opacity-15) +- 使用 CSS animation 实现缓慢浮动效果 +``` + +**i18n 键**: +- `home.heroTitle`, `home.heroTitleHighlight` +- `home.heroSubtitle` +- `home.heroCta`, `home.heroCtaSecondary` +- `home.heroPreviewTitle`, `home.heroPreviewText` + +--- + +### 2.2 Trust Bar(信任背书区) + +**布局**: 水平三等分,居中对齐 + +**内容**: +``` +| 10,000+ 故事已创作 | 5,000+ 家庭信赖 | 98% 满意度 | +``` + +**样式**: +``` +- 背景: 浅紫色渐变 (from-purple-50 to-pink-50) +- 数字: 大号加粗,渐变色 +- 文字: 灰色小号 +- 分隔: 使用竖线或间距分隔 +``` + +**交互**: +``` +- 数字使用计数动画 (从 0 递增到目标值) +- 使用 IntersectionObserver 触发动画 +- 动画时长: 2 秒,使用 easeOutQuart 缓动 +``` + +**实现要点**: +```typescript +// 计数动画函数 +function animateCount(target: number, duration: number, callback: (value: number) => void) { + const start = performance.now() + const step = (timestamp: number) => { + const progress = Math.min((timestamp - start) / duration, 1) + const eased = 1 - Math.pow(1 - progress, 4) // easeOutQuart + callback(Math.floor(eased * target)) + if (progress < 1) requestAnimationFrame(step) + } + requestAnimationFrame(step) +} +``` + +**i18n 键**: +- `home.trustStoriesCreated`, `home.trustFamilies`, `home.trustSatisfaction` + +--- + +### 2.3 Features Section(功能特性区) + +**布局**: 标题 + 副标题 + 6 卡片网格 (3x2,移动端 1 列) + +**标题区**: +``` +- 主标题: "为什么选择梦语织机" +- 副标题: "我们用 AI 技术和教育理念,为每个孩子打造独一无二的成长故事" +``` + +**6 个功能卡片**: + +| # | 图标 | 标题 | 描述 | +|---|------|------|------| +| 1 | SparklesIcon | AI 智能创作 | 输入几个关键词,AI 即刻为您的孩子创作一个充满想象力的原创故事 | +| 2 | UserIcon | 个性化记忆 | 系统记住孩子的喜好和成长轨迹,故事越来越懂 TA | +| 3 | PhotoIcon | 精美 AI 插画 | 为每个故事自动生成独特的精美封面插画,让故事更加生动 | +| 4 | SpeakerWaveIcon | 温暖语音朗读 | 专业级 AI 配音,温暖的声音陪伴孩子进入甜美梦乡 | +| 5 | AcademicCapIcon | 教育主题融入 | 勇气、友谊、分享、诚实...在故事中自然传递正向价值观 | +| 6 | GlobeAltIcon | 故事宇宙 | 创建专属世界观,让喜爱的角色在不同故事中持续冒险 | + +**卡片样式**: +``` +- 使用 BaseCard 组件,添加 hover 效果 +- 图标: 48x48,紫色渐变背景圆形容器 +- 标题: font-bold text-gray-800 +- 描述: text-gray-600 text-sm +- hover: 上移 + 阴影增强 +``` + +**滚动动画**: +``` +- 使用 IntersectionObserver +- 卡片依次渐入 (stagger 100ms) +- 动画: opacity 0->1, translateY 20px->0 +``` + +**i18n 键**: +- `home.featuresTitle`, `home.featuresSubtitle` +- `home.feature1Title` ~ `home.feature6Title` +- `home.feature1Desc` ~ `home.feature6Desc` + +--- + +### 2.4 How It Works Section(使用流程区) + +**布局**: 标题 + 4 步骤水平排列(移动端垂直) + +**步骤内容**: + +| 步骤 | 图标 | 标题 | 描述 | +|------|------|------|------| +| 1 | LightBulbIcon | 输入灵感 | 输入关键词、角色或简单想法 | +| 2 | CpuChipIcon | AI 创作 | AI 根据输入生成专属故事 | +| 3 | PaintBrushIcon | 丰富内容 | 自动生成精美插画和语音 | +| 4 | ShareIcon | 分享故事 | 保存收藏,随时为孩子讲述 | + +**样式**: +``` +- 步骤编号: 圆形渐变背景,白色数字 +- 步骤之间: 虚线连接 (桌面端水平,移动端垂直) +- 图标: 在编号下方,较大尺寸 +- 文字: 居中对齐 +``` + +**连接线实现**: +```css +/* 桌面端水平连接线 */ +.step-connector { + position: absolute; + top: 24px; + left: 100%; + width: 100%; + height: 2px; + background: linear-gradient(90deg, #c084fc, #f472b6); + opacity: 0.3; +} + +/* 移动端隐藏水平线,显示垂直线 */ +@media (max-width: 768px) { + .step-connector { display: none; } + .step-connector-vertical { display: block; } +} +``` + +**i18n 键**: +- `home.howItWorksTitle`, `home.howItWorksSubtitle` +- `home.step1Title` ~ `home.step4Title` +- `home.step1Desc` ~ `home.step4Desc` + +--- + +### 2.5 Product Showcase Section(产品展示区) + +**布局**: 两栏,左侧功能列表,右侧模拟界面 + +**左侧内容**: +``` +- 小标题: "专为家长设计" +- 大标题: "简单易用,功能强大" +- 功能列表 (带勾选图标): + ✓ 直观的创作界面,几秒即可生成故事 + ✓ 多孩子档案管理,每个孩子独立记忆 + ✓ 故事历史永久保存,随时回顾美好时光 + ✓ 支持中英双语,培养语言能力 +``` + +**右侧内容**: +``` +- 模拟的产品界面截图 (CSS 绘制) +- 包含: + - 模拟浏览器顶栏 (三个圆点) + - 模拟导航栏 + - 模拟故事卡片列表 +- 使用 glass 样式 + 阴影 +- 轻微倾斜 (transform: perspective + rotateY) +``` + +**模拟界面 CSS**: +```css +.mock-browser { + background: white; + border-radius: 12px; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + transform: perspective(1000px) rotateY(-5deg); +} + +.mock-browser-bar { + height: 32px; + background: #f1f5f9; + border-radius: 12px 12px 0 0; + display: flex; + align-items: center; + padding: 0 12px; + gap: 6px; +} + +.mock-dot { + width: 10px; + height: 10px; + border-radius: 50%; +} +.mock-dot-red { background: #ef4444; } +.mock-dot-yellow { background: #eab308; } +.mock-dot-green { background: #22c55e; } +``` + +**i18n 键**: +- `home.showcaseTitle`, `home.showcaseSubtitle` +- `home.showcaseFeature1` ~ `home.showcaseFeature4` + +--- + +### 2.6 Testimonials Section(用户评价区) + +**布局**: 标题 + 3 评价卡片水平排列 + +**评价内容**: + +| # | 评价 | 用户名 | 身份 | +|---|------|--------|------| +| 1 | "每晚睡前,女儿都要听一个新故事。梦语织机让我不再为编故事发愁,而且故事质量真的很高!" | 小雨妈妈 | 5岁女孩家长 | +| 2 | "最惊喜的是个性化功能,系统记住了儿子喜欢恐龙和太空,每个故事都能戳中他的兴趣点。" | 航航爸爸 | 6岁男孩家长 | +| 3 | "语音朗读功能太棒了!出差时也能远程给孩子讲故事,声音温暖自然,孩子很喜欢。" | 朵朵妈妈 | 4岁女孩家长 | + +**卡片样式**: +``` +- 背景: glass 样式 +- 顶部: 引号图标 (大号,低透明度) +- 评价文字: 斜体,灰色 +- 底部: 头像 + 用户名 + 身份 +- 头像: 渐变色圆形 + 首字母 +``` + +**头像生成**: +```typescript +// 根据名字生成渐变色头像 +function getAvatarStyle(name: string) { + const colors = [ + ['#667eea', '#764ba2'], + ['#f093fb', '#f5576c'], + ['#4facfe', '#00f2fe'], + ] + const index = name.charCodeAt(0) % colors.length + return { + background: `linear-gradient(135deg, ${colors[index][0]}, ${colors[index][1]})`, + } +} +``` + +**i18n 键**: +- `home.testimonialsTitle`, `home.testimonialsSubtitle` +- `home.testimonial1Text` ~ `home.testimonial3Text` +- `home.testimonial1Name` ~ `home.testimonial3Name` +- `home.testimonial1Role` ~ `home.testimonial3Role` + +--- + +### 2.7 FAQ Section(常见问题区) + +**布局**: 标题 + 手风琴问答列表 + +**问答内容**: + +| # | 问题 | 答案 | +|---|------|------| +| 1 | 梦语织机适合多大的孩子? | 我们专为 3-8 岁儿童设计,故事内容、语言难度和教育主题都针对这个年龄段优化。 | +| 2 | 生成的故事安全吗? | 绝对安全。所有故事都经过内容过滤,确保适合儿童阅读,传递积极正向的价值观。 | +| 3 | 可以自定义故事角色吗? | 可以!您可以在孩子档案中设置喜好,或在创作时指定角色名称、特点,AI 会将其融入故事。 | +| 4 | 故事会重复吗? | 不会。每个故事都是 AI 实时原创生成的,即使使用相同关键词,也会产生不同的故事。 | +| 5 | 支持哪些语言? | 目前支持中文和英文,您可以随时切换界面语言,故事也会相应调整。 | + +**手风琴实现**: +```typescript +const expandedFaq = ref(null) + +function toggleFaq(index: number) { + expandedFaq.value = expandedFaq.value === index ? null : index +} +``` + +**样式**: +``` +- 问题行: 可点击,右侧箭头图标 +- 展开时: 箭头旋转 180°,答案滑入显示 +- 使用 Transition 组件实现平滑动画 +``` + +**i18n 键**: +- `home.faqTitle` +- `home.faq1Question` ~ `home.faq5Question` +- `home.faq1Answer` ~ `home.faq5Answer` + +--- + +### 2.8 Final CTA Section(底部转化区) + +**布局**: 居中,渐变背景 + +**内容**: +``` +- 大标题: "准备好为孩子创造魔法了吗?" +- 副标题: "立即开始,让 AI 为您的孩子编织独一无二的成长故事" +- CTA 按钮: "免费开始创作" (大号,btn-magic) +- 小字: "无需信用卡,立即体验" +``` + +**背景**: +```css +.cta-section { + background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%); + position: relative; + overflow: hidden; +} + +/* 装饰性圆形 */ +.cta-section::before { + content: ''; + position: absolute; + width: 400px; + height: 400px; + background: rgba(255, 255, 255, 0.1); + border-radius: 50%; + top: -200px; + right: -100px; +} +``` + +**i18n 键**: +- `home.ctaTitle`, `home.ctaSubtitle` +- `home.ctaButton`, `home.ctaNote` + +--- + +## 3. 创作模态框规范 + +### 3.1 触发方式 +- 点击 Hero 区 "开始创作" 按钮 +- 点击 Final CTA 区按钮 +- 已登录用户直接打开模态框 +- 未登录用户跳转登录流程 + +### 3.2 模态框结构 +``` +- 遮罩层: 半透明黑色背景 +- 模态框: 居中,最大宽度 600px +- 关闭按钮: 右上角 X 图标 +- 内容: 复用原有表单逻辑 +``` + +### 3.3 表单内容(保留原有逻辑) +``` +1. 输入类型切换: 关键词创作 / 故事润色 +2. 孩子档案选择 (可选) +3. 故事宇宙选择 (可选,依赖档案) +4. 输入区域 (关键词或故事文本) +5. 教育主题选择 (可选) +6. 提交按钮 +``` + +### 3.4 状态管理 +```typescript +const showCreateModal = ref(false) + +function openCreateModal() { + if (!userStore.user) { + // 跳转登录 + return + } + showCreateModal.value = true +} +``` + +--- + +## 4. 动画规范 + +### 4.1 滚动渐入动画 + +**实现方式**: 使用 IntersectionObserver + CSS Transition + +```typescript +// composables/useScrollAnimation.ts +export function useScrollAnimation() { + const observedElements = ref>(new Set()) + + onMounted(() => { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + entry.target.classList.add('animate-in') + observer.unobserve(entry.target) + } + }) + }, + { threshold: 0.1 } + ) + + document.querySelectorAll('.scroll-animate').forEach((el) => { + observer.observe(el) + }) + }) +} +``` + +**CSS**: +```css +.scroll-animate { + opacity: 0; + transform: translateY(20px); + transition: opacity 0.6s ease, transform 0.6s ease; +} + +.scroll-animate.animate-in { + opacity: 1; + transform: translateY(0); +} + +/* 延迟类 */ +.delay-100 { transition-delay: 100ms; } +.delay-200 { transition-delay: 200ms; } +.delay-300 { transition-delay: 300ms; } +``` + +### 4.2 浮动动画 + +```css +@keyframes float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-10px); } +} + +.animate-float { + animation: float 3s ease-in-out infinite; +} + +.animate-float-slow { + animation: float 5s ease-in-out infinite; +} +``` + +### 4.3 数字计数动画 + +见 2.2 节 Trust Bar 实现要点。 + +--- + +## 5. 响应式规范 + +### 5.1 断点定义 +``` +- sm: 640px +- md: 768px +- lg: 1024px +- xl: 1280px +``` + +### 5.2 各区块响应式行为 + +| 区块 | 桌面端 | 平板端 | 移动端 | +|------|--------|--------|--------| +| Hero | 两栏 60/40 | 两栏 50/50 | 单栏堆叠 | +| Trust Bar | 水平三等分 | 水平三等分 | 垂直堆叠 | +| Features | 3x2 网格 | 2x3 网格 | 单列 | +| How It Works | 水平 4 步 | 水平 4 步 | 垂直 4 步 | +| Showcase | 两栏 | 两栏 | 单栏堆叠 | +| Testimonials | 水平 3 卡 | 水平 3 卡 | 单列滚动 | +| FAQ | 单列 | 单列 | 单列 | +| Final CTA | 居中 | 居中 | 居中 | + +--- + +## 6. 暗色模式规范 + +### 6.1 颜色映射 + +| 元素 | 亮色模式 | 暗色模式 | +|------|----------|----------| +| 背景 | 渐变浅色 | 渐变深色 | +| 卡片背景 | rgba(255,255,255,0.7) | rgba(15,23,42,0.6) | +| 主文字 | gray-800 | gray-100 | +| 次文字 | gray-600 | gray-400 | +| 边框 | gray-200 | gray-700 | + +### 6.2 实现方式 +使用 Tailwind dark: 前缀,配合现有 .dark 类切换。 + +--- + +## 7. 验收标准 + +### 7.1 功能验收 +- [ ] Hero 区正确显示,CTA 按钮可点击 +- [ ] Trust Bar 数字动画正常触发 +- [ ] Features 6 个卡片正确显示 +- [ ] How It Works 4 步骤正确显示,连接线可见 +- [ ] Product Showcase 模拟界面正确渲染 +- [ ] Testimonials 3 个评价卡片正确显示 +- [ ] FAQ 手风琴展开/收起正常 +- [ ] Final CTA 按钮可点击 +- [ ] 创作模态框正常打开/关闭 +- [ ] 故事生成功能正常(保留原有逻辑) + +### 7.2 样式验收 +- [ ] 所有文案使用 i18n,中英文切换正常 +- [ ] 响应式布局在 320px ~ 1920px 宽度下正常 +- [ ] 暗色模式下所有元素可读 +- [ ] 滚动动画流畅,无卡顿 +- [ ] 所有图标正确显示 + +### 7.3 性能验收 +- [ ] 首屏加载时间 < 3s +- [ ] Lighthouse Performance 分数 > 80 +- [ ] 无控制台错误 + +--- + +## 8. 文件变更清单 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `frontend/src/views/Home.vue` | 重写 | 完整重构落地页 | +| `frontend/src/locales/zh.json` | 已更新 | 新增落地页文案 | +| `frontend/src/locales/en.json` | 已更新 | 新增落地页文案 | +| `frontend/src/style.css` | 修改 | 新增动画和样式类 | +| `frontend/src/composables/useScrollAnimation.ts` | 新建 | 滚动动画 composable | + +--- + +## 9. 依赖说明 + +### 9.1 现有依赖(无需新增) +- Vue 3 +- vue-router +- vue-i18n +- Pinia +- Tailwind CSS +- @heroicons/vue + +### 9.2 需要使用的图标 +```typescript +import { + SparklesIcon, + UserIcon, + PhotoIcon, + SpeakerWaveIcon, + AcademicCapIcon, + GlobeAltIcon, + LightBulbIcon, + CpuChipIcon, + PaintBrushIcon, + ShareIcon, + CheckIcon, + ChevronDownIcon, + XMarkIcon, + ArrowRightIcon, +} from '@heroicons/vue/24/outline' +``` + +--- + +## 10. 实现顺序建议 + +1. **Phase 1**: 基础结构 + - 创建页面骨架(8 个 section) + - 实现 Hero 区(不含动画) + - 实现创作模态框 + +2. **Phase 2**: 内容区块 + - Trust Bar + 计数动画 + - Features 卡片 + - How It Works 步骤 + +3. **Phase 3**: 展示区块 + - Product Showcase + - Testimonials + - FAQ 手风琴 + +4. **Phase 4**: 收尾 + - Final CTA + - 滚动动画 + - 响应式调整 + - 暗色模式适配 + +--- + +*文档版本: 1.0* +*创建时间: 2025-12-30* +*作者: Claude Code* diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..1c374bf --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + 梦语织机 - AI儿童故事生成器 + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..fa47985 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,30 @@ +server { + listen 80; + server_name localhost; + + # 静态文件服务 + location / { + root /usr/share/nginx/html; + index index.html index.htm; + # SPA 路由支持: 找不到文件时回退到 index.html + try_files $uri $uri/ /index.html; + } + + # 反向代理: 将 /api 请求转发给后端容器 + location /api/ { + proxy_pass http://backend:8000/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + # 静态资源代理 (后端生成的图片) + location /static/ { + proxy_pass http://backend:8000/static/; + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..9dfa62a --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2627 @@ +{ + "name": "dreamweaver-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dreamweaver-frontend", + "version": "0.1.0", + "dependencies": { + "@heroicons/vue": "^2.2.0", + "@vueuse/core": "^11.0.0", + "pinia": "^2.2.0", + "vue": "^3.5.0", + "vue-i18n": "^11.2.2", + "vue-router": "^4.4.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.1.0", + "autoprefixer": "^10.4.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", + "typescript": "^5.6.0", + "vite": "^5.4.0", + "vue-tsc": "^2.1.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "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==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@heroicons/vue": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/@heroicons/vue/-/vue-2.2.0.tgz", + "integrity": "sha512-G3dbSxoeEKqbi/DFalhRxJU4mTXJn7GwZ7ae8NuEQzd1bqdd0jAbdaBZlHPcvPD2xI1iGzNVB4k20Un2AguYPw==", + "license": "MIT", + "peerDependencies": { + "vue": ">= 3" + } + }, + "node_modules/@intlify/core-base": { + "version": "11.2.2", + "resolved": "https://registry.npmmirror.com/@intlify/core-base/-/core-base-11.2.2.tgz", + "integrity": "sha512-0mCTBOLKIqFUP3BzwuFW23hYEl9g/wby6uY//AC5hTgQfTsM2srCYF2/hYGp+a5DZ/HIFIgKkLJMzXTt30r0JQ==", + "license": "MIT", + "dependencies": { + "@intlify/message-compiler": "11.2.2", + "@intlify/shared": "11.2.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/message-compiler": { + "version": "11.2.2", + "resolved": "https://registry.npmmirror.com/@intlify/message-compiler/-/message-compiler-11.2.2.tgz", + "integrity": "sha512-XS2p8Ff5JxWsKhgfld4/MRQzZRQ85drMMPhb7Co6Be4ZOgqJX1DzcZt0IFgGTycgqL8rkYNwgnD443Q+TapOoA==", + "license": "MIT", + "dependencies": { + "@intlify/shared": "11.2.2", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/shared": { + "version": "11.2.2", + "resolved": "https://registry.npmmirror.com/@intlify/shared/-/shared-11.2.2.tgz", + "integrity": "sha512-OtCmyFpSXxNu/oET/aN6HtPCbZ01btXVd0f3w00YsHOb13Kverk1jzA2k47pAekM55qbUw421fvPF1yxZ+gicw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "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/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==", + "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/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.25", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.25.tgz", + "integrity": "sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.25", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.25", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.25.tgz", + "integrity": "sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.25", + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.25", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.25.tgz", + "integrity": "sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.25", + "@vue/compiler-dom": "3.5.25", + "@vue/compiler-ssr": "3.5.25", + "@vue/shared": "3.5.25", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.25", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.25.tgz", + "integrity": "sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.25", + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmmirror.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.25", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.25.tgz", + "integrity": "sha512-5xfAypCQepv4Jog1U4zn8cZIcbKKFka3AgWHEFQeK65OW+Ys4XybP6z2kKgws4YB43KGpqp5D/K3go2UPPunLA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.25", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.25.tgz", + "integrity": "sha512-Z751v203YWwYzy460bzsYQISDfPjHTl+6Zzwo/a3CsAf+0ccEjQ8c+0CdX1WsumRTHeywvyUFtW6KvNukT/smA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.25", + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.25", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.25.tgz", + "integrity": "sha512-a4WrkYFbb19i9pjkz38zJBg8wa/rboNERq3+hRRb0dHiJh13c+6kAbgqCPfMaJ2gg4weWD3APZswASOfmKwamA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.25", + "@vue/runtime-core": "3.5.25", + "@vue/shared": "3.5.25", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.25", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.25.tgz", + "integrity": "sha512-UJaXR54vMG61i8XNIzTSf2Q7MOqZHpp8+x3XLGtE3+fL+nQd+k7O5+X3D/uWrnQXOdMw5VPih+Uremcw+u1woQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.25", + "@vue/shared": "3.5.25" + }, + "peerDependencies": { + "vue": "3.5.25" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.25", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.25.tgz", + "integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "11.3.0", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-11.3.0.tgz", + "integrity": "sha512-7OC4Rl1f9G8IT6rUfi9JrKiXy4bfmHhZ5x2Ceojy0jnd3mHNEvV4JaRygH362ror6/NZ+Nl+n13LPzGiPN8cKA==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "11.3.0", + "@vueuse/shared": "11.3.0", + "vue-demi": ">=0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "11.3.0", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-11.3.0.tgz", + "integrity": "sha512-pwDnDspTqtTo2HwfLw4Rp6yywuuBdYnPYDq+mO38ZYKGebCUQC/nVj/PXSiK9HX5otxLz8Fn7ECPbjiRz2CC3g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "11.3.0", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-11.3.0.tgz", + "integrity": "sha512-P8gSSWQeucH5821ek2mn/ciCk+MS/zoRKqdQIM3bHq6p7GXDAJLmnRRKmF5F65sAVJIfzQlwR3aDzwCn10s8hA==", + "license": "MIT", + "dependencies": { + "vue-demi": ">=0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.22", + "resolved": "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.22.tgz", + "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.27.0", + "caniuse-lite": "^1.0.30001754", + "fraction.js": "^5.3.4", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.31", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz", + "integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.0", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.0.tgz", + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "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.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001757", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", + "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", + "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/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.262", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.262.tgz", + "integrity": "sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "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.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmmirror.com/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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.27", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmmirror.com/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "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==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "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/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmmirror.com/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "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.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmmirror.com/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.18", + "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-3.4.18.tgz", + "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmmirror.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "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/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.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": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.25", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.25.tgz", + "integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.25", + "@vue/compiler-sfc": "3.5.25", + "@vue/runtime-dom": "3.5.25", + "@vue/server-renderer": "3.5.25", + "@vue/shared": "3.5.25" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-i18n": { + "version": "11.2.2", + "resolved": "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-11.2.2.tgz", + "integrity": "sha512-ULIKZyRluUPRCZmihVgUvpq8hJTtOqnbGZuv4Lz+byEKZq4mU0g92og414l6f/4ju+L5mORsiUuEPYrAuX2NJg==", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "11.2.2", + "@intlify/shared": "11.2.2", + "@vue/devtools-api": "^6.5.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vue-router": { + "version": "4.6.3", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.3.tgz", + "integrity": "sha512-ARBedLm9YlbvQomnmq91Os7ck6efydTSpRP3nuOKCvgJOHNrhRoJDSKtee8kcL1Vf7nz6U+PMBL+hTvR3bTVQg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..a751c0a --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,28 @@ +{ + "name": "dreamweaver-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@heroicons/vue": "^2.2.0", + "@vueuse/core": "^11.0.0", + "pinia": "^2.2.0", + "vue": "^3.5.0", + "vue-i18n": "^11.2.2", + "vue-router": "^4.4.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.1.0", + "autoprefixer": "^10.4.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", + "typescript": "^5.6.0", + "vite": "^5.4.0", + "vue-tsc": "^2.1.0" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..f7bc25e --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/landing.html b/frontend/public/landing.html new file mode 100644 index 0000000..cd1830c --- /dev/null +++ b/frontend/public/landing.html @@ -0,0 +1,399 @@ + + + + + + 梦语织机 - AI 儿童故事创作 + + + + +
+
+
+
+
+
+
+ + + + +
+
+
专为 3-8 岁儿童设计
+

为孩子编织
专属的童话梦境

+

输入几个关键词,AI 即刻为孩子创作独一无二的睡前故事。温暖的声音,精美的插画,让每个夜晚都充满想象。

+
+ + +
+
+
+
+
🎨
AI 生成插画
+
+
🐰
+

小兔子的勇气冒险

刚刚生成
+

在一片被晨露打湿的森林里,住着一只名叫棉花的小白兔。今天,她决定独自去森林深处...

+
勇气冒险友谊
+
+
+
🔊
温暖语音朗读
+
+
+
+ + +
+
+
0+
故事已创作
+
0+
家庭信赖
+
0%
满意度
+
+
+ + +
+
+

为什么选择梦语织机

+
+
✍️

智能创作

输入关键词或简单想法,AI 即刻创作充满想象力的原创故事

+
🧒

个性化记忆

系统记住孩子的喜好,故事越来越懂 TA

+
🎨

精美插画

为每个故事自动生成独特的封面插画

+
🔊

温暖朗读

专业配音,陪伴孩子进入甜美梦乡

+
📚

教育主题

勇气、友谊、分享...自然传递正向价值观

+
🌍

故事宇宙

创建专属世界观,角色可在不同故事中复用

+
+
+
+ + +
+
+

简单三步,创造专属故事

+
+
1

输入灵感

几个关键词或简单想法,比如"勇敢的小兔子在森林里冒险"

+
2

AI 创作

AI 即刻理解并创作充满想象力的原创故事,还能生成精美插画

+
3

温暖朗读

选择喜欢的声音,让温暖的朗读陪伴孩子进入甜美梦乡

+
+
+
+ + +
+
+

你可能想知道

+
+
梦语织机专为 3-8 岁儿童设计。我们的故事内容、语言难度和主题都经过精心调整,确保适合这个年龄段孩子的认知发展水平。
+
绝对安全。所有生成的故事都经过多层内容审核,确保不包含任何不适合儿童的内容。我们的 AI 模型经过专门训练,只会生成积极、正向、富有教育意义的故事内容。
+
当然可以!您可以输入孩子喜欢的角色名称、特征,甚至可以把孩子自己设定为故事主角。AI 会根据您的输入创作独一无二的个性化故事。
+
免费版每月可生成 5 个故事,包含基础的语音朗读功能。付费版提供无限故事生成、高级语音选择、AI 插画生成、故事导出等更多功能。
+
+
+
+ + +
+
+

准备好为孩子创造魔法了吗?

+

免费开始,无需信用卡

+ +
+
+ + +
+

© 2024 梦语织机 DreamWeaver. All rights reserved.

+
+ + + + + + + \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..0d40d78 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,53 @@ + + + diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..66e7f2d --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,45 @@ +const BASE_URL = '' + +class ApiClient { + async request(url: string, options: RequestInit = {}): Promise { + const response = await fetch(`${BASE_URL}${url}`, { + ...options, + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: '请求失败' })) + throw new Error(error.detail || '请求失败') + } + + return response.json() + } + + get(url: string): Promise { + return this.request(url) + } + + post(url: string, data?: unknown): Promise { + return this.request(url, { + method: 'POST', + body: data ? JSON.stringify(data) : undefined, + }) + } + + put(url: string, data?: unknown): Promise { + return this.request(url, { + method: 'PUT', + body: data ? JSON.stringify(data) : undefined, + }) + } + + delete(url: string): Promise { + return this.request(url, { method: 'DELETE' }) + } +} + +export const api = new ApiClient() diff --git a/frontend/src/components/AddMemoryModal.vue b/frontend/src/components/AddMemoryModal.vue new file mode 100644 index 0000000..a07756d --- /dev/null +++ b/frontend/src/components/AddMemoryModal.vue @@ -0,0 +1,221 @@ + + + + + diff --git a/frontend/src/components/CreateStoryModal.vue b/frontend/src/components/CreateStoryModal.vue new file mode 100644 index 0000000..b99409d --- /dev/null +++ b/frontend/src/components/CreateStoryModal.vue @@ -0,0 +1,380 @@ + + + + + diff --git a/frontend/src/components/MemoryList.vue b/frontend/src/components/MemoryList.vue new file mode 100644 index 0000000..ac727cb --- /dev/null +++ b/frontend/src/components/MemoryList.vue @@ -0,0 +1,226 @@ + + + diff --git a/frontend/src/components/NavBar.vue b/frontend/src/components/NavBar.vue new file mode 100644 index 0000000..36cd7f8 --- /dev/null +++ b/frontend/src/components/NavBar.vue @@ -0,0 +1,212 @@ + + + diff --git a/frontend/src/components/ui/AnalysisAnimation.vue b/frontend/src/components/ui/AnalysisAnimation.vue new file mode 100644 index 0000000..59f5370 --- /dev/null +++ b/frontend/src/components/ui/AnalysisAnimation.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/frontend/src/components/ui/BaseButton.vue b/frontend/src/components/ui/BaseButton.vue new file mode 100644 index 0000000..cc996c7 --- /dev/null +++ b/frontend/src/components/ui/BaseButton.vue @@ -0,0 +1,87 @@ + + + diff --git a/frontend/src/components/ui/BaseCard.vue b/frontend/src/components/ui/BaseCard.vue new file mode 100644 index 0000000..c096452 --- /dev/null +++ b/frontend/src/components/ui/BaseCard.vue @@ -0,0 +1,43 @@ + + + diff --git a/frontend/src/components/ui/BaseInput.vue b/frontend/src/components/ui/BaseInput.vue new file mode 100644 index 0000000..01b1749 --- /dev/null +++ b/frontend/src/components/ui/BaseInput.vue @@ -0,0 +1,71 @@ + + + diff --git a/frontend/src/components/ui/BaseSelect.vue b/frontend/src/components/ui/BaseSelect.vue new file mode 100644 index 0000000..55ad675 --- /dev/null +++ b/frontend/src/components/ui/BaseSelect.vue @@ -0,0 +1,67 @@ + + + diff --git a/frontend/src/components/ui/BaseTextarea.vue b/frontend/src/components/ui/BaseTextarea.vue new file mode 100644 index 0000000..ca723f0 --- /dev/null +++ b/frontend/src/components/ui/BaseTextarea.vue @@ -0,0 +1,62 @@ + + + diff --git a/frontend/src/components/ui/ConfirmModal.vue b/frontend/src/components/ui/ConfirmModal.vue new file mode 100644 index 0000000..cb840de --- /dev/null +++ b/frontend/src/components/ui/ConfirmModal.vue @@ -0,0 +1,60 @@ + + + diff --git a/frontend/src/components/ui/EmptyState.vue b/frontend/src/components/ui/EmptyState.vue new file mode 100644 index 0000000..fb3eb67 --- /dev/null +++ b/frontend/src/components/ui/EmptyState.vue @@ -0,0 +1,45 @@ + + + diff --git a/frontend/src/components/ui/LoadingSpinner.vue b/frontend/src/components/ui/LoadingSpinner.vue new file mode 100644 index 0000000..6f74cd5 --- /dev/null +++ b/frontend/src/components/ui/LoadingSpinner.vue @@ -0,0 +1,36 @@ + + + diff --git a/frontend/src/components/ui/LoginDialog.vue b/frontend/src/components/ui/LoginDialog.vue new file mode 100644 index 0000000..60f5000 --- /dev/null +++ b/frontend/src/components/ui/LoginDialog.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts new file mode 100644 index 0000000..bab7a3d --- /dev/null +++ b/frontend/src/components/ui/index.ts @@ -0,0 +1,8 @@ +export { default as BaseButton } from './BaseButton.vue' +export { default as BaseCard } from './BaseCard.vue' +export { default as BaseInput } from './BaseInput.vue' +export { default as BaseSelect } from './BaseSelect.vue' +export { default as BaseTextarea } from './BaseTextarea.vue' +export { default as LoadingSpinner } from './LoadingSpinner.vue' +export { default as EmptyState } from './EmptyState.vue' +export { default as ConfirmModal } from './ConfirmModal.vue' diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts new file mode 100644 index 0000000..c40691a --- /dev/null +++ b/frontend/src/i18n.ts @@ -0,0 +1,27 @@ +import { createI18n } from 'vue-i18n' +import en from './locales/en.json' +import zh from './locales/zh.json' + +const messages = { en, zh } + +function detectLocale(): 'en' | 'zh' { + const saved = localStorage.getItem('locale') + if (saved === 'en' || saved === 'zh') return saved + const lang = navigator.language.toLowerCase() + if (lang.startsWith('zh')) return 'zh' + return 'en' +} + +const i18n = createI18n({ + legacy: false, + locale: detectLocale(), + fallbackLocale: 'en', + messages, +}) + +export function setLocale(locale: 'en' | 'zh') { + localStorage.setItem('locale', locale) + i18n.global.locale.value = locale +} + +export default i18n diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json new file mode 100644 index 0000000..d7f61e6 --- /dev/null +++ b/frontend/src/locales/en.json @@ -0,0 +1,154 @@ +{ + "app": { + "title": "DreamWeaver", + "navHome": "Home", + "navMyStories": "My Stories", + "navProfiles": "Profiles", + "navUniverses": "Universes", + "navAdmin": "Providers Admin" + }, + "home": { + "heroTitle": "Weave magical", + "heroTitleHighlight": "bedtime stories for your child", + "heroSubtitle": "AI-powered personalized stories for children aged 3-8, making every bedtime magical", + "heroCta": "Start Creating", + "heroCtaSecondary": "Learn More", + "heroPreviewTitle": "Bunny's Brave Adventure", + "heroPreviewText": "In a forest kissed by morning dew, there lived a little white bunny named Cotton...", + + "trustStoriesCreated": "Stories Created", + "trustFamilies": "Families Trust Us", + "trustSatisfaction": "Satisfaction", + + "featuresTitle": "Why Choose DreamWeaver", + "featuresSubtitle": "We combine AI technology with educational principles to create unique stories for every child", + "feature1Title": "AI-Powered Creation", + "feature1Desc": "Enter a few keywords, and AI instantly creates an imaginative original story for your child", + "feature2Title": "Personalized Memory", + "feature2Desc": "The system remembers your child's preferences and growth, making stories more tailored over time", + "feature3Title": "Beautiful AI Illustrations", + "feature3Desc": "Automatically generate unique cover illustrations for each story, bringing them to life", + "feature4Title": "Warm Voice Narration", + "feature4Desc": "Professional AI narration with a warm voice to accompany your child into sweet dreams", + "feature5Title": "Educational Themes", + "feature5Desc": "Courage, friendship, sharing, honesty... naturally weaving positive values into stories", + "feature6Title": "Story Universe", + "feature6Desc": "Create your own world where beloved characters continue their adventures across stories", + + "howItWorksTitle": "How It Works", + "howItWorksSubtitle": "Four steps to start your magical story journey", + "step1Title": "Enter Ideas", + "step1Desc": "Input keywords, characters, or simple ideas", + "step2Title": "AI Creates", + "step2Desc": "AI generates a unique story based on your input", + "step3Title": "Enrich Content", + "step3Desc": "Auto-generate beautiful illustrations and audio", + "step4Title": "Share Stories", + "step4Desc": "Save and tell stories to your child anytime", + + "showcaseTitle": "Designed for Parents", + "showcaseSubtitle": "Simple to use, powerful features", + "showcaseFeature1": "Intuitive interface, generate stories in seconds", + "showcaseFeature2": "Multi-child profile management with separate memories", + "showcaseFeature3": "Story history saved forever, revisit precious moments", + "showcaseFeature4": "Bilingual support to nurture language skills", + + "testimonialsTitle": "What Parents Say", + "testimonialsSubtitle": "Real feedback from our users", + "testimonial1Text": "Every night before bed, my daughter wants a new story. DreamWeaver saves me from making up stories, and the quality is amazing!", + "testimonial1Name": "Sarah M.", + "testimonial1Role": "Parent of 5-year-old girl", + "testimonial2Text": "The personalization is incredible! It remembers my son loves dinosaurs and space, and every story hits his interests perfectly.", + "testimonial2Name": "Michael T.", + "testimonial2Role": "Parent of 6-year-old boy", + "testimonial3Text": "The voice narration is fantastic! Even when traveling, I can tell stories remotely. The voice is warm and natural, my daughter loves it.", + "testimonial3Name": "Jennifer L.", + "testimonial3Role": "Parent of 4-year-old girl", + + "faqTitle": "Frequently Asked Questions", + "faq1Question": "What age is DreamWeaver suitable for?", + "faq1Answer": "We're designed for children aged 3-8. Story content, language difficulty, and educational themes are all optimized for this age group.", + "faq2Question": "Are the generated stories safe?", + "faq2Answer": "Absolutely safe. All stories go through content filtering to ensure they're appropriate for children and convey positive values.", + "faq3Question": "Can I customize story characters?", + "faq3Answer": "Yes! You can set preferences in your child's profile, or specify character names and traits when creating. AI will incorporate them into the story.", + "faq4Question": "Will stories repeat?", + "faq4Answer": "No. Every story is originally generated by AI in real-time. Even with the same keywords, you'll get different stories each time.", + "faq5Question": "What languages are supported?", + "faq5Answer": "Currently we support Chinese and English. You can switch interface language anytime, and stories will adjust accordingly.", + + "ctaTitle": "Ready to Create Magic for Your Child?", + "ctaSubtitle": "Start now and let AI weave unique stories for your child's growth", + "ctaButton": "Start Creating Free", + "ctaNote": "No credit card required", + + "createModalTitle": "Create New Story", + "inputTypeKeywords": "Keywords", + "inputTypeStory": "Polish Story", + "selectProfile": "Select Child Profile", + "selectProfileOptional": "(Optional)", + "selectUniverse": "Select Story Universe", + "noProfile": "No profile", + "noUniverse": "No universe", + "noUniverseHint": "No universe for this profile yet. Create one in Story Universe.", + "inputLabel": "Enter Keywords", + "inputLabelStory": "Enter Your Story", + "inputPlaceholder": "e.g., bunny, forest, courage, friendship...", + "inputPlaceholderStory": "Enter the story you want to polish...", + "themeLabel": "Select Educational Theme", + "themeOptional": "(Optional)", + "themeCourage": "Courage", + "themeFriendship": "Friendship", + "themeSharing": "Sharing", + "themeHonesty": "Honesty", + "themePersistence": "Persistence", + "themeTolerance": "Tolerance", + "themeCustom": "Or custom...", + "errorEmpty": "Please enter content", + "errorLogin": "Please login first", + "generating": "Weaving your story...", + "loginFirst": "Please Login", + "startCreate": "Create Magic Story" + }, + "stories": { + "myStories": "My Stories", + "view": "View", + "delete": "Delete", + "confirmDelete": "Are you sure to delete this story?", + "noStories": "No stories yet." + }, + "storyDetail": { + "back": "Back", + "generateImage": "Generate Cover", + "playAudio": "Play Audio", + "modeGenerated": "Generated", + "modeEnhanced": "Enhanced" + }, + "admin": { + "title": "Provider Management", + "reload": "Reload Cache", + "create": "Create", + "edit": "Edit", + "save": "Save", + "clear": "Clear", + "delete": "Delete", + "name": "Name", + "type": "Type", + "adapter": "Adapter", + "model": "Model", + "apiBase": "API Base", + "timeout": "Timeout (ms)", + "retries": "Max Retries", + "weight": "Weight", + "priority": "Priority", + "configRef": "Config Ref", + "enabled": "Enabled", + "actions": "Actions" + }, + "common": { + "enabled": "Enabled", + "disabled": "Disabled", + "confirm": "Confirm", + "cancel": "Cancel" + } +} diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json new file mode 100644 index 0000000..b807a7f --- /dev/null +++ b/frontend/src/locales/zh.json @@ -0,0 +1,154 @@ +{ + "app": { + "title": "梦语织机", + "navHome": "首页", + "navMyStories": "我的故事", + "navProfiles": "孩子档案", + "navUniverses": "故事宇宙", + "navAdmin": "供应商管理" + }, + "home": { + "heroTitle": "为孩子编织", + "heroTitleHighlight": "专属的童话梦境", + "heroSubtitle": "AI 智能创作个性化成长故事,陪伴 3-8 岁孩子的每一个美好夜晚", + "heroCta": "开始创作", + "heroCtaSecondary": "了解更多", + "heroPreviewTitle": "小兔子的勇气冒险", + "heroPreviewText": "在一片被晨露打湿的森林里,住着一只名叫棉花的小白兔...", + + "trustStoriesCreated": "故事已创作", + "trustFamilies": "家庭信赖", + "trustSatisfaction": "满意度", + + "featuresTitle": "为什么选择梦语织机", + "featuresSubtitle": "我们用 AI 技术和教育理念,为每个孩子打造独一无二的成长故事", + "feature1Title": "AI 智能创作", + "feature1Desc": "输入几个关键词,AI 即刻为您的孩子创作一个充满想象力的原创故事", + "feature2Title": "个性化记忆", + "feature2Desc": "系统记住孩子的喜好和成长轨迹,故事越来越懂 TA", + "feature3Title": "精美 AI 插画", + "feature3Desc": "为每个故事自动生成独特的精美封面插画,让故事更加生动", + "feature4Title": "温暖语音朗读", + "feature4Desc": "专业级 AI 配音,温暖的声音陪伴孩子进入甜美梦乡", + "feature5Title": "教育主题融入", + "feature5Desc": "勇气、友谊、分享、诚实...在故事中自然传递正向价值观", + "feature6Title": "故事宇宙", + "feature6Desc": "创建专属世界观,让喜爱的角色在不同故事中持续冒险", + + "howItWorksTitle": "如何使用", + "howItWorksSubtitle": "四步开启奇妙故事之旅", + "step1Title": "输入灵感", + "step1Desc": "输入关键词、角色或简单想法", + "step2Title": "AI 创作", + "step2Desc": "AI 根据输入生成专属故事", + "step3Title": "丰富内容", + "step3Desc": "自动生成精美插画和语音", + "step4Title": "分享故事", + "step4Desc": "保存收藏,随时为孩子讲述", + + "showcaseTitle": "专为家长设计", + "showcaseSubtitle": "简单易用,功能强大", + "showcaseFeature1": "直观的创作界面,几秒即可生成故事", + "showcaseFeature2": "多孩子档案管理,每个孩子独立记忆", + "showcaseFeature3": "故事历史永久保存,随时回顾美好时光", + "showcaseFeature4": "支持中英双语,培养语言能力", + + "testimonialsTitle": "家长们怎么说", + "testimonialsSubtitle": "来自真实用户的反馈", + "testimonial1Text": "每晚睡前,女儿都要听一个新故事。梦语织机让我不再为编故事发愁,而且故事质量真的很高!", + "testimonial1Name": "小雨妈妈", + "testimonial1Role": "5岁女孩家长", + "testimonial2Text": "最惊喜的是个性化功能,系统记住了儿子喜欢恐龙和太空,每个故事都能戳中他的兴趣点。", + "testimonial2Name": "航航爸爸", + "testimonial2Role": "6岁男孩家长", + "testimonial3Text": "语音朗读功能太棒了!出差时也能远程给孩子讲故事,声音温暖自然,孩子很喜欢。", + "testimonial3Name": "朵朵妈妈", + "testimonial3Role": "4岁女孩家长", + + "faqTitle": "常见问题", + "faq1Question": "梦语织机适合多大的孩子?", + "faq1Answer": "我们专为 3-8 岁儿童设计,故事内容、语言难度和教育主题都针对这个年龄段优化。", + "faq2Question": "生成的故事安全吗?", + "faq2Answer": "绝对安全。所有故事都经过内容过滤,确保适合儿童阅读,传递积极正向的价值观。", + "faq3Question": "可以自定义故事角色吗?", + "faq3Answer": "可以!您可以在孩子档案中设置喜好,或在创作时指定角色名称、特点,AI 会将其融入故事。", + "faq4Question": "故事会重复吗?", + "faq4Answer": "不会。每个故事都是 AI 实时原创生成的,即使使用相同关键词,也会产生不同的故事。", + "faq5Question": "支持哪些语言?", + "faq5Answer": "目前支持中文和英文,您可以随时切换界面语言,故事也会相应调整。", + + "ctaTitle": "准备好为孩子创造魔法了吗?", + "ctaSubtitle": "立即开始,让 AI 为您的孩子编织独一无二的成长故事", + "ctaButton": "免费开始创作", + "ctaNote": "无需信用卡,立即体验", + + "createModalTitle": "创作新故事", + "inputTypeKeywords": "关键词创作", + "inputTypeStory": "故事润色", + "selectProfile": "选择孩子档案", + "selectProfileOptional": "(可选)", + "selectUniverse": "选择故事宇宙", + "noProfile": "不使用档案", + "noUniverse": "不选择宇宙", + "noUniverseHint": "当前档案暂无宇宙,可在「故事宇宙」中创建", + "inputLabel": "输入关键词", + "inputLabelStory": "输入您的故事", + "inputPlaceholder": "例如:小兔子, 森林, 勇气, 友谊...", + "inputPlaceholderStory": "在这里输入您想要润色的故事...", + "themeLabel": "选择教育主题", + "themeOptional": "(可选)", + "themeCourage": "勇气", + "themeFriendship": "友谊", + "themeSharing": "分享", + "themeHonesty": "诚实", + "themePersistence": "坚持", + "themeTolerance": "包容", + "themeCustom": "或自定义...", + "errorEmpty": "请输入内容", + "errorLogin": "请先登录", + "generating": "正在编织故事...", + "loginFirst": "请先登录", + "startCreate": "开始创作魔法故事" + }, + "stories": { + "myStories": "我的故事", + "view": "查看", + "delete": "删除", + "confirmDelete": "确定删除这个故事吗?", + "noStories": "暂无故事。" + }, + "storyDetail": { + "back": "返回", + "generateImage": "生成封面", + "playAudio": "播放音频", + "modeGenerated": "生成", + "modeEnhanced": "润色" + }, + "admin": { + "title": "供应商管理", + "reload": "重载缓存", + "create": "创建", + "edit": "编辑", + "save": "保存", + "clear": "清空", + "delete": "删除", + "name": "名称", + "type": "类型", + "adapter": "适配器", + "model": "模型", + "apiBase": "API Base", + "timeout": "超时 (ms)", + "retries": "最大重试", + "weight": "权重", + "priority": "优先级", + "configRef": "Config Ref", + "enabled": "启用", + "actions": "操作" + }, + "common": { + "enabled": "启用", + "disabled": "停用", + "confirm": "确认", + "cancel": "取消" + } +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..efc05bb --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,14 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import router from './router' +import App from './App.vue' +import i18n from './i18n' +import './style.css' + +const app = createApp(App) + +app.use(createPinia()) +app.use(router) +app.use(i18n) + +app.mount('#app') diff --git a/frontend/src/router.ts b/frontend/src/router.ts new file mode 100644 index 0000000..9f38d9c --- /dev/null +++ b/frontend/src/router.ts @@ -0,0 +1,77 @@ +import { createRouter, createWebHistory } from 'vue-router' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/', + name: 'home', + component: () => import('./views/Home.vue'), + }, + { + path: '/my-stories', + name: 'my-stories', + component: () => import('./views/MyStories.vue'), + }, + { + path: '/profiles', + name: 'profiles', + component: () => import('./views/ChildProfiles.vue'), + }, + { + path: '/profiles/:id', + name: 'profile-detail', + component: () => import('./views/ChildProfileDetail.vue'), + }, + { + path: '/profiles/:id/timeline', + name: 'profile-timeline', + component: () => import('./views/ChildProfileTimeline.vue'), + }, + { + path: '/universes', + name: 'universes', + component: () => import('./views/Universes.vue'), + }, + { + path: '/universes/:id', + name: 'universe-detail', + component: () => import('./views/UniverseDetail.vue'), + }, + { + path: '/story/:id', + name: 'story-detail', + component: () => import('./views/StoryDetail.vue'), + }, + { + path: '/storybook/view', + name: 'storybook-viewer', + component: () => import('./views/StorybookViewer.vue'), + }, + { + path: '/admin/providers', + name: 'admin-providers', + component: () => import('./views/AdminProviders.vue'), + meta: { requiresAdmin: true }, + }, + ], +}) + +// Admin路由守卫:检查是否已有admin认证 +router.beforeEach((to, _from, next) => { + if (to.meta.requiresAdmin) { + const adminAuth = sessionStorage.getItem('admin_auth') + // 允许访问页面,页面内部会处理登录 + // 这里仅记录访问意图,实际认证由页面组件处理 + if (!adminAuth) { + // 未认证时仍允许访问,页面会显示登录表单 + next() + } else { + next() + } + } else { + next() + } +}) + +export default router diff --git a/frontend/src/stores/storybook.ts b/frontend/src/stores/storybook.ts new file mode 100644 index 0000000..345c93a --- /dev/null +++ b/frontend/src/stores/storybook.ts @@ -0,0 +1,38 @@ + +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export interface StorybookPage { + page_number: number + text: string + image_prompt: string + image_url?: string +} + +export interface Storybook { + id?: number // 新增 + title: string + main_character: string + art_style: string + pages: StorybookPage[] + cover_prompt: string + cover_url?: string +} + +export const useStorybookStore = defineStore('storybook', () => { + const currentStorybook = ref(null) + + function setStorybook(storybook: Storybook) { + currentStorybook.value = storybook + } + + function clearStorybook() { + currentStorybook.value = null + } + + return { + currentStorybook, + setStorybook, + clearStorybook, + } +}) diff --git a/frontend/src/stores/user.ts b/frontend/src/stores/user.ts new file mode 100644 index 0000000..feef998 --- /dev/null +++ b/frontend/src/stores/user.ts @@ -0,0 +1,49 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { api } from '../api/client' + +interface User { + id: string + name: string + avatar_url: string | null + provider: string +} + +export const useUserStore = defineStore('user', () => { + const user = ref(null) + const loading = ref(false) + + async function fetchSession() { + loading.value = true + try { + const data = await api.get<{ user: User | null }>('/auth/session') + user.value = data.user + } catch { + user.value = null + } finally { + loading.value = false + } + } + + function loginWithGithub() { + window.location.href = '/auth/github/signin' + } + + function loginWithGoogle() { + window.location.href = '/auth/google/signin' + } + + async function logout() { + await api.post('/auth/signout') + user.value = null + } + + return { + user, + loading, + fetchSession, + loginWithGithub, + loginWithGoogle, + logout, + } +}) diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..23bb0d6 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,243 @@ +/* 引入霞鹜文楷 - @import 必须在所有其他语句之前 */ +@import url('https://cdn.jsdelivr.net/npm/lxgw-wenkai-screen-web/style.css'); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* 全局基础样式 */ +body { + /* 优先使用文楷,营造书卷气 */ + font-family: 'LXGW WenKai Screen', 'Noto Sans SC', system-ui, sans-serif; + + /* 米色纸张背景 */ + background-color: #FDFBF7; + color: #292524; /* Stone-800 暖炭黑 */ + + /* 细微的纹理 (可选) */ + background-image: radial-gradient(#E5E7EB 1px, transparent 1px); + background-size: 32px 32px; +} + +/* 暗色模式适配 */ +.dark body { + background-color: #1C1917; /* Stone-900 */ + background-image: radial-gradient(#292524 1px, transparent 1px); + color: #E7E5E4; /* Stone-200 */ +} + +/* 自定义滚动条 - 更加柔和 */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: #D6D3D1; /* Stone-300 */ + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: #A8A29E; /* Stone-400 */ +} + +/* 卡片风格 - 实体书签感 */ +.glass { + background-color: #FFFFFF; + border: 1px solid #E7E5E4; /* Stone-200 */ + border-radius: 1rem; /* 16px */ + box-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.05), 0 1px 4px -1px rgba(0, 0, 0, 0.02); + transition: all 0.3s ease; +} + +.dark .glass { + background-color: #292524; + border-color: #44403C; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3); +} + +/* 标题风格 - 不再使用渐变,而是强调字重和颜色 */ +.gradient-text { + background: none; + -webkit-background-clip: unset; + -webkit-text-fill-color: initial; + color: #1F2937; /* Gray-800 */ + font-weight: 800; /* ExtraBold */ + letter-spacing: -0.025em; +} + +.dark .gradient-text { + color: #F3F4F6; +} + +/* 按钮 - 暖色调琥珀色 */ +.btn-magic { + background-color: #F59E0B; /* Amber-500 */ + color: #FFFFFF; + border-radius: 0.75rem; + font-weight: 600; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 2px 4px rgba(245, 158, 11, 0.2); +} + +.btn-magic:hover { + background-color: #D97706; /* Amber-600 */ + transform: translateY(-1px); + box-shadow: 0 4px 6px rgba(245, 158, 11, 0.3); +} + +.btn-magic:active { + transform: translateY(0); +} + +/* 卡片悬浮 */ +.card-hover:hover { + transform: translateY(-4px); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.05), 0 4px 6px -2px rgba(0, 0, 0, 0.025); + border-color: #F59E0B; /* 悬浮时边框变黄 */ +} + +/* 输入框 - 极简白卡纸风格 */ +.input-magic { + background: #FFFFFF; + border: 1px solid #E7E5E4; /* Stone-200 */ + color: #292524; + border-radius: 0.75rem; + transition: all 0.2s ease; +} + +.input-magic:focus { + outline: none; + border-color: #F59E0B; /* Amber-500 */ + box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.1); +} + +.dark .input-magic { + background: #292524; + border-color: #44403C; + color: #E7E5E4; +} + +/* 装饰背景 - 移除复杂的动画光斑,保持干净 */ +.bg-pattern { + background: none; +} + + +.dark body { + background: linear-gradient(135deg, #0f172a 0%, #111827 35%, #1f2937 70%, #111827 100%); +} + +/* 自定义滚动条 */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #f1f5f9; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb { + background: linear-gradient(135deg, #c084fc, #f472b6); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: linear-gradient(135deg, #a855f7, #ec4899); +} + +/* 玻璃态效果 */ +.glass { + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.3); +} + +.dark .glass { + background: rgba(15, 23, 42, 0.6); + border: 1px solid rgba(148, 163, 184, 0.25); +} + +/* 渐变文字 */ +.gradient-text { + background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* 按钮样式 */ +.btn-magic { + background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%); + background-size: 200% 200%; + transition: all 0.3s ease; +} + +.btn-magic:hover { + background-position: 100% 100%; + transform: translateY(-2px); + box-shadow: 0 10px 40px rgba(102, 126, 234, 0.4); +} + +.btn-magic:active { + transform: translateY(0); +} + +.dark .btn-magic { + background: linear-gradient(135deg, #7c3aed 0%, #4c1d95 50%, #a855f7 100%); +} + +/* 卡片悬浮效果 */ +.card-hover { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.card-hover:hover { + transform: translateY(-8px); + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15); +} + +/* 输入框聚焦效果 */ +.input-magic { + transition: all 0.3s ease; + border: 2px solid transparent; + background: linear-gradient(white, white) padding-box, + linear-gradient(135deg, #e9d5ff, #fbcfe8, #bfdbfe) border-box; +} + +.input-magic:focus { + background: linear-gradient(white, white) padding-box, + linear-gradient(135deg, #a855f7, #ec4899, #3b82f6) border-box; + box-shadow: 0 0 0 4px rgba(168, 85, 247, 0.1); +} + +.dark .input-magic { + background: linear-gradient(#0f172a, #0f172a) padding-box, + linear-gradient(135deg, #7c3aed, #db2777, #2563eb) border-box; +} + +/* 装饰性背景 */ +.bg-pattern { + background-image: + radial-gradient(circle at 20% 80%, rgba(168, 85, 247, 0.1) 0%, transparent 50%), + radial-gradient(circle at 80% 20%, rgba(236, 72, 153, 0.1) 0%, transparent 50%), + radial-gradient(circle at 40% 40%, rgba(59, 130, 246, 0.05) 0%, transparent 50%); +} + +.dark .bg-pattern { + background-image: + radial-gradient(circle at 20% 80%, rgba(124, 58, 237, 0.18) 0%, transparent 55%), + radial-gradient(circle at 80% 20%, rgba(236, 72, 153, 0.18) 0%, transparent 55%), + radial-gradient(circle at 40% 40%, rgba(59, 130, 246, 0.12) 0%, transparent 55%); +} + +@media (prefers-reduced-motion: reduce) { + .animate-float, .card-hover, .btn-magic { + animation: none; + transition: none; + } +} diff --git a/frontend/src/views/AdminProviders.vue b/frontend/src/views/AdminProviders.vue new file mode 100644 index 0000000..fc01dd1 --- /dev/null +++ b/frontend/src/views/AdminProviders.vue @@ -0,0 +1,353 @@ + + + diff --git a/frontend/src/views/ChildProfileDetail.vue b/frontend/src/views/ChildProfileDetail.vue new file mode 100644 index 0000000..dd86ba0 --- /dev/null +++ b/frontend/src/views/ChildProfileDetail.vue @@ -0,0 +1,274 @@ + + + diff --git a/frontend/src/views/ChildProfileTimeline.vue b/frontend/src/views/ChildProfileTimeline.vue new file mode 100644 index 0000000..00d86cb --- /dev/null +++ b/frontend/src/views/ChildProfileTimeline.vue @@ -0,0 +1,221 @@ + + + + + diff --git a/frontend/src/views/ChildProfiles.vue b/frontend/src/views/ChildProfiles.vue new file mode 100644 index 0000000..fa9945b --- /dev/null +++ b/frontend/src/views/ChildProfiles.vue @@ -0,0 +1,174 @@ + + + diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue new file mode 100644 index 0000000..4a3b516 --- /dev/null +++ b/frontend/src/views/Home.vue @@ -0,0 +1,473 @@ + + + + + diff --git a/frontend/src/views/MyStories.vue b/frontend/src/views/MyStories.vue new file mode 100644 index 0000000..3b46ebb --- /dev/null +++ b/frontend/src/views/MyStories.vue @@ -0,0 +1,189 @@ + + + diff --git a/frontend/src/views/StoryDetail.vue b/frontend/src/views/StoryDetail.vue new file mode 100644 index 0000000..e9f2f97 --- /dev/null +++ b/frontend/src/views/StoryDetail.vue @@ -0,0 +1,312 @@ + + + diff --git a/frontend/src/views/StorybookViewer.vue b/frontend/src/views/StorybookViewer.vue new file mode 100644 index 0000000..2e8fb3f --- /dev/null +++ b/frontend/src/views/StorybookViewer.vue @@ -0,0 +1,197 @@ + + + + + + diff --git a/frontend/src/views/UniverseDetail.vue b/frontend/src/views/UniverseDetail.vue new file mode 100644 index 0000000..04946da --- /dev/null +++ b/frontend/src/views/UniverseDetail.vue @@ -0,0 +1,208 @@ + + + diff --git a/frontend/src/views/Universes.vue b/frontend/src/views/Universes.vue new file mode 100644 index 0000000..edbcb9c --- /dev/null +++ b/frontend/src/views/Universes.vue @@ -0,0 +1,203 @@ + + + diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..323c78a --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..afcd725 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,46 @@ +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: 'class', + content: [ + "./index.html", + "./src/**/*.{vue,js,ts,jsx,tsx}", + ], + theme: { + extend: { + fontFamily: { + sans: ['Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', 'sans-serif'], + }, + borderRadius: { + '2xl': '1rem', + '3xl': '1.5rem', + }, + boxShadow: { + glass: '0 8px 32px rgba(0,0,0,0.08)', + }, + animation: { + float: 'float 3s ease-in-out infinite', + }, + keyframes: { + float: { + '0%, 100%': { transform: 'translateY(0px)' }, + '50%': { transform: 'translateY(-10px)' }, + }, + }, + colors: { + primary: { + 50: '#fdf4ff', + 100: '#fae8ff', + 200: '#f5d0fe', + 300: '#f0abfc', + 400: '#e879f9', + 500: '#d946ef', + 600: '#c026d3', + 700: '#a21caf', + 800: '#86198f', + 900: '#701a75', + }, + }, + }, + }, + plugins: [], +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..4b6a33b --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..d80961f --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,29 @@ +import { fileURLToPath, URL } from 'node:url' +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:52000', + changeOrigin: true, + }, + '/auth': { + target: 'http://localhost:52000', + changeOrigin: true, + }, + '/admin': { + target: 'http://localhost:52000', + changeOrigin: true, + }, + }, + }, +})