chore: 迁移到 social-app 架构,集成 Supabase 和 taskiq worker
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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)要么已修正文档,要么被明确记录为阻塞项。
|
||||
- 输出报告完整,后续协作者可据此继续推进。
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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/<same_name>/...`; 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<AppColorPalette>()!.*`.
|
||||
- **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<SomeState> {
|
||||
final Logger _logger = getLogger('features.<feature>.<component>');
|
||||
}
|
||||
```
|
||||
|
||||
### 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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 = "../.."
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,45 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="meeyao_qianwen"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.meeyao.meeyao_qianwen
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -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<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
After Width: | Height: | Size: 142 KiB |
@@ -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
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>App</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>io.flutter.flutter.app</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>App</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>13.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
|
After Width: | Height: | Size: 68 B |
@@ -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.
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchImage" width="168" height="185"/>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Flutter View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Meeyao Qianwen</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>meeyao_qianwen</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1 @@
|
||||
#import "GeneratedPluginRegistrant.h"
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
arb-dir: lib/l10n
|
||||
template-arb-file: app_zh.arb
|
||||
output-localization-file: app_localizations.dart
|
||||
output-class: AppLocalizations
|
||||
@@ -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": "免责声明内容展示占位。"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'app/app.dart';
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
runApp(const EryaoApp());
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"}
|
||||
@@ -1,3 +0,0 @@
|
||||
from .settings import Settings, config
|
||||
|
||||
__all__ = ["Settings", "config"]
|
||||
@@ -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_",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = []
|
||||
|
||||
@@ -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 <command>")
|
||||
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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = []
|
||||
@@ -0,0 +1,3 @@
|
||||
from core.taskiq.app import broker, worker_agent_broker, worker_general_broker
|
||||
|
||||
__all__ = ["broker", "worker_agent_broker", "worker_general_broker"]
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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="{}",
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Backend reusable schemas package."""
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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.",
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
|
||||