feat: 添加起卦教程首次访问追踪和Agent时间上下文

- 后端 ProfileSettingsV1 添加 DivinationTutorialSettings 字段
- 前端三个起卦页面添加首次访问检测,自动弹出教程
- 教程展示后更新 settings 标记,避免重复弹出
- 使用本地状态管理避免并发更新覆盖问题
- Agent 系统提示添加时间上下文信息
This commit is contained in:
qzl
2026-04-15 18:56:41 +08:00
parent 55eeab43df
commit 69b34bd723
13 changed files with 624 additions and 0 deletions
@@ -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<String, Object?> 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<void> _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<void> 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<String, dynamic>? 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()` |
@@ -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": {}
}
@@ -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/date_time_picker/date_time_picker_bottom_sheet.dart';
import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart'; import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../settings/data/models/profile_settings.dart';
import '../../data/models/divination_backend_models.dart'; import '../../data/models/divination_backend_models.dart';
import '../../data/apis/divination_api.dart'; import '../../data/apis/divination_api.dart';
import '../../data/models/divination_params.dart'; import '../../data/models/divination_params.dart';
@@ -33,12 +34,17 @@ class AutoDivinationScreen extends StatefulWidget {
required this.runService, required this.runService,
this.divinationApi, this.divinationApi,
required this.onCompleted, required this.onCompleted,
required this.profileSettings,
required this.onProfileSettingsChanged,
}); });
final DivinationParams params; final DivinationParams params;
final DivinationRunService runService; final DivinationRunService runService;
final DivinationApi? divinationApi; final DivinationApi? divinationApi;
final Future<void> Function(DivinationResultData result) onCompleted; final Future<void> Function(DivinationResultData result) onCompleted;
final ProfileSettingsV1 profileSettings;
final Future<void> Function(ProfileSettingsV1 settings)
onProfileSettingsChanged;
@override @override
State<AutoDivinationScreen> createState() => _AutoDivinationScreenState(); State<AutoDivinationScreen> createState() => _AutoDivinationScreenState();
@@ -64,6 +70,8 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
DateTime _lastShake = DateTime.fromMillisecondsSinceEpoch(0); DateTime _lastShake = DateTime.fromMillisecondsSinceEpoch(0);
bool _spinLocked = false; bool _spinLocked = false;
bool _submitting = false; bool _submitting = false;
bool _tutorialChecked = false;
late DivinationTutorialSettings _localTutorialSettings;
final GlobalKey<OnboardingState> _onboardingKey = final GlobalKey<OnboardingState> _onboardingKey =
GlobalKey<OnboardingState>(); GlobalKey<OnboardingState>();
@@ -89,6 +97,37 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
duration: const Duration(milliseconds: 500), duration: const Duration(milliseconds: 500),
)..repeat(reverse: true); )..repeat(reverse: true);
_listenShake(); _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<void> _markTutorialShown() async {
setState(() {
_localTutorialSettings = _localTutorialSettings.copyWith(
autoDivinationShown: true,
);
});
final updated = widget.profileSettings.copyWith(
divinationTutorial: _localTutorialSettings,
);
await widget.onProfileSettingsChanged(updated);
} }
@override @override
@@ -176,6 +215,7 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
key: _onboardingKey, key: _onboardingKey,
steps: steps, steps: steps,
onChanged: _onGuideStepChanged, onChanged: _onGuideStepChanged,
onEnd: (_) => _markTutorialShown(),
child: SingleChildScrollView( child: SingleChildScrollView(
controller: _scrollController, controller: _scrollController,
padding: const EdgeInsets.all(AppSpacing.xl), padding: const EdgeInsets.all(AppSpacing.xl),
@@ -10,6 +10,7 @@ import '../../../../shared/widgets/gua_icon.dart';
import '../../../../shared/widgets/divination/divination_shared_widgets.dart'; import '../../../../shared/widgets/divination/divination_shared_widgets.dart';
import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart'; import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../settings/data/models/profile_settings.dart';
import '../../data/apis/divination_api.dart'; import '../../data/apis/divination_api.dart';
import '../../data/models/divination_params.dart'; import '../../data/models/divination_params.dart';
import '../../data/models/divination_result.dart'; import '../../data/models/divination_result.dart';
@@ -26,6 +27,8 @@ class DivinationScreen extends StatefulWidget {
this.runServiceOverride, this.runServiceOverride,
this.divinationApiOverride, this.divinationApiOverride,
this.allowVibration = true, this.allowVibration = true,
required this.profileSettings,
required this.onProfileSettingsChanged,
}); });
final SessionStore sessionStore; final SessionStore sessionStore;
@@ -34,6 +37,9 @@ class DivinationScreen extends StatefulWidget {
final DivinationRunService? runServiceOverride; final DivinationRunService? runServiceOverride;
final DivinationApi? divinationApiOverride; final DivinationApi? divinationApiOverride;
final bool allowVibration; final bool allowVibration;
final ProfileSettingsV1 profileSettings;
final Future<void> Function(ProfileSettingsV1 settings)
onProfileSettingsChanged;
@override @override
State<DivinationScreen> createState() => _DivinationScreenState(); State<DivinationScreen> createState() => _DivinationScreenState();
@@ -44,6 +50,8 @@ class _DivinationScreenState extends State<DivinationScreen> {
final TextEditingController _questionController = TextEditingController(); final TextEditingController _questionController = TextEditingController();
late final DivinationApi _divinationApi; late final DivinationApi _divinationApi;
late final DivinationRunService _runService; late final DivinationRunService _runService;
bool _tutorialChecked = false;
late DivinationTutorialSettings _localTutorialSettings;
@override @override
void initState() { void initState() {
@@ -65,6 +73,47 @@ class _DivinationScreenState extends State<DivinationScreen> {
userId: widget.userId, userId: widget.userId,
); );
_questionController.addListener(_syncQuestion); _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<void> _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 @override
@@ -161,6 +210,10 @@ class _DivinationScreenState extends State<DivinationScreen> {
return; return;
} }
final updatedSettings = widget.profileSettings.copyWith(
divinationTutorial: _localTutorialSettings,
);
if (_params.method == DivinationMethod.manual) { if (_params.method == DivinationMethod.manual) {
final nextParams = _params.copyWith(divinationTime: DateTime.now()); final nextParams = _params.copyWith(divinationTime: DateTime.now());
Navigator.of(context).push( Navigator.of(context).push(
@@ -170,6 +223,8 @@ class _DivinationScreenState extends State<DivinationScreen> {
runService: _runService, runService: _runService,
divinationApi: _divinationApi, divinationApi: _divinationApi,
onCompleted: widget.onCompleted, onCompleted: widget.onCompleted,
profileSettings: updatedSettings,
onProfileSettingsChanged: widget.onProfileSettingsChanged,
), ),
), ),
); );
@@ -187,6 +242,8 @@ class _DivinationScreenState extends State<DivinationScreen> {
runService: _runService, runService: _runService,
divinationApi: _divinationApi, divinationApi: _divinationApi,
onCompleted: widget.onCompleted, onCompleted: widget.onCompleted,
profileSettings: updatedSettings,
onProfileSettingsChanged: widget.onProfileSettingsChanged,
), ),
), ),
); );
@@ -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/date_time_picker/date_time_picker_bottom_sheet.dart';
import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart'; import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../settings/data/models/profile_settings.dart';
import '../../data/models/divination_backend_models.dart'; import '../../data/models/divination_backend_models.dart';
import '../../data/apis/divination_api.dart'; import '../../data/apis/divination_api.dart';
import '../../data/models/divination_params.dart'; import '../../data/models/divination_params.dart';
@@ -30,12 +31,17 @@ class ManualDivinationScreen extends StatefulWidget {
required this.runService, required this.runService,
this.divinationApi, this.divinationApi,
required this.onCompleted, required this.onCompleted,
required this.profileSettings,
required this.onProfileSettingsChanged,
}); });
final DivinationParams params; final DivinationParams params;
final DivinationRunService runService; final DivinationRunService runService;
final DivinationApi? divinationApi; final DivinationApi? divinationApi;
final Future<void> Function(DivinationResultData result) onCompleted; final Future<void> Function(DivinationResultData result) onCompleted;
final ProfileSettingsV1 profileSettings;
final Future<void> Function(ProfileSettingsV1 settings)
onProfileSettingsChanged;
@override @override
State<ManualDivinationScreen> createState() => _ManualDivinationScreenState(); State<ManualDivinationScreen> createState() => _ManualDivinationScreenState();
@@ -47,6 +53,8 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
final List<YaoType?> _selectedYaos = List<YaoType?>.filled(6, null); final List<YaoType?> _selectedYaos = List<YaoType?>.filled(6, null);
late final AnimationController _blinkController; late final AnimationController _blinkController;
bool _submitting = false; bool _submitting = false;
bool _tutorialChecked = false;
late DivinationTutorialSettings _localTutorialSettings;
final GlobalKey<OnboardingState> _onboardingKey = final GlobalKey<OnboardingState> _onboardingKey =
GlobalKey<OnboardingState>(); GlobalKey<OnboardingState>();
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
@@ -66,6 +74,37 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
vsync: this, vsync: this,
duration: const Duration(milliseconds: 500), duration: const Duration(milliseconds: 500),
)..repeat(reverse: true); )..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<void> _markTutorialShown() async {
setState(() {
_localTutorialSettings = _localTutorialSettings.copyWith(
manualDivinationShown: true,
);
});
final updated = widget.profileSettings.copyWith(
divinationTutorial: _localTutorialSettings,
);
await widget.onProfileSettingsChanged(updated);
} }
@override @override
@@ -146,6 +185,7 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
key: _onboardingKey, key: _onboardingKey,
steps: guideSteps, steps: guideSteps,
onChanged: _onGuideStepChanged, onChanged: _onGuideStepChanged,
onEnd: (_) => _markTutorialShown(),
child: SingleChildScrollView( child: SingleChildScrollView(
controller: _scrollController, controller: _scrollController,
padding: const EdgeInsets.all(AppSpacing.xl), padding: const EdgeInsets.all(AppSpacing.xl),
@@ -127,6 +127,8 @@ class _HomeScreenState extends State<HomeScreen> {
allowVibration: widget.profileSettings.notification.allowVibration, allowVibration: widget.profileSettings.notification.allowVibration,
notificationBloc: widget.notificationBloc, notificationBloc: widget.notificationBloc,
notificationRepository: widget.notificationRepository, notificationRepository: widget.notificationRepository,
profileSettings: widget.profileSettings,
onProfileSettingsChanged: widget.onProfileSettingsChanged,
), ),
_ProfileTab( _ProfileTab(
account: widget.account, account: widget.account,
@@ -160,6 +162,8 @@ class _HomeScreenState extends State<HomeScreen> {
userId: widget.account, userId: widget.account,
onCompleted: widget.onDivinationCompleted, onCompleted: widget.onDivinationCompleted,
allowVibration: widget.profileSettings.notification.allowVibration, allowVibration: widget.profileSettings.notification.allowVibration,
profileSettings: widget.profileSettings,
onProfileSettingsChanged: widget.onProfileSettingsChanged,
), ),
), ),
); );
@@ -177,6 +181,8 @@ class _HomeTab extends StatelessWidget {
required this.allowVibration, required this.allowVibration,
required this.notificationBloc, required this.notificationBloc,
required this.notificationRepository, required this.notificationRepository,
required this.profileSettings,
required this.onProfileSettingsChanged,
}); });
final List<DivinationResultData> historyItems; final List<DivinationResultData> historyItems;
@@ -189,6 +195,9 @@ class _HomeTab extends StatelessWidget {
final bool allowVibration; final bool allowVibration;
final NotificationBloc notificationBloc; final NotificationBloc notificationBloc;
final NotificationRepository notificationRepository; final NotificationRepository notificationRepository;
final ProfileSettingsV1 profileSettings;
final Future<void> Function(ProfileSettingsV1 settings)
onProfileSettingsChanged;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -304,6 +313,9 @@ class _HomeTab extends StatelessWidget {
userId: userId, userId: userId,
onCompleted: onDivinationCompleted, onCompleted: onDivinationCompleted,
allowVibration: allowVibration, allowVibration: allowVibration,
profileSettings: profileSettings,
onProfileSettingsChanged:
onProfileSettingsChanged,
), ),
), ),
); );
@@ -51,6 +51,14 @@ class ProfileApi {
'allow_notifications': settings.notification.allowNotifications, 'allow_notifications': settings.notification.allowNotifications,
'allow_vibration': settings.notification.allowVibration, '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<Map<String, dynamic>>( final json = await _apiClient.rawDio.patch<Map<String, dynamic>>(
@@ -119,6 +127,23 @@ class ProfileApi {
) )
: const NotificationSettings(); : const NotificationSettings();
final divinationTutorialRaw = settingsRaw is Map<String, dynamic>
? settingsRaw['divination_tutorial']
: null;
final divinationTutorial = divinationTutorialRaw is Map<String, dynamic>
? 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( return ProfileSettingsV1(
displayName: (json['display_name'] as String?) ?? '', displayName: (json['display_name'] as String?) ?? '',
bio: (json['bio'] as String?) ?? '', bio: (json['bio'] as String?) ?? '',
@@ -130,6 +155,7 @@ class ProfileApi {
const <String, dynamic>{}) const <String, dynamic>{})
: const <String, dynamic>{}, : const <String, dynamic>{},
notification: notification, notification: notification,
divinationTutorial: divinationTutorial,
); );
} }
} }
@@ -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 { class ProfileSettingsV1 {
const ProfileSettingsV1({ const ProfileSettingsV1({
this.version = 1, this.version = 1,
@@ -69,6 +94,7 @@ class ProfileSettingsV1 {
this.preferences = const PreferenceSettings(), this.preferences = const PreferenceSettings(),
this.privacy = const <String, Object?>{}, this.privacy = const <String, Object?>{},
this.notification = const NotificationSettings(), this.notification = const NotificationSettings(),
this.divinationTutorial = const DivinationTutorialSettings(),
}); });
final int version; final int version;
@@ -79,6 +105,7 @@ class ProfileSettingsV1 {
final PreferenceSettings preferences; final PreferenceSettings preferences;
final Map<String, Object?> privacy; final Map<String, Object?> privacy;
final NotificationSettings notification; final NotificationSettings notification;
final DivinationTutorialSettings divinationTutorial;
ProfileSettingsV1 copyWith({ ProfileSettingsV1 copyWith({
int? version, int? version,
@@ -89,6 +116,7 @@ class ProfileSettingsV1 {
PreferenceSettings? preferences, PreferenceSettings? preferences,
Map<String, Object?>? privacy, Map<String, Object?>? privacy,
NotificationSettings? notification, NotificationSettings? notification,
DivinationTutorialSettings? divinationTutorial,
}) { }) {
return ProfileSettingsV1( return ProfileSettingsV1(
version: version ?? this.version, version: version ?? this.version,
@@ -99,6 +127,7 @@ class ProfileSettingsV1 {
preferences: preferences ?? this.preferences, preferences: preferences ?? this.preferences,
privacy: privacy ?? this.privacy, privacy: privacy ?? this.privacy,
notification: notification ?? this.notification, notification: notification ?? this.notification,
divinationTutorial: divinationTutorial ?? this.divinationTutorial,
); );
} }
@@ -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/auto_divination_screen.dart';
import 'package:meeyao_qianwen/features/divination/presentation/screens/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/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'; import 'package:meeyao_qianwen/l10n/app_localizations.dart';
void main() { void main() {
@@ -18,6 +19,9 @@ void main() {
api: DivinationApi(apiClient: ApiClient(baseUrl: 'http://localhost:5775')), api: DivinationApi(apiClient: ApiClient(baseUrl: 'http://localhost:5775')),
); );
final sessionStore = SessionStore(LocalKvStore()); final sessionStore = SessionStore(LocalKvStore());
final profileSettings = ProfileSettingsV1.defaultsForLocale(
const Locale('zh'),
);
testWidgets('divination screen navigates to auto screen', (tester) async { testWidgets('divination screen navigates to auto screen', (tester) async {
await tester.pumpWidget( await tester.pumpWidget(
@@ -36,6 +40,8 @@ void main() {
userId: 'user_test', userId: 'user_test',
onCompleted: (_) async {}, onCompleted: (_) async {},
runServiceOverride: runService, runServiceOverride: runService,
profileSettings: profileSettings,
onProfileSettingsChanged: (_) async {},
), ),
), ),
); );
@@ -77,6 +83,8 @@ void main() {
params: params, params: params,
runService: runService, runService: runService,
onCompleted: (_) async {}, onCompleted: (_) async {},
profileSettings: profileSettings,
onProfileSettingsChanged: (_) async {},
), ),
), ),
); );
@@ -108,6 +116,8 @@ void main() {
userId: 'user_test', userId: 'user_test',
onCompleted: (_) async {}, onCompleted: (_) async {},
runServiceOverride: runService, runServiceOverride: runService,
profileSettings: profileSettings,
onProfileSettingsChanged: (_) async {},
), ),
), ),
); );
@@ -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/models/divination_params.dart';
import 'package:meeyao_qianwen/features/divination/data/services/divination_run_service.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/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'; import 'package:meeyao_qianwen/l10n/app_localizations.dart';
void main() { void main() {
@@ -24,6 +25,9 @@ void main() {
apiClient: ApiClient(baseUrl: 'http://localhost:5775'), apiClient: ApiClient(baseUrl: 'http://localhost:5775'),
), ),
); );
final profileSettings = ProfileSettingsV1.defaultsForLocale(
const Locale('zh'),
);
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
@@ -39,6 +43,8 @@ void main() {
params: params, params: params,
runService: runService, runService: runService,
onCompleted: (_) async {}, onCompleted: (_) async {},
profileSettings: profileSettings,
onProfileSettingsChanged: (_) async {},
), ),
), ),
); );
@@ -2,6 +2,8 @@ from __future__ import annotations
from typing import Any, Sequence from typing import Any, Sequence
from datetime import datetime, timezone
from ag_ui.core.types import Tool from ag_ui.core.types import Tool
from core.agentscope.prompts.agent_prompt import build_agent_prompt from core.agentscope.prompts.agent_prompt import build_agent_prompt
from core.agentscope.prompts.sections import wrap_section 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)) 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( def build_system_prompt(
*, *,
agent_type: AgentType, agent_type: AgentType,
ai_language: str, ai_language: str,
llm_config: SystemAgentLLMConfig | None = None, llm_config: SystemAgentLLMConfig | None = None,
tools: Sequence[Tool | dict[str, Any]] | None = None, tools: Sequence[Tool | dict[str, Any]] | None = None,
now_utc: datetime | None = None,
) -> str: ) -> str:
sections: list[str | None] = [ sections: list[str | None] = [
_build_time_context(now_utc=now_utc),
_build_safety_section(), _build_safety_section(),
build_agent_prompt( build_agent_prompt(
agent_type=agent_type, agent_type=agent_type,
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import contextlib import contextlib
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Awaitable, Callable from typing import Any, Awaitable, Callable
from ag_ui.core.types import RunAgentInput from ag_ui.core.types import RunAgentInput
@@ -275,6 +276,7 @@ class AgentScopeRunner:
ai_language=ai_language, ai_language=ai_language,
llm_config=stage_config.llm_config, llm_config=stage_config.llm_config,
tools=None, tools=None,
now_utc=datetime.now(timezone.utc),
) )
_, worker_payload_raw = await finalize_json_response( _, worker_payload_raw = await finalize_json_response(
+9
View File
@@ -46,11 +46,20 @@ class NotificationSettings(BaseModel):
allow_vibration: bool = True 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): class ProfileSettingsV1(BaseModel):
version: Literal[1] = 1 version: Literal[1] = 1
preferences: PreferenceSettings = Field(default_factory=PreferenceSettings) preferences: PreferenceSettings = Field(default_factory=PreferenceSettings)
privacy: dict[str, object] = Field(default_factory=dict) privacy: dict[str, object] = Field(default_factory=dict)
notification: NotificationSettings = Field(default_factory=NotificationSettings) notification: NotificationSettings = Field(default_factory=NotificationSettings)
divination_tutorial: DivinationTutorialSettings = Field(
default_factory=DivinationTutorialSettings
)
ProfileSettingsUnion = ProfileSettingsV1 ProfileSettingsUnion = ProfileSettingsV1