From 69b34bd723260467397ea511073b8ad8e461eb8e Mon Sep 17 00:00:00 2001 From: qzl Date: Wed, 15 Apr 2026 18:56:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=B5=B7=E5=8D=A6?= =?UTF-8?q?=E6=95=99=E7=A8=8B=E9=A6=96=E6=AC=A1=E8=AE=BF=E9=97=AE=E8=BF=BD?= =?UTF-8?q?=E8=B8=AA=E5=92=8CAgent=E6=97=B6=E9=97=B4=E4=B8=8A=E4=B8=8B?= =?UTF-8?q?=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端 ProfileSettingsV1 添加 DivinationTutorialSettings 字段 - 前端三个起卦页面添加首次访问检测,自动弹出教程 - 教程展示后更新 settings 标记,避免重复弹出 - 使用本地状态管理避免并发更新覆盖问题 - Agent 系统提示添加时间上下文信息 --- .../prd.md | 330 ++++++++++++++++++ .../task.json | 44 +++ .../screens/auto_divination_screen.dart | 40 +++ .../screens/divination_screen.dart | 57 +++ .../screens/manual_divination_screen.dart | 40 +++ .../presentation/screens/home_screen.dart | 12 + .../settings/data/apis/profile_api.dart | 26 ++ .../data/models/profile_settings.dart | 29 ++ .../divination/divination_screen_test.dart | 10 + .../manual_divination_screen_test.dart | 6 + .../core/agentscope/prompts/system_prompt.py | 19 + backend/src/core/agentscope/runtime/runner.py | 2 + backend/src/schemas/shared/user.py | 9 + 13 files changed, 624 insertions(+) create mode 100644 .trellis/tasks/04-15-divination-tutorial-first-visit/prd.md create mode 100644 .trellis/tasks/04-15-divination-tutorial-first-visit/task.json diff --git a/.trellis/tasks/04-15-divination-tutorial-first-visit/prd.md b/.trellis/tasks/04-15-divination-tutorial-first-visit/prd.md new file mode 100644 index 0000000..b9b0ce7 --- /dev/null +++ b/.trellis/tasks/04-15-divination-tutorial-first-visit/prd.md @@ -0,0 +1,330 @@ +# PRD: Divination Tutorial First-Visit Tracking + +## Background + +用户首次进入起卦相关页面时,应该自动弹出教程引导。当前实现已有教程弹窗逻辑(用户可主动点击按钮触发),但缺少"首次访问自动弹出"的能力。 + +需要区分三个页面的首次访问状态: +1. **起卦方式选择页面** (divination_screen.dart) - 显示手动起卦教程弹窗 (DivinationGuideDialog) +2. **自动起卦页面** (auto_divination_screen.dart) - 显示 Onboarding 步骤引导 +3. **手动起卦页面** (manual_divination_screen.dart) - 显示 Onboarding 步骤引导 + +## Goal + +1. 在用户 profile 的 settings 中添加字段,记录三个页面的教程是否已展示 +2. 前端在进入对应页面时检查 settings,首次访问自动弹出教程 +3. 教程展示后,前端调用后端 API 更新 settings 标记为已展示 +4. 老用户(settings 中没有这些字段)默认视为已看过教程,不弹出 + +## Scope + +- **In scope**: + - 后端: ProfileSettingsV1 schema 添加 `divination_tutorial` 字段 + - 后端: 更新 API 端点支持新字段 + - 前端: ProfileSettingsV1 model 添加对应字段 + - 前端: 三个起卦页面的首次访问检测和教程自动弹出逻辑 +- **Out of scope**: + - 新增教程内容(复用现有教程逻辑) + - 用户主动触发教程的按钮(已存在,保持不变) + +## Current Architecture + +### 前端现有教程逻辑 + +| 页面 | 文件路径 | 教程触发方式 | +|------|----------|-------------| +| 起卦方式选择 | `apps/lib/features/divination/presentation/screens/divination_screen.dart` | `DivinationGuideDialog` 弹窗,`_showGuide()` 函数 | +| 自动起卦 | `apps/lib/features/divination/presentation/screens/auto_divination_screen.dart` | `Onboarding` 组件,`_showGuide()` 函数 | +| 手动起卦 | `apps/lib/features/divination/presentation/screens/manual_divination_screen.dart` | `Onboarding` 组件,`_showGuide()` 函数 | + +### 后端 Settings 结构 + +**文件**: `backend/src/schemas/shared/user.py` + +```python +class ProfileSettingsV1(BaseModel): + version: Literal[1] = 1 + preferences: PreferenceSettings = Field(default_factory=PreferenceSettings) + privacy: dict[str, object] = Field(default_factory=dict) + notification: NotificationSettings = Field(default_factory=NotificationSettings) +``` + +### 前端 Settings Model + +**文件**: `apps/lib/features/settings/data/models/profile_settings.dart` + +```dart +class ProfileSettingsV1 { + final int version; + final String displayName; + final String bio; + final String? avatarPath; + final String? avatarUrl; + final PreferenceSettings preferences; + final Map privacy; + final NotificationSettings notification; +} +``` + +### 更新 Settings API + +- **端点**: `PATCH /users/me/settings` +- **Service**: `backend/src/v1/users/service.py` → `update_settings()` +- **前端 API**: `apps/lib/features/settings/data/apis/profile_api.dart` → `updateSettings()` + +## Technical Design + +### 1. 后端 Schema 变更 + +**新增 `DivinationTutorialSettings`**: + +```python +class DivinationTutorialSettings(BaseModel): + """起卦教程首次访问状态""" + divination_entry_shown: bool = False # 起卦方式选择页教程已展示 + auto_divination_shown: bool = False # 自动起卦页教程已展示 + manual_divination_shown: bool = False # 手动起卦页教程已展示 +``` + +**修改 `ProfileSettingsV1`**: + +```python +class ProfileSettingsV1(BaseModel): + version: Literal[1] = 1 + preferences: PreferenceSettings = Field(default_factory=PreferenceSettings) + privacy: dict[str, object] = Field(default_factory=dict) + notification: NotificationSettings = Field(default_factory=NotificationSettings) + divination_tutorial: DivinationTutorialSettings = Field(default_factory=DivinationTutorialSettings) +``` + +### 2. 前端 Model 变更 + +**新增 `DivinationTutorialSettings`**: + +```dart +class DivinationTutorialSettings { + const DivinationTutorialSettings({ + this.divinationEntryShown = false, + this.autoDivinationShown = false, + this.manualDivinationShown = false, + }); + + final bool divinationEntryShown; + final bool autoDivinationShown; + final bool manualDivinationShown; + + DivinationTutorialSettings copyWith({...}); +} +``` + +**修改 `ProfileSettingsV1`**: + +```dart +class ProfileSettingsV1 { + // ... existing fields ... + final DivinationTutorialSettings divinationTutorial; + + ProfileSettingsV1 copyWith({ + // ... existing params ... + DivinationTutorialSettings? divinationTutorial, + }); +} +``` + +### 3. 前端页面逻辑变更 + +**起卦方式选择页面** (`divination_screen.dart`): + +```dart +// 在 initState 或 didChangeDependencies 中检查 +void _checkFirstVisit() { + final settings = // 从 widget 或 context 获取 profileSettings + if (!settings.divinationTutorial.divinationEntryShown) { + // 延迟显示,确保页面渲染完成 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _showGuide(context, l10n); + }); + // 调用 API 更新 settings + _markTutorialShown('divination_entry'); + } +} + +Future _markTutorialShown(String tutorialKey) async { + // 调用 onProfileSettingsChanged 更新 settings +} +``` + +**自动起卦页面** (`auto_divination_screen.dart`): + +```dart +void _checkFirstVisit() { + final settings = // 获取 profileSettings + if (!settings.divinationTutorial.autoDivinationShown) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _showGuide(); + }); + _markTutorialShown('auto_divination'); + } +} +``` + +**手动起卦页面** (`manual_divination_screen.dart`): + +```dart +void _checkFirstVisit() { + final settings = // 获取 profileSettings + if (!settings.divinationTutorial.manualDivinationShown) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _showGuide(); + }); + _markTutorialShown('manual_divination'); + } +} +``` + +### 4. Settings 传递方式(已确认) + +**采用构造函数参数传入方式**(选项 A)。 + +**调用链分析**: + +``` +EryaoApp (app.dart) + └── HomeScreen + ├── profileSettings: ProfileSettingsV1 + ├── onProfileSettingsChanged: Future Function(ProfileSettingsV1) + └── _onStartDivination() + └── DivinationScreen <-- 需要添加 profileSettings 和 onProfileSettingsChanged + ├── _onStart() → AutoDivinationScreen <-- 需要添加 + └── _onStart() → ManualDivinationScreen <-- 需要添加 +``` + +**需要修改的文件**: + +| 文件 | 修改内容 | +|------|----------| +| `home_screen.dart:155-166` | `_onStartDivination()` 传入 `profileSettings` 和 `onProfileSettingsChanged` | +| `divination_screen.dart` | 构造函数添加 `profileSettings` 和 `onProfileSettingsChanged`;添加首次访问检测 | +| `divination_screen.dart:164-176` | `_onStart()` 传入参数到 `ManualDivinationScreen` 和 `AutoDivinationScreen` | +| `auto_divination_screen.dart` | 构造函数添加参数;添加首次访问检测 | +| `manual_divination_screen.dart` | 构造函数添加参数;添加首次访问检测 | + +### 5. 教程关闭回调处理 + +**Onboarding 组件** (用于自动/手动起卦页面): +- `Onboarding` 组件有 `onEnd` 回调参数 +- 在 `onEnd` 中调用 `_markTutorialShown()` + +**DivinationGuideDialog** (用于起卦方式选择页面): +- 这是一个 `Dialog`,关闭时 `Navigator.pop()` 被调用 +- 需要在 `showDialog` 的 `.then()` 中处理关闭回调 +- 或者在弹窗显示后立即标记(因为用户已经看到了) + +**推荐方案**: 在弹窗显示后立即标记,无需等待关闭 +- 用户看到教程即视为"已展示" +- 避免因用户快速关闭导致 settings 未更新 + +### 6. 老用户兼容 + +- 后端新字段默认值为 `false` +- 数据库中现有用户 settings 没有 `divination_tutorial` 字段 +- 前端解析时: + - 如果字段不存在,**视为已看过教程**(不弹出) + - 实现方式: JSON 解析时提供默认值 `true`,或检查字段是否存在 + +**前端兼容逻辑**: + +```dart +factory DivinationTutorialSettings.fromJson(Map? json) { + if (json == null) { + // 老用户没有这个字段,默认全部 true(已看过) + return const DivinationTutorialSettings( + divinationEntryShown: true, + autoDivinationShown: true, + manualDivinationShown: true, + ); + } + return DivinationTutorialSettings( + divinationEntryShown: json['divination_entry_shown'] ?? true, + autoDivinationShown: json['auto_divination_shown'] ?? true, + manualDivinationShown: json['manual_divination_shown'] ?? true, + ); +} +``` + +### 6. API 更新流程 + +用户首次看到教程后: + +1. 前端调用 `_markTutorialShown('auto_divination')` +2. 构建 `ProfileSettingsV1` 的 copyWith 更新对应字段 +3. 调用 `onProfileSettingsChanged(updatedSettings)` +4. 后端保存新 settings + +**注意**: 只更新一个字段时,需要保留其他字段不变。 + +### 7. Onboarding 组件 onEnd 回调 + +查看 `auto_divination_screen.dart:145-148`: + +```dart +return Onboarding( + key: _onboardingKey, + steps: steps, + onChanged: _onGuideStepChanged, + // 需要添加: onEnd: _markTutorialShown, +); +``` + +`manual_divination_screen.dart` 同理。 + +## Implementation Checklist + +### 后端 + +- [ ] `backend/src/schemas/shared/user.py` 添加 `DivinationTutorialSettings` 和修改 `ProfileSettingsV1` +- [ ] 确认 API 端点无需修改(使用现有的 PATCH + 完整 settings 替换) +- [ ] 单元测试: 新 schema 的序列化/反序列化 + +### 前端 + +- [ ] `apps/lib/features/settings/data/models/profile_settings.dart` 添加 `DivinationTutorialSettings` +- [ ] `apps/lib/features/settings/data/apis/profile_api.dart` 更新 `_toSettings()` 解析逻辑 +- [ ] 确认三个页面如何获取 `ProfileSettingsV1`(需要调查) +- [ ] `divination_screen.dart` 添加首次访问检测 +- [ ] `auto_divination_screen.dart` 添加首次访问检测 +- [ ] `manual_divination_screen.dart` 添加首次访问检测 +- [ ] 测试老用户兼容性(settings 无此字段时不弹出) + +### 测试场景 + +1. **新用户首次访问**: 进入页面 → 自动弹出教程 → settings 更新为 shown +2. **新用户再次访问**: 进入页面 → 不弹出教程 +3. **老用户首次访问**: settings 无此字段 → 不弹出教程(视为已看过) +4. **用户主动触发**: 点击教程按钮 → 正常弹出(无论是否首次) + +## Risks & Mitigations + +| 风险 | 缓解措施 | +|------|---------| +| 前端解析 settings 时字段缺失 | 使用 `?? true` 默认值,老用户视为已看过 | +| 教程弹出时机过早导致 UI 闪烁 | 使用 `addPostFrameCallback` 延迟到下一帧 | +| API 调用失败导致 settings 未更新 | 不影响用户体验,下次访问仍会弹出(符合预期) | +| 并发更新 settings 可能覆盖其他字段 | 前端使用 copyWith 保留其他字段 | + +## Open Questions + +1. ~~**Settings 传递方式**~~: 已确认使用构造函数参数传入。 +2. ~~**教程关闭回调**~~: 建议在弹窗显示后立即标记,无需等待关闭。 + +## File Changes Summary + +| 文件 | 类型 | 修改内容 | +|------|------|----------| +| `backend/src/schemas/shared/user.py` | 后端 | 添加 `DivinationTutorialSettings` 和修改 `ProfileSettingsV1` | +| `apps/lib/features/settings/data/models/profile_settings.dart` | 前端 | 添加 `DivinationTutorialSettings` 和修改 `ProfileSettingsV1` | +| `apps/lib/features/settings/data/apis/profile_api.dart` | 前端 | 更新 `_toSettings()` 解析新字段 | +| `apps/lib/features/home/presentation/screens/home_screen.dart` | 前端 | `_onStartDivination()` 传入 `profileSettings` 和 `onProfileSettingsChanged` | +| `apps/lib/features/divination/presentation/screens/divination_screen.dart` | 前端 | 添加构造函数参数 + 首次访问检测 + `_markTutorialShown()` | +| `apps/lib/features/divination/presentation/screens/auto_divination_screen.dart` | 前端 | 添加构造函数参数 + 首次访问检测 + `_markTutorialShown()` | +| `apps/lib/features/divination/presentation/screens/manual_divination_screen.dart` | 前端 | 添加构造函数参数 + 首次访问检测 + `_markTutorialShown()` | diff --git a/.trellis/tasks/04-15-divination-tutorial-first-visit/task.json b/.trellis/tasks/04-15-divination-tutorial-first-visit/task.json new file mode 100644 index 0000000..53d2f0f --- /dev/null +++ b/.trellis/tasks/04-15-divination-tutorial-first-visit/task.json @@ -0,0 +1,44 @@ +{ + "id": "divination-tutorial-first-visit", + "name": "divination-tutorial-first-visit", + "title": "divination-tutorial-first-visit", + "description": "", + "status": "planning", + "dev_type": null, + "scope": null, + "priority": "P2", + "creator": "zl-q", + "assignee": "zl-q", + "createdAt": "2026-04-15", + "completedAt": null, + "branch": null, + "base_branch": "dev", + "worktree_path": null, + "current_phase": 0, + "next_action": [ + { + "phase": 1, + "action": "implement" + }, + { + "phase": 2, + "action": "check" + }, + { + "phase": 3, + "action": "finish" + }, + { + "phase": 4, + "action": "create-pr" + } + ], + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/apps/lib/features/divination/presentation/screens/auto_divination_screen.dart b/apps/lib/features/divination/presentation/screens/auto_divination_screen.dart index db2675e..a327154 100644 --- a/apps/lib/features/divination/presentation/screens/auto_divination_screen.dart +++ b/apps/lib/features/divination/presentation/screens/auto_divination_screen.dart @@ -18,6 +18,7 @@ import '../../../../shared/widgets/divination/yao_line_row.dart'; import '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; +import '../../../settings/data/models/profile_settings.dart'; import '../../data/models/divination_backend_models.dart'; import '../../data/apis/divination_api.dart'; import '../../data/models/divination_params.dart'; @@ -33,12 +34,17 @@ class AutoDivinationScreen extends StatefulWidget { required this.runService, this.divinationApi, required this.onCompleted, + required this.profileSettings, + required this.onProfileSettingsChanged, }); final DivinationParams params; final DivinationRunService runService; final DivinationApi? divinationApi; final Future Function(DivinationResultData result) onCompleted; + final ProfileSettingsV1 profileSettings; + final Future Function(ProfileSettingsV1 settings) + onProfileSettingsChanged; @override State createState() => _AutoDivinationScreenState(); @@ -64,6 +70,8 @@ class _AutoDivinationScreenState extends State DateTime _lastShake = DateTime.fromMillisecondsSinceEpoch(0); bool _spinLocked = false; bool _submitting = false; + bool _tutorialChecked = false; + late DivinationTutorialSettings _localTutorialSettings; final GlobalKey _onboardingKey = GlobalKey(); @@ -89,6 +97,37 @@ class _AutoDivinationScreenState extends State duration: const Duration(milliseconds: 500), )..repeat(reverse: true); _listenShake(); + _localTutorialSettings = widget.profileSettings.divinationTutorial; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_tutorialChecked) { + _tutorialChecked = true; + _checkFirstVisit(); + } + } + + void _checkFirstVisit() { + if (!_localTutorialSettings.autoDivinationShown) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _showGuide(); + }); + } + } + + Future _markTutorialShown() async { + setState(() { + _localTutorialSettings = _localTutorialSettings.copyWith( + autoDivinationShown: true, + ); + }); + final updated = widget.profileSettings.copyWith( + divinationTutorial: _localTutorialSettings, + ); + await widget.onProfileSettingsChanged(updated); } @override @@ -176,6 +215,7 @@ class _AutoDivinationScreenState extends State key: _onboardingKey, steps: steps, onChanged: _onGuideStepChanged, + onEnd: (_) => _markTutorialShown(), child: SingleChildScrollView( controller: _scrollController, padding: const EdgeInsets.all(AppSpacing.xl), diff --git a/apps/lib/features/divination/presentation/screens/divination_screen.dart b/apps/lib/features/divination/presentation/screens/divination_screen.dart index 63831a8..b104611 100644 --- a/apps/lib/features/divination/presentation/screens/divination_screen.dart +++ b/apps/lib/features/divination/presentation/screens/divination_screen.dart @@ -10,6 +10,7 @@ import '../../../../shared/widgets/gua_icon.dart'; import '../../../../shared/widgets/divination/divination_shared_widgets.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; +import '../../../settings/data/models/profile_settings.dart'; import '../../data/apis/divination_api.dart'; import '../../data/models/divination_params.dart'; import '../../data/models/divination_result.dart'; @@ -26,6 +27,8 @@ class DivinationScreen extends StatefulWidget { this.runServiceOverride, this.divinationApiOverride, this.allowVibration = true, + required this.profileSettings, + required this.onProfileSettingsChanged, }); final SessionStore sessionStore; @@ -34,6 +37,9 @@ class DivinationScreen extends StatefulWidget { final DivinationRunService? runServiceOverride; final DivinationApi? divinationApiOverride; final bool allowVibration; + final ProfileSettingsV1 profileSettings; + final Future Function(ProfileSettingsV1 settings) + onProfileSettingsChanged; @override State createState() => _DivinationScreenState(); @@ -44,6 +50,8 @@ class _DivinationScreenState extends State { final TextEditingController _questionController = TextEditingController(); late final DivinationApi _divinationApi; late final DivinationRunService _runService; + bool _tutorialChecked = false; + late DivinationTutorialSettings _localTutorialSettings; @override void initState() { @@ -65,6 +73,47 @@ class _DivinationScreenState extends State { userId: widget.userId, ); _questionController.addListener(_syncQuestion); + _localTutorialSettings = widget.profileSettings.divinationTutorial; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_tutorialChecked) { + _tutorialChecked = true; + _checkFirstVisit(); + } + } + + void _checkFirstVisit() { + if (!_localTutorialSettings.divinationEntryShown) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final l10n = AppLocalizations.of(context)!; + _showGuide(context, l10n); + _markTutorialShown('divination_entry'); + }); + } + } + + Future _markTutorialShown(String key) async { + setState(() { + _localTutorialSettings = _localTutorialSettings.copyWith( + divinationEntryShown: key == 'divination_entry' + ? true + : _localTutorialSettings.divinationEntryShown, + autoDivinationShown: key == 'auto_divination' + ? true + : _localTutorialSettings.autoDivinationShown, + manualDivinationShown: key == 'manual_divination' + ? true + : _localTutorialSettings.manualDivinationShown, + ); + }); + final updated = widget.profileSettings.copyWith( + divinationTutorial: _localTutorialSettings, + ); + await widget.onProfileSettingsChanged(updated); } @override @@ -161,6 +210,10 @@ class _DivinationScreenState extends State { return; } + final updatedSettings = widget.profileSettings.copyWith( + divinationTutorial: _localTutorialSettings, + ); + if (_params.method == DivinationMethod.manual) { final nextParams = _params.copyWith(divinationTime: DateTime.now()); Navigator.of(context).push( @@ -170,6 +223,8 @@ class _DivinationScreenState extends State { runService: _runService, divinationApi: _divinationApi, onCompleted: widget.onCompleted, + profileSettings: updatedSettings, + onProfileSettingsChanged: widget.onProfileSettingsChanged, ), ), ); @@ -187,6 +242,8 @@ class _DivinationScreenState extends State { runService: _runService, divinationApi: _divinationApi, onCompleted: widget.onCompleted, + profileSettings: updatedSettings, + onProfileSettingsChanged: widget.onProfileSettingsChanged, ), ), ); diff --git a/apps/lib/features/divination/presentation/screens/manual_divination_screen.dart b/apps/lib/features/divination/presentation/screens/manual_divination_screen.dart index 5fc6cfc..bd3f297 100644 --- a/apps/lib/features/divination/presentation/screens/manual_divination_screen.dart +++ b/apps/lib/features/divination/presentation/screens/manual_divination_screen.dart @@ -15,6 +15,7 @@ import '../../../../shared/widgets/divination/yao_line_row.dart'; import '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; +import '../../../settings/data/models/profile_settings.dart'; import '../../data/models/divination_backend_models.dart'; import '../../data/apis/divination_api.dart'; import '../../data/models/divination_params.dart'; @@ -30,12 +31,17 @@ class ManualDivinationScreen extends StatefulWidget { required this.runService, this.divinationApi, required this.onCompleted, + required this.profileSettings, + required this.onProfileSettingsChanged, }); final DivinationParams params; final DivinationRunService runService; final DivinationApi? divinationApi; final Future Function(DivinationResultData result) onCompleted; + final ProfileSettingsV1 profileSettings; + final Future Function(ProfileSettingsV1 settings) + onProfileSettingsChanged; @override State createState() => _ManualDivinationScreenState(); @@ -47,6 +53,8 @@ class _ManualDivinationScreenState extends State final List _selectedYaos = List.filled(6, null); late final AnimationController _blinkController; bool _submitting = false; + bool _tutorialChecked = false; + late DivinationTutorialSettings _localTutorialSettings; final GlobalKey _onboardingKey = GlobalKey(); final ScrollController _scrollController = ScrollController(); @@ -66,6 +74,37 @@ class _ManualDivinationScreenState extends State vsync: this, duration: const Duration(milliseconds: 500), )..repeat(reverse: true); + _localTutorialSettings = widget.profileSettings.divinationTutorial; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_tutorialChecked) { + _tutorialChecked = true; + _checkFirstVisit(); + } + } + + void _checkFirstVisit() { + if (!_localTutorialSettings.manualDivinationShown) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _showGuide(); + }); + } + } + + Future _markTutorialShown() async { + setState(() { + _localTutorialSettings = _localTutorialSettings.copyWith( + manualDivinationShown: true, + ); + }); + final updated = widget.profileSettings.copyWith( + divinationTutorial: _localTutorialSettings, + ); + await widget.onProfileSettingsChanged(updated); } @override @@ -146,6 +185,7 @@ class _ManualDivinationScreenState extends State key: _onboardingKey, steps: guideSteps, onChanged: _onGuideStepChanged, + onEnd: (_) => _markTutorialShown(), child: SingleChildScrollView( controller: _scrollController, padding: const EdgeInsets.all(AppSpacing.xl), diff --git a/apps/lib/features/home/presentation/screens/home_screen.dart b/apps/lib/features/home/presentation/screens/home_screen.dart index 3e1ebdc..8804cf6 100644 --- a/apps/lib/features/home/presentation/screens/home_screen.dart +++ b/apps/lib/features/home/presentation/screens/home_screen.dart @@ -127,6 +127,8 @@ class _HomeScreenState extends State { allowVibration: widget.profileSettings.notification.allowVibration, notificationBloc: widget.notificationBloc, notificationRepository: widget.notificationRepository, + profileSettings: widget.profileSettings, + onProfileSettingsChanged: widget.onProfileSettingsChanged, ), _ProfileTab( account: widget.account, @@ -160,6 +162,8 @@ class _HomeScreenState extends State { userId: widget.account, onCompleted: widget.onDivinationCompleted, allowVibration: widget.profileSettings.notification.allowVibration, + profileSettings: widget.profileSettings, + onProfileSettingsChanged: widget.onProfileSettingsChanged, ), ), ); @@ -177,6 +181,8 @@ class _HomeTab extends StatelessWidget { required this.allowVibration, required this.notificationBloc, required this.notificationRepository, + required this.profileSettings, + required this.onProfileSettingsChanged, }); final List historyItems; @@ -189,6 +195,9 @@ class _HomeTab extends StatelessWidget { final bool allowVibration; final NotificationBloc notificationBloc; final NotificationRepository notificationRepository; + final ProfileSettingsV1 profileSettings; + final Future Function(ProfileSettingsV1 settings) + onProfileSettingsChanged; @override Widget build(BuildContext context) { @@ -304,6 +313,9 @@ class _HomeTab extends StatelessWidget { userId: userId, onCompleted: onDivinationCompleted, allowVibration: allowVibration, + profileSettings: profileSettings, + onProfileSettingsChanged: + onProfileSettingsChanged, ), ), ); diff --git a/apps/lib/features/settings/data/apis/profile_api.dart b/apps/lib/features/settings/data/apis/profile_api.dart index 6a8a9c6..df040e9 100644 --- a/apps/lib/features/settings/data/apis/profile_api.dart +++ b/apps/lib/features/settings/data/apis/profile_api.dart @@ -51,6 +51,14 @@ class ProfileApi { 'allow_notifications': settings.notification.allowNotifications, 'allow_vibration': settings.notification.allowVibration, }, + 'divination_tutorial': { + 'divination_entry_shown': + settings.divinationTutorial.divinationEntryShown, + 'auto_divination_shown': + settings.divinationTutorial.autoDivinationShown, + 'manual_divination_shown': + settings.divinationTutorial.manualDivinationShown, + }, }, }; final json = await _apiClient.rawDio.patch>( @@ -119,6 +127,23 @@ class ProfileApi { ) : const NotificationSettings(); + final divinationTutorialRaw = settingsRaw is Map + ? settingsRaw['divination_tutorial'] + : null; + final divinationTutorial = divinationTutorialRaw is Map + ? DivinationTutorialSettings( + divinationEntryShown: + (divinationTutorialRaw['divination_entry_shown'] as bool?) ?? + true, + autoDivinationShown: + (divinationTutorialRaw['auto_divination_shown'] as bool?) ?? + true, + manualDivinationShown: + (divinationTutorialRaw['manual_divination_shown'] as bool?) ?? + true, + ) + : const DivinationTutorialSettings(); + return ProfileSettingsV1( displayName: (json['display_name'] as String?) ?? '', bio: (json['bio'] as String?) ?? '', @@ -130,6 +155,7 @@ class ProfileApi { const {}) : const {}, notification: notification, + divinationTutorial: divinationTutorial, ); } } diff --git a/apps/lib/features/settings/data/models/profile_settings.dart b/apps/lib/features/settings/data/models/profile_settings.dart index 405544e..0df21fa 100644 --- a/apps/lib/features/settings/data/models/profile_settings.dart +++ b/apps/lib/features/settings/data/models/profile_settings.dart @@ -59,6 +59,31 @@ class NotificationSettings { } } +class DivinationTutorialSettings { + const DivinationTutorialSettings({ + this.divinationEntryShown = true, + this.autoDivinationShown = true, + this.manualDivinationShown = true, + }); + + final bool divinationEntryShown; + final bool autoDivinationShown; + final bool manualDivinationShown; + + DivinationTutorialSettings copyWith({ + bool? divinationEntryShown, + bool? autoDivinationShown, + bool? manualDivinationShown, + }) { + return DivinationTutorialSettings( + divinationEntryShown: divinationEntryShown ?? this.divinationEntryShown, + autoDivinationShown: autoDivinationShown ?? this.autoDivinationShown, + manualDivinationShown: + manualDivinationShown ?? this.manualDivinationShown, + ); + } +} + class ProfileSettingsV1 { const ProfileSettingsV1({ this.version = 1, @@ -69,6 +94,7 @@ class ProfileSettingsV1 { this.preferences = const PreferenceSettings(), this.privacy = const {}, this.notification = const NotificationSettings(), + this.divinationTutorial = const DivinationTutorialSettings(), }); final int version; @@ -79,6 +105,7 @@ class ProfileSettingsV1 { final PreferenceSettings preferences; final Map privacy; final NotificationSettings notification; + final DivinationTutorialSettings divinationTutorial; ProfileSettingsV1 copyWith({ int? version, @@ -89,6 +116,7 @@ class ProfileSettingsV1 { PreferenceSettings? preferences, Map? privacy, NotificationSettings? notification, + DivinationTutorialSettings? divinationTutorial, }) { return ProfileSettingsV1( version: version ?? this.version, @@ -99,6 +127,7 @@ class ProfileSettingsV1 { preferences: preferences ?? this.preferences, privacy: privacy ?? this.privacy, notification: notification ?? this.notification, + divinationTutorial: divinationTutorial ?? this.divinationTutorial, ); } diff --git a/apps/test/features/divination/divination_screen_test.dart b/apps/test/features/divination/divination_screen_test.dart index 197a197..aac3bb0 100644 --- a/apps/test/features/divination/divination_screen_test.dart +++ b/apps/test/features/divination/divination_screen_test.dart @@ -11,6 +11,7 @@ import 'package:meeyao_qianwen/features/divination/data/services/divination_run_ import 'package:meeyao_qianwen/features/divination/presentation/screens/auto_divination_screen.dart'; import 'package:meeyao_qianwen/features/divination/presentation/screens/divination_screen.dart'; import 'package:meeyao_qianwen/features/divination/presentation/screens/manual_divination_screen.dart'; +import 'package:meeyao_qianwen/features/settings/data/models/profile_settings.dart'; import 'package:meeyao_qianwen/l10n/app_localizations.dart'; void main() { @@ -18,6 +19,9 @@ void main() { api: DivinationApi(apiClient: ApiClient(baseUrl: 'http://localhost:5775')), ); final sessionStore = SessionStore(LocalKvStore()); + final profileSettings = ProfileSettingsV1.defaultsForLocale( + const Locale('zh'), + ); testWidgets('divination screen navigates to auto screen', (tester) async { await tester.pumpWidget( @@ -36,6 +40,8 @@ void main() { userId: 'user_test', onCompleted: (_) async {}, runServiceOverride: runService, + profileSettings: profileSettings, + onProfileSettingsChanged: (_) async {}, ), ), ); @@ -77,6 +83,8 @@ void main() { params: params, runService: runService, onCompleted: (_) async {}, + profileSettings: profileSettings, + onProfileSettingsChanged: (_) async {}, ), ), ); @@ -108,6 +116,8 @@ void main() { userId: 'user_test', onCompleted: (_) async {}, runServiceOverride: runService, + profileSettings: profileSettings, + onProfileSettingsChanged: (_) async {}, ), ), ); diff --git a/apps/test/features/divination/manual_divination_screen_test.dart b/apps/test/features/divination/manual_divination_screen_test.dart index d695554..b9263a3 100644 --- a/apps/test/features/divination/manual_divination_screen_test.dart +++ b/apps/test/features/divination/manual_divination_screen_test.dart @@ -7,6 +7,7 @@ import 'package:meeyao_qianwen/features/divination/data/apis/divination_api.dart import 'package:meeyao_qianwen/features/divination/data/models/divination_params.dart'; import 'package:meeyao_qianwen/features/divination/data/services/divination_run_service.dart'; import 'package:meeyao_qianwen/features/divination/presentation/screens/manual_divination_screen.dart'; +import 'package:meeyao_qianwen/features/settings/data/models/profile_settings.dart'; import 'package:meeyao_qianwen/l10n/app_localizations.dart'; void main() { @@ -24,6 +25,9 @@ void main() { apiClient: ApiClient(baseUrl: 'http://localhost:5775'), ), ); + final profileSettings = ProfileSettingsV1.defaultsForLocale( + const Locale('zh'), + ); await tester.pumpWidget( MaterialApp( @@ -39,6 +43,8 @@ void main() { params: params, runService: runService, onCompleted: (_) async {}, + profileSettings: profileSettings, + onProfileSettingsChanged: (_) async {}, ), ), ); diff --git a/backend/src/core/agentscope/prompts/system_prompt.py b/backend/src/core/agentscope/prompts/system_prompt.py index 56769dd..081e273 100644 --- a/backend/src/core/agentscope/prompts/system_prompt.py +++ b/backend/src/core/agentscope/prompts/system_prompt.py @@ -2,6 +2,8 @@ from __future__ import annotations from typing import Any, Sequence +from datetime import datetime, timezone + from ag_ui.core.types import Tool from core.agentscope.prompts.agent_prompt import build_agent_prompt from core.agentscope.prompts.sections import wrap_section @@ -48,14 +50,31 @@ def _build_output_rules(*, ai_language: str) -> str: return wrap_section("output", "\n".join(rules)) +def _build_time_context(*, now_utc: datetime | None) -> str: + if now_utc is None: + now_utc = datetime.now(timezone.utc) + if now_utc.tzinfo is None: + now_utc = now_utc.replace(tzinfo=timezone.utc) + else: + now_utc = now_utc.astimezone(timezone.utc) + + return ( + "[Time Context]\n" + f"- current_time_utc: {now_utc.isoformat()}\n" + f"- This helps you understand the current time (distinct from divination_time in user input)." + ) + + def build_system_prompt( *, agent_type: AgentType, ai_language: str, llm_config: SystemAgentLLMConfig | None = None, tools: Sequence[Tool | dict[str, Any]] | None = None, + now_utc: datetime | None = None, ) -> str: sections: list[str | None] = [ + _build_time_context(now_utc=now_utc), _build_safety_section(), build_agent_prompt( agent_type=agent_type, diff --git a/backend/src/core/agentscope/runtime/runner.py b/backend/src/core/agentscope/runtime/runner.py index ada019e..c4def52 100644 --- a/backend/src/core/agentscope/runtime/runner.py +++ b/backend/src/core/agentscope/runtime/runner.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import contextlib from dataclasses import dataclass +from datetime import datetime, timezone from typing import Any, Awaitable, Callable from ag_ui.core.types import RunAgentInput @@ -275,6 +276,7 @@ class AgentScopeRunner: ai_language=ai_language, llm_config=stage_config.llm_config, tools=None, + now_utc=datetime.now(timezone.utc), ) _, worker_payload_raw = await finalize_json_response( diff --git a/backend/src/schemas/shared/user.py b/backend/src/schemas/shared/user.py index 0011f6b..019daaf 100644 --- a/backend/src/schemas/shared/user.py +++ b/backend/src/schemas/shared/user.py @@ -46,11 +46,20 @@ class NotificationSettings(BaseModel): allow_vibration: bool = True +class DivinationTutorialSettings(BaseModel): + divination_entry_shown: bool = False + auto_divination_shown: bool = False + manual_divination_shown: bool = False + + class ProfileSettingsV1(BaseModel): version: Literal[1] = 1 preferences: PreferenceSettings = Field(default_factory=PreferenceSettings) privacy: dict[str, object] = Field(default_factory=dict) notification: NotificationSettings = Field(default_factory=NotificationSettings) + divination_tutorial: DivinationTutorialSettings = Field( + default_factory=DivinationTutorialSettings + ) ProfileSettingsUnion = ProfileSettingsV1