chore: 迁移到 social-app 架构,集成 Supabase 和 taskiq worker

This commit is contained in:
qzl
2026-04-02 16:36:35 +08:00
parent 695adb7d6f
commit 92cdfd9fca
132 changed files with 5802 additions and 759 deletions
+35 -47
View File
@@ -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
+241 -95
View File
@@ -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/
+77
View File
@@ -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)要么已修正文档,要么被明确记录为阻塞项。
- 输出报告完整,后续协作者可据此继续推进。
+20
View File
@@ -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"
]
}
}
}
+4
View File
@@ -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.
+45
View File
@@ -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
+33
View File
@@ -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'
+201
View File
@@ -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
+16
View File
@@ -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.
+28
View File
@@ -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
+14
View File
@@ -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
+44
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

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>
+24
View File
@@ -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)
}
+2
View File
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
+5
View File
@@ -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
+26
View File
@@ -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")
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

+34
View File
@@ -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
+26
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+13
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

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>
+49
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"
+12
View File
@@ -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.
}
}
+4
View File
@@ -0,0 +1,4 @@
arb-dir: lib/l10n
template-arb-file: app_zh.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
+69
View File
@@ -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": "免责声明内容展示占位。"
}
+8
View File
@@ -0,0 +1,8 @@
import 'package:flutter/widgets.dart';
import 'app/app.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const EryaoApp());
}
+97
View File
@@ -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
+30
View File
@@ -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);
});
}
+26
View File
@@ -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
View File
-14
View File
@@ -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"}
-3
View File
@@ -1,3 +0,0 @@
from .settings import Settings, config
__all__ = ["Settings", "config"]
+70 -86
View File
@@ -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
+2 -2
View File
@@ -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")
+3
View File
@@ -0,0 +1,3 @@
from __future__ import annotations
__all__ = []
+2 -56
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
from __future__ import annotations
__all__ = []
+3
View File
@@ -0,0 +1,3 @@
from core.taskiq.app import broker, worker_agent_broker, worker_general_broker
__all__ = ["broker", "worker_agent_broker", "worker_general_broker"]
+30
View File
@@ -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"]
+8 -9
View File
@@ -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",
]
-41
View File
@@ -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()
)
-17
View File
@@ -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()
)
+26
View File
@@ -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
)
+22
View File
@@ -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)
-39
View File
@@ -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()
)
-14
View File
@@ -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)
-40
View File
@@ -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()
)
+32
View File
@@ -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="{}",
)
-52
View File
@@ -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)
-19
View File
@@ -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)
-30
View File
@@ -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()
)
+1
View File
@@ -0,0 +1 @@
"""Backend reusable schemas package."""
+68
View File
@@ -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
+177
View File
@@ -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
+30
View File
@@ -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)
+349
View File
@@ -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.",
)
+628
View File
@@ -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)
+50
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
from __future__ import annotations

Some files were not shown because too many files have changed in this diff Show More