From b8d3cb4644a651b146a7087981cb21fef0f8731c Mon Sep 17 00:00:00 2001 From: torin Date: Fri, 17 Apr 2026 18:58:11 +0800 Subject: [PATCH] wip: snapshot full local workspace state --- .claude/settings.local.json | 92 +- .../code-architecture/REFACTORING-PRD.md | 832 +-- .../specs/design/BRAND-VISUAL-DIRECTIONS.md | 2 +- .../design/LANDING-PAGE-REFACTOR-SPEC.md | 1278 ++-- .claude/specs/design/PAGE-HIFI-LAYOUT-SPEC.md | 2 +- .../specs/design/WEB-HIFI-PROTOTYPE-SPEC.md | 2 +- .../figma-html/theme-a/account-settings.html | 2 +- .../figma-html/theme-a/admin-providers.html | 2 +- .../theme-a/child-profile-detail.html | 2 +- .../figma-html/theme-a/child-profiles.html | 2 +- .../specs/design/figma-html/theme-a/home.html | 2 +- .../design/figma-html/theme-a/index.html | 2 +- .../design/figma-html/theme-a/login.html | 2 +- .../design/figma-html/theme-a/my-stories.html | 2 +- .../design/figma-html/theme-a/not-found.html | 2 +- .../figma-html/theme-a/push-settings.html | 2 +- .../figma-html/theme-a/story-detail.html | 2 +- .../specs/design/figma-html/theme-a/style.css | 2 +- .../figma-html/theme-a/universe-detail.html | 2 +- .../design/figma-html/theme-a/universes.html | 2 +- .../figma-html/theme-b/account-settings.html | 2 +- .../figma-html/theme-b/admin-providers.html | 2 +- .../theme-b/child-profile-detail.html | 2 +- .../figma-html/theme-b/child-profiles.html | 2 +- .../specs/design/figma-html/theme-b/home.html | 2 +- .../design/figma-html/theme-b/index.html | 2 +- .../design/figma-html/theme-b/login.html | 2 +- .../design/figma-html/theme-b/my-stories.html | 2 +- .../design/figma-html/theme-b/not-found.html | 2 +- .../figma-html/theme-b/push-settings.html | 2 +- .../figma-html/theme-b/story-detail.html | 2 +- .../specs/design/figma-html/theme-b/style.css | 2 +- .../figma-html/theme-b/universe-detail.html | 2 +- .../design/figma-html/theme-b/universes.html | 2 +- .../figma-html/theme-c/account-settings.html | 2 +- .../figma-html/theme-c/admin-providers.html | 2 +- .../theme-c/child-profile-detail.html | 2 +- .../figma-html/theme-c/child-profiles.html | 2 +- .../specs/design/figma-html/theme-c/home.html | 2 +- .../design/figma-html/theme-c/index.html | 2 +- .../design/figma-html/theme-c/login.html | 2 +- .../design/figma-html/theme-c/my-stories.html | 2 +- .../design/figma-html/theme-c/not-found.html | 2 +- .../figma-html/theme-c/push-settings.html | 2 +- .../figma-html/theme-c/story-detail.html | 2 +- .../specs/design/figma-html/theme-c/style.css | 2 +- .../figma-html/theme-c/universe-detail.html | 2 +- .../design/figma-html/theme-c/universes.html | 2 +- .../CHILD-PROFILE-MODEL.md | 858 +-- .../MEMORY-INTELLIGENCE-PRD.md | 1082 ++-- ...MEMORY-PERSONALIZATION-TECHNICAL-REPORT.md | 2 +- .../memory-intelligence/PUSH-TRIGGER-RULES.md | 2 +- .../STORY-UNIVERSE-MODEL.md | 2 +- .../specs/product-roadmap/PRODUCT-VISION.md | 260 +- .../product-roadmap/PROVIDER-PLATFORM-RFC.md | 1354 ++--- .claude/specs/product-roadmap/ROADMAP.md | 338 +- .../specs/robustness-improvement/dev-plan.md | 98 +- .claude/ui-refactor-plan.md | 4 +- .github/workflows/build.yml | 378 +- .gitignore | 88 +- AGENTS.md | 134 + admin-frontend/.gitignore | 34 +- admin-frontend/Dockerfile | 46 +- admin-frontend/index.html | 26 +- admin-frontend/nginx.conf | 74 +- admin-frontend/package-lock.json | 5254 ++++++++--------- admin-frontend/package.json | 54 +- admin-frontend/postcss.config.js | 12 +- admin-frontend/public/favicon.svg | 6 +- admin-frontend/public/landing.html | 796 +-- admin-frontend/src/api/client.ts | 54 +- .../src/components/CreateStoryModal.vue | 752 +-- .../src/components/ui/BaseButton.vue | 2 +- admin-frontend/src/components/ui/BaseCard.vue | 2 +- .../src/components/ui/BaseInput.vue | 2 +- .../src/components/ui/BaseSelect.vue | 2 +- .../src/components/ui/BaseTextarea.vue | 2 +- .../src/components/ui/ConfirmModal.vue | 2 +- .../src/components/ui/EmptyState.vue | 2 +- .../src/components/ui/LoadingSpinner.vue | 2 +- .../src/components/ui/LoginDialog.vue | 272 +- admin-frontend/src/components/ui/index.ts | 2 +- admin-frontend/src/stores/storybook.ts | 76 +- admin-frontend/src/stores/user.ts | 98 +- .../src/views/ChildProfileTimeline.vue | 442 +- admin-frontend/src/views/ChildProfiles.vue | 2 +- admin-frontend/src/views/StoryDetail.vue | 2 +- admin-frontend/src/views/StorybookViewer.vue | 394 +- admin-frontend/src/views/UniverseDetail.vue | 2 +- admin-frontend/src/vite-env.d.ts | 14 +- admin-frontend/tailwind.config.js | 2 +- admin-frontend/tsconfig.json | 48 +- admin-frontend/tsconfig.node.json | 22 +- backend/Dockerfile | 66 +- .../versions/0002_add_api_key_to_providers.py | 58 +- .../0003_add_provider_monitoring_tables.py | 200 +- .../versions/0004_add_child_profiles.py | 2 +- ...005_add_story_universes_and_story_links.py | 2 +- ...006_add_reading_events_and_memory_items.py | 2 +- .../versions/0008_add_pages_to_stories.py | 50 +- backend/app/admin_main.py | 122 +- backend/app/api/memories.py | 536 +- backend/app/api/reading_events.py | 2 +- backend/app/api/universes.py | 2 +- backend/app/core/admin_auth.py | 120 +- backend/app/core/logging.py | 96 +- backend/app/core/prompts.py | 380 +- backend/app/core/rate_limiter.py | 282 +- backend/app/core/security.py | 50 +- backend/app/schemas/__init__.py | 2 +- backend/app/services/adapters/__init__.py | 42 +- backend/app/services/adapters/base.py | 92 +- .../app/services/adapters/image/__init__.py | 6 +- .../services/adapters/image/antigravity.py | 428 +- backend/app/services/adapters/image/cqtai.py | 504 +- backend/app/services/adapters/registry.py | 142 +- .../services/adapters/storybook/__init__.py | 2 +- .../services/adapters/storybook/primary.py | 390 +- .../app/services/adapters/text/__init__.py | 2 +- backend/app/services/adapters/text/gemini.py | 328 +- backend/app/services/adapters/text/models.py | 22 +- backend/app/services/adapters/text/openai.py | 344 +- backend/app/services/adapters/tts/__init__.py | 10 +- backend/app/services/adapters/tts/edge_tts.py | 132 +- .../app/services/adapters/tts/elevenlabs.py | 208 +- backend/app/services/adapters/tts/minimax.py | 298 +- backend/app/services/cost_tracker.py | 392 +- backend/app/services/memory_service.py | 942 +-- backend/app/services/provider_cache.py | 218 +- backend/app/services/provider_metrics.py | 496 +- backend/app/services/secret_service.py | 414 +- backend/app/tasks/memory.py | 58 +- backend/docs/code_review_report.md | 14 - backend/docs/ha_runbook.md | 89 - backend/docs/memory_system_dev.md | 147 - backend/docs/provider_system.md | 246 - backend/docs/refactoring_plan.md | 109 - backend/docs/stories_split_analysis.md | 52 - backend/scripts/add_config_column.py | 54 +- backend/scripts/fix_db_schema.py | 58 +- backend/scripts/manual_init_db.py | 42 +- backend/tests/__init__.py | 2 +- backend/tests/test_auth.py | 130 +- backend/tests/test_provider_router.py | 390 +- backend/tests/test_universes.py | 2 +- docker-compose.prod.yml | 316 +- docker-compose.yml | 366 +- frontend/.gitignore | 34 +- frontend/Dockerfile | 46 +- frontend/index.html | 26 +- frontend/nginx.conf | 60 +- frontend/package-lock.json | 5254 ++++++++--------- frontend/package.json | 56 +- frontend/postcss.config.js | 12 +- frontend/public/favicon.svg | 6 +- frontend/public/landing.html | 796 +-- frontend/src/api/client.ts | 54 +- frontend/src/components/AddMemoryModal.vue | 442 +- frontend/src/components/CreateStoryModal.vue | 729 ++- frontend/src/components/MemoryList.vue | 452 +- .../src/components/ui/AnalysisAnimation.vue | 278 +- frontend/src/components/ui/BaseButton.vue | 2 +- frontend/src/components/ui/BaseCard.vue | 2 +- frontend/src/components/ui/BaseInput.vue | 2 +- frontend/src/components/ui/BaseSelect.vue | 2 +- frontend/src/components/ui/BaseTextarea.vue | 2 +- frontend/src/components/ui/ConfirmModal.vue | 2 +- frontend/src/components/ui/EmptyState.vue | 2 +- frontend/src/components/ui/LoadingSpinner.vue | 2 +- frontend/src/components/ui/LoginDialog.vue | 272 +- frontend/src/components/ui/index.ts | 2 +- frontend/src/router.ts | 2 +- frontend/src/stores/user.ts | 98 +- frontend/src/views/ChildProfileTimeline.vue | 442 +- frontend/src/views/ChildProfiles.vue | 2 +- frontend/src/views/UniverseDetail.vue | 2 +- frontend/src/vite-env.d.ts | 14 +- frontend/tailwind.config.js | 2 +- frontend/tsconfig.json | 48 +- frontend/tsconfig.node.json | 22 +- install.cmd | 432 +- 181 files changed, 16964 insertions(+), 17486 deletions(-) create mode 100644 AGENTS.md delete mode 100644 backend/docs/code_review_report.md delete mode 100644 backend/docs/ha_runbook.md delete mode 100644 backend/docs/memory_system_dev.md delete mode 100644 backend/docs/provider_system.md delete mode 100644 backend/docs/refactoring_plan.md delete mode 100644 backend/docs/stories_split_analysis.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a2e8441..818f54f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,44 +1,48 @@ -{ - "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:*)" - ] - } -} +{ + "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:*)", + "Bash(git commit:*)", + "Bash(git remote add:*)", + "Bash(git push:*)", + "Bash(git branch:*)" + ] + } +} diff --git a/.claude/specs/code-architecture/REFACTORING-PRD.md b/.claude/specs/code-architecture/REFACTORING-PRD.md index 4c31d94..34a5237 100644 --- a/.claude/specs/code-architecture/REFACTORING-PRD.md +++ b/.claude/specs/code-architecture/REFACTORING-PRD.md @@ -1,416 +1,416 @@ -# 代码架构重构 PRD - -> 版本: 1.0 -> 日期: 2025-01-21 -> 状态: 待实施 - ---- - -## 1. 背景与目标 - -### 1.1 现状问题 - -经过代码审计,发现以下架构债务: - -| 问题 | 位置 | 影响 | -|------|------|------| -| 缺少 Schemas 层 | Backend API | 类型不安全,文档生成差 | -| Fat Controller | `stories.py` 562行 | 难测试,职责混乱 | -| 重复加载逻辑 | Frontend 5+ 组件 | 代码冗余,维护困难 | -| 无 Repository 层 | Backend | DB 查询散落,难复用 | - -### 1.2 重构目标 - -- **后端**:引入 Pydantic Schemas 层,分离请求/响应模型 -- **前端**:抽取 Vue Composables,消除重复逻辑 -- **原则**:渐进式重构,不破坏现有功能 - ---- - -## 2. 后端重构:Schemas 层 - -### 2.1 目标结构 - -``` -backend/app/ -├── api/ # 路由层(瘦身) -│ ├── stories.py -│ └── ... -├── schemas/ # 新增:Pydantic 模型 -│ ├── __init__.py -│ ├── auth.py -│ ├── story.py -│ ├── profile.py -│ ├── universe.py -│ ├── memory.py -│ ├── push_config.py -│ └── common.py # 分页、错误响应等通用结构 -├── models/ # SQLAlchemy ORM(原 db/) -└── services/ # 业务逻辑 -``` - -### 2.2 Schema 设计规范 - -#### 命名约定 - -| 类型 | 命名模式 | 示例 | -|------|----------|------| -| 创建请求 | `{Entity}Create` | `StoryCreate` | -| 更新请求 | `{Entity}Update` | `ProfileUpdate` | -| 响应模型 | `{Entity}Response` | `StoryResponse` | -| 列表响应 | `{Entity}ListResponse` | `StoryListResponse` | - -#### 示例:Story Schema - -```python -# schemas/story.py -from datetime import datetime -from typing import Literal -from pydantic import BaseModel, Field - -class StoryCreate(BaseModel): - """创建故事请求""" - type: Literal["keywords", "enhance", "storybook"] - data: str = Field(..., min_length=1, max_length=2000) - education_theme: str | None = None - child_profile_id: str | None = None - universe_id: str | None = None - -class StoryResponse(BaseModel): - """故事响应""" - id: int - title: str - story_text: str | None - pages: list[dict] | None - image_url: str | None - mode: str - created_at: datetime - - class Config: - from_attributes = True # 支持 ORM 模型转换 - -class StoryListResponse(BaseModel): - """故事列表响应""" - stories: list[StoryResponse] - total: int - page: int - page_size: int -``` - -### 2.3 迁移步骤 - -| 阶段 | 任务 | 文件 | -|------|------|------| -| P1 | 创建 `schemas/common.py` | 分页、错误响应 | -| P2 | 创建 `schemas/story.py` | 故事相关模型 | -| P3 | 创建 `schemas/profile.py` | 档案相关模型 | -| P4 | 创建 `schemas/universe.py` | 宇宙相关模型 | -| P5 | 创建 `schemas/auth.py` | 认证相关模型 | -| P6 | 创建 `schemas/memory.py` | 记忆相关模型 | -| P7 | 创建 `schemas/push_config.py` | 推送配置模型 | -| P8 | 重构 `api/stories.py` | 使用新 schemas | -| P9 | 重构其他 API 文件 | 逐个迁移 | - -### 2.4 验收标准 - -- [ ] 所有 API endpoint 使用 `response_model` 参数 -- [ ] 请求体使用 Pydantic 模型而非 `Body(...)` -- [ ] OpenAPI 文档自动生成完整类型 -- [ ] 现有测试全部通过 -- [ ] `ruff check` 无错误 - ---- - -## 3. 前端重构:Composables - -### 3.1 目标结构 - -``` -frontend/src/ -├── composables/ # 新增:可复用逻辑 -│ ├── index.ts -│ ├── useAsyncData.ts # 异步数据加载 -│ ├── useFormValidation.ts # 表单验证 -│ └── useDateFormat.ts # 日期格式化 -├── components/ -├── stores/ -└── views/ -``` - -### 3.2 Composable 设计 - -#### 3.2.1 useAsyncData - -**用途**:统一处理 API 数据加载的 loading/error 状态 - -```typescript -// composables/useAsyncData.ts -import { ref, type Ref } from 'vue' - -interface AsyncDataOptions { - immediate?: boolean // 立即执行,默认 true - initialData?: T // 初始数据 - onError?: (e: Error) => void -} - -interface AsyncDataReturn { - data: Ref - loading: Ref - error: Ref - execute: () => Promise - reset: () => void -} - -export function useAsyncData( - fetcher: () => Promise, - options: AsyncDataOptions = {} -): AsyncDataReturn { - const { immediate = true, initialData = null, onError } = options - - const data = ref(initialData) as Ref - const loading = ref(false) - const error = ref('') - - async function execute() { - loading.value = true - error.value = '' - try { - data.value = await fetcher() - } catch (e) { - const message = e instanceof Error ? e.message : '加载失败' - error.value = message - onError?.(e instanceof Error ? e : new Error(message)) - } finally { - loading.value = false - } - } - - function reset() { - data.value = initialData - loading.value = false - error.value = '' - } - - if (immediate) { - execute() - } - - return { data, loading, error, execute, reset } -} -``` - -**使用示例**: - -```typescript -// views/MyStories.vue(重构前 40 行 → 重构后 10 行) -import { useAsyncData } from '@/composables' -import { api } from '@/api/client' - -const { data: response, loading, error } = useAsyncData( - () => api.get('/api/stories') -) -const stories = computed(() => response.value?.stories ?? []) -``` - -#### 3.2.2 useFormValidation - -**用途**:统一表单验证逻辑 - -```typescript -// composables/useFormValidation.ts -import { ref, reactive } from 'vue' - -type ValidationRule = (value: unknown) => string | true - -interface FieldRules { - [field: string]: ValidationRule[] -} - -export function useFormValidation>( - initialData: T, - rules: FieldRules -) { - const form = reactive({ ...initialData }) - const errors = reactive>({}) - - function validate(): boolean { - let valid = true - for (const [field, fieldRules] of Object.entries(rules)) { - const value = form[field] - for (const rule of fieldRules) { - const result = rule(value) - if (result !== true) { - errors[field] = result - valid = false - break - } else { - errors[field] = '' - } - } - } - return valid - } - - function reset() { - Object.assign(form, initialData) - Object.keys(errors).forEach(k => errors[k] = '') - } - - return { form, errors, validate, reset } -} - -// 常用验证规则 -export const required = (msg = '此字段必填') => - (v: unknown) => (v !== null && v !== undefined && v !== '') || msg - -export const minLength = (min: number, msg?: string) => - (v: unknown) => (typeof v === 'string' && v.length >= min) || msg || `至少 ${min} 个字符` - -export const maxLength = (max: number, msg?: string) => - (v: unknown) => (typeof v === 'string' && v.length <= max) || msg || `最多 ${max} 个字符` -``` - -#### 3.2.3 useDateFormat - -**用途**:统一日期格式化 - -```typescript -// composables/useDateFormat.ts -export function useDateFormat() { - function formatDate(dateStr: string | Date, format: 'date' | 'datetime' | 'relative' = 'date'): string { - const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr - - if (format === 'relative') { - return formatRelative(date) - } - - const year = date.getFullYear() - const month = String(date.getMonth() + 1).padStart(2, '0') - const day = String(date.getDate()).padStart(2, '0') - - if (format === 'date') { - return `${year}-${month}-${day}` - } - - const hours = String(date.getHours()).padStart(2, '0') - const minutes = String(date.getMinutes()).padStart(2, '0') - return `${year}-${month}-${day} ${hours}:${minutes}` - } - - function formatRelative(date: Date): string { - const now = new Date() - const diffMs = now.getTime() - date.getTime() - const diffMins = Math.floor(diffMs / 60000) - const diffHours = Math.floor(diffMs / 3600000) - const diffDays = Math.floor(diffMs / 86400000) - - if (diffMins < 1) return '刚刚' - if (diffMins < 60) return `${diffMins} 分钟前` - if (diffHours < 24) return `${diffHours} 小时前` - if (diffDays < 7) return `${diffDays} 天前` - return formatDate(date, 'date') - } - - return { formatDate, formatRelative } -} -``` - -### 3.3 迁移步骤 - -| 阶段 | 任务 | 影响文件 | -|------|------|----------| -| P1 | 创建 `composables/useAsyncData.ts` | 新文件 | -| P2 | 创建 `composables/useDateFormat.ts` | 新文件 | -| P3 | 创建 `composables/useFormValidation.ts` | 新文件 | -| P4 | 创建 `composables/index.ts` | 统一导出 | -| P5 | 重构 `views/MyStories.vue` | 使用 useAsyncData | -| P6 | 重构 `views/ChildProfiles.vue` | 使用 useAsyncData | -| P7 | 重构 `views/Universes.vue` | 使用 useAsyncData | -| P8 | 重构 `components/CreateStoryModal.vue` | 使用 useFormValidation | -| P9 | 同步重构 `admin-frontend/` | 复制 composables | - -### 3.4 验收标准 - -- [ ] `composables/` 目录包含 3 个核心 composable -- [ ] 至少 3 个 view 组件使用 `useAsyncData` -- [ ] `CreateStoryModal` 使用 `useFormValidation` -- [ ] `npm run build` 无类型错误 -- [ ] 现有功能正常运行 - ---- - -## 4. 风险与缓解 - -| 风险 | 影响 | 缓解措施 | -|------|------|----------| -| 重构引入 bug | 高 | 每个阶段运行测试 | -| 类型不兼容 | 中 | 使用 `from_attributes = True` | -| 前端构建失败 | 中 | 逐文件迁移,及时 commit | - ---- - -## 5. 时间估算 - -| 模块 | 工作量 | 预计时间 | -|------|--------|----------| -| Backend Schemas | 10 个新文件 + 9 个 API 重构 | 3-4 小时 | -| Frontend Composables | 4 个新文件 + 6 个组件重构 | 2-3 小时 | -| 测试验证 | 全量回归 | 1 小时 | -| **总计** | | **6-8 小时** | - ---- - -## 6. 后续迭代 - -本次重构完成后,可继续: - -1. **Repository 层**:抽象 DB 查询逻辑 -2. **Service 层瘦身**:拆分 `provider_router.py` (433行) -3. **前端共享包**:`frontend` 和 `admin-frontend` 共用 UI 组件 -4. **API 版本化**:引入 `/api/v1/` 前缀 - ---- - -## 附录:文件清单 - -### 新增文件 - -``` -backend/app/schemas/ -├── __init__.py -├── common.py -├── auth.py -├── story.py -├── profile.py -├── universe.py -├── memory.py -└── push_config.py - -frontend/src/composables/ -├── index.ts -├── useAsyncData.ts -├── useFormValidation.ts -└── useDateFormat.ts -``` - -### 修改文件 - -``` -backend/app/api/ -├── stories.py -├── profiles.py -├── universes.py -├── memories.py -├── push_configs.py -├── reading_events.py -└── auth.py - -frontend/src/views/ -├── MyStories.vue -├── ChildProfiles.vue -├── Universes.vue -└── ... - -frontend/src/components/ -└── CreateStoryModal.vue -``` +# 代码架构重构 PRD + +> 版本: 1.0 +> 日期: 2025-01-21 +> 状态: 待实施 + +--- + +## 1. 背景与目标 + +### 1.1 现状问题 + +经过代码审计,发现以下架构债务: + +| 问题 | 位置 | 影响 | +|------|------|------| +| 缺少 Schemas 层 | Backend API | 类型不安全,文档生成差 | +| Fat Controller | `stories.py` 562行 | 难测试,职责混乱 | +| 重复加载逻辑 | Frontend 5+ 组件 | 代码冗余,维护困难 | +| 无 Repository 层 | Backend | DB 查询散落,难复用 | + +### 1.2 重构目标 + +- **后端**:引入 Pydantic Schemas 层,分离请求/响应模型 +- **前端**:抽取 Vue Composables,消除重复逻辑 +- **原则**:渐进式重构,不破坏现有功能 + +--- + +## 2. 后端重构:Schemas 层 + +### 2.1 目标结构 + +``` +backend/app/ +├── api/ # 路由层(瘦身) +│ ├── stories.py +│ └── ... +├── schemas/ # 新增:Pydantic 模型 +│ ├── __init__.py +│ ├── auth.py +│ ├── story.py +│ ├── profile.py +│ ├── universe.py +│ ├── memory.py +│ ├── push_config.py +│ └── common.py # 分页、错误响应等通用结构 +├── models/ # SQLAlchemy ORM(原 db/) +└── services/ # 业务逻辑 +``` + +### 2.2 Schema 设计规范 + +#### 命名约定 + +| 类型 | 命名模式 | 示例 | +|------|----------|------| +| 创建请求 | `{Entity}Create` | `StoryCreate` | +| 更新请求 | `{Entity}Update` | `ProfileUpdate` | +| 响应模型 | `{Entity}Response` | `StoryResponse` | +| 列表响应 | `{Entity}ListResponse` | `StoryListResponse` | + +#### 示例:Story Schema + +```python +# schemas/story.py +from datetime import datetime +from typing import Literal +from pydantic import BaseModel, Field + +class StoryCreate(BaseModel): + """创建故事请求""" + type: Literal["keywords", "enhance", "storybook"] + data: str = Field(..., min_length=1, max_length=2000) + education_theme: str | None = None + child_profile_id: str | None = None + universe_id: str | None = None + +class StoryResponse(BaseModel): + """故事响应""" + id: int + title: str + story_text: str | None + pages: list[dict] | None + image_url: str | None + mode: str + created_at: datetime + + class Config: + from_attributes = True # 支持 ORM 模型转换 + +class StoryListResponse(BaseModel): + """故事列表响应""" + stories: list[StoryResponse] + total: int + page: int + page_size: int +``` + +### 2.3 迁移步骤 + +| 阶段 | 任务 | 文件 | +|------|------|------| +| P1 | 创建 `schemas/common.py` | 分页、错误响应 | +| P2 | 创建 `schemas/story.py` | 故事相关模型 | +| P3 | 创建 `schemas/profile.py` | 档案相关模型 | +| P4 | 创建 `schemas/universe.py` | 宇宙相关模型 | +| P5 | 创建 `schemas/auth.py` | 认证相关模型 | +| P6 | 创建 `schemas/memory.py` | 记忆相关模型 | +| P7 | 创建 `schemas/push_config.py` | 推送配置模型 | +| P8 | 重构 `api/stories.py` | 使用新 schemas | +| P9 | 重构其他 API 文件 | 逐个迁移 | + +### 2.4 验收标准 + +- [ ] 所有 API endpoint 使用 `response_model` 参数 +- [ ] 请求体使用 Pydantic 模型而非 `Body(...)` +- [ ] OpenAPI 文档自动生成完整类型 +- [ ] 现有测试全部通过 +- [ ] `ruff check` 无错误 + +--- + +## 3. 前端重构:Composables + +### 3.1 目标结构 + +``` +frontend/src/ +├── composables/ # 新增:可复用逻辑 +│ ├── index.ts +│ ├── useAsyncData.ts # 异步数据加载 +│ ├── useFormValidation.ts # 表单验证 +│ └── useDateFormat.ts # 日期格式化 +├── components/ +├── stores/ +└── views/ +``` + +### 3.2 Composable 设计 + +#### 3.2.1 useAsyncData + +**用途**:统一处理 API 数据加载的 loading/error 状态 + +```typescript +// composables/useAsyncData.ts +import { ref, type Ref } from 'vue' + +interface AsyncDataOptions { + immediate?: boolean // 立即执行,默认 true + initialData?: T // 初始数据 + onError?: (e: Error) => void +} + +interface AsyncDataReturn { + data: Ref + loading: Ref + error: Ref + execute: () => Promise + reset: () => void +} + +export function useAsyncData( + fetcher: () => Promise, + options: AsyncDataOptions = {} +): AsyncDataReturn { + const { immediate = true, initialData = null, onError } = options + + const data = ref(initialData) as Ref + const loading = ref(false) + const error = ref('') + + async function execute() { + loading.value = true + error.value = '' + try { + data.value = await fetcher() + } catch (e) { + const message = e instanceof Error ? e.message : '加载失败' + error.value = message + onError?.(e instanceof Error ? e : new Error(message)) + } finally { + loading.value = false + } + } + + function reset() { + data.value = initialData + loading.value = false + error.value = '' + } + + if (immediate) { + execute() + } + + return { data, loading, error, execute, reset } +} +``` + +**使用示例**: + +```typescript +// views/MyStories.vue(重构前 40 行 → 重构后 10 行) +import { useAsyncData } from '@/composables' +import { api } from '@/api/client' + +const { data: response, loading, error } = useAsyncData( + () => api.get('/api/stories') +) +const stories = computed(() => response.value?.stories ?? []) +``` + +#### 3.2.2 useFormValidation + +**用途**:统一表单验证逻辑 + +```typescript +// composables/useFormValidation.ts +import { ref, reactive } from 'vue' + +type ValidationRule = (value: unknown) => string | true + +interface FieldRules { + [field: string]: ValidationRule[] +} + +export function useFormValidation>( + initialData: T, + rules: FieldRules +) { + const form = reactive({ ...initialData }) + const errors = reactive>({}) + + function validate(): boolean { + let valid = true + for (const [field, fieldRules] of Object.entries(rules)) { + const value = form[field] + for (const rule of fieldRules) { + const result = rule(value) + if (result !== true) { + errors[field] = result + valid = false + break + } else { + errors[field] = '' + } + } + } + return valid + } + + function reset() { + Object.assign(form, initialData) + Object.keys(errors).forEach(k => errors[k] = '') + } + + return { form, errors, validate, reset } +} + +// 常用验证规则 +export const required = (msg = '此字段必填') => + (v: unknown) => (v !== null && v !== undefined && v !== '') || msg + +export const minLength = (min: number, msg?: string) => + (v: unknown) => (typeof v === 'string' && v.length >= min) || msg || `至少 ${min} 个字符` + +export const maxLength = (max: number, msg?: string) => + (v: unknown) => (typeof v === 'string' && v.length <= max) || msg || `最多 ${max} 个字符` +``` + +#### 3.2.3 useDateFormat + +**用途**:统一日期格式化 + +```typescript +// composables/useDateFormat.ts +export function useDateFormat() { + function formatDate(dateStr: string | Date, format: 'date' | 'datetime' | 'relative' = 'date'): string { + const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr + + if (format === 'relative') { + return formatRelative(date) + } + + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + + if (format === 'date') { + return `${year}-${month}-${day}` + } + + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + return `${year}-${month}-${day} ${hours}:${minutes}` + } + + function formatRelative(date: Date): string { + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMs / 3600000) + const diffDays = Math.floor(diffMs / 86400000) + + if (diffMins < 1) return '刚刚' + if (diffMins < 60) return `${diffMins} 分钟前` + if (diffHours < 24) return `${diffHours} 小时前` + if (diffDays < 7) return `${diffDays} 天前` + return formatDate(date, 'date') + } + + return { formatDate, formatRelative } +} +``` + +### 3.3 迁移步骤 + +| 阶段 | 任务 | 影响文件 | +|------|------|----------| +| P1 | 创建 `composables/useAsyncData.ts` | 新文件 | +| P2 | 创建 `composables/useDateFormat.ts` | 新文件 | +| P3 | 创建 `composables/useFormValidation.ts` | 新文件 | +| P4 | 创建 `composables/index.ts` | 统一导出 | +| P5 | 重构 `views/MyStories.vue` | 使用 useAsyncData | +| P6 | 重构 `views/ChildProfiles.vue` | 使用 useAsyncData | +| P7 | 重构 `views/Universes.vue` | 使用 useAsyncData | +| P8 | 重构 `components/CreateStoryModal.vue` | 使用 useFormValidation | +| P9 | 同步重构 `admin-frontend/` | 复制 composables | + +### 3.4 验收标准 + +- [ ] `composables/` 目录包含 3 个核心 composable +- [ ] 至少 3 个 view 组件使用 `useAsyncData` +- [ ] `CreateStoryModal` 使用 `useFormValidation` +- [ ] `npm run build` 无类型错误 +- [ ] 现有功能正常运行 + +--- + +## 4. 风险与缓解 + +| 风险 | 影响 | 缓解措施 | +|------|------|----------| +| 重构引入 bug | 高 | 每个阶段运行测试 | +| 类型不兼容 | 中 | 使用 `from_attributes = True` | +| 前端构建失败 | 中 | 逐文件迁移,及时 commit | + +--- + +## 5. 时间估算 + +| 模块 | 工作量 | 预计时间 | +|------|--------|----------| +| Backend Schemas | 10 个新文件 + 9 个 API 重构 | 3-4 小时 | +| Frontend Composables | 4 个新文件 + 6 个组件重构 | 2-3 小时 | +| 测试验证 | 全量回归 | 1 小时 | +| **总计** | | **6-8 小时** | + +--- + +## 6. 后续迭代 + +本次重构完成后,可继续: + +1. **Repository 层**:抽象 DB 查询逻辑 +2. **Service 层瘦身**:拆分 `provider_router.py` (433行) +3. **前端共享包**:`frontend` 和 `admin-frontend` 共用 UI 组件 +4. **API 版本化**:引入 `/api/v1/` 前缀 + +--- + +## 附录:文件清单 + +### 新增文件 + +``` +backend/app/schemas/ +├── __init__.py +├── common.py +├── auth.py +├── story.py +├── profile.py +├── universe.py +├── memory.py +└── push_config.py + +frontend/src/composables/ +├── index.ts +├── useAsyncData.ts +├── useFormValidation.ts +└── useDateFormat.ts +``` + +### 修改文件 + +``` +backend/app/api/ +├── stories.py +├── profiles.py +├── universes.py +├── memories.py +├── push_configs.py +├── reading_events.py +└── auth.py + +frontend/src/views/ +├── MyStories.vue +├── ChildProfiles.vue +├── Universes.vue +└── ... + +frontend/src/components/ +└── CreateStoryModal.vue +``` diff --git a/.claude/specs/design/BRAND-VISUAL-DIRECTIONS.md b/.claude/specs/design/BRAND-VISUAL-DIRECTIONS.md index e80ff6a..b488f43 100644 --- a/.claude/specs/design/BRAND-VISUAL-DIRECTIONS.md +++ b/.claude/specs/design/BRAND-VISUAL-DIRECTIONS.md @@ -150,4 +150,4 @@ ## 推荐 -建议 Web MVP 使用方案 A(Soft Aurora),兼顾温暖与信任。方案 B/C 可作为后续主题或 A/B 测试备选。 +建议 Web MVP 使用方案 A(Soft Aurora),兼顾温暖与信任。方案 B/C 可作为后续主题或 A/B 测试备选。 diff --git a/.claude/specs/design/LANDING-PAGE-REFACTOR-SPEC.md b/.claude/specs/design/LANDING-PAGE-REFACTOR-SPEC.md index df702ac..12c3f3e 100644 --- a/.claude/specs/design/LANDING-PAGE-REFACTOR-SPEC.md +++ b/.claude/specs/design/LANDING-PAGE-REFACTOR-SPEC.md @@ -1,639 +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* +# 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/.claude/specs/design/PAGE-HIFI-LAYOUT-SPEC.md b/.claude/specs/design/PAGE-HIFI-LAYOUT-SPEC.md index 9af9670..de1bfd9 100644 --- a/.claude/specs/design/PAGE-HIFI-LAYOUT-SPEC.md +++ b/.claude/specs/design/PAGE-HIFI-LAYOUT-SPEC.md @@ -227,4 +227,4 @@ - 组件做 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 index 9227d72..c90c3ad 100644 --- a/.claude/specs/design/WEB-HIFI-PROTOTYPE-SPEC.md +++ b/.claude/specs/design/WEB-HIFI-PROTOTYPE-SPEC.md @@ -296,4 +296,4 @@ - 设计系统库(色板、文字、组件) - 全流程高保真页面 -- 原型链接(Figma 中生成) +- 原型链接(Figma 中生成) diff --git a/.claude/specs/design/figma-html/theme-a/account-settings.html b/.claude/specs/design/figma-html/theme-a/account-settings.html index 030ccfa..24dea4c 100644 --- a/.claude/specs/design/figma-html/theme-a/account-settings.html +++ b/.claude/specs/design/figma-html/theme-a/account-settings.html @@ -56,4 +56,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-a/admin-providers.html b/.claude/specs/design/figma-html/theme-a/admin-providers.html index 4ddb60f..481e987 100644 --- a/.claude/specs/design/figma-html/theme-a/admin-providers.html +++ b/.claude/specs/design/figma-html/theme-a/admin-providers.html @@ -71,4 +71,4 @@ - + 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 index a7c66e2..1346b08 100644 --- a/.claude/specs/design/figma-html/theme-a/child-profile-detail.html +++ b/.claude/specs/design/figma-html/theme-a/child-profile-detail.html @@ -85,4 +85,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-a/child-profiles.html b/.claude/specs/design/figma-html/theme-a/child-profiles.html index 000393d..1d8322f 100644 --- a/.claude/specs/design/figma-html/theme-a/child-profiles.html +++ b/.claude/specs/design/figma-html/theme-a/child-profiles.html @@ -55,4 +55,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-a/home.html b/.claude/specs/design/figma-html/theme-a/home.html index c028c08..91a2ef1 100644 --- a/.claude/specs/design/figma-html/theme-a/home.html +++ b/.claude/specs/design/figma-html/theme-a/home.html @@ -108,4 +108,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-a/index.html b/.claude/specs/design/figma-html/theme-a/index.html index 622c4a9..f8d7221 100644 --- a/.claude/specs/design/figma-html/theme-a/index.html +++ b/.claude/specs/design/figma-html/theme-a/index.html @@ -30,4 +30,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-a/login.html b/.claude/specs/design/figma-html/theme-a/login.html index 3226f72..9688bc3 100644 --- a/.claude/specs/design/figma-html/theme-a/login.html +++ b/.claude/specs/design/figma-html/theme-a/login.html @@ -25,4 +25,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-a/my-stories.html b/.claude/specs/design/figma-html/theme-a/my-stories.html index 060b682..4d06335 100644 --- a/.claude/specs/design/figma-html/theme-a/my-stories.html +++ b/.claude/specs/design/figma-html/theme-a/my-stories.html @@ -81,4 +81,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-a/not-found.html b/.claude/specs/design/figma-html/theme-a/not-found.html index 6f65b6a..f90546f 100644 --- a/.claude/specs/design/figma-html/theme-a/not-found.html +++ b/.claude/specs/design/figma-html/theme-a/not-found.html @@ -17,4 +17,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-a/push-settings.html b/.claude/specs/design/figma-html/theme-a/push-settings.html index 6909466..2b439f7 100644 --- a/.claude/specs/design/figma-html/theme-a/push-settings.html +++ b/.claude/specs/design/figma-html/theme-a/push-settings.html @@ -75,4 +75,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-a/story-detail.html b/.claude/specs/design/figma-html/theme-a/story-detail.html index 0b732c1..0ab007d 100644 --- a/.claude/specs/design/figma-html/theme-a/story-detail.html +++ b/.claude/specs/design/figma-html/theme-a/story-detail.html @@ -70,4 +70,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-a/style.css b/.claude/specs/design/figma-html/theme-a/style.css index 734e139..0ec99ce 100644 --- a/.claude/specs/design/figma-html/theme-a/style.css +++ b/.claude/specs/design/figma-html/theme-a/style.css @@ -214,4 +214,4 @@ a { color: var(--primary-600); text-decoration: none; } .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 index 5de8aad..1f075d9 100644 --- a/.claude/specs/design/figma-html/theme-a/universe-detail.html +++ b/.claude/specs/design/figma-html/theme-a/universe-detail.html @@ -61,4 +61,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-a/universes.html b/.claude/specs/design/figma-html/theme-a/universes.html index b257b50..c5ceced 100644 --- a/.claude/specs/design/figma-html/theme-a/universes.html +++ b/.claude/specs/design/figma-html/theme-a/universes.html @@ -61,4 +61,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-b/account-settings.html b/.claude/specs/design/figma-html/theme-b/account-settings.html index 030ccfa..24dea4c 100644 --- a/.claude/specs/design/figma-html/theme-b/account-settings.html +++ b/.claude/specs/design/figma-html/theme-b/account-settings.html @@ -56,4 +56,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-b/admin-providers.html b/.claude/specs/design/figma-html/theme-b/admin-providers.html index 4ddb60f..481e987 100644 --- a/.claude/specs/design/figma-html/theme-b/admin-providers.html +++ b/.claude/specs/design/figma-html/theme-b/admin-providers.html @@ -71,4 +71,4 @@ - + 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 index a7c66e2..1346b08 100644 --- a/.claude/specs/design/figma-html/theme-b/child-profile-detail.html +++ b/.claude/specs/design/figma-html/theme-b/child-profile-detail.html @@ -85,4 +85,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-b/child-profiles.html b/.claude/specs/design/figma-html/theme-b/child-profiles.html index 000393d..1d8322f 100644 --- a/.claude/specs/design/figma-html/theme-b/child-profiles.html +++ b/.claude/specs/design/figma-html/theme-b/child-profiles.html @@ -55,4 +55,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-b/home.html b/.claude/specs/design/figma-html/theme-b/home.html index c028c08..91a2ef1 100644 --- a/.claude/specs/design/figma-html/theme-b/home.html +++ b/.claude/specs/design/figma-html/theme-b/home.html @@ -108,4 +108,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-b/index.html b/.claude/specs/design/figma-html/theme-b/index.html index 622c4a9..f8d7221 100644 --- a/.claude/specs/design/figma-html/theme-b/index.html +++ b/.claude/specs/design/figma-html/theme-b/index.html @@ -30,4 +30,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-b/login.html b/.claude/specs/design/figma-html/theme-b/login.html index 3226f72..9688bc3 100644 --- a/.claude/specs/design/figma-html/theme-b/login.html +++ b/.claude/specs/design/figma-html/theme-b/login.html @@ -25,4 +25,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-b/my-stories.html b/.claude/specs/design/figma-html/theme-b/my-stories.html index 060b682..4d06335 100644 --- a/.claude/specs/design/figma-html/theme-b/my-stories.html +++ b/.claude/specs/design/figma-html/theme-b/my-stories.html @@ -81,4 +81,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-b/not-found.html b/.claude/specs/design/figma-html/theme-b/not-found.html index 6f65b6a..f90546f 100644 --- a/.claude/specs/design/figma-html/theme-b/not-found.html +++ b/.claude/specs/design/figma-html/theme-b/not-found.html @@ -17,4 +17,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-b/push-settings.html b/.claude/specs/design/figma-html/theme-b/push-settings.html index 6909466..2b439f7 100644 --- a/.claude/specs/design/figma-html/theme-b/push-settings.html +++ b/.claude/specs/design/figma-html/theme-b/push-settings.html @@ -75,4 +75,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-b/story-detail.html b/.claude/specs/design/figma-html/theme-b/story-detail.html index 0b732c1..0ab007d 100644 --- a/.claude/specs/design/figma-html/theme-b/story-detail.html +++ b/.claude/specs/design/figma-html/theme-b/story-detail.html @@ -70,4 +70,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-b/style.css b/.claude/specs/design/figma-html/theme-b/style.css index e3e7607..7f76735 100644 --- a/.claude/specs/design/figma-html/theme-b/style.css +++ b/.claude/specs/design/figma-html/theme-b/style.css @@ -214,4 +214,4 @@ a { color: var(--primary-600); text-decoration: none; } .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 index 5de8aad..1f075d9 100644 --- a/.claude/specs/design/figma-html/theme-b/universe-detail.html +++ b/.claude/specs/design/figma-html/theme-b/universe-detail.html @@ -61,4 +61,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-b/universes.html b/.claude/specs/design/figma-html/theme-b/universes.html index b257b50..c5ceced 100644 --- a/.claude/specs/design/figma-html/theme-b/universes.html +++ b/.claude/specs/design/figma-html/theme-b/universes.html @@ -61,4 +61,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-c/account-settings.html b/.claude/specs/design/figma-html/theme-c/account-settings.html index 030ccfa..24dea4c 100644 --- a/.claude/specs/design/figma-html/theme-c/account-settings.html +++ b/.claude/specs/design/figma-html/theme-c/account-settings.html @@ -56,4 +56,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-c/admin-providers.html b/.claude/specs/design/figma-html/theme-c/admin-providers.html index 4ddb60f..481e987 100644 --- a/.claude/specs/design/figma-html/theme-c/admin-providers.html +++ b/.claude/specs/design/figma-html/theme-c/admin-providers.html @@ -71,4 +71,4 @@ - + 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 index a7c66e2..1346b08 100644 --- a/.claude/specs/design/figma-html/theme-c/child-profile-detail.html +++ b/.claude/specs/design/figma-html/theme-c/child-profile-detail.html @@ -85,4 +85,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-c/child-profiles.html b/.claude/specs/design/figma-html/theme-c/child-profiles.html index 000393d..1d8322f 100644 --- a/.claude/specs/design/figma-html/theme-c/child-profiles.html +++ b/.claude/specs/design/figma-html/theme-c/child-profiles.html @@ -55,4 +55,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-c/home.html b/.claude/specs/design/figma-html/theme-c/home.html index c028c08..91a2ef1 100644 --- a/.claude/specs/design/figma-html/theme-c/home.html +++ b/.claude/specs/design/figma-html/theme-c/home.html @@ -108,4 +108,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-c/index.html b/.claude/specs/design/figma-html/theme-c/index.html index 622c4a9..f8d7221 100644 --- a/.claude/specs/design/figma-html/theme-c/index.html +++ b/.claude/specs/design/figma-html/theme-c/index.html @@ -30,4 +30,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-c/login.html b/.claude/specs/design/figma-html/theme-c/login.html index 3226f72..9688bc3 100644 --- a/.claude/specs/design/figma-html/theme-c/login.html +++ b/.claude/specs/design/figma-html/theme-c/login.html @@ -25,4 +25,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-c/my-stories.html b/.claude/specs/design/figma-html/theme-c/my-stories.html index 060b682..4d06335 100644 --- a/.claude/specs/design/figma-html/theme-c/my-stories.html +++ b/.claude/specs/design/figma-html/theme-c/my-stories.html @@ -81,4 +81,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-c/not-found.html b/.claude/specs/design/figma-html/theme-c/not-found.html index 6f65b6a..f90546f 100644 --- a/.claude/specs/design/figma-html/theme-c/not-found.html +++ b/.claude/specs/design/figma-html/theme-c/not-found.html @@ -17,4 +17,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-c/push-settings.html b/.claude/specs/design/figma-html/theme-c/push-settings.html index 6909466..2b439f7 100644 --- a/.claude/specs/design/figma-html/theme-c/push-settings.html +++ b/.claude/specs/design/figma-html/theme-c/push-settings.html @@ -75,4 +75,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-c/story-detail.html b/.claude/specs/design/figma-html/theme-c/story-detail.html index 0b732c1..0ab007d 100644 --- a/.claude/specs/design/figma-html/theme-c/story-detail.html +++ b/.claude/specs/design/figma-html/theme-c/story-detail.html @@ -70,4 +70,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-c/style.css b/.claude/specs/design/figma-html/theme-c/style.css index 1e2e620..956155a 100644 --- a/.claude/specs/design/figma-html/theme-c/style.css +++ b/.claude/specs/design/figma-html/theme-c/style.css @@ -214,4 +214,4 @@ a { color: var(--primary-600); text-decoration: none; } .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 index 5de8aad..1f075d9 100644 --- a/.claude/specs/design/figma-html/theme-c/universe-detail.html +++ b/.claude/specs/design/figma-html/theme-c/universe-detail.html @@ -61,4 +61,4 @@ - + diff --git a/.claude/specs/design/figma-html/theme-c/universes.html b/.claude/specs/design/figma-html/theme-c/universes.html index b257b50..c5ceced 100644 --- a/.claude/specs/design/figma-html/theme-c/universes.html +++ b/.claude/specs/design/figma-html/theme-c/universes.html @@ -61,4 +61,4 @@ - + diff --git a/.claude/specs/memory-intelligence/CHILD-PROFILE-MODEL.md b/.claude/specs/memory-intelligence/CHILD-PROFILE-MODEL.md index 494b30e..3bd8eef 100644 --- a/.claude/specs/memory-intelligence/CHILD-PROFILE-MODEL.md +++ b/.claude/specs/memory-intelligence/CHILD-PROFILE-MODEL.md @@ -1,429 +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 天后永久删除 +# 孩子档案数据模型 + +## 概述 + +孩子档案是记忆智能系统的核心,存储孩子的基础信息、兴趣偏好和阅读行为数据。 + +--- + +## 一、数据库模型 + +### 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 index edbf9ab..7687c4d 100644 --- a/.claude/specs/memory-intelligence/MEMORY-INTELLIGENCE-PRD.md +++ b/.claude/specs/memory-intelligence/MEMORY-INTELLIGENCE-PRD.md @@ -1,541 +1,541 @@ -# 记忆智能系统 PRD - -## 概述 - -**功能名称**: 记忆智能 (Memory Intelligence) -**版本**: v1.1 -**优先级**: Phase 2.5 (体验增强后、社区化前) -**目标用户**: 家长 + 3-8 岁儿童 -**更新记录**: 2025-01-22 合并 `backend/docs/memory_system_prd.md` - -### 核心愿景 - -将当前的"数据存储"升级为有温度的**"情感连接系统"**。 -我们不只是在记住数据,而是在**维护孩子与故事世界的关系**。让每一个故事不再是孤立的碎片,而是构建孩子专属"故事宇宙"的砖瓦。 - -### 核心价值 - -让 DreamWeaver 从"故事生成工具"进化为"懂孩子的故事伙伴": -- **记住孩子**: 偏好、成长阶段、兴趣变化 -- **延续故事**: 角色、世界观跨故事延续 -- **主动关怀**: 适时推送个性化故事建议 - -### 产品痛点与解决方案 - -| 用户角色 | 核心痛点 | 解决方案 | 预期价值 | -|---------|---------|---------|---------| -| **孩子** | "上次的小兔子怎么不认识我了?" 故事之间缺乏连续性。 | **角色一致性与记忆注入** 故事开头主动提及往事,角色性格延续。 | 建立情感依恋,提升沉浸感。 | -| **家长** | "这App除了生成故事还能干嘛?" 无法感知产品的长期教育价值。 | **显性化成长轨迹** 词汇量统计、主题变化、成就徽章可视化。 | 提高付费意愿,提供社交货币。 | -| **平台** | 用户用完即走,缺乏留存壁垒。 | **沉没成本与情感资产** 积累的记忆越多,越舍不得离开。 | 提升长期留存率 (LTV)。 | - ---- - -## 一、功能模块 - -### 1.0 记忆分层模型 - -#### 层级 1: 核心档案 (Identity Layer) -*性质:永久、静态、显性* -- **数据**: 姓名、年龄、性别 -- **输入**: 家长在 Onboarding 阶段手动输入 -- **作用**: 决定故事的基础适龄性和称呼 - -#### 层级 2: 故事宇宙 (Universe Layer) -*性质:长期、动态积累、半显性* -- **主角设定**: 姓名、性格特征(勇敢/害羞)、外貌特征(戴眼镜/卷发) -- **常驻配角**: 从随机故事中涌现出的固定伙伴(如"爱吃胡萝卜的松鼠奇奇") -- **世界观**: 故事发生的背景(魔法森林、未来城市、海底世界) -- **成就系统**: 孩子获得的虚拟奖励(勇气勋章、小小探险家) - -#### 层级 3: 工作记忆 (Working Memory) -*性质:短期、自动衰减、隐性* -- **关键情节**: 最近 3 个故事的结局和核心冲突 -- **情感标记**: 孩子对特定内容的反应(根据"重播"、"跳过"推断) -- **新学词汇**: 故事中出现的高级词汇 - -### 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 智能开场白 (Memory Injection) - -在生成新故事时,Prompt 必须包含一段"记忆唤醒"指令: -- **示例**: "小明,还记得上周我们帮小松鼠找回了松果吗?今天,小松鼠带来了一位新朋友..." -- **策略**: 提取权重最高的 Top 3 记忆注入 Prompt - -### 5.3 成就提取 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 清理 - ---- - -## 八、关键功能特性 - -### 8.1 成长时间轴 (Growth Timeline) - -一个可视化的 H5 页面或 App 模块,以时间轴形式展示里程碑: -- 🌟 **初次相遇**: 创建角色的第一天 -- 📖 **阅读打卡**: 累计阅读 10/50/100 本 -- 🏅 **获得成就**: 获得"诚实勋章" -- 🧠 **能力解锁**: 第一次阅读"科幻"题材 - -### 8.2 成就仪式感 (Achievement Ceremony) - -- **触发**: 故事生成并分析后,如果获得新成就 -- **表现**: 弹窗动画 + 音效 + "恭喜获得 [勇气] 徽章" -- **分享**: 允许生成带二维码的成就海报 - ---- - -## 九、记忆类型扩展 - -| 类型 Key | 描述 | 来源 | 过期策略 | -|---------|------|------|---------| -| `recent_story` | 最近读过的故事梗概 | 阅读事件 | 30天衰减 | -| `favorite_character` | 孩子喜欢的角色 | 重播/高评分 | 长期有效 | -| `scary_element` | 孩子害怕/不喜欢的元素 | 跳过/负反馈 | 长期有效 (避雷) | -| `vocabulary_growth` | 新掌握的词汇 | 故事分析 | 90天衰减 | -| `emotional_highlight` | 高光时刻 (如: 特别开心的情节) | 互动数据 | 60天衰减 | - ---- - -## 十、里程碑 - -### Phase 1: 基础建设 (v0.3.0) -- [x] 数据库 `MemoryItem` 表 (已存在) -- [ ] 扩展 `MemoryItem` 类型字段,支持更多维度 -- [ ] 优化 `_build_memory_context`,支持更自然的 Prompt 注入 -- [ ] 前端:简单的"近期回忆"展示列表 - -### M1: 孩子档案基础 -- [ ] 数据库模型 -- [ ] CRUD API -- [ ] 前端档案管理页面 -- [ ] 故事生成时选择档案 - -### M2: 故事宇宙 -- [ ] 宇宙数据模型 -- [ ] Prompt 集成 -- [ ] 成就自动提取 -- [ ] 前端宇宙管理 - -### M3: 主动推送 -- [ ] 推送配置 API -- [ ] Celery Beat 调度 -- [ ] 推送通知集成 (Web Push / 微信) - -### M4: 隐式学习 -- [ ] 行为埋点 -- [ ] 偏好学习算法 -- [ ] 推荐优化 - -### Phase 2: 可视化与成就 (v0.4.0) -- [ ] 实现"成就提取器" (Achievement Extractor) 的闭环通知 -- [ ] 前端:开发"我的成就"和"成长时间轴"页面 -- [ ] 增加故事开场白的动态生成逻辑 - -### Phase 3: 深度智能 (v0.5.0+) -- [ ] 引入向量数据库,实现基于语义的记忆检索 (不仅是时间最近) -- [ ] 情感分析模型:分析用户行为推断情感倾向 - ---- - -## 十一、风险与应对 - -| 风险 | 影响 | 应对 | -|------|------|------| -| 隐私合规 | 高 | 儿童数据加密存储,家长授权机制 | -| 推送骚扰 | 中 | 默认关闭,用户主动开启 | -| 记忆膨胀 | 低 | 定期清理旧数据,限制宇宙数量 | - ---- - -## 十二、相关文档 - -- [孩子档案数据模型](./CHILD-PROFILE-MODEL.md) -- [故事宇宙记忆结构](./STORY-UNIVERSE-MODEL.md) -- [主动推送触发规则](./PUSH-TRIGGER-RULES.md) +# 记忆智能系统 PRD + +## 概述 + +**功能名称**: 记忆智能 (Memory Intelligence) +**版本**: v1.1 +**优先级**: Phase 2.5 (体验增强后、社区化前) +**目标用户**: 家长 + 3-8 岁儿童 +**更新记录**: 2025-01-22 合并 `backend/docs/memory_system_prd.md` + +### 核心愿景 + +将当前的"数据存储"升级为有温度的**"情感连接系统"**。 +我们不只是在记住数据,而是在**维护孩子与故事世界的关系**。让每一个故事不再是孤立的碎片,而是构建孩子专属"故事宇宙"的砖瓦。 + +### 核心价值 + +让 DreamWeaver 从"故事生成工具"进化为"懂孩子的故事伙伴": +- **记住孩子**: 偏好、成长阶段、兴趣变化 +- **延续故事**: 角色、世界观跨故事延续 +- **主动关怀**: 适时推送个性化故事建议 + +### 产品痛点与解决方案 + +| 用户角色 | 核心痛点 | 解决方案 | 预期价值 | +|---------|---------|---------|---------| +| **孩子** | "上次的小兔子怎么不认识我了?" 故事之间缺乏连续性。 | **角色一致性与记忆注入** 故事开头主动提及往事,角色性格延续。 | 建立情感依恋,提升沉浸感。 | +| **家长** | "这App除了生成故事还能干嘛?" 无法感知产品的长期教育价值。 | **显性化成长轨迹** 词汇量统计、主题变化、成就徽章可视化。 | 提高付费意愿,提供社交货币。 | +| **平台** | 用户用完即走,缺乏留存壁垒。 | **沉没成本与情感资产** 积累的记忆越多,越舍不得离开。 | 提升长期留存率 (LTV)。 | + +--- + +## 一、功能模块 + +### 1.0 记忆分层模型 + +#### 层级 1: 核心档案 (Identity Layer) +*性质:永久、静态、显性* +- **数据**: 姓名、年龄、性别 +- **输入**: 家长在 Onboarding 阶段手动输入 +- **作用**: 决定故事的基础适龄性和称呼 + +#### 层级 2: 故事宇宙 (Universe Layer) +*性质:长期、动态积累、半显性* +- **主角设定**: 姓名、性格特征(勇敢/害羞)、外貌特征(戴眼镜/卷发) +- **常驻配角**: 从随机故事中涌现出的固定伙伴(如"爱吃胡萝卜的松鼠奇奇") +- **世界观**: 故事发生的背景(魔法森林、未来城市、海底世界) +- **成就系统**: 孩子获得的虚拟奖励(勇气勋章、小小探险家) + +#### 层级 3: 工作记忆 (Working Memory) +*性质:短期、自动衰减、隐性* +- **关键情节**: 最近 3 个故事的结局和核心冲突 +- **情感标记**: 孩子对特定内容的反应(根据"重播"、"跳过"推断) +- **新学词汇**: 故事中出现的高级词汇 + +### 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 智能开场白 (Memory Injection) + +在生成新故事时,Prompt 必须包含一段"记忆唤醒"指令: +- **示例**: "小明,还记得上周我们帮小松鼠找回了松果吗?今天,小松鼠带来了一位新朋友..." +- **策略**: 提取权重最高的 Top 3 记忆注入 Prompt + +### 5.3 成就提取 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 清理 + +--- + +## 八、关键功能特性 + +### 8.1 成长时间轴 (Growth Timeline) + +一个可视化的 H5 页面或 App 模块,以时间轴形式展示里程碑: +- 🌟 **初次相遇**: 创建角色的第一天 +- 📖 **阅读打卡**: 累计阅读 10/50/100 本 +- 🏅 **获得成就**: 获得"诚实勋章" +- 🧠 **能力解锁**: 第一次阅读"科幻"题材 + +### 8.2 成就仪式感 (Achievement Ceremony) + +- **触发**: 故事生成并分析后,如果获得新成就 +- **表现**: 弹窗动画 + 音效 + "恭喜获得 [勇气] 徽章" +- **分享**: 允许生成带二维码的成就海报 + +--- + +## 九、记忆类型扩展 + +| 类型 Key | 描述 | 来源 | 过期策略 | +|---------|------|------|---------| +| `recent_story` | 最近读过的故事梗概 | 阅读事件 | 30天衰减 | +| `favorite_character` | 孩子喜欢的角色 | 重播/高评分 | 长期有效 | +| `scary_element` | 孩子害怕/不喜欢的元素 | 跳过/负反馈 | 长期有效 (避雷) | +| `vocabulary_growth` | 新掌握的词汇 | 故事分析 | 90天衰减 | +| `emotional_highlight` | 高光时刻 (如: 特别开心的情节) | 互动数据 | 60天衰减 | + +--- + +## 十、里程碑 + +### Phase 1: 基础建设 (v0.3.0) +- [x] 数据库 `MemoryItem` 表 (已存在) +- [ ] 扩展 `MemoryItem` 类型字段,支持更多维度 +- [ ] 优化 `_build_memory_context`,支持更自然的 Prompt 注入 +- [ ] 前端:简单的"近期回忆"展示列表 + +### M1: 孩子档案基础 +- [ ] 数据库模型 +- [ ] CRUD API +- [ ] 前端档案管理页面 +- [ ] 故事生成时选择档案 + +### M2: 故事宇宙 +- [ ] 宇宙数据模型 +- [ ] Prompt 集成 +- [ ] 成就自动提取 +- [ ] 前端宇宙管理 + +### M3: 主动推送 +- [ ] 推送配置 API +- [ ] Celery Beat 调度 +- [ ] 推送通知集成 (Web Push / 微信) + +### M4: 隐式学习 +- [ ] 行为埋点 +- [ ] 偏好学习算法 +- [ ] 推荐优化 + +### Phase 2: 可视化与成就 (v0.4.0) +- [ ] 实现"成就提取器" (Achievement Extractor) 的闭环通知 +- [ ] 前端:开发"我的成就"和"成长时间轴"页面 +- [ ] 增加故事开场白的动态生成逻辑 + +### Phase 3: 深度智能 (v0.5.0+) +- [ ] 引入向量数据库,实现基于语义的记忆检索 (不仅是时间最近) +- [ ] 情感分析模型:分析用户行为推断情感倾向 + +--- + +## 十一、风险与应对 + +| 风险 | 影响 | 应对 | +|------|------|------| +| 隐私合规 | 高 | 儿童数据加密存储,家长授权机制 | +| 推送骚扰 | 中 | 默认关闭,用户主动开启 | +| 记忆膨胀 | 低 | 定期清理旧数据,限制宇宙数量 | + +--- + +## 十二、相关文档 + +- [孩子档案数据模型](./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 index 26cc0b3..9bcdc18 100644 --- a/.claude/specs/memory-intelligence/MEMORY-PERSONALIZATION-TECHNICAL-REPORT.md +++ b/.claude/specs/memory-intelligence/MEMORY-PERSONALIZATION-TECHNICAL-REPORT.md @@ -174,4 +174,4 @@ - PRD 里的“记忆系统”完整章节 - 数据模型(含字段 + 时序衰减) - 交互与界面草案 -- 后端实现拆解(任务清单 + 里程碑) +- 后端实现拆解(任务清单 + 里程碑) diff --git a/.claude/specs/memory-intelligence/PUSH-TRIGGER-RULES.md b/.claude/specs/memory-intelligence/PUSH-TRIGGER-RULES.md index 5e93320..cb4eb95 100644 --- a/.claude/specs/memory-intelligence/PUSH-TRIGGER-RULES.md +++ b/.claude/specs/memory-intelligence/PUSH-TRIGGER-RULES.md @@ -126,4 +126,4 @@ CREATE TABLE push_events ( ## 八、相关文档 - [记忆智能系统 PRD](./MEMORY-INTELLIGENCE-PRD.md) -- [孩子档案数据模型](./CHILD-PROFILE-MODEL.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 index 5ccf357..514bcef 100644 --- a/.claude/specs/memory-intelligence/STORY-UNIVERSE-MODEL.md +++ b/.claude/specs/memory-intelligence/STORY-UNIVERSE-MODEL.md @@ -228,4 +228,4 @@ def downgrade(): ## 九、相关文档 - [孩子档案数据模型](./CHILD-PROFILE-MODEL.md) -- [记忆智能系统 PRD](./MEMORY-INTELLIGENCE-PRD.md) +- [记忆智能系统 PRD](./MEMORY-INTELLIGENCE-PRD.md) diff --git a/.claude/specs/product-roadmap/PRODUCT-VISION.md b/.claude/specs/product-roadmap/PRODUCT-VISION.md index 17aa9c5..0e94efd 100644 --- a/.claude/specs/product-roadmap/PRODUCT-VISION.md +++ b/.claude/specs/product-roadmap/PRODUCT-VISION.md @@ -1,130 +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. **商业模式** - 免费/付费边界在哪里? - -请确认以上产品愿景是否符合预期,我们再深入讨论供应商平台化的技术架构。 +# 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 index e2f633d..744c4e9 100644 --- a/.claude/specs/product-roadmap/PROVIDER-PLATFORM-RFC.md +++ b/.claude/specs/product-roadmap/PROVIDER-PLATFORM-RFC.md @@ -1,677 +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) - ---- - -## 确认后删除此区块 - -确认所有决策后,可以删除未选中的选项,保留最终方案作为实现依据。 +# 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 index b00ccec..c7937d2 100644 --- a/.claude/specs/product-roadmap/ROADMAP.md +++ b/.claude/specs/product-roadmap/ROADMAP.md @@ -1,169 +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. **故事分享** - 自然增长引擎 - -是否需要我为这些功能生成详细的技术规格文档? +# 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 index cf52bee..7746b04 100644 --- a/.claude/specs/robustness-improvement/dev-plan.md +++ b/.claude/specs/robustness-improvement/dev-plan.md @@ -1,49 +1,49 @@ -# DreamWeaver 工程鲁棒性改进计划 - -## 概述 -本计划旨在提升 DreamWeaver 项目的工程质量,包括测试覆盖、稳定性、可观测性等方面。 - -## 任务列表 - -### P0 - 关键问题修复 - -#### Task-1: 修复 Rate Limit 内存泄漏 ✅ -- **文件**: `backend/app/api/stories.py` -- **方案**: 已迁移至 Redis 分布式限流,内存泄漏问题不再存在 - -#### Task-2: 添加核心 API 测试 ✅ -- **文件**: `backend/tests/` -- **范围**: test_auth, test_stories, test_profiles, test_universes, test_push_configs, test_reading_events, test_provider_router - -### P1 - 稳定性提升 - -#### Task-3: 添加 API 重试机制 ✅ -- **方案**: 所有适配器已使用 `tenacity` 指数退避重试 (gemini, openai, cqtai, antigravity, minimax, elevenlabs) - -#### Task-4: 添加结构化日志 ✅ -- **文件**: `backend/app/core/logging.py` -- **方案**: structlog JSON/Console 双模式,所有适配器和 provider_router 已集成 - -### P2 - 代码优化 - -#### Task-5: 重构 Provider Router ✅ -- **文件**: `backend/app/services/provider_router.py` -- **方案**: 已实现统一 `_route_with_failover` 函数 - -#### Task-6: 配置外部化 ✅ -- **文件**: `backend/app/core/config.py`, `backend/app/services/provider_router.py` -- **方案**: 所有模型名已移至 Settings,支持环境变量覆盖 - -#### Task-7: 修复脆弱的 URL 解析 ✅ -- **状态**: `drawing.py` 已被适配器系统取代,不再存在 - -## 新增依赖 (已添加) -```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: 覆盖率报告 -``` +# DreamWeaver 工程鲁棒性改进计划 + +## 概述 +本计划旨在提升 DreamWeaver 项目的工程质量,包括测试覆盖、稳定性、可观测性等方面。 + +## 任务列表 + +### P0 - 关键问题修复 + +#### Task-1: 修复 Rate Limit 内存泄漏 ✅ +- **文件**: `backend/app/api/stories.py` +- **方案**: 已迁移至 Redis 分布式限流,内存泄漏问题不再存在 + +#### Task-2: 添加核心 API 测试 ✅ +- **文件**: `backend/tests/` +- **范围**: test_auth, test_stories, test_profiles, test_universes, test_push_configs, test_reading_events, test_provider_router + +### P1 - 稳定性提升 + +#### Task-3: 添加 API 重试机制 ✅ +- **方案**: 所有适配器已使用 `tenacity` 指数退避重试 (gemini, openai, cqtai, antigravity, minimax, elevenlabs) + +#### Task-4: 添加结构化日志 ✅ +- **文件**: `backend/app/core/logging.py` +- **方案**: structlog JSON/Console 双模式,所有适配器和 provider_router 已集成 + +### P2 - 代码优化 + +#### Task-5: 重构 Provider Router ✅ +- **文件**: `backend/app/services/provider_router.py` +- **方案**: 已实现统一 `_route_with_failover` 函数 + +#### Task-6: 配置外部化 ✅ +- **文件**: `backend/app/core/config.py`, `backend/app/services/provider_router.py` +- **方案**: 所有模型名已移至 Settings,支持环境变量覆盖 + +#### Task-7: 修复脆弱的 URL 解析 ✅ +- **状态**: `drawing.py` 已被适配器系统取代,不再存在 + +## 新增依赖 (已添加) +```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: 覆盖率报告 +``` diff --git a/.claude/ui-refactor-plan.md b/.claude/ui-refactor-plan.md index ac709e5..da679ee 100644 --- a/.claude/ui-refactor-plan.md +++ b/.claude/ui-refactor-plan.md @@ -299,5 +299,5 @@ TASK-024 [x]: 性能优化 - 减少 backdrop-filter 阶段一 (TASK-001 ~ 003) → 阶段二 (TASK-004 ~ 012) → 阶段三 (TASK-013 ~ 021) → 阶段四 (TASK-022 ~ 024) 每个任务完成后运行 npm run build 确保无类型错误。 - - + + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5b8e0b2..11f9083 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,189 +1,189 @@ -# .github/workflows/build.yml -# 构建并推送 Docker 镜像到 GitHub Container Registry -# -# 触发条件: -# - push 到 main 分支 -# - 手动触发 (workflow_dispatch) -# - 创建版本标签 (v*) -# -# 镜像命名: -# ghcr.io//dreamweaver-backend:latest -# ghcr.io//dreamweaver-frontend:latest -# ghcr.io//dreamweaver-admin-frontend:latest - -name: Build and Push Docker Images - -on: - push: - branches: [main] - tags: ['v*'] - paths: - - 'backend/**' - - 'frontend/**' - - 'admin-frontend/**' - - '.github/workflows/build.yml' - workflow_dispatch: - inputs: - force_build: - description: 'Force rebuild all images' - required: false - default: 'false' - -env: - REGISTRY: ghcr.io - IMAGE_PREFIX: ${{ github.repository_owner }}/dreamweaver - -jobs: - # ============================================== - # 检测变更的目录 - # ============================================== - changes: - runs-on: ubuntu-latest - outputs: - backend: ${{ steps.filter.outputs.backend }} - frontend: ${{ steps.filter.outputs.frontend }} - admin-frontend: ${{ steps.filter.outputs.admin-frontend }} - steps: - - uses: actions/checkout@v4 - - uses: dorny/paths-filter@v3 - id: filter - with: - filters: | - backend: - - 'backend/**' - frontend: - - 'frontend/**' - admin-frontend: - - 'admin-frontend/**' - - # ============================================== - # 构建后端镜像 - # ============================================== - build-backend: - needs: changes - if: needs.changes.outputs.backend == 'true' || github.event.inputs.force_build == 'true' || startsWith(github.ref, 'refs/tags/') - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Container Registry - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-backend - tags: | - type=ref,event=branch - type=semver,pattern={{version}} - type=sha,prefix= - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - - - name: Build and push - uses: docker/build-push-action@v5 - with: - context: ./backend - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - # ============================================== - # 构建前端镜像 - # ============================================== - build-frontend: - needs: changes - if: needs.changes.outputs.frontend == 'true' || github.event.inputs.force_build == 'true' || startsWith(github.ref, 'refs/tags/') - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Container Registry - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-frontend - tags: | - type=ref,event=branch - type=semver,pattern={{version}} - type=sha,prefix= - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - - - name: Build and push - uses: docker/build-push-action@v5 - with: - context: ./frontend - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - # ============================================== - # 构建管理后台前端镜像 - # ============================================== - build-admin-frontend: - needs: changes - if: needs.changes.outputs.admin-frontend == 'true' || github.event.inputs.force_build == 'true' || startsWith(github.ref, 'refs/tags/') - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Container Registry - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-admin-frontend - tags: | - type=ref,event=branch - type=semver,pattern={{version}} - type=sha,prefix= - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - - - name: Build and push - uses: docker/build-push-action@v5 - with: - context: ./admin-frontend - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max +# .github/workflows/build.yml +# 构建并推送 Docker 镜像到 GitHub Container Registry +# +# 触发条件: +# - push 到 main 分支 +# - 手动触发 (workflow_dispatch) +# - 创建版本标签 (v*) +# +# 镜像命名: +# ghcr.io//dreamweaver-backend:latest +# ghcr.io//dreamweaver-frontend:latest +# ghcr.io//dreamweaver-admin-frontend:latest + +name: Build and Push Docker Images + +on: + push: + branches: [main] + tags: ['v*'] + paths: + - 'backend/**' + - 'frontend/**' + - 'admin-frontend/**' + - '.github/workflows/build.yml' + workflow_dispatch: + inputs: + force_build: + description: 'Force rebuild all images' + required: false + default: 'false' + +env: + REGISTRY: ghcr.io + IMAGE_PREFIX: ${{ github.repository_owner }}/dreamweaver + +jobs: + # ============================================== + # 检测变更的目录 + # ============================================== + changes: + runs-on: ubuntu-latest + outputs: + backend: ${{ steps.filter.outputs.backend }} + frontend: ${{ steps.filter.outputs.frontend }} + admin-frontend: ${{ steps.filter.outputs.admin-frontend }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + backend: + - 'backend/**' + frontend: + - 'frontend/**' + admin-frontend: + - 'admin-frontend/**' + + # ============================================== + # 构建后端镜像 + # ============================================== + build-backend: + needs: changes + if: needs.changes.outputs.backend == 'true' || github.event.inputs.force_build == 'true' || startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-backend + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=sha,prefix= + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: ./backend + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # ============================================== + # 构建前端镜像 + # ============================================== + build-frontend: + needs: changes + if: needs.changes.outputs.frontend == 'true' || github.event.inputs.force_build == 'true' || startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-frontend + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=sha,prefix= + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: ./frontend + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # ============================================== + # 构建管理后台前端镜像 + # ============================================== + build-admin-frontend: + needs: changes + if: needs.changes.outputs.admin-frontend == 'true' || github.event.inputs.force_build == 'true' || startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-admin-frontend + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=sha,prefix= + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: ./admin-frontend + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 3d86c34..17262a1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,44 +1,44 @@ -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -.venv/ -venv/ -ENV/ - -# Node -node_modules/ -dist/ - -# IDE -.idea/ -.vscode/ - -# Claude Code local settings -.claude/settings.local.json -*.swp -*.swo - -# 环境变量 -.env - -# 测试 -.pytest_cache/ -.coverage -htmlcov/ - -# 其他 -*.log -.DS_Store -nul - -# Vite -*.timestamp-*.mjs - -# Python packaging -*.egg-info/ - -# Alembic -alembic/__pycache__/ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +.venv/ +venv/ +ENV/ + +# Node +node_modules/ +dist/ + +# IDE +.idea/ +.vscode/ + +# Claude Code local settings +.claude/settings.local.json +*.swp +*.swo + +# 环境变量 +.env + +# 测试 +.pytest_cache/ +.coverage +htmlcov/ + +# 其他 +*.log +.DS_Store +nul + +# Vite +*.timestamp-*.mjs + +# Python packaging +*.egg-info/ + +# Alembic +alembic/__pycache__/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..93e1b7f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,134 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Project Overview + +DreamWeaver (梦语织机) - AI-powered children's story generation app for ages 3-8. Features story generation from keywords, story enhancement, AI-generated cover images, and text-to-speech narration. + +**Language:** Chinese (Simplified) for UI and comments. + +## Tech Stack + +- **Backend:** FastAPI + PostgreSQL (Neon) + SQLAlchemy (async) + Celery/Redis | **Python 3.11+** +- **Frontend:** Vue 3 + TypeScript + Pinia + Tailwind CSS + vue-i18n +- **Auth:** OAuth 2.0 (GitHub/Google) with JWT in httpOnly cookie +- **AI Services:** Text generation, image generation, TTS (pluggable adapters) + +## Commands + +```bash +# Backend +cd backend +pip install -e . # Install dependencies +pip install -e ".[dev]" # With dev tools (pytest, ruff) +uvicorn app.main:app --reload --port 8000 # Start dev server + +# Celery worker (requires Redis) +celery -A app.tasks worker --loglevel=info + +# Database migrations +alembic upgrade head # Run migrations +alembic revision -m "message" --autogenerate # Generate new migration + +# Linting +ruff check app/ # Check code +ruff check app/ --fix # Auto-fix + +# Testing +pytest # Run all tests +pytest tests/test_auth.py -v # Single test file +pytest -k "test_name" # Match pattern + +# Frontend +cd frontend +npm install +npm run dev # Start dev server (port 5173) +npm run build # Type-check + build +``` + +## Architecture + +``` +backend/app/ +├── main.py # FastAPI app entry, routes registration +├── api/ +│ ├── auth.py # OAuth routes (GitHub/Google) +│ ├── stories.py # Story CRUD and AI generation endpoints +│ ├── profiles.py # User profile management +│ ├── universes.py # Story universe/world management +│ ├── reading_events.py # Reading progress tracking +│ ├── push_configs.py # Push notification settings +│ ├── admin_providers.py # Provider CRUD (admin) +│ └── admin_reload.py # Hot-reload providers (admin) +├── core/ +│ ├── config.py # Pydantic settings from env +│ ├── deps.py # Dependency injection (auth, db session) +│ ├── security.py # JWT token create/verify +│ ├── prompts.py # AI prompt templates +│ └── admin_auth.py # Basic Auth for admin routes +├── db/ +│ ├── database.py # SQLAlchemy async engine + session +│ ├── models.py # User, Story models +│ └── admin_models.py # Provider model +├── services/ +│ ├── adapters/ # Capability adapters (text, image, tts) +│ ├── provider_router.py # Failover routing across providers +│ ├── provider_cache.py # In-memory provider config cache +│ ├── provider_metrics.py # Provider performance metrics +│ └── achievement_extractor.py # Extract achievements from stories +└── tasks/ + ├── achievements.py # Celery task: achievement processing + └── push_notifications.py # Celery task: push notifications + +frontend/src/ +├── api/client.ts # Axios wrapper with auth interceptors +├── stores/user.ts # Pinia user state +├── router.ts # Vue Router config +├── i18n.ts + locales/ # vue-i18n setup +├── components/ # Reusable Vue components +└── views/ # Page components +``` + +## Key Patterns + +- **Async everywhere:** All database and API calls use async/await +- **Dependency injection:** FastAPI `Depends()` for auth and db session +- **JWT auth:** Stored in httpOnly cookie, validated via `get_current_user` dependency +- **Provider routing:** `provider_router.py` tries providers in order, auto-failover on error +- **Background tasks:** Celery workers handle achievements and push notifications +- **Proxy in dev:** Vite proxies `/api`, `/auth`, `/admin` to backend (see `vite.config.ts`) + +## Provider System + +AI providers are configured via env vars (`TEXT_PROVIDERS`, `IMAGE_PROVIDERS`, `TTS_PROVIDERS`) as JSON arrays. The router tries each in order and fails over automatically. + +Admin console (disabled by default): Set `ENABLE_ADMIN_CONSOLE=true` to enable `/admin/providers` CRUD endpoints with Basic Auth (`ADMIN_USERNAME`/`ADMIN_PASSWORD`). + +## Environment Variables + +See `backend/.env.example` for required variables: + +- `DATABASE_URL`, `SECRET_KEY` (required) +- OAuth: `GITHUB_CLIENT_ID/SECRET`, `GOOGLE_CLIENT_ID/SECRET` +- AI: `TEXT_API_KEY`, `TTS_API_BASE`, `TTS_API_KEY`, `IMAGE_API_KEY` +- Providers: `TEXT_PROVIDERS`, `IMAGE_PROVIDERS`, `TTS_PROVIDERS` (JSON arrays) +- Celery: `CELERY_BROKER_URL`, `CELERY_RESULT_BACKEND` (Redis URLs) +- Admin: `ENABLE_ADMIN_CONSOLE`, `ADMIN_USERNAME`, `ADMIN_PASSWORD` + +## API Endpoints + +| Method | Route | Description | +| ------------------- | -------------------------- | --------------------------- | +| GET | `/auth/{provider}/signin` | OAuth login | +| GET | `/auth/session` | Get current user | +| POST | `/api/generate` | Generate/enhance story | +| POST | `/api/image/generate/{id}` | Generate cover image | +| GET | `/api/audio/{id}` | Get TTS audio | +| GET | `/api/stories` | List stories (paginated) | +| GET/DELETE | `/api/stories/{id}` | Story CRUD | +| CRUD | `/api/profiles` | User profiles | +| CRUD | `/api/universes` | Story universes | +| CRUD | `/api/reading-events` | Reading progress | +| CRUD | `/api/push-configs` | Push notification settings | +| GET/POST/PUT/DELETE | `/admin/providers` | Provider management (admin) | diff --git a/admin-frontend/.gitignore b/admin-frontend/.gitignore index d735f80..1a99ec6 100644 --- a/admin-frontend/.gitignore +++ b/admin-frontend/.gitignore @@ -1,17 +1,17 @@ -# Dependencies -node_modules/ - -# Build -dist/ - -# IDE -.idea/ -.vscode/ -*.swp -*.swo - -# OS -.DS_Store - -# Logs -*.log +# Dependencies +node_modules/ + +# Build +dist/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store + +# Logs +*.log diff --git a/admin-frontend/Dockerfile b/admin-frontend/Dockerfile index cbd1703..b5a1329 100644 --- a/admin-frontend/Dockerfile +++ b/admin-frontend/Dockerfile @@ -1,23 +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;"] +# 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/admin-frontend/index.html b/admin-frontend/index.html index 230e85d..8d2a0ec 100644 --- a/admin-frontend/index.html +++ b/admin-frontend/index.html @@ -1,13 +1,13 @@ - - - - - - - DreamWeaver Admin - - -
- - - + + + + + + + DreamWeaver Admin + + +
+ + + diff --git a/admin-frontend/nginx.conf b/admin-frontend/nginx.conf index bd9a60c..91ee35b 100644 --- a/admin-frontend/nginx.conf +++ b/admin-frontend/nginx.conf @@ -1,37 +1,37 @@ -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; - } - - # 反向代理: 将 /admin 请求转发给管理后端 - location /admin/ { - proxy_pass http://backend-admin:8001/admin/; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - } - - # 仍保留 /api 以备偶尔调用通用接口(Auth等) - location /api/ { - proxy_pass http://backend-admin:8001/api/; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - } - - # 静态资源代理 - location /static/ { - proxy_pass http://backend-admin:8001/static/; - } - - error_page 500 502 503 504 /50x.html; - location = /50x.html { - root /usr/share/nginx/html; - } -} +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; + } + + # 反向代理: 将 /admin 请求转发给管理后端 + location /admin/ { + proxy_pass http://backend-admin:8001/admin/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + # 仍保留 /api 以备偶尔调用通用接口(Auth等) + location /api/ { + proxy_pass http://backend-admin:8001/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # 静态资源代理 + location /static/ { + proxy_pass http://backend-admin:8001/static/; + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } +} diff --git a/admin-frontend/package-lock.json b/admin-frontend/package-lock.json index 9dfa62a..fa86916 100644 --- a/admin-frontend/package-lock.json +++ b/admin-frontend/package-lock.json @@ -1,2627 +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" - } - } - } -} +{ + "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/admin-frontend/package.json b/admin-frontend/package.json index 80f839c..703038c 100644 --- a/admin-frontend/package.json +++ b/admin-frontend/package.json @@ -1,28 +1,28 @@ -{ - "name": "dreamweaver-admin-console", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "dev": "vite --port 5174", - "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" - } +{ + "name": "dreamweaver-admin-console", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 5174", + "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" + } } \ No newline at end of file diff --git a/admin-frontend/postcss.config.js b/admin-frontend/postcss.config.js index 2e7af2b..5eec88d 100644 --- a/admin-frontend/postcss.config.js +++ b/admin-frontend/postcss.config.js @@ -1,6 +1,6 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/admin-frontend/public/favicon.svg b/admin-frontend/public/favicon.svg index f7bc25e..7868aa1 100644 --- a/admin-frontend/public/favicon.svg +++ b/admin-frontend/public/favicon.svg @@ -1,3 +1,3 @@ - - - + + + diff --git a/admin-frontend/public/landing.html b/admin-frontend/public/landing.html index cd1830c..34e9526 100644 --- a/admin-frontend/public/landing.html +++ b/admin-frontend/public/landing.html @@ -1,399 +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.

-
- - - - - - + + + + + + 梦语织机 - 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/admin-frontend/src/api/client.ts b/admin-frontend/src/api/client.ts index 66e7f2d..918f353 100644 --- a/admin-frontend/src/api/client.ts +++ b/admin-frontend/src/api/client.ts @@ -1,28 +1,28 @@ -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) - } - +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', @@ -41,5 +41,5 @@ class ApiClient { return this.request(url, { method: 'DELETE' }) } } - -export const api = new ApiClient() + +export const api = new ApiClient() diff --git a/admin-frontend/src/components/CreateStoryModal.vue b/admin-frontend/src/components/CreateStoryModal.vue index 3a4df6c..7a4c75a 100644 --- a/admin-frontend/src/components/CreateStoryModal.vue +++ b/admin-frontend/src/components/CreateStoryModal.vue @@ -1,376 +1,376 @@ - - - - - + + + + + diff --git a/admin-frontend/src/components/ui/BaseButton.vue b/admin-frontend/src/components/ui/BaseButton.vue index cc996c7..39838bf 100644 --- a/admin-frontend/src/components/ui/BaseButton.vue +++ b/admin-frontend/src/components/ui/BaseButton.vue @@ -84,4 +84,4 @@ function handleClick(event: MouseEvent) { - + diff --git a/admin-frontend/src/components/ui/BaseCard.vue b/admin-frontend/src/components/ui/BaseCard.vue index c096452..d849125 100644 --- a/admin-frontend/src/components/ui/BaseCard.vue +++ b/admin-frontend/src/components/ui/BaseCard.vue @@ -40,4 +40,4 @@ const baseClasses = computed(() => [
- + diff --git a/admin-frontend/src/components/ui/BaseInput.vue b/admin-frontend/src/components/ui/BaseInput.vue index 01b1749..64ce94a 100644 --- a/admin-frontend/src/components/ui/BaseInput.vue +++ b/admin-frontend/src/components/ui/BaseInput.vue @@ -68,4 +68,4 @@ const passthroughAttrs = computed(() => { {{ props.error }}

- + diff --git a/admin-frontend/src/components/ui/BaseSelect.vue b/admin-frontend/src/components/ui/BaseSelect.vue index 55ad675..e05a89e 100644 --- a/admin-frontend/src/components/ui/BaseSelect.vue +++ b/admin-frontend/src/components/ui/BaseSelect.vue @@ -64,4 +64,4 @@ function handleChange(event: Event) { - + diff --git a/admin-frontend/src/components/ui/BaseTextarea.vue b/admin-frontend/src/components/ui/BaseTextarea.vue index ca723f0..2e39389 100644 --- a/admin-frontend/src/components/ui/BaseTextarea.vue +++ b/admin-frontend/src/components/ui/BaseTextarea.vue @@ -59,4 +59,4 @@ const passthroughAttrs = computed(() => { - + diff --git a/admin-frontend/src/components/ui/ConfirmModal.vue b/admin-frontend/src/components/ui/ConfirmModal.vue index cb840de..1dca8a8 100644 --- a/admin-frontend/src/components/ui/ConfirmModal.vue +++ b/admin-frontend/src/components/ui/ConfirmModal.vue @@ -57,4 +57,4 @@ const headerClasses = computed(() => { - + diff --git a/admin-frontend/src/components/ui/EmptyState.vue b/admin-frontend/src/components/ui/EmptyState.vue index fb3eb67..5ae6e1a 100644 --- a/admin-frontend/src/components/ui/EmptyState.vue +++ b/admin-frontend/src/components/ui/EmptyState.vue @@ -42,4 +42,4 @@ function handleAction() { {{ props.actionText }} - + diff --git a/admin-frontend/src/components/ui/LoadingSpinner.vue b/admin-frontend/src/components/ui/LoadingSpinner.vue index 6f74cd5..db15c0b 100644 --- a/admin-frontend/src/components/ui/LoadingSpinner.vue +++ b/admin-frontend/src/components/ui/LoadingSpinner.vue @@ -33,4 +33,4 @@ const sizeClasses = computed(() => { {{ props.text }}

- + diff --git a/admin-frontend/src/components/ui/LoginDialog.vue b/admin-frontend/src/components/ui/LoginDialog.vue index 60f5000..4b8c562 100644 --- a/admin-frontend/src/components/ui/LoginDialog.vue +++ b/admin-frontend/src/components/ui/LoginDialog.vue @@ -1,136 +1,136 @@ - - - - - + + + + + diff --git a/admin-frontend/src/components/ui/index.ts b/admin-frontend/src/components/ui/index.ts index bab7a3d..4bcd89a 100644 --- a/admin-frontend/src/components/ui/index.ts +++ b/admin-frontend/src/components/ui/index.ts @@ -5,4 +5,4 @@ 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' +export { default as ConfirmModal } from './ConfirmModal.vue' diff --git a/admin-frontend/src/stores/storybook.ts b/admin-frontend/src/stores/storybook.ts index 345c93a..da45149 100644 --- a/admin-frontend/src/stores/storybook.ts +++ b/admin-frontend/src/stores/storybook.ts @@ -1,38 +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, - } -}) + +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 index feef998..3c74ecb 100644 --- a/admin-frontend/src/stores/user.ts +++ b/admin-frontend/src/stores/user.ts @@ -1,49 +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, - } -}) +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/views/ChildProfileTimeline.vue b/admin-frontend/src/views/ChildProfileTimeline.vue index 00d86cb..edc6cd2 100644 --- a/admin-frontend/src/views/ChildProfileTimeline.vue +++ b/admin-frontend/src/views/ChildProfileTimeline.vue @@ -1,221 +1,221 @@ - - - - - + + + + + diff --git a/admin-frontend/src/views/ChildProfiles.vue b/admin-frontend/src/views/ChildProfiles.vue index fa9945b..946e97c 100644 --- a/admin-frontend/src/views/ChildProfiles.vue +++ b/admin-frontend/src/views/ChildProfiles.vue @@ -171,4 +171,4 @@ onMounted(fetchProfiles) - + diff --git a/admin-frontend/src/views/StoryDetail.vue b/admin-frontend/src/views/StoryDetail.vue index e9f2f97..581e5bc 100644 --- a/admin-frontend/src/views/StoryDetail.vue +++ b/admin-frontend/src/views/StoryDetail.vue @@ -309,4 +309,4 @@ onUnmounted(() => { @cancel="showDeleteConfirm = false" /> - + diff --git a/admin-frontend/src/views/StorybookViewer.vue b/admin-frontend/src/views/StorybookViewer.vue index 2e8fb3f..8cfac8f 100644 --- a/admin-frontend/src/views/StorybookViewer.vue +++ b/admin-frontend/src/views/StorybookViewer.vue @@ -1,197 +1,197 @@ - - - - - - + + + + + + diff --git a/admin-frontend/src/views/UniverseDetail.vue b/admin-frontend/src/views/UniverseDetail.vue index 04946da..26cd5cd 100644 --- a/admin-frontend/src/views/UniverseDetail.vue +++ b/admin-frontend/src/views/UniverseDetail.vue @@ -205,4 +205,4 @@ onMounted(fetchUniverse) - + diff --git a/admin-frontend/src/vite-env.d.ts b/admin-frontend/src/vite-env.d.ts index 323c78a..aa09e49 100644 --- a/admin-frontend/src/vite-env.d.ts +++ b/admin-frontend/src/vite-env.d.ts @@ -1,7 +1,7 @@ -/// - -declare module '*.vue' { - import type { DefineComponent } from 'vue' - const component: DefineComponent<{}, {}, any> - export default component -} +/// + +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 index afcd725..020fb56 100644 --- a/admin-frontend/tailwind.config.js +++ b/admin-frontend/tailwind.config.js @@ -1,4 +1,4 @@ -/** @type {import('tailwindcss').Config} */ +/** @type {import('tailwindcss').Config} */ export default { darkMode: 'class', content: [ diff --git a/admin-frontend/tsconfig.json b/admin-frontend/tsconfig.json index 4b6a33b..0c3dc92 100644 --- a/admin-frontend/tsconfig.json +++ b/admin-frontend/tsconfig.json @@ -1,24 +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" }] -} +{ + "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 index 97ede7e..b850582 100644 --- a/admin-frontend/tsconfig.node.json +++ b/admin-frontend/tsconfig.node.json @@ -1,11 +1,11 @@ -{ - "compilerOptions": { - "composite": true, - "skipLibCheck": true, - "module": "ESNext", - "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true, - "strict": true - }, - "include": ["vite.config.ts"] -} +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/backend/Dockerfile b/backend/Dockerfile index 3abb42c..4e57efb 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,33 +1,33 @@ -FROM python:3.11-slim - -WORKDIR /app - -# 设置环境变量 -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 - -# 安装系统工具 (curl用于可能的健康检查) -RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* - -# 1. 缓存层:仅复制依赖定义并安装 -# 创建伪造的 app 目录以满足 pip install . 的要求 -COPY pyproject.toml . -RUN mkdir app && touch app/__init__.py -RUN pip install --no-cache-dir . - -# 2. 源码层:复制真实代码 -COPY app ./app -COPY alembic ./alembic -COPY alembic.ini . - -# 再次安装本身(不带依赖),确保源码更新被标记为已安装 -RUN pip install --no-cache-dir --no-deps . - -# 创建静态文件目录 -RUN mkdir -p static/images - -# 暴露端口 -EXPOSE 8000 - -# 默认启动命令(可被 docker-compose 覆盖) -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +FROM python:3.11-slim + +WORKDIR /app + +# 设置环境变量 +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +# 安装系统工具 (curl用于可能的健康检查) +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* + +# 1. 缓存层:仅复制依赖定义并安装 +# 创建伪造的 app 目录以满足 pip install . 的要求 +COPY pyproject.toml . +RUN mkdir app && touch app/__init__.py +RUN pip install --no-cache-dir . + +# 2. 源码层:复制真实代码 +COPY app ./app +COPY alembic ./alembic +COPY alembic.ini . + +# 再次安装本身(不带依赖),确保源码更新被标记为已安装 +RUN pip install --no-cache-dir --no-deps . + +# 创建静态文件目录 +RUN mkdir -p static/images + +# 暴露端口 +EXPOSE 8000 + +# 默认启动命令(可被 docker-compose 覆盖) +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/alembic/versions/0002_add_api_key_to_providers.py b/backend/alembic/versions/0002_add_api_key_to_providers.py index 17b04c3..2d9d100 100644 --- a/backend/alembic/versions/0002_add_api_key_to_providers.py +++ b/backend/alembic/versions/0002_add_api_key_to_providers.py @@ -1,29 +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") +"""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 index 3a2a091..0478aa3 100644 --- a/backend/alembic/versions/0003_add_provider_monitoring_tables.py +++ b/backend/alembic/versions/0003_add_provider_monitoring_tables.py @@ -1,100 +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") +"""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 index 802baeb..47f85b3 100644 --- a/backend/alembic/versions/0004_add_child_profiles.py +++ b/backend/alembic/versions/0004_add_child_profiles.py @@ -39,4 +39,4 @@ def upgrade(): def downgrade(): op.drop_index("idx_child_profiles_user_id", table_name="child_profiles") - op.drop_table("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 index 8902e95..0fed4c5 100644 --- a/backend/alembic/versions/0005_add_story_universes_and_story_links.py +++ b/backend/alembic/versions/0005_add_story_universes_and_story_links.py @@ -64,4 +64,4 @@ def downgrade(): 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") + 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 index c8a4ffc..6302a32 100644 --- a/backend/alembic/versions/0006_add_reading_events_and_memory_items.py +++ b/backend/alembic/versions/0006_add_reading_events_and_memory_items.py @@ -75,4 +75,4 @@ def downgrade(): 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") + op.drop_table("reading_events") diff --git a/backend/alembic/versions/0008_add_pages_to_stories.py b/backend/alembic/versions/0008_add_pages_to_stories.py index d706141..5d05d50 100644 --- a/backend/alembic/versions/0008_add_pages_to_stories.py +++ b/backend/alembic/versions/0008_add_pages_to_stories.py @@ -1,25 +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') +"""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/admin_main.py b/backend/app/admin_main.py index 51e9599..d365d6a 100644 --- a/backend/app/admin_main.py +++ b/backend/app/admin_main.py @@ -1,61 +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"} +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/memories.py b/backend/app/api/memories.py index 67e4717..93961c3 100644 --- a/backend/app/api/memories.py +++ b/backend/app/api/memories.py @@ -1,268 +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} +"""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/reading_events.py b/backend/app/api/reading_events.py index dd4a4af..2f9b6ab 100644 --- a/backend/app/api/reading_events.py +++ b/backend/app/api/reading_events.py @@ -117,4 +117,4 @@ async def create_reading_event( await db.commit() await db.refresh(event) - return event + return event diff --git a/backend/app/api/universes.py b/backend/app/api/universes.py index d5ab681..7c72fc5 100644 --- a/backend/app/api/universes.py +++ b/backend/app/api/universes.py @@ -198,4 +198,4 @@ async def add_achievement( await db.commit() await db.refresh(universe) - return universe + return universe diff --git a/backend/app/core/admin_auth.py b/backend/app/core/admin_auth.py index 687b165..462d928 100644 --- a/backend/app/core/admin_auth.py +++ b/backend/app/core/admin_auth.py @@ -1,60 +1,60 @@ -import secrets - -from fastapi import Depends, HTTPException, Request, status -from fastapi.security import HTTPBasic, HTTPBasicCredentials - -from app.core.config import settings -from app.core.rate_limiter import ( - clear_failed_attempts, - is_locked_out, - record_failed_attempt, -) - -security = HTTPBasic() - -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" - - -async def admin_guard( - request: Request, - credentials: HTTPBasicCredentials = Depends(security), -): - client_ip = _get_client_ip(request) - lockout_key = f"admin_login:{client_ip}" - - # 检查是否被锁定 - remaining = await is_locked_out(lockout_key, MAX_ATTEMPTS, LOCKOUT_SECONDS) - if remaining > 0: - raise HTTPException( - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - detail=f"登录尝试过多,请 {remaining} 秒后重试", - ) - - # 使用 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): - await record_failed_attempt(lockout_key, MAX_ATTEMPTS, LOCKOUT_SECONDS) - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="用户名或密码错误", - ) - - # 登录成功,清除失败记录 - await clear_failed_attempts(lockout_key) - return True +import secrets + +from fastapi import Depends, HTTPException, Request, status +from fastapi.security import HTTPBasic, HTTPBasicCredentials + +from app.core.config import settings +from app.core.rate_limiter import ( + clear_failed_attempts, + is_locked_out, + record_failed_attempt, +) + +security = HTTPBasic() + +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" + + +async def admin_guard( + request: Request, + credentials: HTTPBasicCredentials = Depends(security), +): + client_ip = _get_client_ip(request) + lockout_key = f"admin_login:{client_ip}" + + # 检查是否被锁定 + remaining = await is_locked_out(lockout_key, MAX_ATTEMPTS, LOCKOUT_SECONDS) + if remaining > 0: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=f"登录尝试过多,请 {remaining} 秒后重试", + ) + + # 使用 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): + await record_failed_attempt(lockout_key, MAX_ATTEMPTS, LOCKOUT_SECONDS) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户名或密码错误", + ) + + # 登录成功,清除失败记录 + await clear_failed_attempts(lockout_key) + return True diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py index 893a175..f5fac23 100644 --- a/backend/app/core/logging.py +++ b/backend/app/core/logging.py @@ -1,48 +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) +"""结构化日志配置。""" + +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 index cc07ff5..dec18c1 100644 --- a/backend/app/core/prompts.py +++ b/backend/app/core/prompts.py @@ -1,190 +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. -""" +# 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/rate_limiter.py b/backend/app/core/rate_limiter.py index 3fa3f14..8ccce60 100644 --- a/backend/app/core/rate_limiter.py +++ b/backend/app/core/rate_limiter.py @@ -1,141 +1,141 @@ -"""Redis-backed rate limiter with in-memory fallback. - -Uses a fixed-window counter pattern via Redis INCR + EXPIRE. -Falls back to an in-memory TTLCache when Redis is unavailable, -preserving identical behavior for dev/test environments. -""" - -import time - -from cachetools import TTLCache -from fastapi import HTTPException - -from app.core.logging import get_logger -from app.core.redis import get_redis - -logger = get_logger(__name__) - -# ── In-memory fallback caches ────────────────────────────────────────────── -_local_rate_cache: TTLCache[str, int] = TTLCache(maxsize=10000, ttl=120) -_local_lockout_cache: TTLCache[str, tuple[int, float]] = TTLCache(maxsize=1000, ttl=900) - - -async def check_rate_limit(key: str, limit: int, window_seconds: int) -> None: - """Check and increment a sliding-window rate counter. - - Args: - key: Unique identifier (e.g. ``"story:"``). - limit: Maximum requests allowed within the window. - window_seconds: Window duration in seconds. - - Raises: - HTTPException: 429 when the limit is exceeded. - """ - try: - redis = await get_redis() - # Fixed-window bucket: key + minute boundary - bucket = int(time.time() // window_seconds) - redis_key = f"ratelimit:{key}:{bucket}" - - count = await redis.incr(redis_key) - if count == 1: - await redis.expire(redis_key, window_seconds) - - if count > limit: - raise HTTPException( - status_code=429, - detail="Too many requests, please slow down.", - ) - return - except HTTPException: - raise - except Exception as exc: - logger.warning("rate_limit_redis_fallback", error=str(exc)) - - # ── Fallback: in-memory counter ──────────────────────────────────────── - count = _local_rate_cache.get(key, 0) + 1 - _local_rate_cache[key] = count - if count > limit: - raise HTTPException( - status_code=429, - detail="Too many requests, please slow down.", - ) - - -async def record_failed_attempt( - key: str, - max_attempts: int, - lockout_seconds: int, -) -> bool: - """Record a failed login attempt and return whether the key is locked out. - - Args: - key: Unique identifier (e.g. ``"admin_login:"``). - max_attempts: Number of failures before lockout. - lockout_seconds: Duration of lockout in seconds. - - Returns: - ``True`` if the key is now locked out, ``False`` otherwise. - """ - try: - redis = await get_redis() - redis_key = f"lockout:{key}" - - count = await redis.incr(redis_key) - if count == 1: - await redis.expire(redis_key, lockout_seconds) - - return count >= max_attempts - except Exception as exc: - logger.warning("lockout_redis_fallback", error=str(exc)) - - # ── Fallback ─────────────────────────────────────────────────────────── - if key in _local_lockout_cache: - attempts, first_fail = _local_lockout_cache[key] - _local_lockout_cache[key] = (attempts + 1, first_fail) - return (attempts + 1) >= max_attempts - else: - _local_lockout_cache[key] = (1, time.time()) - return 1 >= max_attempts - - -async def is_locked_out(key: str, max_attempts: int, lockout_seconds: int) -> int: - """Check if a key is currently locked out. - - Returns: - Remaining lockout seconds (> 0 means locked), 0 means not locked. - """ - try: - redis = await get_redis() - redis_key = f"lockout:{key}" - - count = await redis.get(redis_key) - if count is not None and int(count) >= max_attempts: - ttl = await redis.ttl(redis_key) - return max(ttl, 0) - return 0 - except Exception as exc: - logger.warning("lockout_check_redis_fallback", error=str(exc)) - - # ── Fallback ─────────────────────────────────────────────────────────── - if key in _local_lockout_cache: - attempts, first_fail = _local_lockout_cache[key] - if attempts >= max_attempts: - remaining = int(lockout_seconds - (time.time() - first_fail)) - if remaining > 0: - return remaining - else: - del _local_lockout_cache[key] - return 0 - - -async def clear_failed_attempts(key: str) -> None: - """Clear lockout state on successful login.""" - try: - redis = await get_redis() - await redis.delete(f"lockout:{key}") - except Exception as exc: - logger.warning("lockout_clear_redis_fallback", error=str(exc)) - - # Always clear local cache too - _local_lockout_cache.pop(key, None) +"""Redis-backed rate limiter with in-memory fallback. + +Uses a fixed-window counter pattern via Redis INCR + EXPIRE. +Falls back to an in-memory TTLCache when Redis is unavailable, +preserving identical behavior for dev/test environments. +""" + +import time + +from cachetools import TTLCache +from fastapi import HTTPException + +from app.core.logging import get_logger +from app.core.redis import get_redis + +logger = get_logger(__name__) + +# ── In-memory fallback caches ────────────────────────────────────────────── +_local_rate_cache: TTLCache[str, int] = TTLCache(maxsize=10000, ttl=120) +_local_lockout_cache: TTLCache[str, tuple[int, float]] = TTLCache(maxsize=1000, ttl=900) + + +async def check_rate_limit(key: str, limit: int, window_seconds: int) -> None: + """Check and increment a sliding-window rate counter. + + Args: + key: Unique identifier (e.g. ``"story:"``). + limit: Maximum requests allowed within the window. + window_seconds: Window duration in seconds. + + Raises: + HTTPException: 429 when the limit is exceeded. + """ + try: + redis = await get_redis() + # Fixed-window bucket: key + minute boundary + bucket = int(time.time() // window_seconds) + redis_key = f"ratelimit:{key}:{bucket}" + + count = await redis.incr(redis_key) + if count == 1: + await redis.expire(redis_key, window_seconds) + + if count > limit: + raise HTTPException( + status_code=429, + detail="Too many requests, please slow down.", + ) + return + except HTTPException: + raise + except Exception as exc: + logger.warning("rate_limit_redis_fallback", error=str(exc)) + + # ── Fallback: in-memory counter ──────────────────────────────────────── + count = _local_rate_cache.get(key, 0) + 1 + _local_rate_cache[key] = count + if count > limit: + raise HTTPException( + status_code=429, + detail="Too many requests, please slow down.", + ) + + +async def record_failed_attempt( + key: str, + max_attempts: int, + lockout_seconds: int, +) -> bool: + """Record a failed login attempt and return whether the key is locked out. + + Args: + key: Unique identifier (e.g. ``"admin_login:"``). + max_attempts: Number of failures before lockout. + lockout_seconds: Duration of lockout in seconds. + + Returns: + ``True`` if the key is now locked out, ``False`` otherwise. + """ + try: + redis = await get_redis() + redis_key = f"lockout:{key}" + + count = await redis.incr(redis_key) + if count == 1: + await redis.expire(redis_key, lockout_seconds) + + return count >= max_attempts + except Exception as exc: + logger.warning("lockout_redis_fallback", error=str(exc)) + + # ── Fallback ─────────────────────────────────────────────────────────── + if key in _local_lockout_cache: + attempts, first_fail = _local_lockout_cache[key] + _local_lockout_cache[key] = (attempts + 1, first_fail) + return (attempts + 1) >= max_attempts + else: + _local_lockout_cache[key] = (1, time.time()) + return 1 >= max_attempts + + +async def is_locked_out(key: str, max_attempts: int, lockout_seconds: int) -> int: + """Check if a key is currently locked out. + + Returns: + Remaining lockout seconds (> 0 means locked), 0 means not locked. + """ + try: + redis = await get_redis() + redis_key = f"lockout:{key}" + + count = await redis.get(redis_key) + if count is not None and int(count) >= max_attempts: + ttl = await redis.ttl(redis_key) + return max(ttl, 0) + return 0 + except Exception as exc: + logger.warning("lockout_check_redis_fallback", error=str(exc)) + + # ── Fallback ─────────────────────────────────────────────────────────── + if key in _local_lockout_cache: + attempts, first_fail = _local_lockout_cache[key] + if attempts >= max_attempts: + remaining = int(lockout_seconds - (time.time() - first_fail)) + if remaining > 0: + return remaining + else: + del _local_lockout_cache[key] + return 0 + + +async def clear_failed_attempts(key: str) -> None: + """Clear lockout state on successful login.""" + try: + redis = await get_redis() + await redis.delete(f"lockout:{key}") + except Exception as exc: + logger.warning("lockout_clear_redis_fallback", error=str(exc)) + + # Always clear local cache too + _local_lockout_cache.pop(key, None) diff --git a/backend/app/core/security.py b/backend/app/core/security.py index d5ccb98..a867630 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -1,25 +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 +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/schemas/__init__.py b/backend/app/schemas/__init__.py index d2beb0e..426020f 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1 +1 @@ -"""故事相关 Schema 模块。""" +"""故事相关 Schema 模块。""" diff --git a/backend/app/services/adapters/__init__.py b/backend/app/services/adapters/__init__.py index aed7b69..89f4c32 100644 --- a/backend/app/services/adapters/__init__.py +++ b/backend/app/services/adapters/__init__.py @@ -1,21 +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"] +"""适配器模块 - 供应商平台化架构核心。""" + +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 index 5afb80c..7156bed 100644 --- a/backend/app/services/adapters/base.py +++ b/backend/app/services/adapters/base.py @@ -1,46 +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 +"""适配器基类定义。""" + +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 index 1f0fbb7..dd5c46c 100644 --- a/backend/app/services/adapters/image/__init__.py +++ b/backend/app/services/adapters/image/__init__.py @@ -1,3 +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 +"""图像生成适配器。"""# 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 index 9ca48ea..a50571e 100644 --- a/backend/app/services/adapters/image/antigravity.py +++ b/backend/app/services/adapters/image/antigravity.py @@ -1,214 +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", ""))) +"""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 index 89e465b..674d599 100644 --- a/backend/app/services/adapters/image/cqtai.py +++ b/backend/app/services/adapters/image/cqtai.py @@ -1,252 +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}") +"""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 index 60a5cbc..f5f79d0 100644 --- a/backend/app/services/adapters/registry.py +++ b/backend/app/services/adapters/registry.py @@ -1,73 +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): - """装饰器:注册适配器类。 - - 用法: +"""适配器注册表 - 支持动态注册和工厂创建。""" + +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) + ... + """ + + 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 index dc26763..25dd17a 100644 --- a/backend/app/services/adapters/storybook/__init__.py +++ b/backend/app/services/adapters/storybook/__init__.py @@ -1 +1 @@ -"""Storybook 适配器模块。""" +"""Storybook 适配器模块。""" diff --git a/backend/app/services/adapters/storybook/primary.py b/backend/app/services/adapters/storybook/primary.py index 8987889..671acd0 100644 --- a/backend/app/services/adapters/storybook/primary.py +++ b/backend/app/services/adapters/storybook/primary.py @@ -1,195 +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() +"""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 index b42c816..da004a9 100644 --- a/backend/app/services/adapters/text/__init__.py +++ b/backend/app/services/adapters/text/__init__.py @@ -1 +1 @@ -"""文本生成适配器。""" +"""文本生成适配器。""" diff --git a/backend/app/services/adapters/text/gemini.py b/backend/app/services/adapters/text/gemini.py index 6be9d52..cf8eb70 100644 --- a/backend/app/services/adapters/text/gemini.py +++ b/backend/app/services/adapters/text/gemini.py @@ -1,164 +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() +"""文本生成适配器 (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 index 01ba09c..b08be90 100644 --- a/backend/app/services/adapters/text/models.py +++ b/backend/app/services/adapters/text/models.py @@ -1,11 +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 +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 index 0c1d654..035470a 100644 --- a/backend/app/services/adapters/text/openai.py +++ b/backend/app/services/adapters/text/openai.py @@ -1,172 +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 +"""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 index 4b3b162..c139d78 100644 --- a/backend/app/services/adapters/tts/__init__.py +++ b/backend/app/services/adapters/tts/__init__.py @@ -1,5 +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 +"""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 index 4d68bbb..4a5dcdb 100644 --- a/backend/app/services/adapters/tts/edge_tts.py +++ b/backend/app/services/adapters/tts/edge_tts.py @@ -1,66 +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! +"""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 index 9b96ad7..c134f4a 100644 --- a/backend/app/services/adapters/tts/elevenlabs.py +++ b/backend/app/services/adapters/tts/elevenlabs.py @@ -1,104 +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 +"""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 index cdacc83..1fe8b74 100644 --- a/backend/app/services/adapters/tts/minimax.py +++ b/backend/app/services/adapters/tts/minimax.py @@ -1,149 +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() +"""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 index 021250f..d79d934 100644 --- a/backend/app/services/cost_tracker.py +++ b/backend/app/services/cost_tracker.py @@ -1,196 +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() +"""成本追踪服务。 + +记录 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 index d047f3c..ccf9a92 100644 --- a/backend/app/services/memory_service.py +++ b/backend/app/services/memory_service.py @@ -1,471 +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, - ) - +"""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 index e1a0db3..23000f5 100644 --- a/backend/app/services/provider_cache.py +++ b/backend/app/services/provider_cache.py @@ -1,109 +1,109 @@ -"""Redis-backed cache for providers loaded from DB.""" - -import json -from collections import defaultdict -from typing import Literal - -from pydantic import BaseModel -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.logging import get_logger -from app.core.redis import get_redis -from app.db.admin_models import Provider - -logger = get_logger(__name__) - -ProviderType = Literal["text", "image", "tts", "storybook"] - - -class CachedProvider(BaseModel): - """Serializable provider configuration matching DB model fields.""" - id: str - name: str - type: str - adapter: str - model: str | None = None - api_base: str | None = None - api_key: str | None = None - 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 - - -# Local memory fallback (L1 cache) -_local_cache: dict[ProviderType, list[CachedProvider]] = defaultdict(list) -CACHE_KEY = "dreamweaver:providers:config" - - -async def reload_providers(db: AsyncSession) -> dict[ProviderType, list[CachedProvider]]: - """Reload providers from DB and update Redis cache.""" - try: - result = await db.execute(select(Provider).where(Provider.enabled == True)) # noqa: E712 - providers = result.scalars().all() - - # Convert to Pydantic models - cached_list = [] - for p in providers: - cached_list.append(CachedProvider( - id=p.id, - name=p.name, - type=p.type, - adapter=p.adapter, - model=p.model, - api_base=p.api_base, - api_key=p.api_key, - timeout_ms=p.timeout_ms, - max_retries=p.max_retries, - weight=p.weight, - priority=p.priority, - enabled=p.enabled, - config_json=p.config_json, - config_ref=p.config_ref - )) - - # Group by type - grouped: dict[str, list[CachedProvider]] = defaultdict(list) - for cp in cached_list: - grouped[cp.type].append(cp) - - # Sort - for k in grouped: - grouped[k].sort(key=lambda x: (x.priority, x.weight), reverse=True) - - # Update Redis - redis = await get_redis() - # Serialize entire dict structure - # Pydantic -> dict -> json - json_data = {k: [p.model_dump() for p in v] for k, v in grouped.items()} - await redis.set(CACHE_KEY, json.dumps(json_data)) - - # Update local cache - _local_cache.clear() - _local_cache.update(grouped) - return grouped - - except Exception as e: - logger.error("failed_to_reload_providers", error=str(e)) - raise - - -async def get_providers(provider_type: ProviderType) -> list[CachedProvider]: - """Get providers from Redis (preferred) or local fallback.""" - try: - redis = await get_redis() - data = await redis.get(CACHE_KEY) - if data: - raw_dict = json.loads(data) - if provider_type in raw_dict: - return [CachedProvider(**item) for item in raw_dict[provider_type]] - return [] - except Exception as e: - logger.warning("redis_cache_read_failed", error=str(e)) - - # Fallback to local memory - return _local_cache.get(provider_type, []) +"""Redis-backed cache for providers loaded from DB.""" + +import json +from collections import defaultdict +from typing import Literal + +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.logging import get_logger +from app.core.redis import get_redis +from app.db.admin_models import Provider + +logger = get_logger(__name__) + +ProviderType = Literal["text", "image", "tts", "storybook"] + + +class CachedProvider(BaseModel): + """Serializable provider configuration matching DB model fields.""" + id: str + name: str + type: str + adapter: str + model: str | None = None + api_base: str | None = None + api_key: str | None = None + 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 + + +# Local memory fallback (L1 cache) +_local_cache: dict[ProviderType, list[CachedProvider]] = defaultdict(list) +CACHE_KEY = "dreamweaver:providers:config" + + +async def reload_providers(db: AsyncSession) -> dict[ProviderType, list[CachedProvider]]: + """Reload providers from DB and update Redis cache.""" + try: + result = await db.execute(select(Provider).where(Provider.enabled == True)) # noqa: E712 + providers = result.scalars().all() + + # Convert to Pydantic models + cached_list = [] + for p in providers: + cached_list.append(CachedProvider( + id=p.id, + name=p.name, + type=p.type, + adapter=p.adapter, + model=p.model, + api_base=p.api_base, + api_key=p.api_key, + timeout_ms=p.timeout_ms, + max_retries=p.max_retries, + weight=p.weight, + priority=p.priority, + enabled=p.enabled, + config_json=p.config_json, + config_ref=p.config_ref + )) + + # Group by type + grouped: dict[str, list[CachedProvider]] = defaultdict(list) + for cp in cached_list: + grouped[cp.type].append(cp) + + # Sort + for k in grouped: + grouped[k].sort(key=lambda x: (x.priority, x.weight), reverse=True) + + # Update Redis + redis = await get_redis() + # Serialize entire dict structure + # Pydantic -> dict -> json + json_data = {k: [p.model_dump() for p in v] for k, v in grouped.items()} + await redis.set(CACHE_KEY, json.dumps(json_data)) + + # Update local cache + _local_cache.clear() + _local_cache.update(grouped) + return grouped + + except Exception as e: + logger.error("failed_to_reload_providers", error=str(e)) + raise + + +async def get_providers(provider_type: ProviderType) -> list[CachedProvider]: + """Get providers from Redis (preferred) or local fallback.""" + try: + redis = await get_redis() + data = await redis.get(CACHE_KEY) + if data: + raw_dict = json.loads(data) + if provider_type in raw_dict: + return [CachedProvider(**item) for item in raw_dict[provider_type]] + return [] + except Exception as e: + logger.warning("redis_cache_read_failed", error=str(e)) + + # Fallback to local memory + return _local_cache.get(provider_type, []) diff --git a/backend/app/services/provider_metrics.py b/backend/app/services/provider_metrics.py index 8aac415..dea08db 100644 --- a/backend/app/services/provider_metrics.py +++ b/backend/app/services/provider_metrics.py @@ -1,248 +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() +"""供应商指标收集和健康检查服务。""" + +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/secret_service.py b/backend/app/services/secret_service.py index 5934d77..f261200 100644 --- a/backend/app/services/secret_service.py +++ b/backend/app/services/secret_service.py @@ -1,207 +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 +"""供应商密钥加密存储服务。 + +使用 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/memory.py b/backend/app/tasks/memory.py index dbb1130..1b70d92 100644 --- a/backend/app/tasks/memory.py +++ b/backend/app/tasks/memory.py @@ -1,29 +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 + +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/docs/code_review_report.md b/backend/docs/code_review_report.md deleted file mode 100644 index 54d9082..0000000 --- a/backend/docs/code_review_report.md +++ /dev/null @@ -1,14 +0,0 @@ -# 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/ha_runbook.md b/backend/docs/ha_runbook.md deleted file mode 100644 index 12893f4..0000000 --- a/backend/docs/ha_runbook.md +++ /dev/null @@ -1,89 +0,0 @@ -# HA 部署与验证 Runbook(Phase 3 MVP) - -本文档对应 `docker-compose.ha.yml`,用于本地/测试环境验证高可用基础能力。 - -## 1. 启动方式 - -```bash -docker compose -f docker-compose.yml -f docker-compose.ha.yml up -d -``` - -说明: -- 基础业务服务仍来自 `docker-compose.yml`。 -- `docker-compose.ha.yml` 覆盖了 `db`、`redis`,并新增 `db-replica`、`postgres-backup`、`redis-replica`、`redis-sentinel-*`。 - -## 2. 核心环境变量建议 - -在 `backend/.env`(或 shell 环境)中至少配置: - -```env -# PostgreSQL -POSTGRES_USER=dreamweaver -POSTGRES_PASSWORD=dreamweaver_password -POSTGRES_DB=dreamweaver_db -POSTGRES_REPMGR_PASSWORD=repmgr_password - -# Redis Sentinel -REDIS_SENTINEL_ENABLED=true -REDIS_SENTINEL_NODES=redis-sentinel-1:26379,redis-sentinel-2:26379,redis-sentinel-3:26379 -REDIS_SENTINEL_MASTER_NAME=mymaster -REDIS_SENTINEL_DB=0 -REDIS_SENTINEL_SOCKET_TIMEOUT=0.5 - -# 可选:若 Sentinel/Redis 设置了密码 -REDIS_SENTINEL_PASSWORD= - -# 备份周期,默认 86400 秒(1 天) -BACKUP_INTERVAL_SECONDS=86400 -``` - -## 3. 健康检查 - -### 3.1 PostgreSQL 主从 - -```bash -docker compose -f docker-compose.yml -f docker-compose.ha.yml ps -docker exec -it dreamweaver_db_primary psql -U dreamweaver -d dreamweaver_db -c "select now();" -docker exec -it dreamweaver_db_replica psql -U dreamweaver -d dreamweaver_db -c "select pg_is_in_recovery();" -``` - -期望: -- 主库可读写; -- 从库 `pg_is_in_recovery()` 返回 `t`。 - -### 3.2 Redis Sentinel - -```bash -docker exec -it dreamweaver_redis_sentinel_1 redis-cli -p 26379 sentinel masters -docker exec -it dreamweaver_redis_sentinel_1 redis-cli -p 26379 sentinel replicas mymaster -``` - -期望: -- `mymaster` 存在; -- 至少 1 个 replica 被发现。 - -### 3.3 备份任务 - -```bash -docker exec -it dreamweaver_postgres_backup sh -c "ls -lh /backups" -``` - -期望: -- `/backups` 下出现 `.dump` 文件; -- 旧于 7 天的备份会被自动清理。 - -## 4. 故障切换演练(最小) - -```bash -# 模拟 Redis 主节点故障 -docker stop dreamweaver_redis_master - -# 等待 Sentinel 选主后查看 -docker exec -it dreamweaver_redis_sentinel_1 redis-cli -p 26379 sentinel get-master-addr-by-name mymaster -``` - -提示:应用与 Celery 已支持 Sentinel 配置。若未启用 Sentinel,仍可回退到 `REDIS_URL` / `CELERY_BROKER_URL` / `CELERY_RESULT_BACKEND` 直连模式。 - -## 5. 当前已知限制(下一步) - -- PostgreSQL 侧当前仅完成主从拓扑,读写分离(PgBouncer/路由)待后续迭代。 diff --git a/backend/docs/memory_system_dev.md b/backend/docs/memory_system_dev.md deleted file mode 100644 index 3d86d7c..0000000 --- a/backend/docs/memory_system_dev.md +++ /dev/null @@ -1,147 +0,0 @@ -# 记忆系统开发指南 (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/provider_system.md b/backend/docs/provider_system.md deleted file mode 100644 index 551a84f..0000000 --- a/backend/docs/provider_system.md +++ /dev/null @@ -1,246 +0,0 @@ -# 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/docs/refactoring_plan.md b/backend/docs/refactoring_plan.md deleted file mode 100644 index 0aa73f1..0000000 --- a/backend/docs/refactoring_plan.md +++ /dev/null @@ -1,109 +0,0 @@ -# DreamWeaver 重构实施计划 - -## 1. 概述 - -本文档基于对当前架构的深入分析,制定了从稳定性、可维护性到可扩展性的分阶段重构计划。 - -**目标**: -- **短期**:解决单点故障风险,优化开发体验,清理关键技术债。 -- **中期**:提升系统高可用能力,增强监控与可观测性。 -- **长期**:架构演进,支持大规模并发与复杂业务场景。 - ---- - -## 2. 短期优化计划 (1-2周) - -**重点**:消除即时风险,提升部署效率。 - -### 2.1 统一镜像构建 (High Priority) -目前 `backend`, `backend-admin`, `worker`, `celery-beat` 重复构建 4 次,浪费资源且镜像版本可能不一致。 - -- **Action Items**: - - [x] 修改 `backend/Dockerfile` 为通用基础镜像。 - - [x] 更新 `docker-compose.yml`,定义 `backend-base` 服务或使用 `image` 标签共享镜像。 - - [x] 确保所有 Python 服务共用同一构建产物,仅启动命令不同。 - -### 2.2 修复 Provider 缓存与限流 (High Priority) -内存缓存 (`TTLCache`, `_latency_cache`) 在多进程/多实例下失效。 - -- **Action Items**: - - [x] 引入 Redis 作为共享缓存后端。 - - [x] 重构 `_load_provider_cache`,将 Provider 配置缓存至 Redis。 - - [x] 重构 `stories.py` 中的限流逻辑,使用 `redis-cell` 或简单的 Redis 计数器替代 `TTLCache`。 - -### 2.3 拆分 `stories.py` (Medium Priority) -`app/api/stories.py` 超过 600 行,包含 API 定义、业务逻辑、验证逻辑,维护困难。 - -- **Action Items**: - - [x] 创建 `app/services/story_service.py`,迁移生成、润色、PDF生成等核心逻辑。 - - [x] 创建 `app/schemas/story_schemas.py`,迁移 Pydantic 模型(`GenerateRequest`, `StoryResponse` 等)。 - - [x] API 层 `stories.py` 仅保留路由定义和依赖注入,调用 Service 层。 - ---- - -## 3. 中期优化计划 (1-2月) - -**重点**:高可用 (HA) 与系统韧性。 - -### 3.1 数据库高可用 (Critical) -当前 PostgreSQL 为单点,且 Admin/User 混合使用。 - -- **Action Items**: - - [ ] 部署 PostgreSQL 主从复制 (Master-Slave)。 - - [ ] 配置 `PgBouncer` 或 SQLAlchemy 读写分离,减轻主库压力。 - - [ ] 实施数据库自动备份策略 (如 `pg_dump` 定时上传 S3)。 - -### 3.2 消息队列高可用 (Critical) -Redis 单点故障将导致 Celery 任务全盘停摆。 - -- **Action Items**: - - [ ] 迁移至 Redis Sentinel 或 Redis Cluster 模式。 - - [ ] 更新 Celery 配置以支持 Sentinel/Cluster 连接串。 - -### 3.3 增强可观测性 (Important) -目前仅有简单的日志,缺乏系统级指标。 - -- **Action Items**: - - [ ] 集成 Prometheus Client,暴露 `/metrics` 端点。 - - [ ] 部署 Grafana + Prometheus,监控 API 延迟、QPS、Celery 队列积压情况。 - - [ ] 完善 `ProviderMetrics`,增加可视化大盘,实时监控 AI 供应商的成本与成功率。 - -### 3.4 Phase 3 最小可执行任务清单 (MVP) - -目标:在不大改业务代码的前提下,于一个迭代内完成高可用基础设施闭环。 - -- [x] PostgreSQL 主从:新增 `docker-compose.ha.yml`,包含 1 主 1 从与健康检查。 -- [x] PostgreSQL 备份:新增每日备份任务(`pg_dump`)与 7 天保留策略。 -- [x] Redis Sentinel:新增 1 主 2 哨兵最小拓扑,并验证故障切换。 -- [x] Celery 连接:更新 Celery broker/result backend 配置,支持 Sentinel 连接串。 -- [x] 回归验证:执行一次故事生成 + 异步任务链路(worker/beat)冒烟测试。 -- [x] 运行手册:补充故障切换与恢复步骤文档(PostgreSQL/Redis/Celery)。 - ---- - -## 4. 长期架构演进 (季度规划) - -**重点**:业务解耦与规模化。 - -### 4.1 统一 API 网关 -- **当前**:前端直连后端端口,CORS 配置分散。 -- **演进**:引入 Traefik 或 Nginx 作为统一网关,管理路由、SSL、全局限流、统一鉴权。 - -### 4.2 前端工程合并 -- **当前**:User App 和 Admin Console 是完全独立的两个项目,但在组件和工具链上高度重复。 -- **演进**:使用一种 Monorepo 策略或基于路由的单一应用策略,共享组件库和类型定义,减少维护成本。 - -### 4.3 事件驱动架构完善 -- **当前**:部分业务逻辑耦合在 API 中。 -- **演进**:扩展事件总线,将“阅读记录”、“成就解锁”、“通知推送”等非核心链路完全异步化,通过 Domain Events 解耦。 - ---- - -## 5. 实施路线图 - -| 阶段 | 时间估算 | 关键里程碑 | -| :--- | :--- | :--- | -| **Phase 1: 基础夯实** | Week 1-2 | Docker 构建优化上线,Redis 替代内存缓存。 | -| **Phase 2: 代码重构** | Week 3-4 | `stories.py` 拆分完成,Service 层建立。 | -| **Phase 3: 高可用建设** | Month 2 | 数据库与 Redis 实现主备/集群模式。 | -| **Phase 4: 监控体系** | Month 2 | Grafana 监控大盘上线,关键指标报警配置完毕。 | diff --git a/backend/docs/stories_split_analysis.md b/backend/docs/stories_split_analysis.md deleted file mode 100644 index 6260db6..0000000 --- a/backend/docs/stories_split_analysis.md +++ /dev/null @@ -1,52 +0,0 @@ -# `stories.py` 拆分分析 (Phase 2 准备) - -## 当前职责 - -`app/api/stories.py` (591 行) 承担了以下职责: - -| 职责 | 行数 | 描述 | -|---|---|---| -| Pydantic 模型 | ~50 行 | `GenerateRequest`, `StoryResponse`, `FullStoryResponse` 等 | -| 验证逻辑 | ~40 行 | `_validate_profile_and_universe` | -| 路由 + 业务 | ~300 行 | `generate_story`, `generate_story_full`, `generate_story_stream` | -| 绘本逻辑 | ~170 行 | `generate_storybook_api` (含并行图片生成) | -| 成就查询 | ~30 行 | `get_story_achievements` | - -## 缺失端点 - -测试中引用但 **未实现** 的端点(这些应在拆分时一并补充): - -- `GET /api/stories` — 故事列表 (分页) -- `GET /api/stories/{id}` — 故事详情 -- `DELETE /api/stories/{id}` — 故事删除 -- `POST /api/image/generate/{id}` — 封面图片生成 -- `GET /api/audio/{id}` — 语音朗读 - -## 建议拆分结构 - -``` -app/ -├── schemas/ -│ └── story_schemas.py # [NEW] Pydantic 模型 -├── services/ -│ └── story_service.py # [NEW] 核心业务逻辑 -└── api/ - ├── stories.py # [SLIM] 路由定义 + 依赖注入 - └── stories_storybook.py # [NEW] 绘本相关端点 (可选) -``` - -### `story_schemas.py` -- 迁移所有 Pydantic 模型 -- 包括 `GenerateRequest`, `StoryResponse`, `FullStoryResponse`, `StorybookRequest`, `StorybookResponse` 等 - -### `story_service.py` -- `validate_profile_and_universe()` — 验证逻辑 -- `create_story()` — 故事入库 -- `generate_and_save_story()` — 生成 + 保存联合操作 -- `generate_storybook_with_images()` — 绘本并行图片生成 -- 补充: `list_stories()`, `get_story()`, `delete_story()` - -### `stories.py` (瘦路由层) -- 仅保留 `@router` 装饰器和依赖注入 -- 调用 service 层完成业务逻辑 -- 预计 150-200 行 diff --git a/backend/scripts/add_config_column.py b/backend/scripts/add_config_column.py index ec12436..42931e8 100644 --- a/backend/scripts/add_config_column.py +++ b/backend/scripts/add_config_column.py @@ -1,27 +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()) +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 index d8092cd..4de64fe 100644 --- a/backend/scripts/fix_db_schema.py +++ b/backend/scripts/fix_db_schema.py @@ -1,29 +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()) +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 index edef70b..fbad585 100644 --- a/backend/scripts/manual_init_db.py +++ b/backend/scripts/manual_init_db.py @@ -1,21 +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()) +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 index d4839a6..e7c4e9a 100644 --- a/backend/tests/__init__.py +++ b/backend/tests/__init__.py @@ -1 +1 @@ -# Tests package +# Tests package diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 382a11f..cea70ba 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1,65 +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 +"""认证相关测试。""" + +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_provider_router.py b/backend/tests/test_provider_router.py index 23ef4c5..3d61a67 100644 --- a/backend/tests/test_provider_router.py +++ b/backend/tests/test_provider_router.py @@ -1,195 +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() +"""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_universes.py b/backend/tests/test_universes.py index 1604175..4062f0c 100644 --- a/backend/tests/test_universes.py +++ b/backend/tests/test_universes.py @@ -65,4 +65,4 @@ def test_add_achievement(auth_client): ) assert response.status_code == 200 data = response.json() - assert {"type": "勇气", "description": "克服黑暗"} in data["achievements"] + assert {"type": "勇气", "description": "克服黑暗"} in data["achievements"] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 09d9d49..b7c367b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,158 +1,158 @@ -# docker-compose.prod.yml -# 生产部署配置 - 使用预构建镜像,不包含 build 指令 -# 镜像通过 GitHub Actions 或本地 docker build 预先构建 -# -# 使用方式: -# docker compose -f docker-compose.prod.yml up -d -# -# 镜像构建 (手动): -# docker build -t dreamweaver-backend:latest ./backend -# docker build -t dreamweaver-frontend:latest ./frontend -# docker build -t dreamweaver-admin-frontend:latest ./admin-frontend - -services: - # ============================================== - # 前端服务 (C端用户 App) - # ============================================== - frontend: - image: ${REGISTRY:-}dreamweaver-frontend:${TAG:-latest} - container_name: dreamweaver_frontend - restart: always - ports: - - "52080:80" - depends_on: - - backend - - # ============================================== - # 管理后台前端 (Admin Console) - # ============================================== - frontend-admin: - image: ${REGISTRY:-}dreamweaver-admin-frontend:${TAG:-latest} - container_name: dreamweaver_frontend_admin - restart: always - ports: - - "52888:80" - depends_on: - - backend-admin - - # ============================================== - # 后端服务 (FastAPI) - # ============================================== - backend: - image: ${REGISTRY:-}dreamweaver-backend:${TAG:-latest} - container_name: dreamweaver_backend - restart: always - ports: - - "52000:8000" - 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: - image: ${REGISTRY:-}dreamweaver-backend:${TAG:-latest} - container_name: dreamweaver_backend_admin - restart: always - ports: - - "52800:8001" - command: ["uvicorn", "app.admin_main:app", "--host", "0.0.0.0", "--port", "8001"] - 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 - - # ============================================== - # 工作节点 (Celery Worker) - # ============================================== - worker: - image: ${REGISTRY:-}dreamweaver-backend:${TAG:-latest} - 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: - redis: - condition: service_started - db: - condition: service_healthy - - # ============================================== - # 调度节点 (Celery Beat) - # ============================================== - celery-beat: - image: ${REGISTRY:-}dreamweaver-backend:${TAG:-latest} - 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: - redis: - condition: service_started - - # ============================================== - # 数据库 (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" - 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" - volumes: - - redis_data:/data - command: redis-server --appendonly yes - -volumes: - postgres_data: - redis_data: - backend_static: +# docker-compose.prod.yml +# 生产部署配置 - 使用预构建镜像,不包含 build 指令 +# 镜像通过 GitHub Actions 或本地 docker build 预先构建 +# +# 使用方式: +# docker compose -f docker-compose.prod.yml up -d +# +# 镜像构建 (手动): +# docker build -t dreamweaver-backend:latest ./backend +# docker build -t dreamweaver-frontend:latest ./frontend +# docker build -t dreamweaver-admin-frontend:latest ./admin-frontend + +services: + # ============================================== + # 前端服务 (C端用户 App) + # ============================================== + frontend: + image: ${REGISTRY:-}dreamweaver-frontend:${TAG:-latest} + container_name: dreamweaver_frontend + restart: always + ports: + - "52080:80" + depends_on: + - backend + + # ============================================== + # 管理后台前端 (Admin Console) + # ============================================== + frontend-admin: + image: ${REGISTRY:-}dreamweaver-admin-frontend:${TAG:-latest} + container_name: dreamweaver_frontend_admin + restart: always + ports: + - "52888:80" + depends_on: + - backend-admin + + # ============================================== + # 后端服务 (FastAPI) + # ============================================== + backend: + image: ${REGISTRY:-}dreamweaver-backend:${TAG:-latest} + container_name: dreamweaver_backend + restart: always + ports: + - "52000:8000" + 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: + image: ${REGISTRY:-}dreamweaver-backend:${TAG:-latest} + container_name: dreamweaver_backend_admin + restart: always + ports: + - "52800:8001" + command: ["uvicorn", "app.admin_main:app", "--host", "0.0.0.0", "--port", "8001"] + 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 + + # ============================================== + # 工作节点 (Celery Worker) + # ============================================== + worker: + image: ${REGISTRY:-}dreamweaver-backend:${TAG:-latest} + 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: + redis: + condition: service_started + db: + condition: service_healthy + + # ============================================== + # 调度节点 (Celery Beat) + # ============================================== + celery-beat: + image: ${REGISTRY:-}dreamweaver-backend:${TAG:-latest} + 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: + redis: + condition: service_started + + # ============================================== + # 数据库 (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" + 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" + volumes: + - redis_data:/data + command: redis-server --appendonly yes + +volumes: + postgres_data: + redis_data: + backend_static: diff --git a/docker-compose.yml b/docker-compose.yml index e31dbe0..87bf0e8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,183 +1,183 @@ -# docker-compose.yml -# 开发环境配置 - 支持本地构建和快速迭代 -# -# 使用方式: -# docker compose up -d # 启动所有服务 -# docker compose up -d --build # 重新构建并启动 -# docker compose logs -f backend # 查看日志 -# -# 生产部署请使用: docker-compose.prod.yml - -services: - # ============================================== - # 前端服务 (C端用户 App) - # ============================================== - frontend: - build: ./frontend - image: dreamweaver-frontend:dev - container_name: dreamweaver_frontend - restart: unless-stopped - ports: - - "52080:80" - depends_on: - - backend - - # ============================================== - # 管理后台前端 (Admin Console) - # ============================================== - frontend-admin: - build: ./admin-frontend - image: dreamweaver-admin-frontend:dev - container_name: dreamweaver_frontend_admin - restart: unless-stopped - ports: - - "52888:80" - depends_on: - - backend-admin - - # ============================================== - # 后端服务 (FastAPI) - # ============================================== - backend: - build: ./backend - image: dreamweaver-backend:dev - container_name: dreamweaver_backend - restart: unless-stopped - ports: - - "52000:8000" - 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 - - REDIS_URL=redis://redis:6379/0 - volumes: - - backend_static:/app/static - depends_on: - db: - condition: service_healthy - redis: - condition: service_started - - # ============================================== - # 管理后台后端 (Admin Backend) - 复用 backend 镜像 - # ============================================== - backend-admin: - image: dreamweaver-backend:dev - container_name: dreamweaver_backend_admin - restart: unless-stopped - ports: - - "52800:8001" - command: ["uvicorn", "app.admin_main:app", "--host", "0.0.0.0", "--port", "8001"] - 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 - - REDIS_URL=redis://redis:6379/0 - volumes: - - backend_static:/app/static - depends_on: - backend: - condition: service_started - db: - condition: service_healthy - redis: - condition: service_started - - # ============================================== - # 工作节点 (Celery Worker) - 复用 backend 镜像 - # ============================================== - worker: - image: dreamweaver-backend:dev - container_name: dreamweaver_worker - command: celery -A app.core.celery_app worker --loglevel=info - restart: unless-stopped - 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 - - REDIS_URL=redis://redis:6379/0 - depends_on: - backend: - condition: service_started - redis: - condition: service_started - db: - condition: service_healthy - - # ============================================== - # 调度节点 (Celery Beat) - 复用 backend 镜像 - # ============================================== - celery-beat: - image: dreamweaver-backend:dev - container_name: dreamweaver_beat - command: celery -A app.core.celery_app beat --loglevel=info - restart: unless-stopped - 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 - - REDIS_URL=redis://redis:6379/0 - depends_on: - backend: - condition: service_started - redis: - condition: service_started - - # ============================================== - # 数据库 (PostgreSQL) - # ============================================== - db: - image: postgres:15-alpine - container_name: dreamweaver_db - restart: unless-stopped - environment: - POSTGRES_USER: ${POSTGRES_USER:-dreamweaver} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dreamweaver_password} - POSTGRES_DB: ${POSTGRES_DB:-dreamweaver_db} - ports: - - "52432:5432" - 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: unless-stopped - ports: - - "52379:6379" - volumes: - - redis_data:/data - command: redis-server --appendonly yes - - # ============================================== - # 数据库管理 (Adminer) - 仅开发环境 - # ============================================== - adminer: - image: adminer - container_name: dreamweaver_adminer - restart: unless-stopped - ports: - - "52999:8080" - depends_on: - - db - profiles: - - dev # 仅在 --profile dev 时启动 - -volumes: - postgres_data: - redis_data: - backend_static: +# docker-compose.yml +# 开发环境配置 - 支持本地构建和快速迭代 +# +# 使用方式: +# docker compose up -d # 启动所有服务 +# docker compose up -d --build # 重新构建并启动 +# docker compose logs -f backend # 查看日志 +# +# 生产部署请使用: docker-compose.prod.yml + +services: + # ============================================== + # 前端服务 (C端用户 App) + # ============================================== + frontend: + build: ./frontend + image: dreamweaver-frontend:dev + container_name: dreamweaver_frontend + restart: unless-stopped + ports: + - "52080:80" + depends_on: + - backend + + # ============================================== + # 管理后台前端 (Admin Console) + # ============================================== + frontend-admin: + build: ./admin-frontend + image: dreamweaver-admin-frontend:dev + container_name: dreamweaver_frontend_admin + restart: unless-stopped + ports: + - "52888:80" + depends_on: + - backend-admin + + # ============================================== + # 后端服务 (FastAPI) + # ============================================== + backend: + build: ./backend + image: dreamweaver-backend:dev + container_name: dreamweaver_backend + restart: unless-stopped + ports: + - "52000:8000" + 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 + - REDIS_URL=redis://redis:6379/0 + volumes: + - backend_static:/app/static + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + + # ============================================== + # 管理后台后端 (Admin Backend) - 复用 backend 镜像 + # ============================================== + backend-admin: + image: dreamweaver-backend:dev + container_name: dreamweaver_backend_admin + restart: unless-stopped + ports: + - "52800:8001" + command: ["uvicorn", "app.admin_main:app", "--host", "0.0.0.0", "--port", "8001"] + 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 + - REDIS_URL=redis://redis:6379/0 + volumes: + - backend_static:/app/static + depends_on: + backend: + condition: service_started + db: + condition: service_healthy + redis: + condition: service_started + + # ============================================== + # 工作节点 (Celery Worker) - 复用 backend 镜像 + # ============================================== + worker: + image: dreamweaver-backend:dev + container_name: dreamweaver_worker + command: celery -A app.core.celery_app worker --loglevel=info + restart: unless-stopped + 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 + - REDIS_URL=redis://redis:6379/0 + depends_on: + backend: + condition: service_started + redis: + condition: service_started + db: + condition: service_healthy + + # ============================================== + # 调度节点 (Celery Beat) - 复用 backend 镜像 + # ============================================== + celery-beat: + image: dreamweaver-backend:dev + container_name: dreamweaver_beat + command: celery -A app.core.celery_app beat --loglevel=info + restart: unless-stopped + 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 + - REDIS_URL=redis://redis:6379/0 + depends_on: + backend: + condition: service_started + redis: + condition: service_started + + # ============================================== + # 数据库 (PostgreSQL) + # ============================================== + db: + image: postgres:15-alpine + container_name: dreamweaver_db + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-dreamweaver} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dreamweaver_password} + POSTGRES_DB: ${POSTGRES_DB:-dreamweaver_db} + ports: + - "52432:5432" + 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: unless-stopped + ports: + - "52379:6379" + volumes: + - redis_data:/data + command: redis-server --appendonly yes + + # ============================================== + # 数据库管理 (Adminer) - 仅开发环境 + # ============================================== + adminer: + image: adminer + container_name: dreamweaver_adminer + restart: unless-stopped + ports: + - "52999:8080" + depends_on: + - db + profiles: + - dev # 仅在 --profile dev 时启动 + +volumes: + postgres_data: + redis_data: + backend_static: diff --git a/frontend/.gitignore b/frontend/.gitignore index d735f80..1a99ec6 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,17 +1,17 @@ -# Dependencies -node_modules/ - -# Build -dist/ - -# IDE -.idea/ -.vscode/ -*.swp -*.swo - -# OS -.DS_Store - -# Logs -*.log +# Dependencies +node_modules/ + +# Build +dist/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store + +# Logs +*.log diff --git a/frontend/Dockerfile b/frontend/Dockerfile index cbd1703..b5a1329 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,23 +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;"] +# 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/index.html b/frontend/index.html index 1c374bf..3b74e18 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,13 +1,13 @@ - - - - - - - 梦语织机 - AI儿童故事生成器 - - -
- - - + + + + + + + 梦语织机 - AI儿童故事生成器 + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf index fa47985..7dca59e 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,30 +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; - } -} +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 index 9dfa62a..fa86916 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,2627 +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" - } - } - } -} +{ + "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 index a751c0a..3c771eb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,28 +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" - } -} +{ + "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 index 2e7af2b..5eec88d 100644 --- a/frontend/postcss.config.js +++ b/frontend/postcss.config.js @@ -1,6 +1,6 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index f7bc25e..7868aa1 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1,3 +1,3 @@ - - - + + + diff --git a/frontend/public/landing.html b/frontend/public/landing.html index cd1830c..34e9526 100644 --- a/frontend/public/landing.html +++ b/frontend/public/landing.html @@ -1,399 +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.

-
- - - - - - + + + + + + 梦语织机 - 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/api/client.ts b/frontend/src/api/client.ts index 66e7f2d..918f353 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,28 +1,28 @@ -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) - } - +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', @@ -41,5 +41,5 @@ class ApiClient { return this.request(url, { method: 'DELETE' }) } } - -export const api = new ApiClient() + +export const api = new ApiClient() diff --git a/frontend/src/components/AddMemoryModal.vue b/frontend/src/components/AddMemoryModal.vue index a07756d..f2e070e 100644 --- a/frontend/src/components/AddMemoryModal.vue +++ b/frontend/src/components/AddMemoryModal.vue @@ -1,221 +1,221 @@ - - - - - + + + + + diff --git a/frontend/src/components/CreateStoryModal.vue b/frontend/src/components/CreateStoryModal.vue index b99409d..fbb808c 100644 --- a/frontend/src/components/CreateStoryModal.vue +++ b/frontend/src/components/CreateStoryModal.vue @@ -1,380 +1,377 @@ - - - - - + error.value = e instanceof Error ? e.message : '生成失败' + } finally { + loading.value = false + } +} + + + + + diff --git a/frontend/src/components/MemoryList.vue b/frontend/src/components/MemoryList.vue index ac727cb..a3d9379 100644 --- a/frontend/src/components/MemoryList.vue +++ b/frontend/src/components/MemoryList.vue @@ -1,226 +1,226 @@ - - - + + + diff --git a/frontend/src/components/ui/AnalysisAnimation.vue b/frontend/src/components/ui/AnalysisAnimation.vue index 59f5370..81d0a4f 100644 --- a/frontend/src/components/ui/AnalysisAnimation.vue +++ b/frontend/src/components/ui/AnalysisAnimation.vue @@ -1,139 +1,139 @@ - - - - - + + + + + diff --git a/frontend/src/components/ui/BaseButton.vue b/frontend/src/components/ui/BaseButton.vue index cc996c7..39838bf 100644 --- a/frontend/src/components/ui/BaseButton.vue +++ b/frontend/src/components/ui/BaseButton.vue @@ -84,4 +84,4 @@ function handleClick(event: MouseEvent) { - + diff --git a/frontend/src/components/ui/BaseCard.vue b/frontend/src/components/ui/BaseCard.vue index c096452..d849125 100644 --- a/frontend/src/components/ui/BaseCard.vue +++ b/frontend/src/components/ui/BaseCard.vue @@ -40,4 +40,4 @@ const baseClasses = computed(() => [
- + diff --git a/frontend/src/components/ui/BaseInput.vue b/frontend/src/components/ui/BaseInput.vue index 01b1749..64ce94a 100644 --- a/frontend/src/components/ui/BaseInput.vue +++ b/frontend/src/components/ui/BaseInput.vue @@ -68,4 +68,4 @@ const passthroughAttrs = computed(() => { {{ props.error }}

- + diff --git a/frontend/src/components/ui/BaseSelect.vue b/frontend/src/components/ui/BaseSelect.vue index 55ad675..e05a89e 100644 --- a/frontend/src/components/ui/BaseSelect.vue +++ b/frontend/src/components/ui/BaseSelect.vue @@ -64,4 +64,4 @@ function handleChange(event: Event) { - + diff --git a/frontend/src/components/ui/BaseTextarea.vue b/frontend/src/components/ui/BaseTextarea.vue index ca723f0..2e39389 100644 --- a/frontend/src/components/ui/BaseTextarea.vue +++ b/frontend/src/components/ui/BaseTextarea.vue @@ -59,4 +59,4 @@ const passthroughAttrs = computed(() => { - + diff --git a/frontend/src/components/ui/ConfirmModal.vue b/frontend/src/components/ui/ConfirmModal.vue index cb840de..1dca8a8 100644 --- a/frontend/src/components/ui/ConfirmModal.vue +++ b/frontend/src/components/ui/ConfirmModal.vue @@ -57,4 +57,4 @@ const headerClasses = computed(() => { - + diff --git a/frontend/src/components/ui/EmptyState.vue b/frontend/src/components/ui/EmptyState.vue index fb3eb67..5ae6e1a 100644 --- a/frontend/src/components/ui/EmptyState.vue +++ b/frontend/src/components/ui/EmptyState.vue @@ -42,4 +42,4 @@ function handleAction() { {{ props.actionText }} - + diff --git a/frontend/src/components/ui/LoadingSpinner.vue b/frontend/src/components/ui/LoadingSpinner.vue index 6f74cd5..db15c0b 100644 --- a/frontend/src/components/ui/LoadingSpinner.vue +++ b/frontend/src/components/ui/LoadingSpinner.vue @@ -33,4 +33,4 @@ const sizeClasses = computed(() => { {{ props.text }}

- + diff --git a/frontend/src/components/ui/LoginDialog.vue b/frontend/src/components/ui/LoginDialog.vue index 60f5000..4b8c562 100644 --- a/frontend/src/components/ui/LoginDialog.vue +++ b/frontend/src/components/ui/LoginDialog.vue @@ -1,136 +1,136 @@ - - - - - + + + + + diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index bab7a3d..4bcd89a 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -5,4 +5,4 @@ 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' +export { default as ConfirmModal } from './ConfirmModal.vue' diff --git a/frontend/src/router.ts b/frontend/src/router.ts index 9f38d9c..b658e34 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -44,7 +44,7 @@ const router = createRouter({ component: () => import('./views/StoryDetail.vue'), }, { - path: '/storybook/view', + path: '/storybook/view/:id?', name: 'storybook-viewer', component: () => import('./views/StorybookViewer.vue'), }, diff --git a/frontend/src/stores/user.ts b/frontend/src/stores/user.ts index feef998..3c74ecb 100644 --- a/frontend/src/stores/user.ts +++ b/frontend/src/stores/user.ts @@ -1,49 +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, - } -}) +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/views/ChildProfileTimeline.vue b/frontend/src/views/ChildProfileTimeline.vue index 00d86cb..edc6cd2 100644 --- a/frontend/src/views/ChildProfileTimeline.vue +++ b/frontend/src/views/ChildProfileTimeline.vue @@ -1,221 +1,221 @@ - - - - - + + + + + diff --git a/frontend/src/views/ChildProfiles.vue b/frontend/src/views/ChildProfiles.vue index fa9945b..946e97c 100644 --- a/frontend/src/views/ChildProfiles.vue +++ b/frontend/src/views/ChildProfiles.vue @@ -171,4 +171,4 @@ onMounted(fetchProfiles) - + diff --git a/frontend/src/views/UniverseDetail.vue b/frontend/src/views/UniverseDetail.vue index 04946da..26cd5cd 100644 --- a/frontend/src/views/UniverseDetail.vue +++ b/frontend/src/views/UniverseDetail.vue @@ -205,4 +205,4 @@ onMounted(fetchUniverse) - + diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 323c78a..aa09e49 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -1,7 +1,7 @@ -/// - -declare module '*.vue' { - import type { DefineComponent } from 'vue' - const component: DefineComponent<{}, {}, any> - export default component -} +/// + +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 index afcd725..020fb56 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -1,4 +1,4 @@ -/** @type {import('tailwindcss').Config} */ +/** @type {import('tailwindcss').Config} */ export default { darkMode: 'class', content: [ diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 4b6a33b..0c3dc92 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,24 +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" }] -} +{ + "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 index 97ede7e..b850582 100644 --- a/frontend/tsconfig.node.json +++ b/frontend/tsconfig.node.json @@ -1,11 +1,11 @@ -{ - "compilerOptions": { - "composite": true, - "skipLibCheck": true, - "module": "ESNext", - "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true, - "strict": true - }, - "include": ["vite.config.ts"] -} +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/install.cmd b/install.cmd index bb32aa9..19f56cb 100644 --- a/install.cmd +++ b/install.cmd @@ -1,216 +1,216 @@ -@echo off -setlocal enabledelayedexpansion - -REM Claude Code Windows CMD Bootstrap Script -REM Installs Claude Code for environments where PowerShell is not available - -REM Parse command line argument -set "TARGET=%~1" -if "!TARGET!"=="" set "TARGET=latest" - -REM Validate target parameter -if /i "!TARGET!"=="stable" goto :target_valid -if /i "!TARGET!"=="latest" goto :target_valid -echo !TARGET! | findstr /r "^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*" >nul -if !ERRORLEVEL! equ 0 goto :target_valid - -echo Usage: %0 [stable^|latest^|VERSION] >&2 -echo Example: %0 1.0.58 >&2 -exit /b 1 - -:target_valid - -REM Check for 64-bit Windows -if /i "%PROCESSOR_ARCHITECTURE%"=="AMD64" goto :arch_valid -if /i "%PROCESSOR_ARCHITECTURE%"=="ARM64" goto :arch_valid -if /i "%PROCESSOR_ARCHITEW6432%"=="AMD64" goto :arch_valid -if /i "%PROCESSOR_ARCHITEW6432%"=="ARM64" goto :arch_valid - -echo Claude Code does not support 32-bit Windows. Please use a 64-bit version of Windows. >&2 -exit /b 1 - -:arch_valid - -REM Set constants -set "GCS_BUCKET=https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases" -set "DOWNLOAD_DIR=%USERPROFILE%\.claude\downloads" -set "PLATFORM=win32-x64" - -REM Create download directory -if not exist "!DOWNLOAD_DIR!" mkdir "!DOWNLOAD_DIR!" - -REM Check for curl availability -curl --version >nul 2>&1 -if !ERRORLEVEL! neq 0 ( - echo curl is required but not available. Please install curl or use PowerShell installer. >&2 - exit /b 1 -) - -REM Always download latest version (which has the most up-to-date installer) -call :download_file "!GCS_BUCKET!/latest" "!DOWNLOAD_DIR!\latest" -if !ERRORLEVEL! neq 0 ( - echo Failed to get latest version >&2 - exit /b 1 -) - -REM Read version from file -set /p VERSION=<"!DOWNLOAD_DIR!\latest" -del "!DOWNLOAD_DIR!\latest" - -REM Download manifest -call :download_file "!GCS_BUCKET!/!VERSION!/manifest.json" "!DOWNLOAD_DIR!\manifest.json" -if !ERRORLEVEL! neq 0 ( - echo Failed to get manifest >&2 - exit /b 1 -) - -REM Extract checksum from manifest -call :parse_manifest "!DOWNLOAD_DIR!\manifest.json" "!PLATFORM!" -if !ERRORLEVEL! neq 0 ( - echo Platform !PLATFORM! not found in manifest >&2 - del "!DOWNLOAD_DIR!\manifest.json" 2>nul - exit /b 1 -) -del "!DOWNLOAD_DIR!\manifest.json" - -REM Download binary -set "BINARY_PATH=!DOWNLOAD_DIR!\claude-!VERSION!-!PLATFORM!.exe" -call :download_file "!GCS_BUCKET!/!VERSION!/!PLATFORM!/claude.exe" "!BINARY_PATH!" -if !ERRORLEVEL! neq 0 ( - echo Failed to download binary >&2 - if exist "!BINARY_PATH!" del "!BINARY_PATH!" - exit /b 1 -) - -REM Verify checksum -call :verify_checksum "!BINARY_PATH!" "!EXPECTED_CHECKSUM!" -if !ERRORLEVEL! neq 0 ( - echo Checksum verification failed >&2 - del "!BINARY_PATH!" - exit /b 1 -) - -REM Run claude install to set up launcher and shell integration -echo Setting up Claude Code... -"!BINARY_PATH!" install "!TARGET!" -set "INSTALL_RESULT=!ERRORLEVEL!" - -REM Clean up downloaded file -REM Wait a moment for any file handles to be released -timeout /t 1 /nobreak >nul 2>&1 -del /f "!BINARY_PATH!" >nul 2>&1 -if exist "!BINARY_PATH!" ( - echo Warning: Could not remove temporary file: !BINARY_PATH! -) - -if !INSTALL_RESULT! neq 0 ( - echo Installation failed >&2 - exit /b 1 -) - -echo. -echo Installation complete^^! -echo. -exit /b 0 - -REM ============================================================================ -REM SUBROUTINES -REM ============================================================================ - -:download_file -REM Downloads a file using curl -REM Args: %1=URL, %2=OutputPath -set "URL=%~1" -set "OUTPUT=%~2" - -curl -fsSL "!URL!" -o "!OUTPUT!" -exit /b !ERRORLEVEL! - -:parse_manifest -REM Parse JSON manifest to extract checksum for platform -REM Args: %1=ManifestPath, %2=Platform -set "MANIFEST_PATH=%~1" -set "PLATFORM_NAME=%~2" -set "EXPECTED_CHECKSUM=" - -REM Use findstr to find platform section, then look for checksum -set "FOUND_PLATFORM=" -set "IN_PLATFORM_SECTION=" - -REM Read the manifest line by line -for /f "usebackq tokens=*" %%i in ("!MANIFEST_PATH!") do ( - set "LINE=%%i" - - REM Check if this line contains our platform - echo !LINE! | findstr /c:"\"%PLATFORM_NAME%\":" >nul - if !ERRORLEVEL! equ 0 ( - set "IN_PLATFORM_SECTION=1" - ) - - REM If we're in the platform section, look for checksum - if defined IN_PLATFORM_SECTION ( - echo !LINE! | findstr /c:"\"checksum\":" >nul - if !ERRORLEVEL! equ 0 ( - REM Extract checksum value - for /f "tokens=2 delims=:" %%j in ("!LINE!") do ( - set "CHECKSUM_PART=%%j" - REM Remove quotes, whitespace, and comma - set "CHECKSUM_PART=!CHECKSUM_PART: =!" - set "CHECKSUM_PART=!CHECKSUM_PART:"=!" - set "CHECKSUM_PART=!CHECKSUM_PART:,=!" - - REM Check if it looks like a SHA256 (64 hex chars) - if not "!CHECKSUM_PART!"=="" ( - call :check_length "!CHECKSUM_PART!" 64 - if !ERRORLEVEL! equ 0 ( - set "EXPECTED_CHECKSUM=!CHECKSUM_PART!" - exit /b 0 - ) - ) - ) - ) - - REM Check if we've left the platform section (closing brace) - echo !LINE! | findstr /c:"}" >nul - if !ERRORLEVEL! equ 0 set "IN_PLATFORM_SECTION=" - ) -) - -if "!EXPECTED_CHECKSUM!"=="" exit /b 1 -exit /b 0 - -:check_length -REM Check if string length equals expected length -REM Args: %1=String, %2=ExpectedLength -set "STR=%~1" -set "EXPECTED_LEN=%~2" -set "LEN=0" -:count_loop -if "!STR:~%LEN%,1!"=="" goto :count_done -set /a LEN+=1 -goto :count_loop -:count_done -if %LEN%==%EXPECTED_LEN% exit /b 0 -exit /b 1 - -:verify_checksum -REM Verify file checksum using certutil -REM Args: %1=FilePath, %2=ExpectedChecksum -set "FILE_PATH=%~1" -set "EXPECTED=%~2" - -for /f "skip=1 tokens=*" %%i in ('certutil -hashfile "!FILE_PATH!" SHA256') do ( - set "ACTUAL=%%i" - set "ACTUAL=!ACTUAL: =!" - if "!ACTUAL!"=="CertUtil:Thecommandcompletedsuccessfully." goto :verify_done - if "!ACTUAL!" neq "" ( - if /i "!ACTUAL!"=="!EXPECTED!" ( - exit /b 0 - ) else ( - exit /b 1 - ) - ) -) - -:verify_done -exit /b 1 +@echo off +setlocal enabledelayedexpansion + +REM Claude Code Windows CMD Bootstrap Script +REM Installs Claude Code for environments where PowerShell is not available + +REM Parse command line argument +set "TARGET=%~1" +if "!TARGET!"=="" set "TARGET=latest" + +REM Validate target parameter +if /i "!TARGET!"=="stable" goto :target_valid +if /i "!TARGET!"=="latest" goto :target_valid +echo !TARGET! | findstr /r "^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*" >nul +if !ERRORLEVEL! equ 0 goto :target_valid + +echo Usage: %0 [stable^|latest^|VERSION] >&2 +echo Example: %0 1.0.58 >&2 +exit /b 1 + +:target_valid + +REM Check for 64-bit Windows +if /i "%PROCESSOR_ARCHITECTURE%"=="AMD64" goto :arch_valid +if /i "%PROCESSOR_ARCHITECTURE%"=="ARM64" goto :arch_valid +if /i "%PROCESSOR_ARCHITEW6432%"=="AMD64" goto :arch_valid +if /i "%PROCESSOR_ARCHITEW6432%"=="ARM64" goto :arch_valid + +echo Claude Code does not support 32-bit Windows. Please use a 64-bit version of Windows. >&2 +exit /b 1 + +:arch_valid + +REM Set constants +set "GCS_BUCKET=https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases" +set "DOWNLOAD_DIR=%USERPROFILE%\.claude\downloads" +set "PLATFORM=win32-x64" + +REM Create download directory +if not exist "!DOWNLOAD_DIR!" mkdir "!DOWNLOAD_DIR!" + +REM Check for curl availability +curl --version >nul 2>&1 +if !ERRORLEVEL! neq 0 ( + echo curl is required but not available. Please install curl or use PowerShell installer. >&2 + exit /b 1 +) + +REM Always download latest version (which has the most up-to-date installer) +call :download_file "!GCS_BUCKET!/latest" "!DOWNLOAD_DIR!\latest" +if !ERRORLEVEL! neq 0 ( + echo Failed to get latest version >&2 + exit /b 1 +) + +REM Read version from file +set /p VERSION=<"!DOWNLOAD_DIR!\latest" +del "!DOWNLOAD_DIR!\latest" + +REM Download manifest +call :download_file "!GCS_BUCKET!/!VERSION!/manifest.json" "!DOWNLOAD_DIR!\manifest.json" +if !ERRORLEVEL! neq 0 ( + echo Failed to get manifest >&2 + exit /b 1 +) + +REM Extract checksum from manifest +call :parse_manifest "!DOWNLOAD_DIR!\manifest.json" "!PLATFORM!" +if !ERRORLEVEL! neq 0 ( + echo Platform !PLATFORM! not found in manifest >&2 + del "!DOWNLOAD_DIR!\manifest.json" 2>nul + exit /b 1 +) +del "!DOWNLOAD_DIR!\manifest.json" + +REM Download binary +set "BINARY_PATH=!DOWNLOAD_DIR!\claude-!VERSION!-!PLATFORM!.exe" +call :download_file "!GCS_BUCKET!/!VERSION!/!PLATFORM!/claude.exe" "!BINARY_PATH!" +if !ERRORLEVEL! neq 0 ( + echo Failed to download binary >&2 + if exist "!BINARY_PATH!" del "!BINARY_PATH!" + exit /b 1 +) + +REM Verify checksum +call :verify_checksum "!BINARY_PATH!" "!EXPECTED_CHECKSUM!" +if !ERRORLEVEL! neq 0 ( + echo Checksum verification failed >&2 + del "!BINARY_PATH!" + exit /b 1 +) + +REM Run claude install to set up launcher and shell integration +echo Setting up Claude Code... +"!BINARY_PATH!" install "!TARGET!" +set "INSTALL_RESULT=!ERRORLEVEL!" + +REM Clean up downloaded file +REM Wait a moment for any file handles to be released +timeout /t 1 /nobreak >nul 2>&1 +del /f "!BINARY_PATH!" >nul 2>&1 +if exist "!BINARY_PATH!" ( + echo Warning: Could not remove temporary file: !BINARY_PATH! +) + +if !INSTALL_RESULT! neq 0 ( + echo Installation failed >&2 + exit /b 1 +) + +echo. +echo Installation complete^^! +echo. +exit /b 0 + +REM ============================================================================ +REM SUBROUTINES +REM ============================================================================ + +:download_file +REM Downloads a file using curl +REM Args: %1=URL, %2=OutputPath +set "URL=%~1" +set "OUTPUT=%~2" + +curl -fsSL "!URL!" -o "!OUTPUT!" +exit /b !ERRORLEVEL! + +:parse_manifest +REM Parse JSON manifest to extract checksum for platform +REM Args: %1=ManifestPath, %2=Platform +set "MANIFEST_PATH=%~1" +set "PLATFORM_NAME=%~2" +set "EXPECTED_CHECKSUM=" + +REM Use findstr to find platform section, then look for checksum +set "FOUND_PLATFORM=" +set "IN_PLATFORM_SECTION=" + +REM Read the manifest line by line +for /f "usebackq tokens=*" %%i in ("!MANIFEST_PATH!") do ( + set "LINE=%%i" + + REM Check if this line contains our platform + echo !LINE! | findstr /c:"\"%PLATFORM_NAME%\":" >nul + if !ERRORLEVEL! equ 0 ( + set "IN_PLATFORM_SECTION=1" + ) + + REM If we're in the platform section, look for checksum + if defined IN_PLATFORM_SECTION ( + echo !LINE! | findstr /c:"\"checksum\":" >nul + if !ERRORLEVEL! equ 0 ( + REM Extract checksum value + for /f "tokens=2 delims=:" %%j in ("!LINE!") do ( + set "CHECKSUM_PART=%%j" + REM Remove quotes, whitespace, and comma + set "CHECKSUM_PART=!CHECKSUM_PART: =!" + set "CHECKSUM_PART=!CHECKSUM_PART:"=!" + set "CHECKSUM_PART=!CHECKSUM_PART:,=!" + + REM Check if it looks like a SHA256 (64 hex chars) + if not "!CHECKSUM_PART!"=="" ( + call :check_length "!CHECKSUM_PART!" 64 + if !ERRORLEVEL! equ 0 ( + set "EXPECTED_CHECKSUM=!CHECKSUM_PART!" + exit /b 0 + ) + ) + ) + ) + + REM Check if we've left the platform section (closing brace) + echo !LINE! | findstr /c:"}" >nul + if !ERRORLEVEL! equ 0 set "IN_PLATFORM_SECTION=" + ) +) + +if "!EXPECTED_CHECKSUM!"=="" exit /b 1 +exit /b 0 + +:check_length +REM Check if string length equals expected length +REM Args: %1=String, %2=ExpectedLength +set "STR=%~1" +set "EXPECTED_LEN=%~2" +set "LEN=0" +:count_loop +if "!STR:~%LEN%,1!"=="" goto :count_done +set /a LEN+=1 +goto :count_loop +:count_done +if %LEN%==%EXPECTED_LEN% exit /b 0 +exit /b 1 + +:verify_checksum +REM Verify file checksum using certutil +REM Args: %1=FilePath, %2=ExpectedChecksum +set "FILE_PATH=%~1" +set "EXPECTED=%~2" + +for /f "skip=1 tokens=*" %%i in ('certutil -hashfile "!FILE_PATH!" SHA256') do ( + set "ACTUAL=%%i" + set "ACTUAL=!ACTUAL: =!" + if "!ACTUAL!"=="CertUtil:Thecommandcompletedsuccessfully." goto :verify_done + if "!ACTUAL!" neq "" ( + if /i "!ACTUAL!"=="!EXPECTED!" ( + exit /b 0 + ) else ( + exit /b 1 + ) + ) +) + +:verify_done +exit /b 1