From 92cdfd9fcaba8927b18bb7e4dc5ddf2126f6975d Mon Sep 17 00:00:00 2001 From: qzl Date: Thu, 2 Apr 2026 16:36:35 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E8=BF=81=E7=A7=BB=E5=88=B0=20social-a?= =?UTF-8?q?pp=20=E6=9E=B6=E6=9E=84=EF=BC=8C=E9=9B=86=E6=88=90=20Supabase?= =?UTF-8?q?=20=E5=92=8C=20taskiq=20worker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 82 +-- .gitignore | 336 +++++++--- .opencode/commands/doc-update.md | 77 +++ .opencode/opencode.json.old | 20 + AGENTS.md | 4 + apps/.gitignore | 45 ++ apps/.metadata | 33 + apps/AGENTS.md | 201 ++++++ apps/README.md | 16 + apps/analysis_options.yaml | 28 + apps/android/.gitignore | 14 + apps/android/app/build.gradle.kts | 44 ++ .../android/app/src/debug/AndroidManifest.xml | 7 + apps/android/app/src/main/AndroidManifest.xml | 45 ++ .../com/meeyao/meeyao_qianwen/MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + .../app/src/main/res/values/styles.xml | 18 + .../app/src/profile/AndroidManifest.xml | 7 + apps/android/build.gradle.kts | 24 + apps/android/gradle.properties | 2 + .../gradle/wrapper/gradle-wrapper.properties | 5 + apps/android/settings.gradle.kts | 26 + apps/assets/images/logo.png | Bin 0 -> 144971 bytes apps/ios/.gitignore | 34 + apps/ios/Flutter/AppFrameworkInfo.plist | 26 + apps/ios/Flutter/Debug.xcconfig | 1 + apps/ios/Flutter/Release.xcconfig | 1 + apps/ios/Runner/AppDelegate.swift | 13 + .../AppIcon.appiconset/Contents.json | 122 ++++ .../Icon-App-1024x1024@1x.png | Bin 0 -> 10932 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 0 -> 295 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 0 -> 450 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 0 -> 282 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 0 -> 462 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 0 -> 704 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 0 -> 586 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 0 -> 1674 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 0 -> 762 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 0 -> 1226 bytes .../Icon-App-83.5x83.5@2x.png | Bin 0 -> 1418 bytes .../LaunchImage.imageset/Contents.json | 23 + .../LaunchImage.imageset/LaunchImage.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/README.md | 5 + .../Runner/Base.lproj/LaunchScreen.storyboard | 37 ++ apps/ios/Runner/Base.lproj/Main.storyboard | 26 + apps/ios/Runner/Info.plist | 49 ++ apps/ios/Runner/Runner-Bridging-Header.h | 1 + apps/ios/RunnerTests/RunnerTests.swift | 12 + apps/l10n.yaml | 4 + apps/lib/l10n/app_zh.arb | 69 ++ apps/lib/main.dart | 8 + apps/pubspec.yaml | 97 +++ apps/test/widget_test.dart | 30 + backend/AGENTS.md | 26 + backend/src/__init__.py | 0 backend/src/app.py | 14 - backend/src/core/config/__init__.py | 3 - backend/src/core/config/settings.py | 156 ++--- .../static/automation/memory_extraction.yaml | 34 - .../config/static/route/frontend_routes.yaml | 158 ----- backend/src/core/db/types.py | 4 +- backend/src/core/runtime/__init__.py | 3 + backend/src/core/runtime/cli.py | 58 +- backend/src/core/runtime/tasks.py | 3 + backend/src/core/taskiq/__init__.py | 3 + backend/src/core/taskiq/app.py | 30 + backend/src/models/__init__.py | 17 +- backend/src/models/divination.py | 41 -- backend/src/models/feedback.py | 17 - backend/src/models/llm.py | 26 + backend/src/models/llm_factory.py | 22 + backend/src/models/log.py | 39 -- backend/src/models/notification.py | 14 - backend/src/models/payment.py | 40 -- backend/src/models/system_agents.py | 32 + backend/src/models/user.py | 52 -- backend/src/models/version.py | 19 - backend/src/models/violation.py | 30 - backend/src/schemas/__init__.py | 1 + backend/src/schemas/agent/__init__.py | 68 ++ backend/src/schemas/agent/forwarded_props.py | 93 +++ backend/src/schemas/agent/runtime_models.py | 177 +++++ backend/src/schemas/agent/system_agent.py | 30 + backend/src/schemas/agent/ui_hints.py | 349 ++++++++++ backend/src/schemas/agent/ui_schema.py | 628 ++++++++++++++++++ backend/src/schemas/agent/visibility.py | 50 ++ backend/src/services/__init__.py | 1 + backend/src/services/base/__init__.py | 28 + backend/src/services/base/redis.py | 136 ++++ .../src/services/base/service_interface.py | 158 +++++ backend/src/services/base/supabase.py | 304 +++++++++ backend/src/services/caches/__init__.py | 4 + backend/src/services/caches/factory.py | 13 + backend/src/services/caches/interfaces.py | 19 + backend/src/services/caches/redis_store.py | 80 +++ backend/src/services/llm_pricing/__init__.py | 5 + backend/src/services/llm_pricing/service.py | 183 +++++ backend/src/v1/__init__.py | 0 backend/src/v1/auth/__init__.py | 1 + .../src/v1/auth/automation_static_config.py | 35 + backend/src/v1/auth/dependencies.py | 27 + backend/src/v1/auth/gateway.py | 430 ++++++++++++ backend/src/v1/auth/rate_limit.py | 114 ++++ backend/src/v1/auth/registration_bootstrap.py | 239 +++++++ backend/src/v1/auth/router.py | 117 ++++ backend/src/v1/auth/schemas.py | 66 ++ backend/src/v1/auth/service.py | 63 ++ .../backend-features.md | 0 infra/docker/docker-compose.yml | 3 + infra/docker/supabase/docker-compose.yml | 214 ++++++ .../supabase/volumes/api/kong-entrypoint.sh | 26 + infra/docker/supabase/volumes/api/kong.yml | 177 +++++ .../docker/supabase/volumes/db/_supabase.sql | 3 + infra/docker/supabase/volumes/db/jwt.sql | 5 + .../supabase/volumes/db/local-dev-grants.sql | 2 + infra/docker/supabase/volumes/db/roles.sql | 9 + infra/docker/supabase/volumes/db/webhooks.sql | 208 ++++++ infra/scripts/app.sh | 7 +- pyproject.toml | 8 +- 132 files changed, 5802 insertions(+), 759 deletions(-) create mode 100644 .opencode/commands/doc-update.md create mode 100644 .opencode/opencode.json.old create mode 100644 apps/.gitignore create mode 100644 apps/.metadata create mode 100644 apps/AGENTS.md create mode 100644 apps/README.md create mode 100644 apps/analysis_options.yaml create mode 100644 apps/android/.gitignore create mode 100644 apps/android/app/build.gradle.kts create mode 100644 apps/android/app/src/debug/AndroidManifest.xml create mode 100644 apps/android/app/src/main/AndroidManifest.xml create mode 100644 apps/android/app/src/main/kotlin/com/meeyao/meeyao_qianwen/MainActivity.kt create mode 100644 apps/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 apps/android/app/src/main/res/drawable/launch_background.xml create mode 100644 apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 apps/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 apps/android/app/src/main/res/values-night/styles.xml create mode 100644 apps/android/app/src/main/res/values/styles.xml create mode 100644 apps/android/app/src/profile/AndroidManifest.xml create mode 100644 apps/android/build.gradle.kts create mode 100644 apps/android/gradle.properties create mode 100644 apps/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 apps/android/settings.gradle.kts create mode 100644 apps/assets/images/logo.png create mode 100644 apps/ios/.gitignore create mode 100644 apps/ios/Flutter/AppFrameworkInfo.plist create mode 100644 apps/ios/Flutter/Debug.xcconfig create mode 100644 apps/ios/Flutter/Release.xcconfig create mode 100644 apps/ios/Runner/AppDelegate.swift create mode 100644 apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png create mode 100644 apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png create mode 100644 apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png create mode 100644 apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png create mode 100644 apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png create mode 100644 apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png create mode 100644 apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png create mode 100644 apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png create mode 100644 apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png create mode 100644 apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png create mode 100644 apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png create mode 100644 apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png create mode 100644 apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png create mode 100644 apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png create mode 100644 apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png create mode 100644 apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json create mode 100644 apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png create mode 100644 apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png create mode 100644 apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png create mode 100644 apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md create mode 100644 apps/ios/Runner/Base.lproj/LaunchScreen.storyboard create mode 100644 apps/ios/Runner/Base.lproj/Main.storyboard create mode 100644 apps/ios/Runner/Info.plist create mode 100644 apps/ios/Runner/Runner-Bridging-Header.h create mode 100644 apps/ios/RunnerTests/RunnerTests.swift create mode 100644 apps/l10n.yaml create mode 100644 apps/lib/l10n/app_zh.arb create mode 100644 apps/lib/main.dart create mode 100644 apps/pubspec.yaml create mode 100644 apps/test/widget_test.dart delete mode 100644 backend/src/__init__.py delete mode 100644 backend/src/app.py delete mode 100644 backend/src/core/config/__init__.py delete mode 100644 backend/src/core/config/static/automation/memory_extraction.yaml delete mode 100644 backend/src/core/config/static/route/frontend_routes.yaml create mode 100644 backend/src/core/runtime/tasks.py create mode 100644 backend/src/core/taskiq/__init__.py create mode 100644 backend/src/core/taskiq/app.py delete mode 100644 backend/src/models/divination.py delete mode 100644 backend/src/models/feedback.py create mode 100644 backend/src/models/llm.py create mode 100644 backend/src/models/llm_factory.py delete mode 100644 backend/src/models/log.py delete mode 100644 backend/src/models/notification.py delete mode 100644 backend/src/models/payment.py create mode 100644 backend/src/models/system_agents.py delete mode 100644 backend/src/models/user.py delete mode 100644 backend/src/models/version.py delete mode 100644 backend/src/models/violation.py create mode 100644 backend/src/schemas/agent/__init__.py create mode 100644 backend/src/schemas/agent/forwarded_props.py create mode 100644 backend/src/schemas/agent/runtime_models.py create mode 100644 backend/src/schemas/agent/system_agent.py create mode 100644 backend/src/schemas/agent/ui_hints.py create mode 100644 backend/src/schemas/agent/ui_schema.py create mode 100644 backend/src/schemas/agent/visibility.py create mode 100644 backend/src/services/base/__init__.py create mode 100644 backend/src/services/base/redis.py create mode 100644 backend/src/services/base/service_interface.py create mode 100644 backend/src/services/base/supabase.py create mode 100644 backend/src/services/caches/__init__.py create mode 100644 backend/src/services/caches/factory.py create mode 100644 backend/src/services/caches/interfaces.py create mode 100644 backend/src/services/caches/redis_store.py create mode 100644 backend/src/services/llm_pricing/__init__.py create mode 100644 backend/src/services/llm_pricing/service.py delete mode 100644 backend/src/v1/__init__.py create mode 100644 backend/src/v1/auth/__init__.py create mode 100644 backend/src/v1/auth/automation_static_config.py create mode 100644 backend/src/v1/auth/dependencies.py create mode 100644 backend/src/v1/auth/gateway.py create mode 100644 backend/src/v1/auth/rate_limit.py create mode 100644 backend/src/v1/auth/registration_bootstrap.py create mode 100644 backend/src/v1/auth/router.py create mode 100644 backend/src/v1/auth/schemas.py create mode 100644 backend/src/v1/auth/service.py rename docs/{reference => references}/backend-features.md (100%) create mode 100644 infra/docker/supabase/docker-compose.yml create mode 100755 infra/docker/supabase/volumes/api/kong-entrypoint.sh create mode 100644 infra/docker/supabase/volumes/api/kong.yml create mode 100644 infra/docker/supabase/volumes/db/_supabase.sql create mode 100644 infra/docker/supabase/volumes/db/jwt.sql create mode 100644 infra/docker/supabase/volumes/db/local-dev-grants.sql create mode 100644 infra/docker/supabase/volumes/db/roles.sql create mode 100644 infra/docker/supabase/volumes/db/webhooks.sql diff --git a/.env.example b/.env.example index 1dcb292..923fc96 100644 --- a/.env.example +++ b/.env.example @@ -26,55 +26,44 @@ ERYAO_REDIS__PORT=6379 ERYAO_REDIS__DB=0 ############ -# MySQL 数据库配置 +# Worker 队列分组配置 +############ +# agent: 常规异步任务 +ERYAO_WORKER__GROUPS__AGENT__CONCURRENCY=2 + +############ +# Supabase 配置 +############ +ERYAO_SUPABASE__PUBLIC_URL=https://your-project.supabase.co +ERYAO_SUPABASE__ANON_KEY= +ERYAO_SUPABASE__SERVICE_ROLE_KEY= +ERYAO_SUPABASE__JWT_SECRET= +ERYAO_SUPABASE__JWT_ALGORITHM=HS256 + +############ +# PostgreSQL 数据库配置(Supabase 本地开发) ############ ERYAO_DATABASE__HOST=localhost -ERYAO_DATABASE__PORT=3306 +ERYAO_DATABASE__PORT=5432 ERYAO_DATABASE__NAME=eryao -ERYAO_DATABASE__USER=root -ERYAO_DATABASE__PASSWORD=your_mysql_password_here +ERYAO_DATABASE__USER=postgres +ERYAO_DATABASE__PASSWORD=change-me-strong-password ############ -# 阿里云短信配置 +# Storage 配置 ############ -ERYAO_ALIYUN_SMS__ACCESS_KEY_ID=your_aliyun_access_key_id -ERYAO_ALIYUN_SMS__ACCESS_KEY_SECRET=your_aliyun_access_key_secret -ERYAO_ALIYUN_SMS__SIGN_NAME=your_sign_name -ERYAO_ALIYUN_SMS__TEMPLATE_CODE=your_template_code +ERYAO_STORAGE__ATTACHMENT__BUCKET=agent-attachments +ERYAO_STORAGE__AVATAR__BUCKET=avatars +ERYAO_STORAGE__SIGNED_URL_TTL_SECONDS=600 +ERYAO_STORAGE__ATTACHMENT__MAX_SIZE_MB=20 +ERYAO_STORAGE__AVATAR__MAX_SIZE_MB=2 +ERYAO_STORAGE__RETENTION_DAYS=30 ############ -# 阿里云内容安全配置 +# LLM API KEY ############ -ERYAO_ALIYUN_CONTENT_SECURITY__ACCESS_KEY_ID=your_aliyun_access_key_id -ERYAO_ALIYUN_CONTENT_SECURITY__ACCESS_KEY_SECRET=your_aliyun_access_key_secret - -############ -# 支付宝配置 -############ -ERYAO_ALIPAY__APP_ID=your_app_id -ERYAO_ALIPAY__MERCHANT_ID=your_merchant_id -ERYAO_ALIPAY__PUBLIC_KEY=your_alipay_public_key -ERYAO_ALIPAY__PRIVATE_KEY=your_alipay_private_key -ERYAO_ALIPAY__NOTIFY_URL=https://your-domain.com/api/payment/notify -ERYAO_ALIPAY__SANDBOX=false - -############ -# DeepSeek API 配置 -############ -ERYAO_DEEPSEEK__API_KEY=your_deepseek_api_key - -############ -# 认证配置 -############ -ERYAO_AUTH__TOKEN_EXPIRATION_DAYS=7 -ERYAO_AUTH__TOKEN_REFRESH_THRESHOLD_HOURS=2 - -############ -# 验证码配置 -############ -ERYAO_VERIFICATION__CODE_LENGTH=6 -ERYAO_VERIFICATION__EXPIRATION_MINUTES=5 -ERYAO_VERIFICATION__TEST_MODE=false +ERYAO_LLM__PROVIDER_KEYS__DASHSCOPE= +ERYAO_LLM__PROVIDER_KEYS__DEEPSEEK= ############ # 敏感词配置 @@ -82,14 +71,13 @@ ERYAO_VERIFICATION__TEST_MODE=false ERYAO_SENSITIVE_WORD__USE_ALIYUN=true ERYAO_SENSITIVE_WORD__FALLBACK_TO_LOCAL=true -############ -# App 版本更新配置 -############ -ERYAO_APP_VERSION__MANIFEST_PATH=deploy/static/releases/manifest.json -ERYAO_APP_VERSION__RELEASE_PATH_PREFIX=releases -ERYAO_APP_VERSION__DOWNLOAD_BASE_URL= - ############ # CORS 配置 ############ ERYAO_CORS__ALLOW_ORIGINS=["http://localhost", "http://localhost:3000"] + +############ +# Test相关 +############ +ERYAO_TEST__PHONE=8613812345678 +ERYAO_TEST__PASSWORD=Test@123456 diff --git a/.gitignore b/.gitignore index 315154d..56dd8ca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,71 +1,198 @@ -# ============================================ -# Environment & Secrets -# ============================================ -.env -.env.local -.env.*.local -!.env.example - -# ============================================ -# Python -# ============================================ +# Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] *$py.class + +# C extensions *.so + +# Distribution / packaging .Python build/ +develop-eggs/ dist/ +downloads/ +eggs/ +.eggs/ +/lib/ +/lib64/ +!apps/lib/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ *.egg-info/ +.installed.cfg *.egg MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ .tox/ .nox/ .coverage .coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ .pytest_cache/ -htmlcov/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# Pipfile.lock + +# UV +# uv.lock + +# poetry +# poetry.lock +# poetry.toml + +# pdm +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# pixi.lock +.pixi + +# PEP 582 +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments .env .envrc .venv +env/ venv/ ENV/ env.bak/ venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy .mypy_cache/ .dmypy.json dmypy.json -.pyre/ -.pytype/ -*.log -db.sqlite3 -# ============================================ -# Flutter -# ============================================ -/bin/cache/ -/bin/internal/ -/dev/benchmarks/ -/dev/bots/ -/dev/docs/ -/dev/integration_tests/**/xcuserdata -/dev/integration_tests/**/Pods -/packages/flutter/coverage/ -version -analysis_benchmark.json -.packages.generated +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml + +# Flutter/Dart/Pub related **/doc/api/ .dart_tool/ -.flutter-plugins -.flutter-plugins-dependencies +.dart_tool/flutter_build/ **/generated_plugin_registrant.dart .packages .pub-preload-cache/ .pub/ build/ flutter_*.png +linked_*.ds +unlinked.ds +unlinked_spec.ds -# Android +# IDE +.idea/ + +# Android related **/android/**/gradle-wrapper.jar .gradle/ **/android/captures/ @@ -75,8 +202,9 @@ flutter_*.png **/android/**/GeneratedPluginRegistrant.java **/android/key.properties *.jks +**/android/**/*.iml -# iOS/XCode +# iOS/XCode related **/ios/**/*.mode1v3 **/ios/**/*.mode2v3 **/ios/**/*.moved-aside @@ -102,8 +230,12 @@ flutter_*.png **/ios/Flutter/app.flx **/ios/Flutter/app.zip **/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh **/ios/ServiceDefinitions.json **/ios/Runner/GeneratedPluginRegistrant.* +**/ios/Podfile.lock +**/ios/Runner.xcodeproj/ +**/ios/Runner.xcworkspace/ # macOS **/Flutter/ephemeral/ @@ -112,78 +244,92 @@ flutter_*.png **/macos/Flutter/ephemeral **/xcuserdata/ +# Linux +**/linux/flutter/generated_plugin_registrant.cc +**/linux/flutter/generated_plugin_registrant.h +**/linux/flutter/generated_plugins.cmake + +# Windows +**/windows/flutter/generated_plugin_registrant.cc +**/windows/flutter/generated_plugin_registrant.h +**/windows/flutter/generated_plugins.cmake +**/windows/runner/ + +# Linux +**/linux/flutter/generated_plugin_registrant.cc +**/linux/flutter/generated_plugin_registrant.h +**/linux/flutter/generated_plugins.cmake + # Coverage coverage/ -# ============================================ -# Kotlin / Gradle / Android -# ============================================ +# Symbols +app.*.symbols + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages +!/dev/ci/**/Gemfile.lock + +# Local environment files +infra/local/env/*.env +configs/env/*.env +infra/cloud/volcano/env/*.env +!infra/local/env/*.env.example +!configs/env/*.env.example +!infra/cloud/volcano/env/*.env.example +.env.local +.env.*.local +.env.cloud +.env.*.cloud +deploy/.env.prod + +# Misc *.class -*.log *.lock +*.swp .buildlog/ .history -build/ -app/build/ -login-service/build/ -.gradle/ -.idea/ -!.idea/codeStyles/ -*.iml -out/ -*.apk -*.aab -*.dex +/logs/ +backend/logs/ +backend/data/analytics/ +*.tar.gz +*.tar +# Docker volumes (local data) +docker/supabase/volumes/db/data/ +infra/docker/volumes/db/data/ +infra/docker/supabase/volumes/db/data/ +infra/docker/supabase/volumes/storage/ -# ============================================ -# Node.js -# ============================================ -node_modules/ -npm-debug.log* -yarn-debug.log* -yarn-error.log* -package-lock.json -yarn.lock +# OpenCode local config +# .opencode/ is now tracked - see .opencode/.gitignore for exclusions -# ============================================ -# Java / Spring Boot -# ============================================ -target/ -*.class -*.jar -*.war -*.ear -hs_err_pid* -spring-boot-*.jar +# Agents and skills +.agents/ -# ============================================ -# IDE -# ============================================ -.vscode/ -*.swp -*.swo -*~ +# Local git worktrees +.worktrees/ +worktrees/ + +# Runtime temp files +.tmp/ + +# macOS system files .DS_Store -Thumbs.db -*.sublime-* -.idea/ -*.iml -atlassian-ide-plugin.xml -.project -.classpath -.settings/ +**/.DS_Store -# ============================================ -# Misc -# ============================================ -*.pid -*.seed -*.pid.lock -*.rdb -*.aof -*.pid +# Deploy releases (APK files only, keep manifest.json) +deploy/static/releases/*.apk +deploy/static/releases/*.ipa -# ============================================ -# Local folders -# ============================================ +# Superset +.superset/ + +# Local agents and skills +.agents/ + +# Old legacy code old/ diff --git a/.opencode/commands/doc-update.md b/.opencode/commands/doc-update.md new file mode 100644 index 0000000..79cb251 --- /dev/null +++ b/.opencode/commands/doc-update.md @@ -0,0 +1,77 @@ +--- +description: 审查并更新 docs/protocols,确保与当前代码实现一致 +--- + +你现在要执行一次“协议文档一致性审查与更新”,目标是让 `docs/protocols/` 成为项目协议与数据格式的最新事实来源,并与当前代码实现保持一致。 + +## 执行目标 + +1. 审查 `docs/protocols/` 下所有协议文档是否过期、缺失或与实现不一致。 +2. 在发现差异时,优先更新协议文档(而不是先改代码),明确兼容策略。 +3. 输出结构化审查结果:发现的问题、已更新内容、仍待确认项。 + +## 约束 + +- 审查范围优先限定在:`docs/protocols/**` 与本次协议相关代码(`backend/**`、`apps/**`)。 +- 不做无关重构,不改动与协议无关模块。 +- 禁止“吞错式”描述:若不确定,明确标记为待确认,不要假设正确。 +- 若涉及破坏性变更,必须在文档中写明迁移与回滚策略。 + +## 步骤 + +1. **建立协议清单** + - 列出 `docs/protocols/` 中所有文档。 + - 为每份文档提取:涉及的接口、事件、字段、枚举、状态码、错误结构。 + +2. **建立实现映射** + - 在 `backend/**`、`apps/**` 中定位对应实现与调用点。 + - 对每个协议项建立“文档 -> 实现”映射(文件路径 + 关键符号/接口名)。 + +3. **逐项比对并分级** + - 比对以下维度: + - 请求/响应结构与字段可选性 + - 字段命名、类型、默认值、约束 + - 错误码/错误体格式 + - 版本号、兼容说明、废弃说明 + - 给每个差异打级别: + - CRITICAL:会导致客户端/服务端不兼容 + - HIGH:行为偏差明显,容易引发线上错误 + - MEDIUM:文档缺失或描述不完整 + - LOW:措辞、示例、格式问题 + +4. **更新文档(优先)** + - 先更新 `docs/protocols/**`,使其反映当前真实实现。 + - 每处更新都要补充“兼容策略”: + - `backward-compatible`(向后兼容)或 + - `requires-migration`(需要迁移) + - 如为 `requires-migration`,补充迁移步骤与回滚注意事项。 + +5. **一致性复核** + - 复查所有变更是否与实现一致。 + - 如仓库有协议相关测试/校验脚本,执行最小必要验证。 + +## 输出格式(必须) + +1. **Protocol Audit Scope** + - 本次审查的文档列表 + - 对应实现文件映射 + +2. **Findings** + - 按 CRITICAL/HIGH/MEDIUM/LOW 分组列出差异 + - 每条包含:文档位置、实现位置、差异说明、影响范围 + +3. **Doc Updates Applied** + - 列出已更新的文档与关键修改点 + - 标注每项兼容策略(`backward-compatible` / `requires-migration`) + +4. **Open Questions** + - 仍需产品/后端/前端确认的点 + +5. **Verification** + - 列出执行的验证命令与结果(通过/失败) + +## 完成标准 + +- `docs/protocols/` 覆盖当前实现的真实协议。 +- 所有发现的关键差异(CRITICAL/HIGH)要么已修正文档,要么被明确记录为阻塞项。 +- 输出报告完整,后续协作者可据此继续推进。 diff --git a/.opencode/opencode.json.old b/.opencode/opencode.json.old new file mode 100644 index 0000000..8cd9d3c --- /dev/null +++ b/.opencode/opencode.json.old @@ -0,0 +1,20 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "supabase": { + "type": "local", + "enabled": true, + "command": [ + "npx", + "-y", + "@aliyun-rds/supabase-mcp-server", + "--supabase-url", + "http://47.112.66.83", + "--supabase-anon-key", + "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJvbGUiOiJhbm9uIiwiaWF0IjoxNzczMDI3NDE5LCJleHAiOjEzMjgzNjY3NDE5fQ.NVXDla5_nYPdcJk_81fc3k1UrnNTrNne_trMqt6Hg4g", + "--supabase-service-role-key", + "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJvbGUiOiJzZXJ2aWNlX3JvbGUiLCJpYXQiOjE3NzMwMjc0MTksImV4cCI6MTMyODM2Njc0MTl9.RzQBia-3QcjupsHnqaxgDWB7wnY9R7Ms9R8pMokyvLY" + ] + } + } +} diff --git a/AGENTS.md b/AGENTS.md index 65e86d2..2b294e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,3 +37,7 @@ Do not place backend/frontend implementation details here. - Update protocol docs before changing data/API/UI contracts. - Document compatibility strategy (backward-compatible vs migration). - Keep frontend/backend implementations aligned with documented protocol. + +## Database Access + +When viewing data in the database, use `supabase mcp` tools (`supabase_execute_sql`, `supabase_list_tables`, etc.) instead of direct queries or other methods. diff --git a/apps/.gitignore b/apps/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/apps/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/apps/.metadata b/apps/.metadata new file mode 100644 index 0000000..e8cf1e4 --- /dev/null +++ b/apps/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "3b62efc2a3da49882f43c372e0bc53daef7295a6" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + - platform: android + create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + - platform: ios + create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/apps/AGENTS.md b/apps/AGENTS.md new file mode 100644 index 0000000..f0f65c4 --- /dev/null +++ b/apps/AGENTS.md @@ -0,0 +1,201 @@ +# Apps Domain Rules + +This file governs `apps/**` (Flutter). Keep rules strict, short, and reusable. + +## Scope & Precedence + +- Inherits root `AGENTS.md` and workspace runtime rules. +- If rules conflict, apply the stricter one. +- Visual language source of truth: `apps/rules/visual_design_language.md`. + +## Flutter Directory Contract (Must) + +- `apps/lib` only allows these second-level directories: `app/`, `core/`, `data/`, `features/`, `shared/`, `l10n/`. +- `apps/lib/main.dart` is the only allowed root entry file. +- Do not add new second-level directories under `apps/lib` without explicit approval. + +## Module Responsibilities (Must) + +- `app/`: app bootstrap, DI wiring, global lifecycle orchestration, router composition. +- `core/`: cross-feature business primitives/protocols/orchestrators (no feature-specific page logic). +- `data/`: shared infrastructure only (cache/network/storage/adapters), not feature business repositories/models. +- `features/`: user-facing bounded feature modules with clear product ownership. +- `shared/`: reusable UI widgets and presentation helpers without feature business orchestration. +- Cross-cutting capabilities (e.g. notification orchestration, UI schema protocol) must live in `core/` + `shared/`, not under `features/`. + +## Placement Rules (Must) + +- Put code in `features/` only when it belongs to one bounded product capability/screen flow. +- Put code in `core/` when it is cross-feature protocol, policy, or orchestration that does not belong to one feature. +- Put reusable UI renderers in `shared/widgets/`; they must not contain feature-only business orchestration. +- In feature data layers, use semantic subfolders: `data/apis/`, `data/repositories/`, `data/services/`, `data/models/`. +- Avoid deep redundant nesting like `models//...`; prefer flat by concern. + +## Shared Data Layer Boundary (Must) + +- Do not place feature business repositories/models under `apps/lib/data/`. +- Feature business repositories/models must live under each feature's `data/` tree. +- `apps/lib/data/` is only for infrastructure abstractions and implementations (cache/network/storage), reusable by features. + +## UI Design System (Must) + +- **Semantic colors**: always use `Theme.of(context).colorScheme.*` (primary, surface, error, etc.). Never hardcode hex or `Colors.*`. +- **Brand palette colors** (event presets, avatar colors, Eisenhower matrix quadrants): use `Theme.of(context).extension()!.*`. +- **Spacing / Radius**: use `AppSpacing` / `AppRadius` from `design_tokens.dart`. No hardcoded values. +- `AppTheme.light` / `AppTheme.dark` provide complete `ColorScheme` (light + dark). `MaterialApp` wires them via `theme:` / `darkTheme:`. +- If a semantic slot is missing from `ColorScheme`, add it to `AppTheme` — do not bypass `colorScheme` with hardcoded values. + +## Reuse & Composition (Must) + +- Prefer `apps/lib/shared/widgets/` before adding new components. +- Extract repeated page structures/components; do not duplicate sibling-page scaffolds. +- Detail page top-right actions must use shared action-menu components. +- Destructive confirmations must use project-consistent shared surfaces. + +## Interaction & Feedback (Must) + +- User feedback: `Toast` / `AppBanner` only. +- Loading indicators: `AppLoadingIndicator` only. +- Form pages should default to keyboard-overlay behavior to avoid full-page layout jumps. + +## Interaction & Feedback (Must) + +## Agent Chat Protocol (Must) + +- Agent chat must follow AG-UI over SSE. +- Lifecycle events are mandatory: `RUN_STARTED` and exactly one of `RUN_FINISHED` or `RUN_ERROR`. +- Current default text delivery is finalized `TEXT_MESSAGE_END` payloads; do not require token-level `TEXT_MESSAGE_CONTENT` unless backend protocol explicitly enables it. + +## HTTP Error Parse Contract (Must) + +- Frontend must parse backend errors as RFC7807: `type/title/status/detail/instance` + extension `code/params`. +- Error code registry single source of truth: `docs/protocols/common/http-error-codes.md`. +- Frontend mapping must be based on documented `code` only (`code -> l10n key`), not inferred from `detail` text. +- Any new/changed code requires protocol doc update first, then frontend mapping update. +- Unknown code fallback order: status-generic localized message -> safe generic localized message. + +## High-Risk Modules (Must) + +### Auth + +- `AuthBloc` is the single source of truth. +- 401 invalidation must go through global callback chain; no feature-level token clearing or direct login navigation. + +### Home Message Viewport + +- Home message auto-scroll/anchor restore must be event-driven. +- Preserve viewport during history prepend and when user is reading above bottom. + +### Cache / Repository + +- Reads/writes that affect consistency must go through repository layer. +- Cache keys and invalidation policy belong to repository, not UI/Bloc. +- Shared cache infrastructure must live under `apps/lib/data/cache/`; feature modules must not duplicate low-level cache store logic. +- Shared cache infrastructure (`apps/lib/data/cache/`) must remain domain-agnostic: do not import `features/**` or business model DTOs there. +- Domain object serialization/deserialization belongs to repository/feature layer via local mappers/codecs; do not centralize feature-specific codecs in shared cache layer. +- Shared cache layer may only encode/decode primitives, collections, and cache metadata wrappers. +- Cache strategy default is `SWR + TTL + invalidation/reload`. +- Local partial cache patching is allowed only for simple single-entity updates with clear rollback paths; complex cross-list/cross-feature states must invalidate and refetch. +- Feature TTL policy must be defined in each feature repository; do not add centralized feature TTL registries in shared cache infra. +- Runtime cache is hybrid (`memory + local persistent`) managed by DI singletons; do not create per-screen/per-widget cache store instances. +- Cross-feature data access must go through app-level facade/usecase boundaries; do not import another feature's data implementation directly from UI/Bloc. +- Repository instances should be resolved from DI singletons to reuse cache and avoid per-feature re-creation. + +### Reminder / Notification Rewrite Boundary + +- Reminder/notification data-interaction logic is under rewrite. Do not reintroduce local-notification scheduling/callback execution paths in `apps/lib/data/services/`. +- During rewrite, keep protocol/orchestration in `core/notification/**` and reusable rendering in `shared/widgets/notification/**`. + +## Testing Policy + +- Prioritize tests for model parsing, service logic, and high-regression interaction flows. +- Simple static UI changes may skip tests. +- Auth/Home/Cache changes must include targeted regression tests. + +## Logging Conventions (Must) + +### Logger Setup + +```dart +import 'core/logging/logger.dart'; + +class SomeBloc extends Cubit { + final Logger _logger = getLogger('features..'); +} +``` + +### Log Level Policy + +| Level | When to Use | Noise Level | +|-------|-------------|-------------| +| **error** | All exceptions and failures - MUST log every error site | Required, never skip | +| **warning** | Degraded behavior, retry, fallback, malformed data | Minimal, only when action taken | +| **info** | Key business events (login, logout, send message) | Minimal, only milestone events | +| **debug** | Detailed flow tracing (only in debug builds) | High, avoid in release | + +### Error Logging Requirements + +**Every try-catch that handles an exception MUST log it:** +```dart +try { + await _repository.someOperation(); +} catch (e, stackTrace) { + _logger.error( + message: 'Operation failed: $operationName', + error: e, + stackTrace: stackTrace, + extra: {'context': 'relevant_data'}, + ); + // handle error +} +``` + +### Info Logging Requirements + +**Only log these milestone events:** +- User login/logout +- Message sent/received +- Data sync completed +- Important state transitions + +```dart +_logger.info( + message: 'User logged in', + extra: {'user_id': user.id}, +); +``` + +### Warning Logging Requirements + +**Only log when taking corrective action:** +- Retrying after failure +- Using fallback data +- Skipping malformed data +- Deprecation warnings + +```dart +_logger.warning( + message: 'Cache miss, loading from remote', + extra: {'key': cacheKey}, +); +``` + +### Module Naming Convention + +| Feature | Module Path | +|---------|------------| +| auth | `features.auth` | +| calendar | `features.calendar` | +| chat | `features.chat` | +| contacts | `features.contacts` | +| home | `features.home` | +| messages | `features.messages` | +| settings | `features.settings` | +| todo | `features.todo` | + +### Prohibited Practices + +- **Never** log sensitive data: passwords, tokens, PII, message content +- **Never** log at debug level in production (release mode) +- **Never** skip error logging even if you "handle" the error +- **Never** log for every iteration in loops - only on failures diff --git a/apps/README.md b/apps/README.md new file mode 100644 index 0000000..a5412d8 --- /dev/null +++ b/apps/README.md @@ -0,0 +1,16 @@ +# meeyao_qianwen + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/apps/analysis_options.yaml b/apps/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/apps/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/apps/android/.gitignore b/apps/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/apps/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts new file mode 100644 index 0000000..0457f73 --- /dev/null +++ b/apps/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.meeyao.meeyao_qianwen" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.meeyao.meeyao_qianwen" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/apps/android/app/src/debug/AndroidManifest.xml b/apps/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/apps/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8683223 --- /dev/null +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/android/app/src/main/kotlin/com/meeyao/meeyao_qianwen/MainActivity.kt b/apps/android/app/src/main/kotlin/com/meeyao/meeyao_qianwen/MainActivity.kt new file mode 100644 index 0000000..48bf774 --- /dev/null +++ b/apps/android/app/src/main/kotlin/com/meeyao/meeyao_qianwen/MainActivity.kt @@ -0,0 +1,5 @@ +package com.meeyao.meeyao_qianwen + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/apps/android/app/src/main/res/drawable-v21/launch_background.xml b/apps/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/apps/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/apps/android/app/src/main/res/drawable/launch_background.xml b/apps/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/apps/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/apps/android/app/src/main/res/values-night/styles.xml b/apps/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/apps/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/android/app/src/main/res/values/styles.xml b/apps/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/apps/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/android/app/src/profile/AndroidManifest.xml b/apps/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/apps/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/apps/android/build.gradle.kts b/apps/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/apps/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/apps/android/gradle.properties b/apps/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/apps/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/apps/android/gradle/wrapper/gradle-wrapper.properties b/apps/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/apps/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/apps/android/settings.gradle.kts b/apps/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/apps/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/apps/assets/images/logo.png b/apps/assets/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..355b95db43d4e45899e747dbe8432cb89bb24905 GIT binary patch literal 144971 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� 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 literal 0 HcmV?d00001 diff --git a/apps/ios/.gitignore b/apps/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/apps/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/apps/ios/Flutter/AppFrameworkInfo.plist b/apps/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..1dc6cf7 --- /dev/null +++ b/apps/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/apps/ios/Flutter/Debug.xcconfig b/apps/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/apps/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/apps/ios/Flutter/Release.xcconfig b/apps/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/apps/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/apps/ios/Runner/AppDelegate.swift b/apps/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/apps/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..dc9ada4725e9b0ddb1deab583e5b5102493aa332 GIT binary patch literal 10932 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 literal 0 HcmV?d00001 diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..797d452e458972bab9d994556c8305db4c827017 GIT binary patch literal 406 zcmV;H0crk;P))>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..6ed2d933e1120817fe9182483a228007b18ab6ae GIT binary patch literal 450 zcmV;z0X_bSP)iGWQ_5NJQ_~rNh*z)}eT%KUb z`7gNk0#AwF^#0T0?hIa^`~Ck;!}#m+_uT050aTR(J!bU#|IzRL%^UsMS#KsYnTF*!YeDOytlP4VhV?b} z%rz_<=#CPc)tU1MZTq~*2=8~iZ!lSa<{9b@2Jl;?IEV8)=fG217*|@)CCYgFze-x? zIFODUIA>nWKpE+bn~n7;-89sa>#DR>TSlqWk*!2hSN6D~Qb#VqbP~4Fk&m`@1$JGr zXPIdeRE&b2Thd#{MtDK$px*d3-Wx``>!oimf%|A-&-q*6KAH)e$3|6JV%HX{Hig)k suLT-RhftRq8b9;(V=235Wa|I=027H2wCDra;{X5v07*qoM6N<$f;9x^2LJ#7 literal 0 HcmV?d00001 diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..4cd7b0099ca80c806f8fe495613e8d6c69460d76 GIT binary patch literal 282 zcmV+#0p(^bcu7P-R4C8Q z&e;xxFbF_Vrezo%_kH*OKhshZ6BFpG-Y1e10`QXJKbND7AMQ&cMj60B5TNObaZxYybcN07*qoM6N<$g3m;S%K!iX literal 0 HcmV?d00001 diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..fe730945a01f64a61e2235dbe3f45b08f7729182 GIT binary patch literal 462 zcmV;<0WtoGP)-}iV`2<;=$?g5M=KQbZ{F&YRNy7Nn@%_*5{gvDM0aKI4?ESmw z{NnZg)A0R`+4?NF_RZexyVB&^^ZvN!{I28tr{Vje;QNTz`dG&Jz0~Ek&f2;*Z7>B|cg}xYpxEFY+0YrKLF;^Q+-HreN0P{&i zK~zY`?b7ECf-n?@;d<&orQ*Q7KoR%4|C>{W^h6@&01>0SKS`dn{Q}GT%Qj_{PLZ_& zs`MFI#j-(>?bvdZ!8^xTwlY{qA)T4QLbY@j(!YJ7aXJervHy6HaG_2SB`6CC{He}f zHVw(fJWApwPq!6VY7r1w-Fs)@ox~N+q|w~e;JI~C4Vf^@d>Wvj=fl`^u9x9wd9 zR%3*Q+)t%S!MU_`id^@&Y{y7-r98lZX0?YrHlfmwb?#}^1b{8g&KzmkE(L>Z&)179 zp<)v6Y}pRl100G2FL_t(o!|l{-Q-VMg#&MKg7c{O0 z2wJImOS3Gy*Z2Qifdv~JYOp;v+U)a|nLoc7hNH;I$;lzDt$}rkaFw1mYK5_0Q(Sut zvbEloxON7$+HSOgC9Z8ltuC&0OSF!-mXv5caV>#bc3@hBPX@I$58-z}(ZZE!t-aOG zpjNkbau@>yEzH(5Yj4kZiMH32XI!4~gVXNnjAvRx;Sdg^`>2DpUEwoMhTs_st8pKG z(%SHyHdU&v%f36~uERh!bd`!T2dw;z6PrOTQ7Vt*#9F2uHlUVnb#ev_o^fh}Dzmq} zWtlk35}k=?xj28uO|5>>$yXadTUE@@IPpgH`gJ~Ro4>jd1IF|(+IX>8M4Ps{PNvmI zNj4D+XgN83gPt_Gm}`Ybv{;+&yu-C(Grdiahmo~BjG-l&mWM+{e5M1sm&=xduwgM9 z`8OEh`=F3r`^E{n_;%9weN{cf2%7=VzC@cYj+lg>+3|D|_1C@{hcU(DyQG_BvBWe? zvTv``=%b1zrol#=R`JB)>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..502f463a9bc882b461c96aadf492d1729e49e725 GIT binary patch literal 586 zcmV-Q0=4~#P)+}#`wDE{8-2Mebf5<{{PqV{TgVcv*r8?UZ3{-|G?_}T*&y;@cqf{ z{Q*~+qr%%p!1pS*_Uicl#q9lc(D`!D`LN62sNwq{oYw(Wmhk)k<@f$!$@ng~_5)Ru z0Z)trIA5^j{DIW^c+vT2%lW+2<(RtE2wR;4O@)Tm`Xr*?A(qYoM}7i5Yxw>D(&6ou zxz!_Xr~yNF+waPe00049Nkl*;a!v6h%{rlvIH#gW3s8p;bFr=l}mRqpW2h zw=OA%hdyL~z+UHOzl0eKhEr$YYOL-c-%Y<)=j?(bzDweB7{b+%_ypvm_cG{SvM=DK zhv{K@m>#Bw>2W$eUI#iU)Wdgs8Y3U+A$Gd&{+j)d)BmGKx+43U_!tik_YlN)>$7G! zhkE!s;%oku3;IwG3U^2kw?z+HM)jB{@zFhK8P#KMSytSthr+4!c(5c%+^UBn`0X*2 zy3(k600_CSZj?O$Qu%&$;|TGUJrptR(HzyIx>5E(2r{eA(<6t3e3I0B)7d6s7?Z5J zZ!rtKvA{MiEBm&KFtoifx>5P^Z=vl)95XJn()aS5%ad(s?4-=Tkis9IGu{`Fy8r+H07*qoM6N<$f20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0ec303439225b78712f49115768196d8d76f6790 GIT binary patch literal 862 zcmV-k1EKthP)20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..e9f5fea27c705180eb716271f41b582e76dcbd90 GIT binary patch literal 1674 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 literal 0 HcmV?d00001 diff --git a/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/apps/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0467bf12aa4d28f374bb26596605a46dcbb3e7c8 GIT binary patch literal 1418 zcmV;51$Fv~P)q zKfU)WzW*n(@|xWGCA9ScMt*e9`2kdxPQ&&>|-UCa7_51w+ zLUsW@ZzZSW0y$)Hp~e9%PvP|a03ks1`~K?q{u;6NC8*{AOqIUq{CL&;p56Lf$oQGq z^={4hPQv)y=I|4n+?>7Fim=dxt1 z2H+Dm+1+fh+IF>G0SjJMkQQre1x4|G*Z==(Ot&kCnUrL4I(rf(ucITwmuHf^hXiJT zkdTm&kdTm&kdTm&kdP`esgWG0BcWCVkVZ&2dUwN`cgM8QJb`Z7Z~e<&Yj2(}>Tmf` zm1{eLgw!b{bXkjWbF%dTkTZEJWyWOb##Lfw4EK2}<0d6%>AGS{po>WCOy&f$Tay_> z?NBlkpo@s-O;0V%Y_Xa-G#_O08q5LR*~F%&)}{}r&L%Sbs8AS4t7Y0NEx*{soY=0MZExqA5XHQkqi#4gW3 zqODM^iyZl;dvf)-bOXtOru(s)Uc7~BFx{w-FK;2{`VA?(g&@3z&bfLFyctOH!cVsF z7IL=fo-qBndRUm;kAdXR4e6>k-z|21AaN%ubeVrHl*<|s&Ax@W-t?LR(P-24A5=>a z*R9#QvjzF8n%@1Nw@?CG@6(%>+-0ASK~jEmCV|&a*7-GKT72W<(TbSjf)&Eme6nGE z>Gkj4Sq&2e+-G%|+NM8OOm5zVl9{Z8Dd8A5z3y8mZ=4Bv4%>as_{9cN#bm~;h>62( zdqY93Zy}v&c4n($Vv!UybR8ocs7#zbfX1IY-*w~)p}XyZ-SFC~4w>BvMVr`dFbelV{lLL0bx7@*ZZdebr3`sP;? zVImji)kG)(6Juv0lz@q`F!k1FE;CQ(D0iG$wchPbKZQELlsZ#~rt8#90Y_Xh&3U-< z{s<&cCV_1`^TD^ia9!*mQDq& zn2{r`j};V|uV%_wsP!zB?m%;FeaRe+X47K0e+KE!8C{gAWF8)lCd1u1%~|M!XNRvw zvtqy3iz0WSpWdhn6$hP8PaRBmp)q`#PCA`Vd#Tc$@f1tAcM>f_I@bC)hkI9|o(Iqv zo}Piadq!j76}004RBio<`)70k^`K1NK)q>w?p^C6J2ZC!+UppiK6&y3Kmbv&O!oYF z34$0Z;QO!JOY#!`qyGH<3Pd}Pt@q*A0V=3SVtWKRR8d8Z&@)3qLPA19LPA19LPEUC YUoZo%k(ykuW&i*H07*qoM6N<$f+CH{y8r+H literal 0 HcmV?d00001 diff --git a/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/apps/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/apps/ios/Runner/Base.lproj/LaunchScreen.storyboard b/apps/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/apps/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/ios/Runner/Base.lproj/Main.storyboard b/apps/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/apps/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/ios/Runner/Info.plist b/apps/ios/Runner/Info.plist new file mode 100644 index 0000000..defc42d --- /dev/null +++ b/apps/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Meeyao Qianwen + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + meeyao_qianwen + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/apps/ios/Runner/Runner-Bridging-Header.h b/apps/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/apps/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/apps/ios/RunnerTests/RunnerTests.swift b/apps/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/apps/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/apps/l10n.yaml b/apps/l10n.yaml new file mode 100644 index 0000000..9fcf641 --- /dev/null +++ b/apps/l10n.yaml @@ -0,0 +1,4 @@ +arb-dir: lib/l10n +template-arb-file: app_zh.arb +output-localization-file: app_localizations.dart +output-class: AppLocalizations diff --git a/apps/lib/l10n/app_zh.arb b/apps/lib/l10n/app_zh.arb new file mode 100644 index 0000000..96c94f0 --- /dev/null +++ b/apps/lib/l10n/app_zh.arb @@ -0,0 +1,69 @@ +{ + "@@locale": "zh", + "appTitle": "觅爻签问", + "welcomeLogin": "欢迎登录", + "loginSubtitle": "请使用手机号登录", + "phoneHint": "请输入手机号码", + "codeHint": "请输入验证码", + "sendCode": "获取验证码", + "sending": "发送中...", + "retryAfter": "{seconds}秒后重试", + "@retryAfter": { + "placeholders": { + "seconds": { + "type": "int" + } + } + }, + "login": "登录", + "agreementPrefix": "我已阅读并同意", + "privacyPolicy": "隐私政策", + "termsOfService": "服务条款", + "disclaimer": "免责声明", + "icp": "粤ICP备2025428416号-1A", + "invalidPhone": "请输入正确的手机号码", + "invalidCode": "请输入6位验证码", + "agreementRequired": "请先勾选协议", + "codeSent": "验证码已发送,请注意查收", + "mockLoginSuccess": "模拟登录成功", + "helloUser": "您好,{name}", + "@helloUser": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "startJourney": "开始您的卦象之旅", + "journeySubtitle": "借助AI智能,探索未来的可能", + "startNow": "立即起卦", + "historyTitle": "历史解卦", + "more": "更多", + "noRecords": "暂无记录", + "noRecordsSubtitle": "您并没有保存任何卦象", + "homeTab": "首页", + "profileTab": "我的", + "notify": "消息通知", + "featurePending": "该功能暂未接入数据", + "welcomeDialogTitle": "欢迎使用觅爻签问", + "welcomeParagraph1": "你好,欢迎来到觅爻签问,这是一个借助于AI解读传统六爻卦象的平台,为用户了解中国传统易学文化提供一个窗口。", + "welcomeParagraph2": "六爻卦象源于《周易》深邃的哲学体系,是古人探索世界运行规律的一种独特方法。古人认为宇宙万物相互关联,在你起卦时,你的心念与时空信息会凝结成卦象的方式呈现出来。", + "welcomeParagraph3": "觅爻签问基于这样的思路,帮助你跳出局限思维,从全局和演变趋势看清矛盾、机会与风险,为判断和行动提供参考。", + "warningTitle": "特别提醒", + "warningBody": "卦象解读结果均由AI生成,仅供娱乐参考,切不可作为商业、医疗等专业领域的决策依据。理性看待卦象,自由掌握人生。", + "scrollHint": "请向下滚动阅读全部内容", + "understood": "我已了解", + "readAllFirst": "请先阅读完整内容", + "categoryCareer": "事业学业", + "categoryLove": "情感婚姻", + "categoryMoney": "财富投资", + "signBest": "上上签", + "signGood": "中上签", + "signNormal": "中下签", + "language": "语言", + "english": "英文", + "chinese": "中文", + "privacyContent": "隐私政策内容展示占位。", + "termsContent": "服务条款内容展示占位。", + "disclaimerContent": "免责声明内容展示占位。" +} diff --git a/apps/lib/main.dart b/apps/lib/main.dart new file mode 100644 index 0000000..22d17cc --- /dev/null +++ b/apps/lib/main.dart @@ -0,0 +1,8 @@ +import 'package:flutter/widgets.dart'; + +import 'app/app.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + runApp(const EryaoApp()); +} diff --git a/apps/pubspec.yaml b/apps/pubspec.yaml new file mode 100644 index 0000000..45d4cfb --- /dev/null +++ b/apps/pubspec.yaml @@ -0,0 +1,97 @@ +name: meeyao_qianwen +description: "觅爻签问 Flutter app" +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.10.7 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + intl: ^0.20.2 + shared_preferences: ^2.5.3 + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + generate: true + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + assets: + - assets/images/logo.png + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/apps/test/widget_test.dart b/apps/test/widget_test.dart new file mode 100644 index 0000000..e412171 --- /dev/null +++ b/apps/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:meeyao_qianwen/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/backend/AGENTS.md b/backend/AGENTS.md index dfe90ca..eb0b94d 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -49,6 +49,32 @@ This file governs `backend/**` only. Keep it minimal, enforceable, and non-dupli - Strong typing required at boundaries (Pydantic/dataclass); avoid weak untyped payload contracts. - Protocol/data contract changes must stay aligned with `docs/protocols/`. +## Database Rules + +- Supabase Auth is identity source; backend enforces business authorization. +- Use service-role DB access only in backend. +- Soft delete uses `deleted_at`; reads must exclude deleted records by default. +- Alembic is the only schema migration source of truth. +- Database migrations use `./infra/scripts/dev-migrate.sh`: + - `migrate` - run migrations only + - `init-data` - seed data only + - `bootstrap` - migrate + init-data + +## Agent Runtime & Tools + +- AG-UI protocol is mandatory for agent loop behavior. +- `ToolAgentOutput.result` is the canonical tool result field. +- Tool results must be machine-oriented and include IDs/outcomes needed for chaining. + +## Tool Schema Rules for Small Models (e.g., qwen3.5-flash) + +- Prefer `operations: list[OperationModel]` over parallel arrays. +- Validate tool args with strict Pydantic models (`extra="forbid"`). +- Keep payloads JSON-native (objects/lists), shallow, and deterministic. +- Make action-specific required fields explicit and fail with structured errors. +- Return per-item outcomes (`success/failed`, identifiers, partial status) for self-correction. +- Avoid broad entry-point coercion fallbacks; fix schema/prompt alignment first. +- Do not pass provider request fields with `None` values (avoid upstream 400 blocking tool calls). ## Testing diff --git a/backend/src/__init__.py b/backend/src/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/app.py b/backend/src/app.py deleted file mode 100644 index 38b8d66..0000000 --- a/backend/src/app.py +++ /dev/null @@ -1,14 +0,0 @@ -from __future__ import annotations - -from fastapi import FastAPI - -app = FastAPI( - title="Eryao API", - description="觅爻签问后端服务", - version="0.1.0", -) - - -@app.get("/health") -async def health_check() -> dict[str, str]: - return {"status": "ok"} diff --git a/backend/src/core/config/__init__.py b/backend/src/core/config/__init__.py deleted file mode 100644 index b1019f5..0000000 --- a/backend/src/core/config/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .settings import Settings, config - -__all__ = ["Settings", "config"] diff --git a/backend/src/core/config/settings.py b/backend/src/core/config/settings.py index 3ca440f..edad279 100644 --- a/backend/src/core/config/settings.py +++ b/backend/src/core/config/settings.py @@ -8,6 +8,7 @@ from pydantic import ( AnyHttpUrl, BaseModel, Field, + SecretStr, computed_field, field_validator, model_validator, @@ -118,11 +119,54 @@ class RedisSettings(BaseModel): return f"redis://{self.host}:{self.port}/{self.db}" +class SupabaseSettings(BaseModel): + public_url: AnyHttpUrl + anon_key: str = "CHANGE_ME" + service_role_key: str = "CHANGE_ME" + jwt_secret: SecretStr | None = Field(default=None, exclude=True) + jwt_algorithm: Literal["HS256"] = "HS256" + jwt_issuer: str | None = None + + @model_validator(mode="after") + def compute_defaults(self) -> "SupabaseSettings": + base = str(self.public_url).rstrip("/") + if self.jwt_issuer is None: + self.jwt_issuer = f"{base}/auth/v1" + + return self + + @computed_field + @property + def url(self) -> str: + return str(self.public_url) + + +class StorageSettings(BaseModel): + provider: Literal["supabase"] = "supabase" + signed_url_ttl_seconds: int = Field(default=600, ge=60, le=3600) + retention_days: int = Field(default=30, ge=1, le=3650) + + class AttachmentSettings(BaseModel): + bucket: str = Field(default="eryao-attachments", min_length=3, max_length=63) + max_size_mb: int = Field(default=20, ge=1, le=200) + + class AvatarSettings(BaseModel): + bucket: str = Field(default="avatars", min_length=3, max_length=63) + max_size_mb: int = Field(default=2, ge=1, le=10) + + attachment: AttachmentSettings = Field(default_factory=AttachmentSettings) + avatar: AvatarSettings = Field(default_factory=AvatarSettings) + + +class LlmSettings(BaseModel): + provider_keys: dict[str, str] = Field(default_factory=dict) + + class DatabaseSettings(BaseModel): host: str = "localhost" - port: int = 3306 - name: str = "eryao" - user: str = "root" + port: int = 5432 + name: str = "postgres" + user: str = "postgres" password: str = "CHANGE_ME" @computed_field @@ -130,83 +174,11 @@ class DatabaseSettings(BaseModel): def url(self) -> str: password = quote(self.password, safe="") return ( - f"mysql+aiomysql://{self.user}:{password}" + f"postgresql+asyncpg://{self.user}:{password}" f"@{self.host}:{self.port}/{self.name}" ) -class AppVersionSettings(BaseModel): - manifest_path: str = Field( - default="deploy/static/releases/manifest.json", - description="发布清单文件路径,相对于项目根目录", - ) - release_path_prefix: str = Field( - default="releases", - description="下载 URL 中文件目录前缀", - ) - download_base_url: AnyHttpUrl | None = Field( - default=None, - description="下载链接基础域名,如 https://your-domain.com", - ) - - @field_validator("download_base_url", mode="before") - @classmethod - def empty_download_base_url_to_none(cls, value: object) -> object: - if value == "": - return None - return value - - @field_validator("manifest_path") - @classmethod - def validate_manifest_path(cls, value: str) -> str: - normalized = Path(value) - if normalized.is_absolute() or ".." in normalized.parts: - raise ValueError("manifest_path must be a safe relative path") - return value - - -class AliyunSmsSettings(BaseModel): - access_key_id: str = "CHANGE_ME" - access_key_secret: str = "CHANGE_ME" - sign_name: str = "CHANGE_ME" - template_code: str = "CHANGE_ME" - region_id: str = "cn-hangzhou" - endpoint: str = "dysmsapi.aliyuncs.com" - test_mode: bool = False - - -class AliyunContentSecuritySettings(BaseModel): - access_key_id: str = "CHANGE_ME" - access_key_secret: str = "CHANGE_ME" - endpoint: str = "green-cip.cn-shenzhen.aliyuncs.com" - - -class AlipaySettings(BaseModel): - app_id: str = "CHANGE_ME" - merchant_id: str = "CHANGE_ME" - public_key: str = "CHANGE_ME" - private_key: str = "CHANGE_ME" - sign_type: str = "RSA2" - notify_url: str = "" - timeout_express: str = "30m" - sandbox: bool = False - - -class DeepSeekSettings(BaseModel): - api_key: str = "CHANGE_ME" - - -class AuthSettings(BaseModel): - token_expiration_days: int = 7 - token_refresh_threshold_hours: int = 2 - - -class VerificationSettings(BaseModel): - code_length: int = 6 - expiration_minutes: int = 5 - test_mode: bool = False - - class SensitiveWordSettings(BaseModel): use_aliyun: bool = True fallback_to_local: bool = True @@ -217,6 +189,11 @@ class TestSettings(BaseModel): password: str = "" +class TaskiqSettings(BaseModel): + broker_url: str | None = None + result_backend_url: str | None = None + + def _resolve_env_file() -> str: current = Path(__file__).resolve() for parent in [current, *current.parents]: @@ -233,24 +210,31 @@ class Settings(BaseSettings): runtime: RuntimeSettings = RuntimeSettings() cors: CorsSettings = CorsSettings() redis: RedisSettings = RedisSettings() - database: DatabaseSettings = DatabaseSettings() - app_version: AppVersionSettings = AppVersionSettings() - aliyun_sms: AliyunSmsSettings = AliyunSmsSettings() - aliyun_content_security: AliyunContentSecuritySettings = ( - AliyunContentSecuritySettings() + supabase: SupabaseSettings = Field( + default_factory=lambda: SupabaseSettings(public_url="http://localhost:8001") ) - alipay: AlipaySettings = AlipaySettings() - deepseek: DeepSeekSettings = DeepSeekSettings() - auth: AuthSettings = AuthSettings() - verification: VerificationSettings = VerificationSettings() - sensitive_word: SensitiveWordSettings = SensitiveWordSettings() + storage: StorageSettings = StorageSettings() + llm: LlmSettings = LlmSettings() + database: DatabaseSettings = DatabaseSettings() + sensitive_word: SensitiveWordSettings = Field(default_factory=SensitiveWordSettings) test: TestSettings = Field(default_factory=TestSettings) + taskiq: TaskiqSettings = Field(default_factory=TaskiqSettings) @computed_field @property def database_url(self) -> str: return self.database.url + @computed_field + @property + def taskiq_broker_url(self) -> str: + return self.taskiq.broker_url or self.redis.url + + @computed_field + @property + def taskiq_result_backend_url(self) -> str: + return self.taskiq.result_backend_url or self.redis.url + model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( env_file=_resolve_env_file(), env_prefix="ERYAO_", diff --git a/backend/src/core/config/static/automation/memory_extraction.yaml b/backend/src/core/config/static/automation/memory_extraction.yaml deleted file mode 100644 index c4abc4a..0000000 --- a/backend/src/core/config/static/automation/memory_extraction.yaml +++ /dev/null @@ -1,34 +0,0 @@ -input_template: | - 你正在执行一次"自动化记忆回顾与整理"任务。 - - 任务目标: - 1) 回顾最近两天的聊天与上下文,识别用户长期偏好、习惯和关键事实的变化。 - 2) 对已经失效、被否定或明显过期的信息执行遗忘。 - 3) 对新增且有证据支持的信息执行写入。 - 4) 严禁编造;没有证据就不要写入。 - 5) 只更新最小必要字段,避免过度覆盖。 - - 输出要求: - - 必须使用以下固定格式输出: - <----------【周期任务输出】----------> - 【记忆回顾】<一句人性化总结,说明今天主要发生了什么> - 【新增记忆】<按"X条:要点1;要点2"描述;没有则写"0条"> - 【遗忘记忆】<按"X条:要点1;要点2"描述;没有则写"0条"> - 【未来展望】<基于本次记忆变化,给出1-2条温和、可执行的后续建议;若暂无建议则说明"可继续观察"> - - 表达风格: - - 语言自然、温和、可读,像助理在做每日回顾。 - - 结论先行,避免空话,不要输出与任务无关的闲聊内容。 -enabled_tools: - - memory.write - - memory.forget -context: - source: latest_chat - window_mode: day - window_count: 2 -schedule: - type: daily - run_at: - hour: 8 - minute: 0 - weekdays: null diff --git a/backend/src/core/config/static/route/frontend_routes.yaml b/backend/src/core/config/static/route/frontend_routes.yaml deleted file mode 100644 index 748c7b5..0000000 --- a/backend/src/core/config/static/route/frontend_routes.yaml +++ /dev/null @@ -1,158 +0,0 @@ -version: "1.0" -routes: - - route_id: auth.boot - path: /boot - description: Bootstraps auth session and redirects to login or home. - category: auth - auth_required: false - - route_id: auth.login - path: /login - description: Login entry for unauthenticated users. - category: auth - auth_required: false - - route_id: home.main - path: / - description: Main assistant home screen. - category: home - auth_required: true - - route_id: message.invite_list - path: /messages/invites - description: Lists message invitations. - category: messages - auth_required: true - - route_id: message.invite_detail - path: /messages/invites/{id} - description: Shows details for a single invitation. - category: messages - auth_required: true - path_params: - - id - - route_id: contacts.list - path: /contacts - description: Contact list and quick relationship actions. - category: contacts - auth_required: true - - route_id: contacts.add - path: /contacts/add - description: Create or edit a contact profile. - category: contacts - auth_required: true - - route_id: calendar.dayweek - path: /calendar/dayweek - description: Day and week calendar view. - category: calendar - auth_required: true - query_params: - - date - - from - - route_id: calendar.month - path: /calendar/month - description: Month calendar overview. - category: calendar - auth_required: true - query_params: - - from - - route_id: calendar.event_detail - path: /calendar/events/{id} - description: Detail page for one calendar event. - category: calendar - auth_required: true - path_params: - - id - - route_id: calendar.event_create - path: /calendar/events/new - description: Create page for one calendar event. - category: calendar - auth_required: true - query_params: - - date - - route_id: calendar.event_edit - path: /calendar/events/{id}/edit - description: Edit page for one calendar event. - category: calendar - auth_required: true - path_params: - - id - - route_id: calendar.event_share - path: /calendar/events/{id}/share - description: Share settings page for one calendar event. - category: calendar - auth_required: true - path_params: - - id - - route_id: todo.list - path: /todo - description: Todo quadrants and backlog overview. - category: todo - auth_required: true - - route_id: todo.create - path: /todo/new - description: Create page for one todo item. - category: todo - auth_required: true - - route_id: todo.detail - path: /todo/{id} - description: Detail page for one todo item. - category: todo - auth_required: true - path_params: - - id - - route_id: todo.edit - path: /todo/{id}/edit - description: Dedicated subpage for editing one todo item (not an in-page modal). - category: todo - auth_required: true - path_params: - - id - - route_id: settings.main - path: /settings - description: Settings hub page. - category: settings - auth_required: true - - route_id: settings.features - path: /settings/features - description: Automation job list page. - category: settings - auth_required: true - - route_id: settings.job_new - path: /settings/job/new - description: Create page for one automation job. - category: settings - auth_required: true - - route_id: settings.job_detail - path: /settings/job/{id} - description: Detail page for one automation job. - category: settings - auth_required: true - path_params: - - id - - route_id: settings.memory - path: /settings/memory - description: Memory preferences and controls. - category: settings - auth_required: true - - route_id: settings.memory_user - path: /settings/memory/user - description: User memory summary view. - category: settings - auth_required: true - - route_id: settings.memory_work - path: /settings/memory/work - description: Work memory summary view. - category: settings - auth_required: true - - route_id: settings.memory_user_edit - path: /settings/memory/user/edit - description: Edit user memory details. - category: settings - auth_required: true - - route_id: settings.memory_work_edit - path: /settings/memory/work/edit - description: Edit work memory details. - category: settings - auth_required: true - - route_id: settings.edit_profile - path: /edit-profile - description: Profile editing page. - category: settings - auth_required: true diff --git a/backend/src/core/db/types.py b/backend/src/core/db/types.py index 462f8ff..f2a615d 100644 --- a/backend/src/core/db/types.py +++ b/backend/src/core/db/types.py @@ -1,6 +1,6 @@ from __future__ import annotations from sqlalchemy import JSON -from sqlalchemy.dialects.mysql import JSON as MySQLJSON +from sqlalchemy.dialects.postgresql import JSONB -json_type = JSON().with_variant(MySQLJSON, "mysql") +json_jsonb = JSON().with_variant(JSONB, "postgresql") diff --git a/backend/src/core/runtime/__init__.py b/backend/src/core/runtime/__init__.py index e69de29..4d21ee8 100644 --- a/backend/src/core/runtime/__init__.py +++ b/backend/src/core/runtime/__init__.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +__all__ = [] diff --git a/backend/src/core/runtime/cli.py b/backend/src/core/runtime/cli.py index 0fe7a9a..9b289aa 100644 --- a/backend/src/core/runtime/cli.py +++ b/backend/src/core/runtime/cli.py @@ -5,9 +5,6 @@ import subprocess import sys from pathlib import Path -from apscheduler.schedulers.asyncio import AsyncIOScheduler -from apscheduler.triggers.interval import IntervalTrigger -from core.automation.scheduler import run_automation_scheduler_scan from core.config.initial.init_data import initialize_data from core.config.settings import config from core.logging import get_logger @@ -16,7 +13,6 @@ logger = get_logger("core.runtime.cli") def _resolve_alembic_path() -> Path: - """Resolve alembic.ini path relative to project root.""" project_root = Path(__file__).parents[3] alembic_path = project_root / "alembic" / "alembic.ini" if not alembic_path.exists(): @@ -25,7 +21,6 @@ def _resolve_alembic_path() -> Path: def _redact_sensitive(text: str) -> str: - """Redact sensitive information from log output.""" import re SENSITIVE_KEYS = ("password", "token", "secret", "api_key") @@ -40,7 +35,6 @@ def _redact_sensitive(text: str) -> str: def run_migrations() -> bool: - """Run alembic migrations in a subprocess to avoid event loop conflicts.""" import os logger.info("Running alembic migrations") @@ -75,7 +69,6 @@ def run_migrations() -> bool: async def run_init_data() -> bool: - """Initialize bootstrap data.""" logger.info("Running init-data") try: result = await initialize_data() @@ -90,7 +83,6 @@ async def run_init_data() -> bool: async def bootstrap() -> bool: - """Run migrations followed by init-data.""" logger.info("Starting bootstrap (migrate + init-data)") if not run_migrations(): @@ -105,52 +97,11 @@ async def bootstrap() -> bool: return True -async def run_automation_scheduler_forever() -> None: - if not config.automation_scheduler.enabled: - logger.info("Automation scheduler disabled by config") - return - - interval_seconds = int(config.automation_scheduler.interval_seconds) - batch_limit = int(config.automation_scheduler.batch_limit) - logger.info( - "Starting automation scheduler", - interval_seconds=interval_seconds, - batch_limit=batch_limit, - ) - - async def scan_job() -> None: - try: - await run_automation_scheduler_scan(limit=batch_limit) - except Exception as exc: - logger.exception("Automation scheduler scan failed", error=str(exc)) - - scheduler = AsyncIOScheduler() - scheduler.add_job( - scan_job, - trigger=IntervalTrigger(seconds=interval_seconds), - id="automation_scheduler_scan", - name="Automation scheduler scan", - replace_existing=True, - max_instances=1, - coalesce=True, - ) - scheduler.start() - - stop_event = asyncio.Event() - try: - await stop_event.wait() - finally: - scheduler.shutdown(wait=False) - - def main() -> int: - """CLI entry point.""" if len(sys.argv) < 2: logger.error("No command provided") logger.info("Usage: python -m core.runtime.cli ") - logger.info( - "Available commands: migrate, init-data, bootstrap, automation-scheduler" - ) + logger.info("Available commands: migrate, init-data, bootstrap") return 1 command = sys.argv[1] @@ -161,14 +112,9 @@ def main() -> int: success = asyncio.run(run_init_data()) elif command == "bootstrap": success = asyncio.run(bootstrap()) - elif command == "automation-scheduler": - asyncio.run(run_automation_scheduler_forever()) - return 0 else: logger.error("Unknown command", command=command) - logger.info( - "Available commands: migrate, init-data, bootstrap, automation-scheduler" - ) + logger.info("Available commands: migrate, init-data, bootstrap") return 1 return 0 if success else 1 diff --git a/backend/src/core/runtime/tasks.py b/backend/src/core/runtime/tasks.py new file mode 100644 index 0000000..4d21ee8 --- /dev/null +++ b/backend/src/core/runtime/tasks.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +__all__ = [] diff --git a/backend/src/core/taskiq/__init__.py b/backend/src/core/taskiq/__init__.py new file mode 100644 index 0000000..f767be6 --- /dev/null +++ b/backend/src/core/taskiq/__init__.py @@ -0,0 +1,3 @@ +from core.taskiq.app import broker, worker_agent_broker, worker_general_broker + +__all__ = ["broker", "worker_agent_broker", "worker_general_broker"] diff --git a/backend/src/core/taskiq/app.py b/backend/src/core/taskiq/app.py new file mode 100644 index 0000000..22ab470 --- /dev/null +++ b/backend/src/core/taskiq/app.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from taskiq_redis import ListQueueBroker, RedisAsyncResultBackend + +from core.config.settings import config +from core.logging import configure_logging, log_service_banner + + +configure_logging(config) +log_service_banner( + service_name=config.runtime.service_name, + environment=config.runtime.environment, +) + + +def _build_broker(queue_name: str) -> ListQueueBroker: + return ListQueueBroker( + url=config.taskiq_broker_url, + queue_name=queue_name, + ).with_result_backend( + RedisAsyncResultBackend(redis_url=config.taskiq_result_backend_url) + ) + + +worker_agent_broker = _build_broker("agent") +worker_general_broker = _build_broker("general") + +broker = worker_agent_broker + +__all__ = ["broker", "worker_agent_broker", "worker_general_broker"] diff --git a/backend/src/models/__init__.py b/backend/src/models/__init__.py index d862204..5296c3d 100644 --- a/backend/src/models/__init__.py +++ b/backend/src/models/__init__.py @@ -1,12 +1,11 @@ -from . import user, divination, payment, notification, feedback, version, log, violation +from __future__ import annotations + +from models.llm import Llm +from models.llm_factory import LlmFactory +from models.system_agents import SystemAgents __all__ = [ - "user", - "divination", - "payment", - "notification", - "feedback", - "version", - "log", - "violation", + "Llm", + "LlmFactory", + "SystemAgents", ] diff --git a/backend/src/models/divination.py b/backend/src/models/divination.py deleted file mode 100644 index 45af944..0000000 --- a/backend/src/models/divination.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -from core.db.base import Base, TimestampMixin -from sqlalchemy import BigInteger, DateTime, Integer, String, Text, func -from sqlalchemy.orm import Mapped, mapped_column - - -class DivinationRecord(TimestampMixin, Base): - __tablename__ = "user_divination_records" - - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) - trace_id: Mapped[str] = mapped_column(String(64), nullable=False) - question: Mapped[str] = mapped_column(Text, nullable=False) - question_type: Mapped[str] = mapped_column(String(50), nullable=False) - divination_data: Mapped[str] = mapped_column(Text, nullable=False) - deepseek_request: Mapped[str] = mapped_column(Text, nullable=False) - deepseek_response: Mapped[str | None] = mapped_column(Text, nullable=True) - interpretation_result: Mapped[str | None] = mapped_column(Text, nullable=True) - api_success: Mapped[bool] = mapped_column(Integer, nullable=False, default=0) - error_message: Mapped[str | None] = mapped_column(Text, nullable=True) - api_duration_ms: Mapped[int | None] = mapped_column(BigInteger, nullable=True) - phone_number: Mapped[str | None] = mapped_column(String(20), nullable=True) - - -class DivinationHistory(TimestampMixin, Base): - __tablename__ = "user_divination_history" - - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) - phone_number: Mapped[str] = mapped_column(String(20), nullable=False) - local_record_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) - json_data: Mapped[str] = mapped_column(Text, nullable=False) - ai_result: Mapped[str] = mapped_column(Text, nullable=False) - question_type: Mapped[str] = mapped_column(String(50), nullable=False) - question: Mapped[str] = mapped_column(Text, nullable=False) - timestamp: Mapped[int] = mapped_column(BigInteger, nullable=False) - is_active: Mapped[bool] = mapped_column(Integer, nullable=False, default=1) - sync_time: Mapped[str] = mapped_column( - DateTime, nullable=False, server_default=func.now() - ) diff --git a/backend/src/models/feedback.py b/backend/src/models/feedback.py deleted file mode 100644 index 78bb864..0000000 --- a/backend/src/models/feedback.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -from core.db.base import Base -from sqlalchemy import BigInteger, DateTime, String, Text, func -from sqlalchemy.orm import Mapped, mapped_column - - -class UserFeedback(Base): - __tablename__ = "user_feedback" - - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) - phone_number: Mapped[str] = mapped_column(String(20), nullable=False) - content: Mapped[str] = mapped_column(Text, nullable=False) - created_at: Mapped[str] = mapped_column( - DateTime, nullable=False, server_default=func.now() - ) diff --git a/backend/src/models/llm.py b/backend/src/models/llm.py new file mode 100644 index 0000000..7cd2a74 --- /dev/null +++ b/backend/src/models/llm.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import uuid + +from sqlalchemy import ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, SoftDeleteMixin, TimestampMixin + + +class Llm(TimestampMixin, SoftDeleteMixin, Base): + __tablename__: str = "llms" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + factory_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("llm_factory.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + model_code: Mapped[str] = mapped_column( + String(50), nullable=False, unique=True, index=True + ) diff --git a/backend/src/models/llm_factory.py b/backend/src/models/llm_factory.py new file mode 100644 index 0000000..c45f93d --- /dev/null +++ b/backend/src/models/llm_factory.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import uuid + +from sqlalchemy import String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, SoftDeleteMixin, TimestampMixin + + +class LlmFactory(TimestampMixin, SoftDeleteMixin, Base): + __tablename__: str = "llm_factory" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + name: Mapped[str] = mapped_column( + String(50), nullable=False, unique=True, index=True + ) + request_url: Mapped[str] = mapped_column(String(255), nullable=False) + avatar: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/src/models/log.py b/backend/src/models/log.py deleted file mode 100644 index f94bf3f..0000000 --- a/backend/src/models/log.py +++ /dev/null @@ -1,39 +0,0 @@ -from __future__ import annotations - -from core.db.base import Base -from sqlalchemy import BigInteger, DateTime, Integer, String, Text, func -from sqlalchemy.orm import Mapped, mapped_column - - -class NetworkAccessLog(Base): - __tablename__ = "network_access_logs" - - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) - phone_number: Mapped[str | None] = mapped_column(String(20), nullable=True) - client_ip: Mapped[str] = mapped_column(String(45), nullable=False) - client_port: Mapped[int | None] = mapped_column(Integer, nullable=True) - server_ip: Mapped[str] = mapped_column(String(45), nullable=False) - server_port: Mapped[int] = mapped_column(Integer, nullable=False) - http_method: Mapped[str] = mapped_column(String(10), nullable=False) - request_path: Mapped[str] = mapped_column(String(500), nullable=False) - request_url: Mapped[str] = mapped_column(String(1000), nullable=False) - user_agent: Mapped[str | None] = mapped_column(String(1000), nullable=True) - device_info: Mapped[str | None] = mapped_column(Text, nullable=True) - response_status: Mapped[int | None] = mapped_column(Integer, nullable=True) - processing_time_ms: Mapped[int | None] = mapped_column(BigInteger, nullable=True) - request_size: Mapped[int | None] = mapped_column(BigInteger, nullable=True) - response_size: Mapped[int | None] = mapped_column(BigInteger, nullable=True) - x_forwarded_for: Mapped[str | None] = mapped_column(String(500), nullable=True) - x_real_ip: Mapped[str | None] = mapped_column(String(45), nullable=True) - referer: Mapped[str | None] = mapped_column(String(1000), nullable=True) - operation_type: Mapped[str | None] = mapped_column(String(50), nullable=True) - operation_result: Mapped[str | None] = mapped_column(String(20), nullable=True) - error_message: Mapped[str | None] = mapped_column(Text, nullable=True) - session_id: Mapped[str | None] = mapped_column(String(100), nullable=True) - access_time: Mapped[str] = mapped_column( - DateTime, nullable=False, server_default=func.now() - ) - created_at: Mapped[str] = mapped_column( - DateTime, nullable=False, server_default=func.now() - ) diff --git a/backend/src/models/notification.py b/backend/src/models/notification.py deleted file mode 100644 index e6865dc..0000000 --- a/backend/src/models/notification.py +++ /dev/null @@ -1,14 +0,0 @@ -from __future__ import annotations - -from core.db.base import Base -from sqlalchemy import BigInteger, String, Text -from sqlalchemy.orm import Mapped, mapped_column - - -class Notification(Base): - __tablename__ = "notification" - - id: Mapped[str] = mapped_column(String(64), primary_key=True) - title: Mapped[str] = mapped_column(String(255), nullable=False) - content: Mapped[str] = mapped_column(Text, nullable=False) - timestamp: Mapped[int] = mapped_column(BigInteger, nullable=False) diff --git a/backend/src/models/payment.py b/backend/src/models/payment.py deleted file mode 100644 index eb0b76c..0000000 --- a/backend/src/models/payment.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -from core.db.base import Base, TimestampMixin -from sqlalchemy import BigInteger, DateTime, Integer, String, Text, func -from sqlalchemy.orm import Mapped, mapped_column - - -class PaymentOrder(TimestampMixin, Base): - __tablename__ = "payment_order" - - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - order_no: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) - user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) - amount: Mapped[str] = mapped_column(String(20), nullable=False) - coin_count: Mapped[int] = mapped_column(Integer, nullable=False) - subject: Mapped[str] = mapped_column(String(256), nullable=False) - body: Mapped[str | None] = mapped_column(String(512), nullable=True) - channel: Mapped[str] = mapped_column(String(16), nullable=False) - status: Mapped[str] = mapped_column(String(16), nullable=False, default="CREATED") - trade_no: Mapped[str | None] = mapped_column(String(64), nullable=True) - payment_time: Mapped[str | None] = mapped_column(DateTime, nullable=True) - - -class PaymentRecord(Base): - __tablename__ = "payment_record" - - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - order_no: Mapped[str] = mapped_column(String(64), nullable=False) - trade_no: Mapped[str] = mapped_column(String(64), nullable=False) - user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) - channel: Mapped[str] = mapped_column(String(16), nullable=False) - notify_type: Mapped[str] = mapped_column(String(16), nullable=False) - trade_status: Mapped[str] = mapped_column(String(32), nullable=False) - notify_data: Mapped[str] = mapped_column(Text, nullable=False) - process_status: Mapped[str] = mapped_column(String(16), nullable=False) - process_message: Mapped[str | None] = mapped_column(String(512), nullable=True) - coin_added: Mapped[bool] = mapped_column(Integer, nullable=False, default=0) - created_at: Mapped[str] = mapped_column( - DateTime, nullable=False, server_default=func.now() - ) diff --git a/backend/src/models/system_agents.py b/backend/src/models/system_agents.py new file mode 100644 index 0000000..ed89743 --- /dev/null +++ b/backend/src/models/system_agents.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import uuid + +from sqlalchemy import JSON, ForeignKey, String +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, TimestampMixin + + +class SystemAgents(TimestampMixin, Base): + __tablename__: str = "system_agents" + + agent_type: Mapped[str] = mapped_column( + String(20), + primary_key=True, + ) + llm_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("llms.id", ondelete="RESTRICT"), + nullable=False, + ) + status: Mapped[str] = mapped_column( + String(20), + nullable=False, + ) + config: Mapped[dict] = mapped_column( + JSON().with_variant(JSONB, "postgresql"), + nullable=False, + server_default="{}", + ) diff --git a/backend/src/models/user.py b/backend/src/models/user.py deleted file mode 100644 index 4cb5ff0..0000000 --- a/backend/src/models/user.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import annotations - -from core.db.base import Base, TimestampMixin -from sqlalchemy import BigInteger, DateTime, Integer, String, Text, func -from sqlalchemy.orm import Mapped, mapped_column - - -class User(TimestampMixin, Base): - __tablename__ = "user_profile" - - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - phone_number: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) - nickname: Mapped[str] = mapped_column(String(50), nullable=False, default="") - gender: Mapped[str] = mapped_column(String(10), nullable=False, default="男") - birthday: Mapped[str] = mapped_column( - String(20), nullable=False, default="2000-01-01" - ) - signature: Mapped[str] = mapped_column(String(255), nullable=False, default="") - - -class UserToken(Base): - __tablename__ = "user_tokens" - - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) - token: Mapped[str] = mapped_column(String(255), nullable=False) - expire_time: Mapped[str] = mapped_column(DateTime, nullable=False) - created_at: Mapped[str] = mapped_column( - DateTime, nullable=False, server_default=func.now() - ) - - -class VerificationCode(Base): - __tablename__ = "verification_codes" - - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - phone_number: Mapped[str] = mapped_column(String(20), nullable=False) - code: Mapped[str] = mapped_column(String(6), nullable=False) - expiration_time: Mapped[str] = mapped_column(DateTime, nullable=False) - created_at: Mapped[str] = mapped_column( - DateTime, nullable=False, server_default=func.now() - ) - used: Mapped[bool] = mapped_column(Integer, nullable=False, default=0) - - -class UserCoin(Base): - __tablename__ = "user_coin" - - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True) - phone_number: Mapped[str] = mapped_column(String(20), nullable=False) - coin_balance: Mapped[int] = mapped_column(Integer, nullable=False, default=3) diff --git a/backend/src/models/version.py b/backend/src/models/version.py deleted file mode 100644 index 3a3101f..0000000 --- a/backend/src/models/version.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import annotations - -from core.db.base import Base, TimestampMixin -from sqlalchemy import BigInteger, Integer, String, Text -from sqlalchemy.orm import Mapped, mapped_column - - -class AppVersion(TimestampMixin, Base): - __tablename__ = "app_version" - - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - version_name: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) - version_code: Mapped[int] = mapped_column(Integer, unique=True, nullable=False) - min_supported_version: Mapped[str] = mapped_column(String(20), nullable=False) - min_supported_code: Mapped[int] = mapped_column(Integer, nullable=False) - is_force_update: Mapped[bool] = mapped_column(Integer, nullable=False, default=0) - update_message: Mapped[str | None] = mapped_column(Text, nullable=True) - download_url: Mapped[str | None] = mapped_column(String(500), nullable=True) - is_active: Mapped[bool] = mapped_column(Integer, nullable=False, default=1) diff --git a/backend/src/models/violation.py b/backend/src/models/violation.py deleted file mode 100644 index 0158e67..0000000 --- a/backend/src/models/violation.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -from core.db.base import Base -from sqlalchemy import BigInteger, DateTime, Float, Integer, String, Text, func -from sqlalchemy.orm import Mapped, mapped_column - - -class SensitiveWordViolation(Base): - __tablename__ = "sensitive_word_violations" - - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) - user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) - content_type: Mapped[str] = mapped_column(String(20), nullable=False) - original_content: Mapped[str] = mapped_column(Text, nullable=False) - violation_type: Mapped[str] = mapped_column(String(30), nullable=False) - detection_service: Mapped[str] = mapped_column( - String(20), nullable=False, default="LOCAL" - ) - risk_level: Mapped[str | None] = mapped_column(String(50), nullable=True) - confidence: Mapped[float | None] = mapped_column(Float, nullable=True) - aliyun_response: Mapped[str | None] = mapped_column(Text, nullable=True) - matched_words: Mapped[str] = mapped_column(Text, nullable=False) - client_ip: Mapped[str | None] = mapped_column(String(45), nullable=True) - user_agent: Mapped[str | None] = mapped_column(Text, nullable=True) - violation_time: Mapped[str] = mapped_column( - DateTime, nullable=False, server_default=func.now() - ) - created_at: Mapped[str] = mapped_column( - DateTime, nullable=False, server_default=func.now() - ) diff --git a/backend/src/schemas/__init__.py b/backend/src/schemas/__init__.py index e69de29..82184cb 100644 --- a/backend/src/schemas/__init__.py +++ b/backend/src/schemas/__init__.py @@ -0,0 +1 @@ +"""Backend reusable schemas package.""" diff --git a/backend/src/schemas/agent/__init__.py b/backend/src/schemas/agent/__init__.py new file mode 100644 index 0000000..183634c --- /dev/null +++ b/backend/src/schemas/agent/__init__.py @@ -0,0 +1,68 @@ +from schemas.agent.forwarded_props import ( + ClientTimeContext, + ForwardedPropsPayload, + parse_forwarded_props_client_time, + parse_forwarded_props_runtime_mode, +) +from schemas.agent.forwarded_props import RuntimeMode +from schemas.agent.runtime_models import ( + AgentOutput, + ConstraintItem, + ExecutionMode, + KeyEntity, + NormalizedTaskInput, + ResultTyping, + ResultType, + RouterAgentOutput, + RunStatus, + TaskType, + TaskTyping, + ToolAgentOutput, + ToolStatus, + WorkerAgentOutputLite, + WorkerAgentOutputRich, + resolve_worker_output_model, +) +from schemas.agent.system_agent import AgentType, SystemAgentLLMConfig +from schemas.agent.visibility import SystemVisibilityBit, VisibilityMask, bit_mask +from schemas.agent.ui_hints import ( + UiHintAction, + UiHintIntent, + UiHintSection, + UiHintStatus, + UiHintsPayload, +) + +__all__ = [ + "AgentType", + "AgentOutput", + "ConstraintItem", + "ExecutionMode", + "ForwardedPropsPayload", + "KeyEntity", + "NormalizedTaskInput", + "ResultTyping", + "ClientTimeContext", + "ResultType", + "RouterAgentOutput", + "RunStatus", + "RuntimeMode", + "TaskType", + "TaskTyping", + "SystemAgentLLMConfig", + "SystemVisibilityBit", + "ToolAgentOutput", + "ToolStatus", + "UiHintAction", + "UiHintIntent", + "UiHintSection", + "UiHintStatus", + "UiHintsPayload", + "VisibilityMask", + "WorkerAgentOutputLite", + "WorkerAgentOutputRich", + "bit_mask", + "parse_forwarded_props_client_time", + "parse_forwarded_props_runtime_mode", + "resolve_worker_output_model", +] diff --git a/backend/src/schemas/agent/forwarded_props.py b/backend/src/schemas/agent/forwarded_props.py new file mode 100644 index 0000000..9e8250b --- /dev/null +++ b/backend/src/schemas/agent/forwarded_props.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum +import re +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + StrictInt, + ValidationError, + field_validator, +) + +_RFC3339_WITH_TZ_PATTERN = re.compile( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$" +) + + +class ClientTimeContext(BaseModel): + model_config = ConfigDict(extra="forbid") + + device_timezone: str = Field( + ..., + description="IANA timezone from client device, e.g. America/Los_Angeles.", + ) + client_now_iso: str = Field( + ..., + description="RFC3339 datetime with timezone offset from client device.", + ) + client_epoch_ms: StrictInt = Field( + ..., + ge=0, + description="Unix epoch milliseconds from client device.", + ) + + @field_validator("device_timezone") + @classmethod + def validate_device_timezone(cls, value: str) -> str: + try: + ZoneInfo(value) + except ZoneInfoNotFoundError as exc: + raise ValueError("invalid client_time.device_timezone") from exc + return value + + @field_validator("client_now_iso") + @classmethod + def validate_client_now_iso(cls, value: str) -> str: + if not _RFC3339_WITH_TZ_PATTERN.fullmatch(value): + raise ValueError("invalid client_time.client_now_iso") + normalized = value.replace("Z", "+00:00") + try: + parsed = datetime.fromisoformat(normalized) + except ValueError as exc: + raise ValueError("invalid client_time.client_now_iso") from exc + if parsed.tzinfo is None: + raise ValueError("invalid client_time.client_now_iso") + return value + + +class RuntimeMode(str, Enum): + CHAT = "chat" + AUTOMATION = "automation" + + +class ForwardedPropsPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + runtime_mode: RuntimeMode + client_time: ClientTimeContext | None = None + + +def parse_forwarded_props(forwarded_props: object) -> ForwardedPropsPayload: + if not isinstance(forwarded_props, dict): + raise ValueError("invalid RunAgentInput.forwardedProps") + try: + return ForwardedPropsPayload.model_validate(forwarded_props) + except ValidationError as exc: + raise ValueError("invalid RunAgentInput.forwardedProps") from exc + + +def parse_forwarded_props_client_time( + forwarded_props: object, +) -> ClientTimeContext | None: + payload = parse_forwarded_props(forwarded_props) + return payload.client_time + + +def parse_forwarded_props_runtime_mode(forwarded_props: object) -> RuntimeMode: + payload = parse_forwarded_props(forwarded_props) + return payload.runtime_mode diff --git a/backend/src/schemas/agent/runtime_models.py b/backend/src/schemas/agent/runtime_models.py new file mode 100644 index 0000000..fb8c4a2 --- /dev/null +++ b/backend/src/schemas/agent/runtime_models.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from enum import Enum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from schemas.agent.ui_hints import UiHintsPayload + + +class TaskType(str, Enum): + KNOWLEDGE = "knowledge" + RECOMMENDATION = "recommendation" + PLANNING = "planning" + SCHEDULING = "scheduling" + REMINDER_MANAGEMENT = "reminder_management" + TODO_MANAGEMENT = "todo_management" + COMMUNICATION_DRAFTING = "communication_drafting" + INFORMATION_ORGANIZATION = "information_organization" + STATUS_TRACKING = "status_tracking" + TRANSACTION_ASSIST = "transaction_assist" + ACTION_EXECUTION = "action_execution" + TROUBLESHOOTING = "troubleshooting" + UNKNOWN = "unknown" + + +class ResultType(str, Enum): + DIRECT_ANSWER = "direct_answer" + OPTIONS_WITH_RECOMMENDATION = "options_with_recommendation" + ACTION_PLAN = "action_plan" + SCHEDULE_PROPOSAL = "schedule_proposal" + TODO_LIST = "todo_list" + DRAFT_MESSAGE = "draft_message" + SUMMARY = "summary" + PROGRESS_SUMMARY = "progress_summary" + DIAGNOSIS_REPORT = "diagnosis_report" + STRUCTURED_PAYLOAD = "structured_payload" + EXECUTION_REPORT = "execution_report" + CLARIFICATION_REQUEST = "clarification_request" + SAFETY_BLOCK = "safety_block" + ERROR_REPORT = "error_report" + UNKNOWN = "unknown" + + +class TaskTyping(BaseModel): + model_config = ConfigDict(extra="forbid") + + primary: TaskType + secondary: list[TaskType] = Field(default_factory=list, max_length=3) + + +class ResultTyping(BaseModel): + model_config = ConfigDict(extra="forbid") + + primary: ResultType + secondary: list[ResultType] = Field(default_factory=list, max_length=3) + + +class ExecutionMode(str, Enum): + ONESTEP = "onestep" + TOOL_ASSISTED = "tool_assisted" + MULTISTEP = "multistep" + + +class RunStatus(str, Enum): + SUCCESS = "success" + PARTIAL_SUCCESS = "partial_success" + FAILED = "failed" + + +class ToolStatus(str, Enum): + SUCCESS = "success" + FAILURE = "failure" + PARTIAL = "partial" + + +class KeyEntity(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str + type: str + value: str | None = None + + @field_validator("value", mode="before") + @classmethod + def normalize_value(cls, value: object) -> object: + if value is None: + return None + if isinstance(value, str): + return value + if isinstance(value, bool | int | float): + return str(value) + return value + + +class ConstraintItem(BaseModel): + model_config = ConfigDict(extra="forbid") + + key: str + value: str + required: bool = True + + @field_validator("value", mode="before") + @classmethod + def normalize_value(cls, value: object) -> object: + if isinstance(value, bool | int | float): + return str(value) + return value + + +class NormalizedTaskInput(BaseModel): + model_config = ConfigDict(extra="forbid") + + user_text: str + multimodal_summary: list[str] = Field(default_factory=list) + context_summary: str = Field(default="", max_length=2000) + + +class RouterAgentOutput(BaseModel): + model_config = ConfigDict(extra="forbid") + + normalized_task_input: NormalizedTaskInput + key_entities: list[KeyEntity] = Field(default_factory=list) + constraints: list[ConstraintItem] = Field(default_factory=list) + task_typing: TaskTyping + execution_mode: ExecutionMode + result_typing: ResultTyping + + +class ErrorInfo(BaseModel): + model_config = ConfigDict(extra="forbid") + + code: str + message: str + retryable: bool = False + details: dict[str, Any] | None = None + + +class ToolAgentOutput(BaseModel): + model_config = ConfigDict(extra="forbid") + + tool_name: str + tool_call_id: str + tool_call_args: dict[str, Any] | None = None + status: ToolStatus + result: str + error: ErrorInfo | None = None + + +class WorkerAgentOutputLite(BaseModel): + model_config = ConfigDict(extra="forbid") + + status: RunStatus = RunStatus.SUCCESS + answer: str + key_points: list[str] = Field(default_factory=list) + result_type: ResultType = ResultType.UNKNOWN + suggested_actions: list[str] = Field(default_factory=list) + error: ErrorInfo | None = None + + +class WorkerAgentOutputRich(WorkerAgentOutputLite): + ui_hints: UiHintsPayload | None = None + + +class AgentOutput(WorkerAgentOutputRich): + model_config = ConfigDict(extra="forbid") + + +WorkerAgentOutput = WorkerAgentOutputLite | WorkerAgentOutputRich + + +def resolve_worker_output_model( + execution_mode: ExecutionMode, +) -> type[WorkerAgentOutputLite]: + if execution_mode == ExecutionMode.ONESTEP: + return WorkerAgentOutputLite + return WorkerAgentOutputRich diff --git a/backend/src/schemas/agent/system_agent.py b/backend/src/schemas/agent/system_agent.py new file mode 100644 index 0000000..8d01970 --- /dev/null +++ b/backend/src/schemas/agent/system_agent.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from enum import Enum + +from pydantic import BaseModel, Field + + +class AgentType(str, Enum): + ROUTER = "router" + WORKER = "worker" + + +class ContextBuildStrategy(str, Enum): + DAY = "day" + NUMBER = "number" + + +class ContextMessagesConfig(BaseModel): + mode: ContextBuildStrategy = ContextBuildStrategy.NUMBER + count: int = Field(default=20, ge=1, le=200) + + +class SystemAgentLLMConfig(BaseModel): + temperature: float | None = Field(default=None, ge=0.0, le=2.0) + max_tokens: int | None = Field(default=None, ge=1) + timeout_seconds: float | None = Field(default=30.0, gt=0.0, le=300.0) + context_messages: ContextMessagesConfig = Field( + default_factory=ContextMessagesConfig + ) + enabled_tools: list[str] = Field(default_factory=list, max_length=32) diff --git a/backend/src/schemas/agent/ui_hints.py b/backend/src/schemas/agent/ui_hints.py new file mode 100644 index 0000000..54f461b --- /dev/null +++ b/backend/src/schemas/agent/ui_hints.py @@ -0,0 +1,349 @@ +""" +UiHints - 描述性 UI 提示 + +设计原则: +- 描述性而非渲染性: 告诉编译器“要展示什么”,而不是“如何渲染” +- 最小化 token: 保持字段简洁 +- 可编译: 可机械转换为 UiSchemaRenderer +- 尽量无损: hints 中的主要内容字段应尽量被保留到 renderer 中 + +Version: 2.1 +""" + +from __future__ import annotations + +from enum import Enum +import re +from typing import Any, ClassVar, Literal + +from pydantic import BaseModel, ConfigDict, Field +from pydantic import field_validator + +_NAVIGATION_PATH_PATTERN = re.compile(r"^/[A-Za-z0-9/_-]*$") +_NAVIGATION_PARAM_KEY_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,31}$") +_MAX_NAVIGATION_PARAMS = 8 + + +# ============================================================ +# Enums +# ============================================================ + + +class UiHintStatus(str, Enum): + INFO = "info" + SUCCESS = "success" + WARNING = "warning" + ERROR = "error" + PENDING = "pending" + + +class UiHintIntent(str, Enum): + """主要展示意图(弱提示,不应决定字段生死)""" + + MESSAGE = "message" # 普通消息/说明 + DATA = "data" # 数据/结果摘要 + LIST = "list" # 列表为主 + STATUS = "status" # 状态结果为主 + FORM = "form" # 结构化内容(当前不表示真实输入表单) + MIXED = "mixed" # 混合内容 + + +class UiHintActionStyle(str, Enum): + PRIMARY = "primary" + SECONDARY = "secondary" + GHOST = "ghost" + DANGER = "danger" + + +class UiHintTextFormat(str, Enum): + PLAIN = "plain" + MARKDOWN = "markdown" + + +class UiHintActionType(str, Enum): + NAVIGATION = "navigation" + URL = "url" + EVENT = "event" + TOOL = "tool" + COPY = "copy" + PAYLOAD = "payload" + + +class UiHintIconSource(str, Enum): + ICON = "icon" + EMOJI = "emoji" + URL = "url" + + +# ============================================================ +# Base Config +# ============================================================ + + +class UiHintBaseModel(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict( + extra="forbid", + populate_by_name=True, + ) + + +# ============================================================ +# Action Targets +# ============================================================ + + +class UiHintActionNavigation(UiHintBaseModel): + type: Literal["navigation"] + path: str = Field(..., description="Internal route path.") + params: dict[str, Any] | None = Field(default=None, description="Route params.") + + @field_validator("path") + @classmethod + def validate_navigation_path(cls, value: str) -> str: + path = value.strip() + if not path: + raise ValueError("navigation path must not be empty") + if len(path) > 256: + raise ValueError("navigation path is too long") + if path.startswith("//") or "://" in path: + raise ValueError("navigation path must be internal") + if "?" in path or "#" in path: + raise ValueError("navigation path must not contain query or fragment") + if ":" in path: + raise ValueError("navigation path must be concrete without placeholders") + if _NAVIGATION_PATH_PATTERN.fullmatch(path) is None: + raise ValueError("navigation path contains unsupported characters") + return path + + @field_validator("params") + @classmethod + def validate_navigation_params( + cls, value: dict[str, Any] | None + ) -> dict[str, Any] | None: + if value is None: + return None + if len(value) > _MAX_NAVIGATION_PARAMS: + raise ValueError("navigation params exceed limit") + + normalized: dict[str, Any] = {} + for key, param_value in value.items(): + if _NAVIGATION_PARAM_KEY_PATTERN.fullmatch(key) is None: + raise ValueError("navigation param key is invalid") + if isinstance(param_value, (str, int, float, bool)): + normalized[key] = param_value + continue + raise ValueError("navigation params must be scalar") + return normalized + + +class UiHintActionUrl(UiHintBaseModel): + type: Literal["url"] + url: str = Field(..., description="External URL.") + target: Literal["_self", "_blank"] | None = Field(default=None) + + +class UiHintActionEvent(UiHintBaseModel): + type: Literal["event"] + event: str = Field(..., description="Frontend event name.") + payload: dict[str, Any] | None = Field(default=None) + + +class UiHintActionTool(UiHintBaseModel): + type: Literal["tool"] + tool_id: str = Field(alias="toolId", description="Tool identifier.") + params: dict[str, Any] | None = Field(default=None) + + +class UiHintActionCopy(UiHintBaseModel): + type: Literal["copy"] + content: str = Field(..., description="Content to copy.") + success_message: str | None = Field(alias="successMessage", default=None) + + +class UiHintActionPayload(UiHintBaseModel): + type: Literal["payload"] + payload: dict[str, Any] = Field(..., description="Structured payload.") + submit_to: str | None = Field(alias="submitTo", default=None) + + +UiHintActionTarget = ( + UiHintActionNavigation + | UiHintActionUrl + | UiHintActionEvent + | UiHintActionTool + | UiHintActionCopy + | UiHintActionPayload +) + + +class UiHintAction(UiHintBaseModel): + label: str = Field(..., description="Button label.") + style: UiHintActionStyle | None = Field(default=None, description="Button style.") + disabled: bool = Field(default=False, description="Disabled state.") + action: UiHintActionTarget = Field(..., description="Action to execute.") + + +# ============================================================ +# Small Descriptive Models +# ============================================================ + + +class UiHintIcon(UiHintBaseModel): + source: UiHintIconSource = Field(default=UiHintIconSource.ICON) + value: str = Field(..., description="Icon identifier / emoji / url.") + color: str | None = Field(default=None) + size: int | None = Field(default=None) + + +class UiHintKvItem(UiHintBaseModel): + key: str = Field(..., description="Key identifier.") + label: str | None = Field(default=None, description="Display label.") + value: Any = Field(default=None, description="Value.") + copyable: bool = Field(default=False, description="Allow copy.") + + +class UiHintListItem(UiHintBaseModel): + id: str | None = Field(default=None) + title: str = Field(..., description="Item title.") + subtitle: str | None = Field(default=None) + description: str | None = Field(default=None) + icon: UiHintIcon | None = Field(default=None) + status: UiHintStatus | None = Field(default=None) + actions: list[UiHintAction] = Field(default_factory=list) + + @field_validator("status", mode="before") + @classmethod + def normalize_status(cls, value: object) -> object: + if value is None: + return None + if isinstance(value, dict): + status_type = value.get("type") + if isinstance(status_type, str): + return status_type + status_value = value.get("status") + if isinstance(status_value, str): + return status_value + return value + + +class UiHintSection(UiHintBaseModel): + title: str | None = Field(default=None, description="Section title.") + description: str | None = Field(default=None, description="Section description.") + icon: UiHintIcon | None = Field(default=None, description="Section icon.") + + content: str | None = Field(default=None, description="Main text content.") + content_format: UiHintTextFormat = Field( + default=UiHintTextFormat.PLAIN, + alias="contentFormat", + description="Section content text format.", + ) + + items: list[UiHintKvItem] = Field(default_factory=list, description="KV items.") + list_items: list[UiHintListItem] = Field( + default_factory=list, + alias="listItems", + description="List items.", + ) + actions: list[UiHintAction] = Field(default_factory=list, description="Actions.") + + +# ============================================================ +# Root Payload +# ============================================================ + + +class UiHintsPayload(UiHintBaseModel): + """ + 描述性 UI 提示 + + 设计目标: + - agent 输出尽可能短 + - 不表达布局细节 + - 编译器负责转换为完整 UiSchemaRenderer + """ + + model_config: ClassVar[ConfigDict] = ConfigDict( + extra="forbid", + populate_by_name=True, + json_schema_extra={ + "examples": [ + { + "intent": "status", + "status": "success", + "title": "日程已创建", + "body": "本次创建已成功完成。", + "items": [ + {"key": "title", "label": "主题", "value": "Q1 规划会议"}, + {"key": "time", "label": "时间", "value": "2026-03-15 14:00"}, + ], + "actions": [ + { + "label": "查看详情", + "style": "primary", + "action": { + "type": "navigation", + "path": "/calendar/evt_123", + }, + }, + { + "label": "删除", + "style": "danger", + "action": { + "type": "tool", + "toolId": "calendar.delete", + "params": {"eventId": "evt_123"}, + }, + }, + ], + } + ] + }, + ) + + version: str = Field(default="2.1") + + intent: UiHintIntent = Field( + default=UiHintIntent.MESSAGE, + description="Primary display intent.", + ) + status: UiHintStatus = Field( + default=UiHintStatus.INFO, + description="Overall status.", + ) + + title: str | None = Field(default=None, description="Top-level title.") + description: str | None = Field(default=None, description="Top-level description.") + + body: str | None = Field(default=None, description="Top-level main body text.") + body_format: UiHintTextFormat = Field( + default=UiHintTextFormat.PLAIN, + alias="bodyFormat", + description="Body text format.", + ) + + items: list[UiHintKvItem] = Field( + default_factory=list, + description="Top-level key-value items.", + ) + list_items: list[UiHintListItem] = Field( + default_factory=list, + alias="listItems", + description="Top-level list items.", + ) + sections: list[UiHintSection] = Field( + default_factory=list, + description="Grouped sections.", + ) + actions: list[UiHintAction] = Field( + default_factory=list, + description="Top-level actions.", + ) + + icon: UiHintIcon | None = Field( + default=None, + description="Top-level icon.", + ) + meta: dict[str, Any] = Field( + default_factory=dict, + description="Extra meta, e.g. requestId/toolId/traceId/userId.", + ) diff --git a/backend/src/schemas/agent/ui_schema.py b/backend/src/schemas/agent/ui_schema.py new file mode 100644 index 0000000..279db4d --- /dev/null +++ b/backend/src/schemas/agent/ui_schema.py @@ -0,0 +1,628 @@ +""" +UI Schema Renderer Protocol + +目标: +- 只保留“基础组件 + 布局容器” +- 最终返回一个 UiSchemaRenderer +- 前端只需要递归渲染 root 布局树即可 + +Version: 2.0 +""" + +from __future__ import annotations + +from enum import Enum +from typing import Any, Literal, TypedDict, Union + +# ============================================================ +# Enums +# ============================================================ + + +class UiStatus(str, Enum): + INFO = "info" + SUCCESS = "success" + WARNING = "warning" + ERROR = "error" + PENDING = "pending" + + +class IconSource(str, Enum): + ICON = "icon" + EMOJI = "emoji" + URL = "url" + + +class TextFormat(str, Enum): + PLAIN = "plain" + MARKDOWN = "markdown" + + +class TextRole(str, Enum): + TITLE = "title" + SUBTITLE = "subtitle" + BODY = "body" + CAPTION = "caption" + CODE = "code" + + +class ButtonStyle(str, Enum): + PRIMARY = "primary" + SECONDARY = "secondary" + GHOST = "ghost" + DANGER = "danger" + + +class LayoutDirection(str, Enum): + VERTICAL = "vertical" + HORIZONTAL = "horizontal" + + +class LayoutAppearance(str, Enum): + PLAIN = "plain" + CARD = "card" + SECTION = "section" + + +class LayoutAlign(str, Enum): + START = "start" + CENTER = "center" + END = "end" + STRETCH = "stretch" + + +class LayoutJustify(str, Enum): + START = "start" + CENTER = "center" + END = "end" + SPACE_BETWEEN = "space-between" + + +class RendererTheme(str, Enum): + DEFAULT = "default" + LIGHT = "light" + DARK = "dark" + + +# ============================================================ +# Meta +# ============================================================ + + +class UiMeta(TypedDict, total=False): + requestId: str + toolId: str + traceId: str + userId: str + + +# ============================================================ +# Action Payloads +# ============================================================ + + +class NavigateAction(TypedDict, total=False): + type: Literal["navigation"] + path: str + params: dict[str, Any] + + +class UrlAction(TypedDict, total=False): + type: Literal["url"] + url: str + target: Literal["_self", "_blank"] + + +class EventAction(TypedDict, total=False): + type: Literal["event"] + event: str + payload: dict[str, Any] + + +class ToolAction(TypedDict, total=False): + type: Literal["tool"] + toolId: str + params: dict[str, Any] + + +class CopyAction(TypedDict, total=False): + type: Literal["copy"] + content: str + successMessage: str + + +class PayloadAction(TypedDict, total=False): + type: Literal["payload"] + payload: dict[str, Any] + submitTo: str + + +UiActionPayload = Union[ + NavigateAction, + UrlAction, + EventAction, + ToolAction, + CopyAction, + PayloadAction, +] + + +# ============================================================ +# Shared Small Types +# ============================================================ + + +class UiIconSpec(TypedDict, total=False): + source: str + value: str + color: str + size: int + + +class UiKvItem(TypedDict, total=False): + key: str + label: str + value: Any + copyable: bool + + +class UiBaseNode(TypedDict, total=False): + id: str + visible: bool + + +# ============================================================ +# Primitive Components +# ============================================================ + + +class UiTextNode(UiBaseNode, total=False): + type: Literal["text"] + content: str + format: str # TextFormat + role: str # TextRole + status: str # UiStatus + maxLines: int + + +class UiIconNode(UiBaseNode, total=False): + type: Literal["icon"] + source: str # IconSource + value: str + color: str + size: int + + +class UiBadgeNode(UiBaseNode, total=False): + type: Literal["badge"] + label: str + status: str # UiStatus + + +class UiButtonNode(UiBaseNode, total=False): + type: Literal["button"] + label: str + style: str # ButtonStyle + disabled: bool + icon: UiIconSpec + action: UiActionPayload + + +class UiKvNode(UiBaseNode, total=False): + type: Literal["kv"] + items: list[UiKvItem] + columns: int + + +class UiDividerNode(UiBaseNode, total=False): + type: Literal["divider"] + inset: int + + +# ============================================================ +# Layout Containers +# ============================================================ + + +class UiStackNode(UiBaseNode, total=False): + type: Literal["stack"] + direction: str # LayoutDirection + gap: int + appearance: str # LayoutAppearance + status: str # UiStatus + align: str # LayoutAlign + justify: str # LayoutJustify + wrap: bool + children: list["UiNode"] + + +class UiGridNode(UiBaseNode, total=False): + type: Literal["grid"] + columns: int + gap: int + appearance: str # LayoutAppearance + status: str # UiStatus + children: list["UiNode"] + + +UiNode = Union[ + UiTextNode, + UiIconNode, + UiBadgeNode, + UiButtonNode, + UiKvNode, + UiDividerNode, + UiStackNode, + UiGridNode, +] + +UiLayoutNode = Union[UiStackNode, UiGridNode] + + +# ============================================================ +# Root Renderer +# ============================================================ + + +class UiSchemaRenderer(TypedDict, total=False): + version: str + locale: str + status: str # UiStatus + theme: str # RendererTheme + meta: UiMeta + root: UiLayoutNode + + +# ============================================================ +# Root Builder +# ============================================================ + + +def build_renderer( + root: UiLayoutNode, + *, + version: str = "2.0", + locale: str = "zh-CN", + status: UiStatus = UiStatus.INFO, + theme: RendererTheme = RendererTheme.DEFAULT, + meta: UiMeta | None = None, +) -> UiSchemaRenderer: + renderer: UiSchemaRenderer = { + "version": version, + "locale": locale, + "status": status.value, + "theme": theme.value, + "root": root, + } + if meta: + renderer["meta"] = meta + return renderer + + +# ============================================================ +# Primitive Builders +# ============================================================ + + +def build_text( + content: str, + *, + node_id: str | None = None, + format: TextFormat = TextFormat.PLAIN, + role: TextRole = TextRole.BODY, + status: UiStatus | None = None, + max_lines: int | None = None, + visible: bool = True, +) -> UiTextNode: + node: UiTextNode = { + "type": "text", + "content": content, + "format": format.value, + "role": role.value, + "visible": visible, + } + if node_id: + node["id"] = node_id + if status: + node["status"] = status.value + if max_lines is not None: + node["maxLines"] = max_lines + return node + + +def build_icon( + source: IconSource, + value: str, + *, + node_id: str | None = None, + color: str | None = None, + size: int | None = None, + visible: bool = True, +) -> UiIconNode: + node: UiIconNode = { + "type": "icon", + "source": source.value, + "value": value, + "visible": visible, + } + if node_id: + node["id"] = node_id + if color: + node["color"] = color + if size is not None: + node["size"] = size + return node + + +def build_badge( + label: str, + *, + node_id: str | None = None, + status: UiStatus = UiStatus.INFO, + visible: bool = True, +) -> UiBadgeNode: + node: UiBadgeNode = { + "type": "badge", + "label": label, + "status": status.value, + "visible": visible, + } + if node_id: + node["id"] = node_id + return node + + +def build_button( + label: str, + action: UiActionPayload, + *, + node_id: str | None = None, + style: ButtonStyle = ButtonStyle.PRIMARY, + disabled: bool = False, + icon: UiIconSpec | None = None, + visible: bool = True, +) -> UiButtonNode: + node: UiButtonNode = { + "type": "button", + "label": label, + "style": style.value, + "disabled": disabled, + "action": action, + "visible": visible, + } + if node_id: + node["id"] = node_id + if icon: + node["icon"] = icon + return node + + +def build_kv( + items: list[UiKvItem], + *, + node_id: str | None = None, + columns: int = 1, + visible: bool = True, +) -> UiKvNode: + node: UiKvNode = { + "type": "kv", + "items": items, + "columns": columns, + "visible": visible, + } + if node_id: + node["id"] = node_id + return node + + +def build_divider( + *, + node_id: str | None = None, + inset: int = 0, + visible: bool = True, +) -> UiDividerNode: + node: UiDividerNode = { + "type": "divider", + "inset": inset, + "visible": visible, + } + if node_id: + node["id"] = node_id + return node + + +# ============================================================ +# Layout Builders +# ============================================================ + + +def build_stack( + children: list[UiNode], + *, + node_id: str | None = None, + direction: LayoutDirection = LayoutDirection.VERTICAL, + gap: int = 12, + appearance: LayoutAppearance = LayoutAppearance.PLAIN, + status: UiStatus | None = None, + align: LayoutAlign = LayoutAlign.START, + justify: LayoutJustify = LayoutJustify.START, + wrap: bool = False, + visible: bool = True, +) -> UiStackNode: + node: UiStackNode = { + "type": "stack", + "direction": direction.value, + "gap": gap, + "appearance": appearance.value, + "align": align.value, + "justify": justify.value, + "wrap": wrap, + "children": children, + "visible": visible, + } + if node_id: + node["id"] = node_id + if status: + node["status"] = status.value + return node + + +def build_grid( + children: list[UiNode], + *, + columns: int, + node_id: str | None = None, + gap: int = 12, + appearance: LayoutAppearance = LayoutAppearance.PLAIN, + status: UiStatus | None = None, + visible: bool = True, +) -> UiGridNode: + node: UiGridNode = { + "type": "grid", + "columns": columns, + "gap": gap, + "appearance": appearance.value, + "children": children, + "visible": visible, + } + if node_id: + node["id"] = node_id + if status: + node["status"] = status.value + return node + + +# ============================================================ +# Small Action Builders +# ============================================================ + + +def action_navigation( + path: str, params: dict[str, Any] | None = None +) -> NavigateAction: + action: NavigateAction = {"type": "navigation", "path": path} + if params: + action["params"] = params + return action + + +def action_url(url: str, target: Literal["_self", "_blank"] = "_blank") -> UrlAction: + return {"type": "url", "url": url, "target": target} + + +def action_event(event: str, payload: dict[str, Any] | None = None) -> EventAction: + action: EventAction = {"type": "event", "event": event} + if payload: + action["payload"] = payload + return action + + +def action_tool(tool_id: str, params: dict[str, Any] | None = None) -> ToolAction: + action: ToolAction = {"type": "tool", "toolId": tool_id} + if params: + action["params"] = params + return action + + +def action_copy(content: str, success_message: str | None = None) -> CopyAction: + action: CopyAction = {"type": "copy", "content": content} + if success_message: + action["successMessage"] = success_message + return action + + +def action_payload( + payload: dict[str, Any], submit_to: str | None = None +) -> PayloadAction: + action: PayloadAction = {"type": "payload", "payload": payload} + if submit_to: + action["submitTo"] = submit_to + return action + + +# ============================================================ +# Derived Helpers (协议外的便捷封装,不是基础原语) +# ============================================================ + + +def build_card( + children: list[UiNode], + *, + node_id: str | None = None, + gap: int = 12, + status: UiStatus | None = None, +) -> UiStackNode: + return build_stack( + children, + node_id=node_id, + direction=LayoutDirection.VERTICAL, + gap=gap, + appearance=LayoutAppearance.CARD, + status=status, + ) + + +def build_section( + title: str, + children: list[UiNode], + *, + node_id: str | None = None, + description: str | None = None, + status: UiStatus | None = None, + gap: int = 12, +) -> UiStackNode: + header_nodes: list[UiNode] = [build_text(title, role=TextRole.TITLE)] + if description: + header_nodes.append(build_text(description, role=TextRole.CAPTION)) + + all_children = header_nodes + children + return build_stack( + all_children, + node_id=node_id, + direction=LayoutDirection.VERTICAL, + gap=gap, + appearance=LayoutAppearance.SECTION, + status=status, + ) + + +def build_status_panel( + title: str, + message: str, + *, + status: UiStatus, + primary_button: UiButtonNode | None = None, + secondary_button: UiButtonNode | None = None, + node_id: str | None = None, +) -> UiStackNode: + status_label = f"ui.status.{status.value}" + children: list[UiNode] = [ + build_stack( + [ + build_text(title, role=TextRole.TITLE), + build_badge(label=status_label, status=status), + ], + direction=LayoutDirection.HORIZONTAL, + gap=8, + align=LayoutAlign.CENTER, + justify=LayoutJustify.SPACE_BETWEEN, + ), + build_text(message, role=TextRole.BODY, status=status), + ] + + actions: list[UiNode] = [] + if primary_button: + actions.append(primary_button) + if secondary_button: + actions.append(secondary_button) + + if actions: + children.append( + build_stack( + actions, + direction=LayoutDirection.HORIZONTAL, + gap=8, + ) + ) + + return build_card(children, node_id=node_id, status=status) diff --git a/backend/src/schemas/agent/visibility.py b/backend/src/schemas/agent/visibility.py new file mode 100644 index 0000000..7af41d3 --- /dev/null +++ b/backend/src/schemas/agent/visibility.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from enum import IntEnum + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class SystemVisibilityBit(IntEnum): + UI_HISTORY = 0 + CONTEXT_ASSEMBLY = 1 + + +class VisibilityMask(BaseModel): + model_config = ConfigDict(extra="forbid") + + value: int = Field(..., ge=0, le=(1 << 63) - 1) + + @classmethod + def from_bits(cls, *, bits: list[int]) -> "VisibilityMask": + mask = 0 + for bit in bits: + validate_visibility_bit(bit=bit) + mask |= 1 << bit + return cls(value=mask) + + def contains(self, *, bit: int) -> bool: + validate_visibility_bit(bit=bit) + return bool(self.value & (1 << bit)) + + +class VisibilityBitRef(BaseModel): + model_config = ConfigDict(extra="forbid") + + bit: int = Field(..., ge=0, le=63) + + @field_validator("bit") + @classmethod + def _validate_bit(cls, value: int) -> int: + validate_visibility_bit(bit=value) + return value + + +def validate_visibility_bit(*, bit: int) -> None: + if bit < 0 or bit > 63: + raise ValueError("visibility bit must be in range [0, 63]") + + +def bit_mask(*, bit: int) -> int: + validate_visibility_bit(bit=bit) + return 1 << bit diff --git a/backend/src/services/__init__.py b/backend/src/services/__init__.py index e69de29..9d48db4 100644 --- a/backend/src/services/__init__.py +++ b/backend/src/services/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/src/services/base/__init__.py b/backend/src/services/base/__init__.py new file mode 100644 index 0000000..d115d21 --- /dev/null +++ b/backend/src/services/base/__init__.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from services.base.redis import RedisService, get_or_init_redis_client, redis_service +from services.base.service_interface import ( + BaseServiceProvider, + ServiceRegistry, + close_registered_services, + initialize_registered_services, + register_service, + register_service_instance, + resolve_registered_services, +) +from services.base.supabase import SupabaseService, supabase_service + +__all__ = [ + "BaseServiceProvider", + "RedisService", + "ServiceRegistry", + "SupabaseService", + "close_registered_services", + "get_or_init_redis_client", + "initialize_registered_services", + "redis_service", + "register_service", + "register_service_instance", + "resolve_registered_services", + "supabase_service", +] diff --git a/backend/src/services/base/redis.py b/backend/src/services/base/redis.py new file mode 100644 index 0000000..86e67e0 --- /dev/null +++ b/backend/src/services/base/redis.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import asyncio +import inspect +from typing import Any, Dict, Optional + +import redis.asyncio as redis + +from core.config.settings import RedisSettings, config + +from .service_interface import BaseServiceProvider, register_service_instance + + +class RedisService(BaseServiceProvider): + def __init__(self, settings: RedisSettings | None = None) -> None: + super().__init__("redis") + self._settings = settings or config.redis + self._client: Optional[redis.Redis] = None + self._loop_id: int | None = None + + def _build_client(self) -> redis.Redis: + return redis.from_url( + self._settings.url, + decode_responses=True, + socket_connect_timeout=self._settings.socket_connect_timeout, + socket_timeout=self._settings.socket_timeout, + max_connections=self._settings.max_connections, + ) + + def _require_client(self) -> redis.Redis: + client = self._client + if client is None: + raise RuntimeError("Redis client is not initialized") + return client + + async def initialize(self, **_: Any) -> bool: + try: + client = self._build_client() + ping_result = client.ping() + if inspect.isawaitable(ping_result): + await ping_result + self._client = client + self._loop_id = _current_loop_id() + self._set_initialized(True) + self.logger.info("Redis service initialized") + return True + except Exception as exc: # noqa: BLE001 + self.logger.warning("Redis service initialization failed", error=str(exc)) + self._client = None + self._loop_id = None + self._set_initialized(False) + return False + + async def close(self) -> bool: + client = self._client + if client is None: + self._loop_id = None + return True + try: + await client.aclose() + self.logger.info("Redis service closed") + return True + except Exception as exc: # noqa: BLE001 + self.logger.exception("Redis service close failed", error=str(exc)) + return False + finally: + self._client = None + self._loop_id = None + self._set_initialized(False) + + async def health_check(self) -> Dict[str, Any]: + client = self._client + if client is None: + return {"status": "unhealthy", "details": {"error": "not initialized"}} + try: + ping_result = client.ping() + ping = ( + await ping_result if inspect.isawaitable(ping_result) else ping_result + ) + info_result = client.info() + info = ( + await info_result if inspect.isawaitable(info_result) else info_result + ) + return { + "status": "healthy" if ping else "unhealthy", + "details": { + "ping": ping, + "redis_version": info.get("redis_version"), + "connected_clients": info.get("connected_clients"), + "used_memory": info.get("used_memory_human"), + "uptime_in_seconds": info.get("uptime_in_seconds"), + }, + } + except Exception as exc: # noqa: BLE001 + self.logger.warning("Redis health check failed", error=str(exc)) + return {"status": "unhealthy", "details": {"error": str(exc)}} + + def get_client(self) -> redis.Redis: + return self._require_client() + + +def _current_loop_id() -> int | None: + try: + return id(asyncio.get_running_loop()) + except RuntimeError: + return None + + +async def get_or_init_redis_client() -> redis.Redis: + current_loop_id = _current_loop_id() + bound_loop_id = redis_service._loop_id + if ( + redis_service.is_initialized + and bound_loop_id is not None + and current_loop_id is not None + and bound_loop_id != current_loop_id + ): + redis_service.logger.warning( + "Redis client bound to different event loop; reinitializing", + previous_loop_id=bound_loop_id, + current_loop_id=current_loop_id, + ) + redis_service._client = None + redis_service._loop_id = None + redis_service._set_initialized(False) + + if not redis_service.is_initialized: + initialized = await redis_service.initialize() + if not initialized: + raise RuntimeError("Redis service initialization failed") + return redis_service.get_client() + + +redis_service: RedisService = register_service_instance("redis", RedisService()) + +__all__ = ["RedisService", "get_or_init_redis_client", "redis_service"] diff --git a/backend/src/services/base/service_interface.py b/backend/src/services/base/service_interface.py new file mode 100644 index 0000000..b516e8e --- /dev/null +++ b/backend/src/services/base/service_interface.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Callable, Dict, Optional, TypeVar + +from core.logging import get_logger + + +class BaseServiceProvider(ABC): + def __init__(self, service_name: str) -> None: + self.service_name = service_name + self._initialized = False + self.logger = get_logger("services.base").bind(service=service_name) + + @abstractmethod + async def initialize(self, **kwargs: Any) -> bool: + raise NotImplementedError + + @abstractmethod + async def close(self) -> bool: + raise NotImplementedError + + @abstractmethod + async def health_check(self) -> Dict[str, Any]: + raise NotImplementedError + + @property + def is_initialized(self) -> bool: + return self._initialized + + def _set_initialized(self, value: bool) -> None: + self._initialized = value + + def get_service_info(self) -> Dict[str, Any]: + return { + "name": self.service_name, + "initialized": self._initialized, + "type": self.__class__.__name__, + } + + +class ServiceRegistry: + _services: Dict[str, Callable[..., BaseServiceProvider]] = {} + + @classmethod + def register( + cls, service_name: str, factory: Callable[..., BaseServiceProvider] + ) -> None: + cls._services = {**cls._services, service_name: factory} + + @classmethod + def get_service_factory( + cls, service_name: str + ) -> Optional[Callable[..., BaseServiceProvider]]: + return cls._services.get(service_name) + + @classmethod + def list_services(cls) -> list[str]: + return sorted(cls._services.keys()) + + @classmethod + def create_service( + cls, service_name: str, **kwargs: Any + ) -> Optional[BaseServiceProvider]: + return cls.get_service(service_name, **kwargs) + + @classmethod + def get_service( + cls, service_name: str, **kwargs: Any + ) -> Optional[BaseServiceProvider]: + factory = cls.get_service_factory(service_name) + if not factory: + return None + return factory(**kwargs) + + +def register_service(service_name: str) -> Callable[[type], type]: + def decorator(service_class: type) -> type: + ServiceRegistry.register(service_name, service_class) + return service_class + + return decorator + + +TService = TypeVar("TService", bound=BaseServiceProvider) + + +def register_service_instance(service_name: str, service: TService) -> TService: + ServiceRegistry.register(service_name, lambda: service) + return service + + +def resolve_registered_services(service_names: list[str]) -> list[BaseServiceProvider]: + services: list[BaseServiceProvider] = [] + for service_name in service_names: + service = ServiceRegistry.get_service(service_name) + if service is None: + raise RuntimeError(f"Service is not registered: {service_name}") + services.append(service) + return services + + +async def close_registered_services(services: list[BaseServiceProvider]) -> bool: + lifecycle_logger = get_logger("services.base.lifecycle") + all_closed = True + for service in reversed(services): + try: + closed = await service.close() + except Exception as exc: # noqa: BLE001 + lifecycle_logger.warning( + "Failed to close service", + service=service.service_name, + error=str(exc), + ) + all_closed = False + continue + if not closed: + lifecycle_logger.warning( + "Service close returned false", + service=service.service_name, + ) + all_closed = False + return all_closed + + +async def initialize_registered_services( + service_names: list[str], +) -> tuple[bool, list[BaseServiceProvider]]: + lifecycle_logger = get_logger("services.base.lifecycle") + initialized_services: list[BaseServiceProvider] = [] + try: + services = resolve_registered_services(service_names) + except RuntimeError as exc: + lifecycle_logger.error("Failed to resolve registered services", error=str(exc)) + return False, [] + + for service in services: + try: + initialized = await service.initialize() + except Exception as exc: # noqa: BLE001 + lifecycle_logger.warning( + "Service initialization raised exception", + service=service.service_name, + error=str(exc), + ) + initialized = False + + if not initialized: + lifecycle_logger.error( + "Service initialization failed, rolling back", + service=service.service_name, + ) + await close_registered_services(initialized_services) + return False, [] + + initialized_services.append(service) + + return True, initialized_services diff --git a/backend/src/services/base/supabase.py b/backend/src/services/base/supabase.py new file mode 100644 index 0000000..64a7e4f --- /dev/null +++ b/backend/src/services/base/supabase.py @@ -0,0 +1,304 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +from supabase import create_client +from storage3.exceptions import StorageApiError + +from core.config.settings import SupabaseSettings, config + +from .service_interface import BaseServiceProvider, register_service_instance + + +class SupabaseService(BaseServiceProvider): + def __init__(self, settings: SupabaseSettings | None = None) -> None: + super().__init__("supabase") + self._settings = settings or config.supabase + self._client: Any = None + self._admin_client: Any = None + + async def initialize(self, **_: Any) -> bool: + try: + self._init_clients() + await self._ensure_storage_bucket() + self._set_initialized(True) + self.logger.info("Supabase service initialized") + return True + except Exception as exc: # noqa: BLE001 + self.logger.warning( + "Supabase service initialization failed", error=str(exc) + ) + self._client = None + self._admin_client = None + self._set_initialized(False) + return False + + async def close(self) -> bool: + self._client = None + self._admin_client = None + self._set_initialized(False) + self.logger.info("Supabase service closed") + return True + + async def health_check(self) -> dict[str, Any]: + client = self._client + admin_client = self._admin_client + if client is None or admin_client is None: + return {"status": "unhealthy", "details": {"error": "not initialized"}} + try: + await asyncio.to_thread(client.auth.get_session) + await asyncio.to_thread( + admin_client.auth.admin.list_users, page=1, per_page=1 + ) + return { + "status": "healthy", + "details": { + "anon_client": "ready", + "admin_client": "ready", + }, + } + except Exception as exc: # noqa: BLE001 + self.logger.warning("Supabase health check failed", error=str(exc)) + return {"status": "unhealthy", "details": {"error": str(exc)}} + + def get_client(self) -> Any: + return self._require_client() + + def get_admin_client(self) -> Any: + return self._require_admin_client() + + def _require_client(self) -> Any: + if self._client is None or self._admin_client is None: + self._init_clients() + self._set_initialized(True) + self.logger.info("Supabase service lazily initialized") + client = self._client + if client is None: + raise RuntimeError("Supabase client is not initialized") + return client + + def _require_admin_client(self) -> Any: + if self._client is None or self._admin_client is None: + self._init_clients() + self._set_initialized(True) + self.logger.info("Supabase service lazily initialized") + admin_client = self._admin_client + if admin_client is None: + raise RuntimeError("Supabase admin client is not initialized") + return admin_client + + def _init_clients(self) -> None: + self._client = create_client( + self._settings.url, + self._settings.anon_key, + ) + self._admin_client = create_client( + self._settings.url, + self._settings.service_role_key, + ) + + async def _ensure_storage_bucket(self) -> None: + storage = getattr(self._admin_client, "storage", None) + if storage is None: + self.logger.warning("Storage client unavailable, skipping bucket check") + return + + get_bucket = getattr(storage, "get_bucket", None) + if not callable(get_bucket): + self.logger.warning("Storage get_bucket unavailable, skipping bucket check") + return + + buckets = [ + (config.storage.attachment.bucket, False), + (config.storage.avatar.bucket, True), + ] + + def _check_and_create() -> None: + for bucket_name, is_public in buckets: + try: + get_bucket(bucket_name) + self.logger.debug( + "Storage bucket already exists", bucket=bucket_name + ) + except Exception: # noqa: BLE001 + create_bucket = getattr(storage, "create_bucket", None) + if not callable(create_bucket): + self.logger.warning( + "Storage create_bucket unavailable, skipping bucket creation" + ) + return + try: + create_bucket(bucket_name, options={"public": is_public}) + self.logger.info( + "Storage bucket created", + bucket=bucket_name, + public=is_public, + ) + except Exception as exc: # noqa: BLE001 + msg = str(exc).lower() + if "already exists" in msg or "duplicate" in msg: + self.logger.debug( + "Storage bucket already exists (race)", + bucket=bucket_name, + ) + continue + self.logger.warning( + "Failed to create storage bucket", + bucket=bucket_name, + error=str(exc), + ) + + await asyncio.to_thread(_check_and_create) + + def _get_storage(self) -> Any: + """Get the storage client from admin client.""" + client = self.get_admin_client() + storage = getattr(client, "storage", None) + if storage is None: + raise RuntimeError("Supabase storage client unavailable") + return storage + + def _get_bucket_client(self, bucket: str) -> Any: + """Get a bucket client for the specified bucket.""" + storage = self._get_storage() + from_bucket = getattr(storage, "from_", None) + if not callable(from_bucket): + raise RuntimeError("Supabase storage bucket accessor unavailable") + return from_bucket(bucket) + + def _validate_bucket(self, bucket: str) -> None: + """Validate that the bucket matches one of configured storage buckets.""" + allowed_buckets = { + config.storage.attachment.bucket, + config.storage.avatar.bucket, + } + if bucket not in allowed_buckets: + raise RuntimeError("Invalid storage bucket") + + def _ensure_bucket_client(self, bucket: str) -> Any: + """Validate bucket and return authenticated bucket client.""" + self._validate_bucket(bucket) + return self._get_bucket_client(bucket) + + def _is_bucket_not_found_error(self, exc: Exception) -> bool: + """Check if the exception indicates a bucket was not found.""" + if isinstance(exc, StorageApiError): + message = str(exc).lower() + return "bucket" in message and "not found" in message + message = str(exc).lower() + return "bucket" in message and "not found" in message + + async def upload_bytes( + self, + *, + bucket: str, + path: str, + content: bytes, + content_type: str, + ) -> str: + def _upload() -> object: + bucket_client = self._ensure_bucket_client(bucket) + upload = getattr(bucket_client, "upload", None) + if not callable(upload): + raise RuntimeError("Supabase storage upload is unavailable") + return upload( + path, + content, + { + "content-type": content_type, + "upsert": "true", + }, + ) + + try: + await asyncio.to_thread(_upload) + except Exception as exc: # noqa: BLE001 + if not self._is_bucket_not_found_error(exc): + raise + await self._ensure_bucket_exists(bucket=bucket) + await asyncio.to_thread(_upload) + return path + + async def _ensure_bucket_exists(self, *, bucket: str) -> None: + def _ensure() -> None: + storage = self._get_storage() + get_bucket = getattr(storage, "get_bucket", None) + if not callable(get_bucket): + raise RuntimeError("Supabase storage get_bucket is unavailable") + try: + get_bucket(bucket) + except Exception as exc: # noqa: BLE001 + msg = str(exc).lower() + if "bucket" in msg and "not found" in msg: + raise RuntimeError(f"Storage bucket '{bucket}' does not exist") + raise + + await asyncio.to_thread(_ensure) + + async def download_bytes(self, *, bucket: str, path: str) -> bytes: + def _download() -> object: + bucket_client = self._ensure_bucket_client(bucket) + download = getattr(bucket_client, "download", None) + if not callable(download): + raise RuntimeError("Supabase storage download is unavailable") + return download(path) + + raw = await asyncio.to_thread(_download) + if isinstance(raw, bytes): + return raw + if isinstance(raw, bytearray): + return bytes(raw) + if isinstance(raw, memoryview): + return raw.tobytes() + raise RuntimeError("Invalid attachment payload") + + async def create_signed_url( + self, + *, + bucket: str, + path: str, + expires_in_seconds: int, + ) -> str: + def _create_signed_url() -> object: + bucket_client = self._ensure_bucket_client(bucket) + signer = getattr(bucket_client, "create_signed_url", None) + if not callable(signer): + raise RuntimeError("Supabase storage signed url is unavailable") + return signer(path, expires_in_seconds) + + raw = await asyncio.to_thread(_create_signed_url) + if isinstance(raw, str): + return raw + if isinstance(raw, dict): + signed_url = raw.get("signedURL") or raw.get("signedUrl") or raw.get("url") + if isinstance(signed_url, str) and signed_url: + return signed_url + raise RuntimeError("Invalid signed url payload") + + def parse_signed_url(self, url: str) -> tuple[str, str]: + from urllib.parse import urlparse + + parsed = urlparse(url) + path_parts = parsed.path.strip("/").split("/") + + if ( + len(path_parts) < 4 + or path_parts[0] != "storage" + or path_parts[1] != "v1" + or path_parts[2] != "object" + or path_parts[3] != "sign" + ): + raise RuntimeError("Invalid signed URL format") + + bucket = path_parts[4] + path = "/".join(path_parts[5:]) + + return bucket, path + + +supabase_service: SupabaseService = register_service_instance( + "supabase", SupabaseService() +) + +__all__ = ["SupabaseService", "supabase_service"] diff --git a/backend/src/services/caches/__init__.py b/backend/src/services/caches/__init__.py new file mode 100644 index 0000000..c7ca694 --- /dev/null +++ b/backend/src/services/caches/__init__.py @@ -0,0 +1,4 @@ +from .factory import get_cache_store +from .interfaces import CacheStore + +__all__ = ["CacheStore", "get_cache_store"] diff --git a/backend/src/services/caches/factory.py b/backend/src/services/caches/factory.py new file mode 100644 index 0000000..0ed3c34 --- /dev/null +++ b/backend/src/services/caches/factory.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from .interfaces import CacheStore +from .redis_store import RedisCacheStore + +_cache_store: CacheStore | None = None + + +def get_cache_store() -> CacheStore: + global _cache_store + if _cache_store is None: + _cache_store = RedisCacheStore() + return _cache_store diff --git a/backend/src/services/caches/interfaces.py b/backend/src/services/caches/interfaces.py new file mode 100644 index 0000000..ff5950b --- /dev/null +++ b/backend/src/services/caches/interfaces.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import Protocol + + +class CacheStore(Protocol): + async def hgetall(self, key: str, /) -> dict[str, str]: ... + + async def hset(self, key: str, /, mapping: dict[str, str]) -> int: ... + + async def hincrby(self, key: str, field: str, amount: int = 1, /) -> int: ... + + async def expire(self, key: str, ttl_seconds: int, /) -> int: ... + + async def delete(self, *keys: str) -> int: ... + + async def sadd(self, key: str, *members: str) -> int: ... + + async def smembers(self, key: str, /) -> set[str]: ... diff --git a/backend/src/services/caches/redis_store.py b/backend/src/services/caches/redis_store.py new file mode 100644 index 0000000..9a7a7cf --- /dev/null +++ b/backend/src/services/caches/redis_store.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import inspect +from typing import Any + +from services.base.redis import get_or_init_redis_client + +from .interfaces import CacheStore + + +def _to_text(value: Any) -> str | None: + if isinstance(value, str): + return value + if isinstance(value, bytes): + try: + return value.decode("utf-8") + except UnicodeDecodeError: + return None + return None + + +async def _maybe_await(value: Any) -> Any: + if inspect.isawaitable(value): + return await value + return value + + +class RedisCacheStore(CacheStore): + async def hgetall(self, key: str) -> dict[str, str]: + client = await get_or_init_redis_client() + raw = await _maybe_await(client.hgetall(key)) + if not isinstance(raw, dict): + return {} + + decoded: dict[str, str] = {} + for raw_key, raw_value in raw.items(): + key_text = _to_text(raw_key) + value_text = _to_text(raw_value) + if key_text is None or value_text is None: + continue + decoded[key_text] = value_text + return decoded + + async def hset(self, key: str, mapping: dict[str, str]) -> int: + client = await get_or_init_redis_client() + result = await _maybe_await(client.hset(key, mapping=mapping)) + return int(result) + + async def hincrby(self, key: str, field: str, amount: int = 1) -> int: + client = await get_or_init_redis_client() + result = await _maybe_await(client.hincrby(key, field, amount)) + return int(result) + + async def expire(self, key: str, ttl_seconds: int) -> int: + client = await get_or_init_redis_client() + result = await _maybe_await(client.expire(key, ttl_seconds)) + return int(result) + + async def delete(self, *keys: str) -> int: + if not keys: + return 0 + client = await get_or_init_redis_client() + result = await _maybe_await(client.delete(*keys)) + return int(result) + + async def sadd(self, key: str, *members: str) -> int: + if not members: + return 0 + client = await get_or_init_redis_client() + result = await _maybe_await(client.sadd(key, *members)) + return int(result) + + async def smembers(self, key: str) -> set[str]: + client = await get_or_init_redis_client() + raw = await _maybe_await(client.smembers(key)) + if isinstance(raw, set): + return {value for item in raw if (value := _to_text(item))} + if isinstance(raw, list | tuple): + return {value for item in raw if (value := _to_text(item))} + return set() diff --git a/backend/src/services/llm_pricing/__init__.py b/backend/src/services/llm_pricing/__init__.py new file mode 100644 index 0000000..623a075 --- /dev/null +++ b/backend/src/services/llm_pricing/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from services.llm_pricing.service import LlmPricingService + +__all__ = ["LlmPricingService"] diff --git a/backend/src/services/llm_pricing/service.py b/backend/src/services/llm_pricing/service.py new file mode 100644 index 0000000..7c7e1dc --- /dev/null +++ b/backend/src/services/llm_pricing/service.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from core.config.initial.init_data import load_llm_catalog + + +@dataclass(frozen=True) +class PricingTier: + max_prompt_tokens: int + input_cost_per_token: float + output_cost_per_token: float + cache_hit_cost_per_token: float + + +class LlmPricingService: + _pricing_by_model: dict[str, tuple[PricingTier, ...]] + + def __init__(self) -> None: + self._pricing_by_model = self._build_pricing_map() + + @staticmethod + def _build_pricing_map() -> dict[str, tuple[PricingTier, ...]]: + catalog = load_llm_catalog() + pricing_by_model: dict[str, tuple[PricingTier, ...]] = {} + for model in catalog.get("llms", []): + if not isinstance(model, dict): + continue + model_code = str(model.get("model_code", "")).strip().lower() + raw_tiers = model.get("pricing_tiers") + if not isinstance(raw_tiers, list) or not raw_tiers: + continue + + tiers = [ + PricingTier( + max_prompt_tokens=int(item.get("max_prompt_tokens", 0) or 0), + input_cost_per_token=float( + item.get("input_cost_per_token", 0.0) or 0.0 + ), + output_cost_per_token=float( + item.get("output_cost_per_token", 0.0) or 0.0 + ), + cache_hit_cost_per_token=float( + item.get("cache_hit_cost_per_token", 0.0) or 0.0 + ), + ) + for item in raw_tiers + if isinstance(item, dict) + ] + if not tiers: + continue + ordered_tiers = tuple( + sorted(tiers, key=lambda item: item.max_prompt_tokens) + ) + if model_code: + pricing_by_model[model_code] = ordered_tiers + return pricing_by_model + + def calculate_cost( + self, + *, + model: str, + prompt_tokens: int, + completion_tokens: int, + cached_prompt_tokens: int = 0, + ) -> float: + tiers = self._pricing_by_model.get(model.strip().lower()) + if tiers is None: + raise ValueError(f"unknown model pricing: {model}") + + normalized_prompt_tokens = max(int(prompt_tokens), 0) + normalized_completion_tokens = max(int(completion_tokens), 0) + normalized_cached_tokens = min( + max(int(cached_prompt_tokens), 0), normalized_prompt_tokens + ) + uncached_prompt_tokens = normalized_prompt_tokens - normalized_cached_tokens + + selected_tier = tiers[-1] + for tier in tiers: + if normalized_prompt_tokens <= tier.max_prompt_tokens: + selected_tier = tier + break + + cached_token_rate = ( + selected_tier.cache_hit_cost_per_token + if selected_tier.cache_hit_cost_per_token > 0 + else selected_tier.input_cost_per_token + ) + + return float( + uncached_prompt_tokens * selected_tier.input_cost_per_token + + normalized_cached_tokens * cached_token_rate + + normalized_completion_tokens * selected_tier.output_cost_per_token + ) + + def build_usage_metadata( + self, + *, + model: str, + usage_summary: dict[str, Any] | None, + ) -> dict[str, Any]: + summary = usage_summary or {} + input_tokens = max(int(summary.get("input_tokens", 0) or 0), 0) + output_tokens = max(int(summary.get("output_tokens", 0) or 0), 0) + total_tokens = max( + int(summary.get("total_tokens", input_tokens + output_tokens) or 0), 0 + ) + latency_ms = max(int(summary.get("latency_ms", 0) or 0), 0) + cached_prompt_tokens = max(int(summary.get("cached_prompt_tokens", 0) or 0), 0) + prompt_cache_hit_tokens = max( + int(summary.get("prompt_cache_hit_tokens", cached_prompt_tokens) or 0), 0 + ) + prompt_cache_miss_tokens = max( + int( + summary.get( + "prompt_cache_miss_tokens", + max(input_tokens - prompt_cache_hit_tokens, 0), + ) + or 0 + ), + 0, + ) + reasoning_tokens = max(int(summary.get("reasoning_tokens", 0) or 0), 0) + direct_cost_raw = summary.get("direct_cost") + direct_cost_observed = bool(int(summary.get("direct_cost_observed", 0) or 0)) + direct_cost_complete = bool(int(summary.get("direct_cost_complete", 0) or 0)) + model_call_records = max(int(summary.get("model_call_records", 0) or 0), 0) + usage_records = max(int(summary.get("usage_records", 0) or 0), 0) + usage_complete = model_call_records == 0 or model_call_records == usage_records + direct_cost = self._coerce_non_negative_float(direct_cost_raw) + + if ( + usage_complete + and direct_cost_observed + and direct_cost_complete + and direct_cost is not None + ): + cost = direct_cost + cost_source = "provider" + else: + cost = self.calculate_cost( + model=model, + prompt_tokens=input_tokens, + completion_tokens=output_tokens, + cached_prompt_tokens=cached_prompt_tokens, + ) + cost_source = ( + "incomplete_usage_fallback" + if not usage_complete + else ( + "catalog_fallback_incomplete_provider_cost" + if direct_cost_observed and not direct_cost_complete + else "catalog_fallback" + ) + ) + + return { + "model": model, + "inputTokens": input_tokens, + "outputTokens": output_tokens, + "totalTokens": total_tokens, + "cachedPromptTokens": cached_prompt_tokens, + "promptCacheHitTokens": prompt_cache_hit_tokens, + "promptCacheMissTokens": prompt_cache_miss_tokens, + "reasoningTokens": reasoning_tokens, + "cost": cost, + "costSource": cost_source, + "usageComplete": usage_complete, + "latencyMs": latency_ms, + } + + @staticmethod + def _coerce_non_negative_float(value: Any) -> float | None: + if value is None: + return None + try: + parsed = float(value) + except (TypeError, ValueError): + return None + if parsed < 0: + return None + return parsed diff --git a/backend/src/v1/__init__.py b/backend/src/v1/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/v1/auth/__init__.py b/backend/src/v1/auth/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/src/v1/auth/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/src/v1/auth/automation_static_config.py b/backend/src/v1/auth/automation_static_config.py new file mode 100644 index 0000000..ce0ab2d --- /dev/null +++ b/backend/src/v1/auth/automation_static_config.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path +import re +from typing import Any + +import yaml + +from schemas.domain.automation import AutomationJobConfig + +_CONFIG_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") + + +def _automation_yaml_path(config_name: str) -> Path: + if not _CONFIG_NAME_PATTERN.fullmatch(config_name): + raise ValueError("invalid automation config name") + return ( + Path(__file__).resolve().parents[2] + / "core" + / "config" + / "static" + / "automation" + / f"{config_name}.yaml" + ) + + +@lru_cache(maxsize=16) +def load_static_automation_job_config(*, config_name: str) -> AutomationJobConfig: + path = _automation_yaml_path(config_name) + with path.open("r", encoding="utf-8") as file: + loaded: Any = yaml.safe_load(file) or {} + if not isinstance(loaded, dict): + raise ValueError(f"invalid automation config format: {path}") + return AutomationJobConfig.model_validate(loaded) diff --git a/backend/src/v1/auth/dependencies.py b/backend/src/v1/auth/dependencies.py new file mode 100644 index 0000000..1b96624 --- /dev/null +++ b/backend/src/v1/auth/dependencies.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from core.db import get_db +from v1.auth.gateway import SupabaseAuthGateway +from v1.auth.registration_bootstrap import ( + RegistrationAutomationBootstrapService, + RegistrationBootstrapRepository, +) +from v1.auth.service import AuthService + + +def get_auth_service( + session: Annotated[AsyncSession, Depends(get_db)], +) -> AuthService: + bootstrapper = RegistrationAutomationBootstrapService( + repository=RegistrationBootstrapRepository(session=session), + session=session, + ) + return AuthService( + gateway=SupabaseAuthGateway(), + registration_bootstrapper=bootstrapper, + ) diff --git a/backend/src/v1/auth/gateway.py b/backend/src/v1/auth/gateway.py new file mode 100644 index 0000000..ec41f5e --- /dev/null +++ b/backend/src/v1/auth/gateway.py @@ -0,0 +1,430 @@ +from __future__ import annotations + +import asyncio +import time +from typing import Any, cast + +from pydantic import ValidationError + +from supabase import AuthError + +from core.http.errors import ApiProblemError +from core.logging import get_logger +from services.base.supabase import supabase_service +from v1.auth.schemas import ( + AuthUser, + OtpSendRequest, + PhoneSessionCreateRequest, + SessionRefreshRequest, + SessionResponse, + UserByIdResponse, + UserByPhoneResponse, +) +from v1.auth.service import AuthServiceGateway + +logger = get_logger("v1.auth.gateway") + +AUTH_UNAVAILABLE_DETAIL = "Auth service temporarily unavailable" + + +def _auth_error( + *, + status_code: int, + code: str, + detail: str, +) -> ApiProblemError: + return ApiProblemError(status_code=status_code, code=code, detail=detail) + + +class SupabaseAuthGateway(AuthServiceGateway): + def __init__(self) -> None: + self._user_lookup_cache_ttl_seconds: int = 60 + self._user_lookup_cache_expires_at: float = 0.0 + self._users_by_phone: dict[str, Any] = {} + self._users_by_id: dict[str, Any] = {} + + def _get_client(self) -> Any: + return supabase_service.get_client() + + def _get_admin_client(self) -> Any: + return supabase_service.get_admin_client() + + async def send_otp(self, request: OtpSendRequest) -> None: + client = self._get_client() + payload: dict[str, Any] = { + "phone": request.phone, + "options": {"should_create_user": True}, + } + try: + sign_in_with_otp = cast(Any, client.auth.sign_in_with_otp) + await asyncio.to_thread(sign_in_with_otp, payload) + except AuthError as exc: + logger.warning("Send otp failed", error_type=type(exc).__name__) + if _is_auth_upstream_unavailable(exc): + raise _auth_error( + status_code=503, + code="AUTH_SERVICE_UNAVAILABLE", + detail=AUTH_UNAVAILABLE_DETAIL, + ) from exc + raise _auth_error( + status_code=429, + code="AUTH_TOO_MANY_REQUESTS", + detail="Too many requests", + ) from exc + + async def create_phone_session( + self, request: PhoneSessionCreateRequest + ) -> SessionResponse: + client = self._get_client() + payload: dict[str, Any] = { + "type": "sms", + "phone": request.phone, + "token": request.token, + } + try: + verify_otp = cast(Any, client.auth.verify_otp) + response = await asyncio.to_thread(verify_otp, payload) + return _map_auth_response( + response, + "Invalid verification code", + "AUTH_VERIFICATION_CODE_INVALID", + ) + except AuthError as exc: + logger.warning("Create phone session failed", error_type=type(exc).__name__) + if _is_auth_upstream_unavailable(exc): + raise _auth_error( + status_code=503, + code="AUTH_SERVICE_UNAVAILABLE", + detail=AUTH_UNAVAILABLE_DETAIL, + ) from exc + raise _auth_error( + status_code=401, + code="AUTH_VERIFICATION_CODE_INVALID", + detail="Invalid verification code", + ) from exc + + async def refresh_session(self, request: SessionRefreshRequest) -> SessionResponse: + client = self._get_client() + try: + response = await asyncio.to_thread( + client.auth.refresh_session, + request.refresh_token, + ) + return _map_auth_response( + response, + "Invalid refresh token", + "AUTH_REFRESH_TOKEN_INVALID", + ) + except AuthError as exc: + logger.warning("Refresh failed", error_type=type(exc).__name__) + if _is_auth_upstream_unavailable(exc): + raise _auth_error( + status_code=503, + code="AUTH_SERVICE_UNAVAILABLE", + detail=AUTH_UNAVAILABLE_DETAIL, + ) from exc + raise _auth_error( + status_code=401, + code="AUTH_REFRESH_TOKEN_INVALID", + detail="Invalid refresh token", + ) from exc + + async def delete_session(self, refresh_token: str | None) -> None: + if not refresh_token: + raise _auth_error( + status_code=401, + code="AUTH_REFRESH_TOKEN_MISSING", + detail="Missing refresh token", + ) + client = self._get_client() + try: + response = await asyncio.to_thread( + client.auth.refresh_session, + refresh_token, + ) + session = getattr(response, "session", None) + if session is None: + raise _auth_error( + status_code=401, + code="AUTH_REFRESH_TOKEN_INVALID", + detail="Invalid refresh token", + ) + await asyncio.to_thread( + client.auth.set_session, + str(session.access_token), + str(session.refresh_token), + ) + await asyncio.to_thread(client.auth.sign_out) + except AuthError as exc: + logger.warning("Logout failed", error_type=type(exc).__name__) + if _is_auth_upstream_unavailable(exc): + raise _auth_error( + status_code=503, + code="AUTH_SERVICE_UNAVAILABLE", + detail=AUTH_UNAVAILABLE_DETAIL, + ) from exc + raise _auth_error( + status_code=401, + code="AUTH_REFRESH_TOKEN_INVALID", + detail="Invalid refresh token", + ) from exc + + async def get_user_by_phone(self, phone: str) -> UserByPhoneResponse: + normalized_phone = _normalize_phone(phone) + if not normalized_phone: + raise _auth_error( + status_code=404, + code="AUTH_USER_NOT_FOUND", + detail="User not found", + ) + + await self._refresh_user_lookup_cache_if_needed() + + user = self._users_by_phone.get(normalized_phone) + if user is None: + raise _auth_error( + status_code=404, + code="AUTH_USER_NOT_FOUND", + detail="User not found", + ) + + user_phone = _normalize_phone(getattr(user, "phone", "")) + if not user_phone: + raise _auth_error( + status_code=404, + code="AUTH_USER_NOT_FOUND", + detail="User not found", + ) + + return UserByPhoneResponse( + id=str(getattr(user, "id", "")), + phone=user_phone, + created_at=str(getattr(user, "created_at", "")), + phone_confirmed_at=( + str(getattr(user, "phone_confirmed_at", "")) + if getattr(user, "phone_confirmed_at", None) + else None + ), + ) + + async def get_user_by_id(self, user_id: str) -> UserByIdResponse: + users = await self.get_users_by_ids([user_id]) + resolved = users.get(user_id) + if resolved is None: + raise _auth_error( + status_code=404, + code="AUTH_USER_NOT_FOUND", + detail="User not found", + ) + return resolved + + async def get_users_by_ids( + self, user_ids: list[str] + ) -> dict[str, UserByIdResponse]: + await self._refresh_user_lookup_cache_if_needed() + resolved: dict[str, UserByIdResponse] = {} + for raw_user_id in user_ids: + normalized_user_id = raw_user_id.strip() + if not normalized_user_id: + continue + user = self._users_by_id.get(normalized_user_id) + if user is None: + continue + user_attrs = getattr(user, "user", user) + resolved[normalized_user_id] = UserByIdResponse( + id=str(getattr(user_attrs, "id", "")), + phone=getattr(user_attrs, "phone", None), + created_at=str(getattr(user_attrs, "created_at", "")), + phone_confirmed_at=( + str(getattr(user_attrs, "phone_confirmed_at", "")) + if getattr(user_attrs, "phone_confirmed_at", None) + else None + ), + ) + return resolved + + async def search_user_ids_by_phone(self, query: str, limit: int = 20) -> list[str]: + normalized_query = _normalize_phone_search_query(query) + if not normalized_query: + return [] + + await self._refresh_user_lookup_cache_if_needed() + if normalized_query.startswith("+"): + matched_user = self._users_by_phone.get(normalized_query) + if matched_user is None: + return [] + user_id = str(getattr(matched_user, "id", "")) + return [user_id] if user_id else [] + + digits = _digits_only(normalized_query) + if not digits: + return [] + + matched_records: list[tuple[str, str]] = [] + for cached_phone, candidate in self._users_by_phone.items(): + candidate_digits = _digits_only(cached_phone) + if not candidate_digits.endswith(digits): + continue + user_id = str(getattr(candidate, "id", "")) + if user_id: + matched_records.append((cached_phone, user_id)) + + if not matched_records: + return [] + + unique_ids: list[str] = [] + for _, user_id in sorted(matched_records, key=lambda item: item[0]): + if user_id in unique_ids: + continue + unique_ids.append(user_id) + if len(unique_ids) >= max(1, limit): + break + return unique_ids + + async def _refresh_user_lookup_cache_if_needed(self) -> None: + now = time.monotonic() + if now < self._user_lookup_cache_expires_at: + return + + admin_client = self._get_admin_client() + users = await asyncio.to_thread(_list_auth_users, admin_client) + users_by_phone: dict[str, Any] = {} + users_by_id: dict[str, Any] = {} + for candidate in users: + candidate_id = str(getattr(candidate, "id", "")).strip() + if candidate_id: + users_by_id[candidate_id] = candidate + candidate_phone = _normalize_phone(getattr(candidate, "phone", "")) + if candidate_phone: + users_by_phone[candidate_phone] = candidate + self._users_by_id = users_by_id + self._users_by_phone = users_by_phone + self._user_lookup_cache_expires_at = now + self._user_lookup_cache_ttl_seconds + + +def _is_auth_upstream_unavailable(exc: AuthError) -> bool: + raw_status = getattr(exc, "status", None) + if raw_status is None: + raw_status = getattr(exc, "status_code", None) + if isinstance(raw_status, int) and 500 <= raw_status < 600: + return True + + raw_code = getattr(exc, "code", None) + code = str(raw_code).lower() if raw_code is not None else "" + message = str(exc).lower() + indicators = ( + "request_timeout", + "timed out", + "timeout", + "gateway timeout", + "bad_gateway", + "service_unavailable", + "internal_server_error", + "unexpected_failure", + "upstream", + "500", + "502", + "503", + "504", + "5xx", + ) + return any(token in code or token in message for token in indicators) + + +def _map_auth_response( + response: object, failure_message: str, failure_code: str +) -> SessionResponse: + session = getattr(response, "session", None) + user = getattr(response, "user", None) + if session is None or user is None: + raise _auth_error( + status_code=401, + code=failure_code, + detail=failure_message, + ) + + phone = _normalize_phone(getattr(user, "phone", None)) + if not phone: + raise _auth_error( + status_code=401, + code=failure_code, + detail=failure_message, + ) + + try: + auth_user = AuthUser(id=str(user.id), phone=str(phone)) + except ValidationError as exc: + logger.warning( + "Auth response returned invalid phone format", + error_type=type(exc).__name__, + ) + raise _auth_error( + status_code=401, + code=failure_code, + detail=failure_message, + ) from exc + return SessionResponse( + access_token=str(session.access_token), + refresh_token=str(session.refresh_token), + expires_in=int(session.expires_in or 0), + token_type=str(session.token_type), + user=auth_user, + ) + + +def _list_auth_users(client: Any) -> list[Any]: + users: list[Any] = [] + page = 1 + max_pages = 100 + + while page <= max_pages: + response = client.auth.admin.list_users(page=page, per_page=100) + batch = ( + list(response) + if isinstance(response, list) + else list(getattr(response, "users", [])) + ) + users.extend(batch) + + if len(batch) < 100: + break + page += 1 + + return users + + +def _sanitize_phone_token(raw: object) -> str: + token = str(raw).strip() + for separator in (" ", "-", "(", ")"): + token = token.replace(separator, "") + return token + + +def _normalize_phone(raw_phone: object) -> str | None: + phone = _sanitize_phone_token(raw_phone) + if not phone: + return None + if phone.startswith("00") and len(phone) > 2: + return f"+{phone[2:]}" + if phone.startswith("+"): + return phone + if phone.isdigit(): + return f"+{phone}" + return None + + +def _normalize_phone_search_query(raw_query: str) -> str | None: + query = _sanitize_phone_token(raw_query) + if not query: + return None + if query.startswith("00") and len(query) > 2: + return f"+{query[2:]}" + if query.startswith("+"): + return query + if query.isdigit(): + return query + return None + + +def _digits_only(value: str) -> str: + return "".join(ch for ch in value if ch.isdigit()) diff --git a/backend/src/v1/auth/rate_limit.py b/backend/src/v1/auth/rate_limit.py new file mode 100644 index 0000000..e1183ba --- /dev/null +++ b/backend/src/v1/auth/rate_limit.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import asyncio +from collections import deque +from time import monotonic + +from core.http.errors import ApiProblemError + +from core.logging import get_logger +from services.base.redis import get_or_init_redis_client + +_BUCKETS: dict[str, deque[float]] = {} +_LAST_SEEN: dict[str, float] = {} +_LOCK = asyncio.Lock() +_CLEANUP_INTERVAL = 200 +_CALL_COUNT = 0 +logger = get_logger("v1.auth.rate_limit") +_REDIS_LIMIT_SCRIPT = """ +local current = redis.call("INCR", KEYS[1]) +if current == 1 then + redis.call("EXPIRE", KEYS[1], ARGV[1]) +end +return current +""" + + +async def enforce_rate_limit( + *, + scope: str, + identifier: str, + limit: int, + window_seconds: int, +) -> None: + key = f"auth:rate_limit:{scope}:{identifier.lower()}" + try: + await _enforce_rate_limit_with_redis( + key=key, + limit=limit, + window_seconds=window_seconds, + ) + return + except ApiProblemError: + raise + except Exception as exc: # noqa: BLE001 + logger.warning( + "Rate limit fallback to in-memory", + scope=scope, + error_type=type(exc).__name__, + ) + await _enforce_rate_limit_in_memory( + key=key, + limit=limit, + window_seconds=window_seconds, + ) + + +async def _enforce_rate_limit_with_redis( + *, + key: str, + limit: int, + window_seconds: int, +) -> None: + client = await get_or_init_redis_client() + current = await client.eval(_REDIS_LIMIT_SCRIPT, 1, key, window_seconds) # type: ignore[await] + if int(current) > limit: + raise ApiProblemError( + status_code=429, + code="AUTH_TOO_MANY_REQUESTS", + detail="Too many requests", + ) + + +async def _enforce_rate_limit_in_memory( + *, + key: str, + limit: int, + window_seconds: int, +) -> None: + global _CALL_COUNT + now = monotonic() + async with _LOCK: + bucket = _BUCKETS.setdefault(key, deque()) + _LAST_SEEN[key] = now + cutoff = now - float(window_seconds) + while bucket and bucket[0] <= cutoff: + bucket.popleft() + if len(bucket) >= limit: + raise ApiProblemError( + status_code=429, + code="AUTH_TOO_MANY_REQUESTS", + detail="Too many requests", + ) + bucket.append(now) + _CALL_COUNT += 1 + if _CALL_COUNT % _CLEANUP_INTERVAL == 0: + _cleanup_stale_buckets(now) + + +def _cleanup_stale_buckets(now: float) -> None: + stale_keys = [ + key + for key, last_seen in _LAST_SEEN.items() + if key not in _BUCKETS or (not _BUCKETS[key] and now - last_seen > 3600) + ] + for key in stale_keys: + _BUCKETS.pop(key, None) + _LAST_SEEN.pop(key, None) + + +def reset_rate_limit_state() -> None: + _BUCKETS.clear() + _LAST_SEEN.clear() + global _CALL_COUNT + _CALL_COUNT = 0 diff --git a/backend/src/v1/auth/registration_bootstrap.py b/backend/src/v1/auth/registration_bootstrap.py new file mode 100644 index 0000000..d5de7f4 --- /dev/null +++ b/backend/src/v1/auth/registration_bootstrap.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +from datetime import UTC, datetime, time, timedelta +from typing import Protocol +from uuid import UUID, uuid4 +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from core.logging import get_logger +from models.automation_jobs import AutomationJob +from schemas.enums import AutomationJobStatus, MemoryType, ScheduleType +from models.profile import Profile +from schemas.domain.automation import AutomationJobConfig, ScheduleConfig +from schemas.domain.memory_content import UserMemoryContent, WorkProfileContent +from schemas.shared.user import parse_profile_settings +from v1.auth.automation_static_config import load_static_automation_job_config +from v1.auth.schemas import RegistrationBootstrapRequest +from v1.memories.repository import SQLAlchemyMemoriesRepository + +logger = get_logger("v1.auth.registration_bootstrap") + + +class RegistrationBootstrapRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + self._memories_repository = SQLAlchemyMemoriesRepository(session) + + async def get_profile_timezone(self, *, user_id: UUID) -> str: + stmt = select(Profile.settings).where(Profile.id == user_id) + settings = (await self._session.execute(stmt)).scalar_one_or_none() + parsed = parse_profile_settings( + settings if isinstance(settings, dict) else None + ) + return parsed.preferences.timezone + + async def insert_bootstrap_automation_job_if_absent( + self, + *, + owner_id: UUID, + bootstrap_key: str, + title: str, + config: AutomationJobConfig, + timezone_name: str, + next_run_at: datetime, + ) -> bool: + stmt = ( + insert(AutomationJob) + .values( + id=uuid4(), + owner_id=owner_id, + bootstrap_key=bootstrap_key, + title=title, + config=config.model_dump(mode="json"), + next_run_at=next_run_at, + timezone=timezone_name, + status=AutomationJobStatus.ACTIVE, + created_by=owner_id, + ) + .on_conflict_do_nothing( + index_elements=["owner_id", "bootstrap_key"], + index_where=AutomationJob.deleted_at.is_(None) + & AutomationJob.bootstrap_key.is_not(None), + ) + .returning(AutomationJob.id) + ) + inserted_id = (await self._session.execute(stmt)).scalar_one_or_none() + await self._session.flush() + return inserted_id is not None + + async def upsert_initial_memory( + self, + *, + owner_id: UUID, + memory_type: MemoryType, + content: dict, + ) -> bool: + return await self._memories_repository.create_if_absent( + owner_id=owner_id, + memory_type=memory_type, + content=content, + ) + + +class RegistrationBootstrapper(Protocol): + async def ensure_user_automation_jobs(self, *, user_id: str | UUID) -> None: ... + + +class RegistrationBootstrapRepositoryLike(Protocol): + async def get_profile_timezone(self, *, user_id: UUID) -> str: ... + + async def insert_bootstrap_automation_job_if_absent( + self, + *, + owner_id: UUID, + bootstrap_key: str, + title: str, + config: AutomationJobConfig, + timezone_name: str, + next_run_at: datetime, + ) -> bool: ... + + async def upsert_initial_memory( + self, + *, + owner_id: UUID, + memory_type: MemoryType, + content: dict, + ) -> bool: ... + + +class SessionLike(Protocol): + async def commit(self) -> None: ... + + async def rollback(self) -> None: ... + + +def compute_first_run_at_utc( + *, + now_utc: datetime, + timezone_name: str, + schedule: ScheduleConfig, +) -> datetime: + try: + timezone_obj = ZoneInfo(timezone_name) + except ZoneInfoNotFoundError: + timezone_obj = ZoneInfo("UTC") + + local_now = now_utc.astimezone(timezone_obj) + run_clock = time( + hour=schedule.run_at.hour, + minute=schedule.run_at.minute, + tzinfo=timezone_obj, + ) + + if schedule.type == ScheduleType.DAILY: + candidate_local = datetime.combine(local_now.date(), run_clock) + if candidate_local <= local_now: + candidate_local = candidate_local + timedelta(days=1) + return candidate_local.astimezone(UTC) + + weekdays = schedule.weekdays or [] + if not weekdays: + raise ValueError("weekly schedule requires weekdays") + + normalized_weekdays = sorted(set(weekdays)) + for day_offset in range(0, 8): + candidate_day = local_now.date() + timedelta(days=day_offset) + if candidate_day.isoweekday() not in normalized_weekdays: + continue + candidate_local = datetime.combine(candidate_day, run_clock) + if candidate_local > local_now: + return candidate_local.astimezone(UTC) + + fallback_day = local_now.date() + timedelta(days=7) + while fallback_day.isoweekday() not in normalized_weekdays: + fallback_day = fallback_day + timedelta(days=1) + fallback_local = datetime.combine(fallback_day, run_clock) + return fallback_local.astimezone(UTC) + + +class RegistrationAutomationBootstrapService: + def __init__( + self, + *, + repository: RegistrationBootstrapRepositoryLike, + session: SessionLike, + ) -> None: + self._repository = repository + self._session = session + + async def ensure_user_automation_jobs(self, *, user_id: str | UUID) -> None: + request = RegistrationBootstrapRequest.model_validate({"user_id": user_id}) + owner_id = request.user_id + timezone_name = await self._repository.get_profile_timezone(user_id=owner_id) + + definitions = [ + { + "bootstrap_key": "memory_extraction", + "config_name": "memory_extraction", + "title": "记忆推送", + } + ] + + try: + inserted_any = False + created_or_updated_memory = False + + user_initialized = await self._repository.upsert_initial_memory( + owner_id=owner_id, + memory_type=MemoryType.USER, + content=UserMemoryContent().model_dump(mode="json"), + ) + work_initialized = await self._repository.upsert_initial_memory( + owner_id=owner_id, + memory_type=MemoryType.WORK, + content=WorkProfileContent().model_dump(mode="json"), + ) + created_or_updated_memory = user_initialized or work_initialized + + for definition in definitions: + bootstrap_key = str(definition["bootstrap_key"]) + job_config = load_static_automation_job_config( + config_name=str(definition["config_name"]) + ) + schedule = job_config.schedule + if schedule is None: + raise ValueError( + f"bootstrap job {bootstrap_key} has no schedule configured" + ) + next_run_at = compute_first_run_at_utc( + now_utc=datetime.now(UTC), + timezone_name=timezone_name, + schedule=schedule, + ) + inserted = ( + await self._repository.insert_bootstrap_automation_job_if_absent( + owner_id=owner_id, + bootstrap_key=bootstrap_key, + title=str(definition["title"]), + config=job_config, + timezone_name=timezone_name, + next_run_at=next_run_at, + ) + ) + inserted_any = inserted_any or inserted + if inserted_any or created_or_updated_memory: + await self._session.commit() + logger.info( + "user automation jobs bootstrapped", + user_id=user_id, + timezone=timezone_name, + memory_initialized=created_or_updated_memory, + ) + except Exception: + await self._session.rollback() + raise diff --git a/backend/src/v1/auth/router.py b/backend/src/v1/auth/router.py new file mode 100644 index 0000000..2cad943 --- /dev/null +++ b/backend/src/v1/auth/router.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request, Response + +from core.config.settings import config +from v1.auth.rate_limit import enforce_rate_limit +from v1.auth.dependencies import get_auth_service +from v1.auth.schemas import ( + OtpSendRequest, + PhoneSessionCreateRequest, + SessionDeleteRequest, + SessionRefreshRequest, + SessionResponse, +) +from v1.auth.service import AuthService + + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/otp/send", status_code=204) +async def send_otp( + payload: OtpSendRequest, + request: Request, + service: AuthService = Depends(get_auth_service), +) -> Response: + client_ip = _client_ip(request) + await enforce_rate_limit( + scope="otp_send_phone", + identifier=payload.phone, + limit=3, + window_seconds=60, + ) + await enforce_rate_limit( + scope="otp_send_ip", + identifier=client_ip, + limit=20, + window_seconds=60, + ) + await service.send_otp(payload) + return Response(status_code=204) + + +@router.post("/phone-session", response_model=SessionResponse) +async def create_phone_session( + payload: PhoneSessionCreateRequest, + request: Request, + service: AuthService = Depends(get_auth_service), +) -> SessionResponse: + client_ip = _client_ip(request) + await enforce_rate_limit( + scope="phone_session_phone", + identifier=payload.phone, + limit=6, + window_seconds=300, + ) + await enforce_rate_limit( + scope="phone_session_ip", + identifier=client_ip, + limit=20, + window_seconds=300, + ) + return await service.create_phone_session(payload) + + +@router.post("/sessions/refresh", response_model=SessionResponse) +async def refresh_session( + payload: SessionRefreshRequest, + request: Request, + service: AuthService = Depends(get_auth_service), +) -> SessionResponse: + await enforce_rate_limit( + scope="refresh", + identifier=_client_ip(request), + limit=10, + window_seconds=60, + ) + return await service.refresh_session(payload) + + +@router.delete("/sessions", status_code=204) +async def delete_session( + payload: SessionDeleteRequest, + request: Request, + service: AuthService = Depends(get_auth_service), +) -> Response: + await enforce_rate_limit( + scope="logout", + identifier=_client_ip(request), + limit=10, + window_seconds=60, + ) + await service.delete_session(payload.refresh_token) + return Response(status_code=204) + + +def _client_ip(request: Request) -> str: + host = request.client.host if request.client else "" + if not host: + return "unknown" + + if _should_trust_proxy_headers(host): + forwarded_for = request.headers.get("x-forwarded-for", "") + if forwarded_for: + first = forwarded_for.split(",")[0].strip() + if first: + return first + real_ip = request.headers.get("x-real-ip", "").strip() + if real_ip: + return real_ip + + return host + + +def _should_trust_proxy_headers(host: str) -> bool: + trusted_proxies = {entry.strip() for entry in config.runtime.trusted_proxy_ips} + return host in trusted_proxies diff --git a/backend/src/v1/auth/schemas.py b/backend/src/v1/auth/schemas.py new file mode 100644 index 0000000..446283a --- /dev/null +++ b/backend/src/v1/auth/schemas.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +SUPABASE_PASSWORD_MIN_LENGTH = 6 +SUPABASE_PHONE_PATTERN = r"^\+[1-9]\d{7,14}$" + + +class OtpSendRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + phone: str = Field(pattern=SUPABASE_PHONE_PATTERN) + + +class PhoneSessionCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + phone: str = Field(pattern=SUPABASE_PHONE_PATTERN) + token: str = Field(pattern=r"^\d{6}$") + + +class SessionRefreshRequest(BaseModel): + refresh_token: str = Field(min_length=1) + + +class SessionDeleteRequest(BaseModel): + refresh_token: str = Field(min_length=1) + + +class AuthUser(BaseModel): + id: str + phone: str = Field(pattern=SUPABASE_PHONE_PATTERN) + + +class SessionResponse(BaseModel): + access_token: str + refresh_token: str + expires_in: int + token_type: str + user: AuthUser + + +class UserByPhoneResponse(BaseModel): + id: str + phone: str = Field(pattern=SUPABASE_PHONE_PATTERN) + created_at: str + phone_confirmed_at: str | None = None + + +class UserByIdResponse(BaseModel): + id: str + phone: str | None = None + created_at: str + phone_confirmed_at: str | None = None + + +class OtpSendResponse(BaseModel): + phone: str = Field(pattern=SUPABASE_PHONE_PATTERN) + + +class RegistrationBootstrapRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + user_id: UUID diff --git a/backend/src/v1/auth/service.py b/backend/src/v1/auth/service.py new file mode 100644 index 0000000..d16e2ce --- /dev/null +++ b/backend/src/v1/auth/service.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from typing import Protocol + +from v1.auth.schemas import ( + OtpSendRequest, + PhoneSessionCreateRequest, + SessionRefreshRequest, + SessionResponse, +) + + +class AuthServiceGateway(Protocol): + async def send_otp(self, request: OtpSendRequest) -> None: + raise NotImplementedError + + async def create_phone_session( + self, request: PhoneSessionCreateRequest + ) -> SessionResponse: + raise NotImplementedError + + async def refresh_session(self, request: SessionRefreshRequest) -> SessionResponse: + raise NotImplementedError + + async def delete_session(self, refresh_token: str | None) -> None: + raise NotImplementedError + + +class AuthService: + _gateway: AuthServiceGateway + _registration_bootstrapper: RegistrationBootstrapper | None + + def __init__( + self, + gateway: AuthServiceGateway, + registration_bootstrapper: "RegistrationBootstrapper | None" = None, + ) -> None: + self._gateway = gateway + self._registration_bootstrapper = registration_bootstrapper + + async def send_otp(self, request: OtpSendRequest) -> None: + await self._gateway.send_otp(request) + + async def create_phone_session( + self, request: PhoneSessionCreateRequest + ) -> SessionResponse: + response = await self._gateway.create_phone_session(request) + if self._registration_bootstrapper is not None: + await self._registration_bootstrapper.ensure_user_automation_jobs( + user_id=response.user.id + ) + return response + + async def refresh_session(self, request: SessionRefreshRequest) -> SessionResponse: + return await self._gateway.refresh_session(request) + + async def delete_session(self, refresh_token: str | None) -> None: + await self._gateway.delete_session(refresh_token) + + +class RegistrationBootstrapper(Protocol): + async def ensure_user_automation_jobs(self, *, user_id: str) -> None: + raise NotImplementedError diff --git a/docs/reference/backend-features.md b/docs/references/backend-features.md similarity index 100% rename from docs/reference/backend-features.md rename to docs/references/backend-features.md diff --git a/infra/docker/docker-compose.yml b/infra/docker/docker-compose.yml index 70ec29e..329213c 100644 --- a/infra/docker/docker-compose.yml +++ b/infra/docker/docker-compose.yml @@ -1,5 +1,8 @@ name: eryao-local +include: + - ./supabase/docker-compose.yml + services: redis: image: redis:7-alpine diff --git a/infra/docker/supabase/docker-compose.yml b/infra/docker/supabase/docker-compose.yml new file mode 100644 index 0000000..add41e1 --- /dev/null +++ b/infra/docker/supabase/docker-compose.yml @@ -0,0 +1,214 @@ +name: supabase + +services: + db: + container_name: supabase-db + image: supabase/postgres:15.8.1.085 + restart: unless-stopped + volumes: + - ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:ro + - ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:ro + - ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:ro + - ./volumes/db/_supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:ro + - ./volumes/db/local-dev-grants.sql:/docker-entrypoint-initdb.d/init-scripts/100-local-dev-grants.sql:ro + - ./volumes/db/data:/var/lib/postgresql/data + - db-config:/etc/postgresql-custom + healthcheck: + test: ["CMD", "pg_isready", "-U", "postgres", "-h", "localhost"] + interval: 5s + timeout: 5s + retries: 10 + environment: + POSTGRES_HOST: /var/run/postgresql + PGPORT: 5432 + POSTGRES_PORT: 5432 + PGPASSWORD: ${ERYAO_DATABASE__PASSWORD} + POSTGRES_PASSWORD: ${ERYAO_DATABASE__PASSWORD} + PGDATABASE: ${ERYAO_DATABASE__NAME:-eryao} + POSTGRES_DB: ${ERYAO_DATABASE__NAME:-eryao} + JWT_SECRET: ${ERYAO_SUPABASE__JWT_SECRET} + JWT_EXP: 3600 + command: ["postgres", "-c", "config_file=/etc/postgresql/postgresql.conf", "-c", "log_min_messages=fatal"] + ports: + - 127.0.0.1:${ERYAO_DATABASE__PORT:-5432}:5432 + + auth: + container_name: supabase-auth + image: supabase/gotrue:v2.186.0 + restart: unless-stopped + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9999/health"] + interval: 5s + timeout: 5s + retries: 3 + environment: + GOTRUE_API_HOST: 0.0.0.0 + GOTRUE_API_PORT: 9999 + API_EXTERNAL_URL: ${ERYAO_SUPABASE__PUBLIC_URL:-http://localhost:8001} + GOTRUE_DB_DRIVER: postgres + GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${ERYAO_DATABASE__PASSWORD}@db:5432/${ERYAO_DATABASE__NAME:-eryao} + GOTRUE_SITE_URL: http://localhost:3000 + GOTRUE_URI_ALLOW_LIST: "" + GOTRUE_DISABLE_SIGNUP: "false" + GOTRUE_JWT_ADMIN_ROLES: service_role + GOTRUE_JWT_AUD: authenticated + GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated + GOTRUE_JWT_EXP: 3600 + GOTRUE_JWT_SECRET: ${ERYAO_SUPABASE__JWT_SECRET} + GOTRUE_MAILER_AUTOCONFIRM: "false" + GOTRUE_SMTP_ADMIN_EMAIL: dev@example.com + GOTRUE_SMTP_HOST: localhost + GOTRUE_SMTP_PORT: 2500 + GOTRUE_SMTP_USER: disabled + GOTRUE_SMTP_PASS: disabled + GOTRUE_SMTP_SENDER_NAME: disabled + GOTRUE_MAILER_URLPATHS_INVITE: /auth/v1/verify + GOTRUE_MAILER_URLPATHS_CONFIRMATION: /auth/v1/verify + GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/v1/verify + GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/v1/verify + + rest: + container_name: supabase-rest + image: postgrest/postgrest:v14.6 + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + PGRST_DB_URI: postgres://authenticator:${ERYAO_DATABASE__PASSWORD}@db:5432/${ERYAO_DATABASE__NAME:-eryao} + PGRST_DB_SCHEMAS: public,storage,graphql_public + PGRST_DB_MAX_ROWS: 1000 + PGRST_DB_EXTRA_SEARCH_PATH: public + PGRST_DB_ANON_ROLE: anon + PGRST_JWT_SECRET: ${ERYAO_SUPABASE__JWT_SECRET} + PGRST_DB_USE_LEGACY_GUCS: "false" + PGRST_APP_SETTINGS_JWT_SECRET: ${ERYAO_SUPABASE__JWT_SECRET} + PGRST_APP_SETTINGS_JWT_EXP: 3600 + + storage: + container_name: supabase-storage + image: supabase/storage-api:v1.44.2 + restart: unless-stopped + depends_on: + db: + condition: service_healthy + rest: + condition: service_started + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://storage:5000/status"] + interval: 5s + timeout: 5s + retries: 3 + start_period: 10s + environment: + ANON_KEY: ${ERYAO_SUPABASE__ANON_KEY} + SERVICE_KEY: ${ERYAO_SUPABASE__SERVICE_ROLE_KEY} + POSTGREST_URL: http://rest:3000 + AUTH_JWT_SECRET: ${ERYAO_SUPABASE__JWT_SECRET} + DATABASE_URL: postgres://supabase_storage_admin:${ERYAO_DATABASE__PASSWORD}@db:5432/${ERYAO_DATABASE__NAME:-eryao} + STORAGE_PUBLIC_URL: ${ERYAO_SUPABASE__PUBLIC_URL:-http://localhost:8001} + REQUEST_ALLOW_X_FORWARDED_PATH: "true" + FILE_SIZE_LIMIT: 52428800 + STORAGE_BACKEND: file + GLOBAL_S3_BUCKET: ${ERYAO_STORAGE__ATTACHMENT__BUCKET:-agent-chat-attachments} + FILE_STORAGE_BACKEND_PATH: /var/lib/storage + TENANT_ID: local + REGION: local + ENABLE_IMAGE_TRANSFORMATION: "false" + volumes: + - ./volumes/storage:/var/lib/storage + + meta: + container_name: supabase-meta + image: supabase/postgres-meta:v0.95.2 + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + PG_META_PORT: 8080 + PG_META_DB_HOST: db + PG_META_DB_PORT: 5432 + PG_META_DB_NAME: ${ERYAO_DATABASE__NAME:-eryao} + PG_META_DB_USER: postgres + PG_META_DB_PASSWORD: ${ERYAO_DATABASE__PASSWORD} + PG_META_DB_SSL_MODE: disable + healthcheck: + test: ["CMD", "/bin/sh", "-c", "exit 0"] + interval: 10s + timeout: 5s + retries: 1 + + studio: + container_name: supabase-studio + image: supabase/studio:2026.03.16-sha-5528817 + restart: unless-stopped + depends_on: + meta: + condition: service_healthy + environment: + STUDIO_PG_META_URL: http://meta:8080 + POSTGRES_PASSWORD: ${ERYAO_DATABASE__PASSWORD} + POSTGRES_HOST: db + POSTGRES_PORT: 5432 + POSTGRES_DB: ${ERYAO_DATABASE__NAME:-eryao} + POSTGRES_USER: supabase_admin + DEFAULT_ORGANIZATION_NAME: Default Organization + DEFAULT_PROJECT_NAME: Default Project + SUPABASE_URL: http://kong:8000 + SUPABASE_PUBLIC_URL: ${ERYAO_SUPABASE__PUBLIC_URL:-http://localhost:8001} + SUPABASE_ANON_KEY: ${ERYAO_SUPABASE__ANON_KEY} + SUPABASE_SERVICE_KEY: ${ERYAO_SUPABASE__SERVICE_ROLE_KEY} + AUTH_JWT_SECRET: ${ERYAO_SUPABASE__JWT_SECRET} + EDGE_FUNCTIONS_MANAGEMENT_FOLDER: /tmp/functions + LOGFLARE_API_KEY: local-logflare-public-token + LOGFLARE_URL: http://localhost:4000 + NEXT_PUBLIC_ENABLE_LOGS: "false" + + kong: + container_name: supabase-kong + image: kong/kong:3.9.1 + restart: unless-stopped + depends_on: + auth: + condition: service_healthy + rest: + condition: service_started + storage: + condition: service_healthy + studio: + condition: service_started + meta: + condition: service_healthy + healthcheck: + test: ["CMD", "kong", "health"] + interval: 5s + timeout: 5s + retries: 5 + ports: + - 127.0.0.1:8001:8000/tcp + - 127.0.0.1:8443:8443/tcp + volumes: + - ./volumes/api/kong.yml:/home/kong/temp.yml:ro + - ./volumes/api/kong-entrypoint.sh:/home/kong/kong-entrypoint.sh:ro + environment: + KONG_DATABASE: "off" + KONG_DECLARATIVE_CONFIG: /usr/local/kong/kong.yml + KONG_DNS_ORDER: LAST,A,CNAME + KONG_DNS_NOT_FOUND_TTL: 1 + KONG_PLUGINS: request-transformer,cors,key-auth,acl,post-function,basic-auth,ip-restriction + SUPABASE_ANON_KEY: ${ERYAO_SUPABASE__ANON_KEY} + SUPABASE_SERVICE_KEY: ${ERYAO_SUPABASE__SERVICE_ROLE_KEY} + SUPABASE_PUBLISHABLE_KEY: "" + SUPABASE_SECRET_KEY: "" + ANON_KEY_ASYMMETRIC: "" + SERVICE_ROLE_KEY_ASYMMETRIC: "" + DASHBOARD_USERNAME: localadmin + DASHBOARD_PASSWORD: LocalAdmin-Change-This-Now + entrypoint: /home/kong/kong-entrypoint.sh + +volumes: + db-config: diff --git a/infra/docker/supabase/volumes/api/kong-entrypoint.sh b/infra/docker/supabase/volumes/api/kong-entrypoint.sh new file mode 100755 index 0000000..176f058 --- /dev/null +++ b/infra/docker/supabase/volumes/api/kong-entrypoint.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +if [ -n "$SUPABASE_SECRET_KEY" ] && [ -n "$SUPABASE_PUBLISHABLE_KEY" ]; then + export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or (headers.apikey == '$SUPABASE_SECRET_KEY' and 'Bearer $SERVICE_ROLE_KEY_ASYMMETRIC') or (headers.apikey == '$SUPABASE_PUBLISHABLE_KEY' and 'Bearer $ANON_KEY_ASYMMETRIC') or headers.apikey)" +else + export LUA_AUTH_EXPR="\$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_' and headers.authorization) or headers.apikey)" +fi + +awk '{ + result = "" + rest = $0 + while (match(rest, /\$[A-Za-z_][A-Za-z_0-9]*/)) { + varname = substr(rest, RSTART + 1, RLENGTH - 1) + if (varname in ENVIRON) { + result = result substr(rest, 1, RSTART - 1) ENVIRON[varname] + } else { + result = result substr(rest, 1, RSTART + RLENGTH - 1) + } + rest = substr(rest, RSTART + RLENGTH) + } + print result rest +}' /home/kong/temp.yml > "$KONG_DECLARATIVE_CONFIG" + +sed -i '/^[[:space:]]*- key:[[:space:]]*$/d' "$KONG_DECLARATIVE_CONFIG" + +exec /entrypoint.sh kong docker-start diff --git a/infra/docker/supabase/volumes/api/kong.yml b/infra/docker/supabase/volumes/api/kong.yml new file mode 100644 index 0000000..b2356a3 --- /dev/null +++ b/infra/docker/supabase/volumes/api/kong.yml @@ -0,0 +1,177 @@ +_format_version: '2.1' +_transform: true + +consumers: + - username: DASHBOARD + - username: anon + keyauth_credentials: + - key: $SUPABASE_ANON_KEY + - username: service_role + keyauth_credentials: + - key: $SUPABASE_SERVICE_KEY + +acls: + - consumer: anon + group: anon + - consumer: service_role + group: admin + +basicauth_credentials: + - consumer: DASHBOARD + username: "$DASHBOARD_USERNAME" + password: "$DASHBOARD_PASSWORD" + +services: + - name: auth-v1-open + url: http://auth:9999/verify + routes: + - name: auth-v1-open + strip_path: true + paths: + - /auth/v1/verify + plugins: + - name: cors + + - name: auth-v1-open-callback + url: http://auth:9999/callback + routes: + - name: auth-v1-open-callback + strip_path: true + paths: + - /auth/v1/callback + plugins: + - name: cors + + - name: auth-v1-open-jwks + url: http://auth:9999/.well-known/jwks.json + routes: + - name: auth-v1-open-jwks + strip_path: true + paths: + - /auth/v1/.well-known/jwks.json + plugins: + - name: cors + + - name: auth-v1 + url: http://auth:9999/ + routes: + - name: auth-v1-all + strip_path: true + paths: + - /auth/v1/ + plugins: + - name: cors + - name: key-auth + - name: request-transformer + config: + add: + headers: + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" + - name: acl + config: + allow: + - admin + - anon + + - name: rest-v1 + url: http://rest:3000/ + routes: + - name: rest-v1-all + strip_path: true + paths: + - /rest/v1/ + plugins: + - name: cors + - name: key-auth + - name: request-transformer + config: + add: + headers: + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" + - name: acl + config: + allow: + - admin + - anon + + - name: storage-v1 + url: http://storage:5000/ + routes: + - name: storage-v1-all + strip_path: true + paths: + - /storage/v1/ + plugins: + - name: cors + - name: request-transformer + config: + add: + headers: + - "Authorization: $LUA_AUTH_EXPR" + replace: + headers: + - "Authorization: $LUA_AUTH_EXPR" + - name: post-function + config: + access: + - | + local auth = kong.request.get_header("authorization") + if auth == nil or auth == "" or auth:find("^%s*$") then + kong.service.request.clear_header("authorization") + end + + - name: meta + url: http://meta:8080/ + routes: + - name: meta-all + strip_path: true + paths: + - /pg/ + plugins: + - name: key-auth + - name: acl + config: + allow: + - admin + + - name: dashboard + url: http://studio:3000/ + routes: + - name: dashboard-all + strip_path: true + paths: + - / + plugins: + - name: cors + - name: basic-auth + config: + hide_credentials: true + + - name: mcp + _comment: 'MCP: /mcp -> http://studio:3000/api/mcp' + url: http://studio:3000/api/mcp + routes: + - name: mcp + strip_path: true + paths: + - /mcp + plugins: + - name: cors + - name: ip-restriction + config: + allow: + - 127.0.0.1 + - ::1 + - 172.17.0.1 + - 172.18.0.1 + - 172.19.0.1 + - 172.20.0.1 + - 172.21.0.1 + - 172.22.0.1 + deny: [] diff --git a/infra/docker/supabase/volumes/db/_supabase.sql b/infra/docker/supabase/volumes/db/_supabase.sql new file mode 100644 index 0000000..6236ae1 --- /dev/null +++ b/infra/docker/supabase/volumes/db/_supabase.sql @@ -0,0 +1,3 @@ +\set pguser `echo "$POSTGRES_USER"` + +CREATE DATABASE _supabase WITH OWNER :pguser; diff --git a/infra/docker/supabase/volumes/db/jwt.sql b/infra/docker/supabase/volumes/db/jwt.sql new file mode 100644 index 0000000..cfd3b16 --- /dev/null +++ b/infra/docker/supabase/volumes/db/jwt.sql @@ -0,0 +1,5 @@ +\set jwt_secret `echo "$JWT_SECRET"` +\set jwt_exp `echo "$JWT_EXP"` + +ALTER DATABASE postgres SET "app.settings.jwt_secret" TO :'jwt_secret'; +ALTER DATABASE postgres SET "app.settings.jwt_exp" TO :'jwt_exp'; diff --git a/infra/docker/supabase/volumes/db/local-dev-grants.sql b/infra/docker/supabase/volumes/db/local-dev-grants.sql new file mode 100644 index 0000000..0d3116a --- /dev/null +++ b/infra/docker/supabase/volumes/db/local-dev-grants.sql @@ -0,0 +1,2 @@ +grant usage on schema public to postgres; +grant create on schema public to postgres; diff --git a/infra/docker/supabase/volumes/db/roles.sql b/infra/docker/supabase/volumes/db/roles.sql new file mode 100644 index 0000000..db3d152 --- /dev/null +++ b/infra/docker/supabase/volumes/db/roles.sql @@ -0,0 +1,9 @@ +-- NOTE: change to your own passwords for production environments +\set pgpass `echo "$POSTGRES_PASSWORD"` + +ALTER USER authenticator WITH PASSWORD :'pgpass'; +ALTER USER pgbouncer WITH PASSWORD :'pgpass'; +ALTER USER supabase_auth_admin WITH PASSWORD :'pgpass'; +ALTER USER supabase_functions_admin WITH PASSWORD :'pgpass'; +ALTER USER supabase_storage_admin WITH PASSWORD :'pgpass'; +ALTER USER supabase_read_only_user WITH PASSWORD :'pgpass'; diff --git a/infra/docker/supabase/volumes/db/webhooks.sql b/infra/docker/supabase/volumes/db/webhooks.sql new file mode 100644 index 0000000..5837b86 --- /dev/null +++ b/infra/docker/supabase/volumes/db/webhooks.sql @@ -0,0 +1,208 @@ +BEGIN; + -- Create pg_net extension + CREATE EXTENSION IF NOT EXISTS pg_net SCHEMA extensions; + -- Create supabase_functions schema + CREATE SCHEMA supabase_functions AUTHORIZATION supabase_admin; + GRANT USAGE ON SCHEMA supabase_functions TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON FUNCTIONS TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES TO postgres, anon, authenticated, service_role; + -- supabase_functions.migrations definition + CREATE TABLE supabase_functions.migrations ( + version text PRIMARY KEY, + inserted_at timestamptz NOT NULL DEFAULT NOW() + ); + -- Initial supabase_functions migration + INSERT INTO supabase_functions.migrations (version) VALUES ('initial'); + -- supabase_functions.hooks definition + CREATE TABLE supabase_functions.hooks ( + id bigserial PRIMARY KEY, + hook_table_id integer NOT NULL, + hook_name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT NOW(), + request_id bigint + ); + CREATE INDEX supabase_functions_hooks_request_id_idx ON supabase_functions.hooks USING btree (request_id); + CREATE INDEX supabase_functions_hooks_h_table_id_h_name_idx ON supabase_functions.hooks USING btree (hook_table_id, hook_name); + COMMENT ON TABLE supabase_functions.hooks IS 'Supabase Functions Hooks: Audit trail for triggered hooks.'; + CREATE FUNCTION supabase_functions.http_request() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + DECLARE + request_id bigint; + payload jsonb; + url text := TG_ARGV[0]::text; + method text := TG_ARGV[1]::text; + headers jsonb DEFAULT '{}'::jsonb; + params jsonb DEFAULT '{}'::jsonb; + timeout_ms integer DEFAULT 1000; + BEGIN + IF url IS NULL OR url = 'null' THEN + RAISE EXCEPTION 'url argument is missing'; + END IF; + + IF method IS NULL OR method = 'null' THEN + RAISE EXCEPTION 'method argument is missing'; + END IF; + + IF TG_ARGV[2] IS NULL OR TG_ARGV[2] = 'null' THEN + headers = '{"Content-Type": "application/json"}'::jsonb; + ELSE + headers = TG_ARGV[2]::jsonb; + END IF; + + IF TG_ARGV[3] IS NULL OR TG_ARGV[3] = 'null' THEN + params = '{}'::jsonb; + ELSE + params = TG_ARGV[3]::jsonb; + END IF; + + IF TG_ARGV[4] IS NULL OR TG_ARGV[4] = 'null' THEN + timeout_ms = 1000; + ELSE + timeout_ms = TG_ARGV[4]::integer; + END IF; + + CASE + WHEN method = 'GET' THEN + SELECT http_get INTO request_id FROM net.http_get( + url, + params, + headers, + timeout_ms + ); + WHEN method = 'POST' THEN + payload = jsonb_build_object( + 'old_record', OLD, + 'record', NEW, + 'type', TG_OP, + 'table', TG_TABLE_NAME, + 'schema', TG_TABLE_SCHEMA + ); + + SELECT http_post INTO request_id FROM net.http_post( + url, + payload, + params, + headers, + timeout_ms + ); + ELSE + RAISE EXCEPTION 'method argument % is invalid', method; + END CASE; + + INSERT INTO supabase_functions.hooks + (hook_table_id, hook_name, request_id) + VALUES + (TG_RELID, TG_NAME, request_id); + + RETURN NEW; + END + $function$; + -- Supabase super admin + DO + $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = 'supabase_functions_admin' + ) + THEN + CREATE USER supabase_functions_admin NOINHERIT CREATEROLE LOGIN NOREPLICATION; + END IF; + END + $$; + GRANT ALL PRIVILEGES ON SCHEMA supabase_functions TO supabase_functions_admin; + GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA supabase_functions TO supabase_functions_admin; + GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA supabase_functions TO supabase_functions_admin; + ALTER USER supabase_functions_admin SET search_path = "supabase_functions"; + ALTER table "supabase_functions".migrations OWNER TO supabase_functions_admin; + ALTER table "supabase_functions".hooks OWNER TO supabase_functions_admin; + ALTER function "supabase_functions".http_request() OWNER TO supabase_functions_admin; + GRANT supabase_functions_admin TO postgres; + -- Remove unused supabase_pg_net_admin role + DO + $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = 'supabase_pg_net_admin' + ) + THEN + REASSIGN OWNED BY supabase_pg_net_admin TO supabase_admin; + DROP OWNED BY supabase_pg_net_admin; + DROP ROLE supabase_pg_net_admin; + END IF; + END + $$; + -- pg_net grants when extension is already enabled + DO + $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_extension + WHERE extname = 'pg_net' + ) + THEN + GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + END IF; + END + $$; + -- Event trigger for pg_net + CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access() + RETURNS event_trigger + LANGUAGE plpgsql + AS $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_event_trigger_ddl_commands() AS ev + JOIN pg_extension AS ext + ON ev.objid = ext.oid + WHERE ext.extname = 'pg_net' + ) + THEN + GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + END IF; + END; + $$; + COMMENT ON FUNCTION extensions.grant_pg_net_access IS 'Grants access to pg_net'; + DO + $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_event_trigger + WHERE evtname = 'issue_pg_net_access' + ) THEN + CREATE EVENT TRIGGER issue_pg_net_access ON ddl_command_end WHEN TAG IN ('CREATE EXTENSION') + EXECUTE PROCEDURE extensions.grant_pg_net_access(); + END IF; + END + $$; + INSERT INTO supabase_functions.migrations (version) VALUES ('20210809183423_update_grants'); + ALTER function supabase_functions.http_request() SECURITY DEFINER; + ALTER function supabase_functions.http_request() SET search_path = supabase_functions; + REVOKE ALL ON FUNCTION supabase_functions.http_request() FROM PUBLIC; + GRANT EXECUTE ON FUNCTION supabase_functions.http_request() TO postgres, anon, authenticated, service_role; +COMMIT; diff --git a/infra/scripts/app.sh b/infra/scripts/app.sh index 852dfa6..a7ce7de 100755 --- a/infra/scripts/app.sh +++ b/infra/scripts/app.sh @@ -1,7 +1,7 @@ #!/bin/bash set -euo pipefail -ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" SESSION_NAME="${SESSION_NAME:-eryao-dev}" ENV_FILE="$ROOT_DIR/.env" @@ -154,9 +154,14 @@ start() { WEB_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src ERYAO_RUNTIME__SERVICE_NAME=web uv run uvicorn backend.src.app:app --host ${ERYAO_WEB__HOST:-0.0.0.0} --port ${WEB_PORT} --workers ${ERYAO_WEB__WORKERS:-2} --log-level ${UVICORN_LOG_LEVEL}" + WORKER_AGENT_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src ERYAO_RUNTIME__SERVICE_NAME=worker-agent uv run taskiq worker core.taskiq.app:worker_agent_broker core.runtime.tasks --workers ${ERYAO_WORKER__GROUPS__AGENT__CONCURRENCY:-2}" + WORKER_GENERAL_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src ERYAO_RUNTIME__SERVICE_NAME=worker-general uv run taskiq worker core.taskiq.app:worker_general_broker core.runtime.tasks --workers ${ERYAO_WORKER__GROUPS__GENERAL__CONCURRENCY:-1}" + echo "Starting tmux web process in session '$SESSION_NAME'..." tmux new-session -d -s "$SESSION_NAME" -n web "bash -lc \"$WEB_CMD; echo '[web] exited'; exec bash\"" + tmux new-window -t "$SESSION_NAME" -n worker-agent "bash -lc \"$WORKER_AGENT_CMD; echo '[worker-agent] exited'; exec bash\"" + tmux new-window -t "$SESSION_NAME" -n worker-general "bash -lc \"$WORKER_GENERAL_CMD; echo '[worker-general] exited'; exec bash\"" echo "" echo "=== App Started ===" diff --git a/pyproject.toml b/pyproject.toml index 9aff33d..56a8abe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "觅爻签问后端服务" requires-python = ">=3.12" dependencies = [ "alembic==1.18.4", - "aiomysql==0.2.0", + "asyncpg==0.30.0", "cryptography==46.0.3", "email-validator==2.3.0", "fastapi==0.135.1", @@ -16,12 +16,16 @@ dependencies = [ "redis==7.2.1", "sqlalchemy[asyncio]==2.0.48", "structlog==25.5.0", + "supabase==2.21.0", + "storage3==0.8.0", + "taskiq==0.12.1", + "taskiq-redis==1.2.2", "uvicorn[standard]==0.41.0", ] [project.optional-dependencies] dev = [ - "httpx==0.28.1", + "httpx==0.27.2", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-cov==7.0.0",