feat: 接入起卦后端流程并完善积分扣减链路

This commit is contained in:
qzl
2026-04-03 19:04:46 +08:00
parent a136e42290
commit d87b2e1e3a
56 changed files with 3310 additions and 809 deletions
+6
View File
@@ -45,6 +45,12 @@ This file governs `apps/**` (Flutter). Keep rules strict, short, and reusable.
- `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.
## Divination Terminology (Must)
- Divination domain terminology must use fixed Chinese terms in code contracts, protocol fields, and UI semantic labels.
- Do not localize or translate canonical terms such as: 六爻、爻、动爻、静爻、六亲、六神、世爻、应爻、伏神、月建、日辰、月破、日冲、空亡、五行旺衰、上上签、中上签、中下签。
- l10n can translate explanatory copy, but must not alter canonical divination terminology semantics.
## Reuse & Composition (Must)
- Prefer `apps/lib/shared/widgets/` before adding new components.
+32 -1
View File
@@ -9,6 +9,7 @@ import '../features/auth/data/repositories/auth_repository.dart';
import '../features/auth/presentation/bloc/auth_bloc.dart';
import '../features/auth/presentation/bloc/auth_state.dart';
import '../features/auth/presentation/screens/login_screen.dart';
import '../features/divination/data/apis/divination_api.dart';
import '../features/home/presentation/screens/home_screen.dart';
import '../features/settings/data/models/profile_settings.dart';
import '../l10n/app_localizations.dart';
@@ -26,10 +27,14 @@ class EryaoApp extends StatefulWidget {
class _EryaoAppState extends State<EryaoApp> {
final SessionStore _sessionStore = SessionStore(LocalKvStore());
late final AuthBloc _authBloc;
late final DivinationApi _divinationApi;
Locale _locale = const Locale('zh');
ProfileSettingsV1 _profileSettings = ProfileSettingsV1.defaultsForLocale(
const Locale('zh'),
);
int _creditsBalance = 0;
bool _loadingCredits = false;
String? _loadedCreditsUserEmail;
@override
void initState() {
@@ -42,6 +47,7 @@ class _EryaoAppState extends State<EryaoApp> {
},
);
final authApi = AuthApi(apiClient: apiClient);
_divinationApi = DivinationApi(apiClient: apiClient);
final authRepository = AuthRepositoryImpl(
authApi: authApi,
sessionStore: _sessionStore,
@@ -50,6 +56,30 @@ class _EryaoAppState extends State<EryaoApp> {
_bootstrap();
}
void _ensureCreditsLoaded(String userEmail) {
if (_loadingCredits) {
return;
}
if (_loadedCreditsUserEmail == userEmail) {
return;
}
_loadingCredits = true;
_divinationApi
.getPointsBalance()
.then((balance) {
if (!mounted) {
return;
}
setState(() {
_creditsBalance = balance.availableBalance;
_loadedCreditsUserEmail = userEmail;
});
})
.whenComplete(() {
_loadingCredits = false;
});
}
@override
void dispose() {
_authBloc.dispose();
@@ -118,12 +148,13 @@ class _EryaoAppState extends State<EryaoApp> {
}
if (state.status == AuthStatus.authenticated && state.user != null) {
_ensureCreditsLoaded(state.user!.email);
return HomeScreen(
account: state.user!.email,
sessionStore: _sessionStore,
currentLocale: _locale,
profileSettings: _profileSettings,
coinBalance: 100,
coinBalance: _creditsBalance,
onLocaleChanged: _handleInterfaceLanguageChanged,
onLogout: _authBloc.logout,
);
@@ -11,6 +11,12 @@ String mapApiProblemToMessage(ApiProblem problem, AppLocalizations l10n) {
return l10n.errorSessionExpired;
case 'AUTH_SERVICE_UNAVAILABLE':
return l10n.errorServiceUnavailable;
case 'POINTS_INSUFFICIENT_BALANCE':
return l10n.toastCoinInsufficient;
case 'AGENT_SESSION_RUN_LIMIT_EXCEEDED':
return l10n.errorRunLimitExceeded;
case 'AGENT_DIVINATION_PAYLOAD_REQUIRED':
return l10n.errorDivinationPayloadRequired;
default:
break;
}
+16
View File
@@ -44,6 +44,8 @@ class ApiClient {
final Dio _dio;
final Logger _logger = getLogger('data.network.api_client');
Dio get rawDio => _dio;
Future<void> postNoContent(String path, {Map<String, dynamic>? data}) async {
try {
await _dio.post<void>(path, data: data);
@@ -90,6 +92,20 @@ class ApiClient {
}
}
Future<Map<String, dynamic>> getJson(String path) async {
try {
final response = await _dio.get<Map<String, dynamic>>(path);
return response.data ?? <String, dynamic>{};
} on DioException catch (error, stackTrace) {
_logger.error(
message: 'GET json failed',
error: error,
stackTrace: stackTrace,
);
throw _mapProblem(error);
}
}
ApiProblem _mapProblem(DioException error) {
final status = error.response?.statusCode ?? 500;
final data = error.response?.data;
@@ -0,0 +1,230 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import '../../../../core/logging/logger.dart';
import '../../../../core/network/api_problem.dart';
import '../../../../data/network/api_client.dart';
import '../models/divination_backend_models.dart';
import '../models/divination_params.dart';
class DivinationApi {
const DivinationApi({required ApiClient apiClient}) : _apiClient = apiClient;
final ApiClient _apiClient;
static final Logger _logger = getLogger('features.divination.api');
Future<PointsBalanceData> getPointsBalance() async {
final json = await _apiClient.getJson('/api/v1/points/balance');
return PointsBalanceData.fromJson(json);
}
Future<RunAcceptedData> enqueueRun({
required DivinationParams params,
required List<YaoType> yaoStates,
required String threadId,
required String runId,
}) async {
final payload = buildDivinationRunPayload(
params: params,
yaoStates: yaoStates,
threadId: threadId,
runId: runId,
clientNow: DateTime.now(),
);
final json = await _apiClient.postJson('/api/v1/agent/runs', data: payload);
return RunAcceptedData.fromJson(json);
}
Stream<Map<String, dynamic>> streamEvents({
required String threadId,
required String runId,
}) async* {
Response<ResponseBody> response;
try {
response = await _apiClient.rawDio.get<ResponseBody>(
'/api/v1/agent/runs/$threadId/events',
queryParameters: <String, dynamic>{'runId': runId},
options: Options(responseType: ResponseType.stream),
);
} on DioException catch (error, stackTrace) {
_logger.error(
message: 'Failed to open SSE stream for divination run',
error: error,
stackTrace: stackTrace,
extra: <String, dynamic>{'threadId': threadId, 'runId': runId},
);
throw _mapProblem(error);
}
final body = response.data;
if (body == null) {
return;
}
String buffer = '';
try {
await for (final textChunk in utf8.decoder.bind(body.stream)) {
buffer += textChunk.replaceAll('\r\n', '\n');
while (true) {
final splitAt = buffer.indexOf('\n\n');
if (splitAt < 0) {
break;
}
final frame = buffer.substring(0, splitAt);
buffer = buffer.substring(splitAt + 2);
final event = _parseSseFrame(frame);
if (event != null) {
yield event;
}
}
}
} on FormatException catch (error, stackTrace) {
_logger.error(
message: 'Failed to decode SSE stream chunk',
error: error,
stackTrace: stackTrace,
extra: <String, dynamic>{
'threadId': threadId,
'runId': runId,
'bufferLength': buffer.length,
},
);
throw ApiProblem(
status: 502,
title: 'SSE parse error',
detail: error.message,
);
}
if (buffer.trim().isNotEmpty) {
final event = _parseSseFrame(buffer);
if (event != null) {
yield event;
}
}
}
ApiProblem _mapProblem(DioException error) {
final status = error.response?.statusCode ?? 500;
final data = error.response?.data;
if (data is Map<String, dynamic>) {
return ApiProblem(
status: status,
title: (data['title'] as String?) ?? 'Request failed',
detail: (data['detail'] as String?) ?? '',
code: data['code'] as String?,
);
}
return ApiProblem(
status: status,
title: 'Network error',
detail: error.message ?? 'Request failed',
);
}
Map<String, dynamic>? _parseSseFrame(String frame) {
if (frame.startsWith(':')) {
return null;
}
final lines = frame.split('\n');
String eventType = '';
final dataLines = <String>[];
for (final raw in lines) {
final line = raw.trimRight();
if (line.startsWith('event:')) {
eventType = line.substring(6).trim();
continue;
}
if (line.startsWith('data:')) {
dataLines.add(line.substring(5).trimLeft());
}
}
if (dataLines.isEmpty) {
return null;
}
final dataText = dataLines.join('\n');
if (dataText.trim().isEmpty) {
return null;
}
final decoded = jsonDecode(dataText);
if (decoded is! Map<String, dynamic>) {
return null;
}
if (!decoded.containsKey('type') && eventType.isNotEmpty) {
decoded['type'] = eventType;
}
return decoded;
}
}
Map<String, dynamic> buildDivinationRunPayload({
required DivinationParams params,
required List<YaoType> yaoStates,
required String threadId,
required String runId,
required DateTime clientNow,
}) {
if (yaoStates.length != 6) {
throw ArgumentError('yaoStates must contain exactly 6 items');
}
return <String, dynamic>{
'threadId': threadId,
'runId': runId,
'state': <String, dynamic>{},
'messages': [
{'id': 'msg_${runId}_user_0', 'role': 'user', 'content': params.question},
],
'tools': const <Map<String, dynamic>>[],
'context': const <Map<String, dynamic>>[],
'forwardedProps': {
'runtime_mode': 'chat',
'client_time': {
'device_timezone': 'Asia/Shanghai',
'client_now_iso': _toRfc3339Utc(clientNow),
'client_epoch_ms': clientNow.millisecondsSinceEpoch,
},
'divinationPayload': {
'divinationMethod': params.method == DivinationMethod.manual
? '手动起卦'
: '自动起卦',
'questionType': _questionTypeToText(params.questionType),
'question': params.question,
'divinationTimeIso': _toRfc3339Utc(params.divinationTime),
'yaoLines': yaoStates.map(_yaoTypeToText).toList(growable: false),
},
},
};
}
String _toRfc3339Utc(DateTime value) {
return value.toUtc().toIso8601String();
}
String _questionTypeToText(QuestionType type) {
return switch (type) {
QuestionType.career => '事业',
QuestionType.love => '情感',
QuestionType.wealth => '财富',
QuestionType.fortune => '运势',
QuestionType.dream => '解梦',
QuestionType.health => '健康',
QuestionType.study => '学业',
QuestionType.search => '寻物',
QuestionType.other => '其他',
};
}
String _yaoTypeToText(YaoType type) {
return switch (type) {
YaoType.youngYang => '少阳',
YaoType.youngYin => '少阴',
YaoType.oldYang => '老阳',
YaoType.oldYin => '老阴',
YaoType.undetermined => throw ArgumentError(
'yaoStates contains undetermined line',
),
};
}
@@ -0,0 +1,347 @@
import 'divination_params.dart';
import 'divination_result.dart';
class PointsBalanceData {
const PointsBalanceData({
required this.balance,
required this.frozenBalance,
required this.availableBalance,
required this.runCost,
required this.canRun,
});
factory PointsBalanceData.fromJson(Map<String, dynamic> json) {
return PointsBalanceData(
balance: _requiredInt(json, 'balance'),
frozenBalance: _requiredInt(json, 'frozenBalance'),
availableBalance: _requiredInt(json, 'availableBalance'),
runCost: _requiredInt(json, 'runCost'),
canRun: _requiredBool(json, 'canRun'),
);
}
final int balance;
final int frozenBalance;
final int availableBalance;
final int runCost;
final bool canRun;
}
class RunAcceptedData {
const RunAcceptedData({
required this.taskId,
required this.threadId,
required this.runId,
required this.created,
});
factory RunAcceptedData.fromJson(Map<String, dynamic> json) {
return RunAcceptedData(
taskId: _requiredString(json, 'taskId'),
threadId: _requiredString(json, 'threadId'),
runId: _requiredString(json, 'runId'),
created: _requiredBool(json, 'created'),
);
}
final String taskId;
final String threadId;
final String runId;
final bool created;
}
class DivinationRunAggregate {
const DivinationRunAggregate({
required this.derived,
required this.signLevel,
required this.summary,
required this.conclusion,
required this.focusPoints,
required this.advice,
required this.keywords,
required this.answer,
});
final DerivedDivinationData derived;
final String signLevel;
final String summary;
final List<String> conclusion;
final List<String> focusPoints;
final List<String> advice;
final List<String> keywords;
final String answer;
DivinationResultData toViewData(DivinationParams params) {
return DivinationResultData(
params: params,
binaryCode: derived.binaryCode,
changedBinaryCode: derived.changedBinaryCode,
guaName: derived.guaName,
targetGuaName: derived.targetGuaName,
upperName: derived.upperName,
lowerName: derived.lowerName,
signType: signLevel,
keywords: keywords.join(''),
conclusion: _asBullet(conclusion),
analysis: summary.isEmpty ? answer : '$summary\n\n$answer',
suggestion: _asBullet(advice),
ganzhi: GanzhiData(
yearGanZhi: derived.ganzhi.yearGanZhi,
monthGanZhi: derived.ganzhi.monthGanZhi,
dayGanZhi: derived.ganzhi.dayGanZhi,
timeGanZhi: derived.ganzhi.timeGanZhi,
yearKongWang: derived.ganzhi.yearKongWang,
monthKongWang: derived.ganzhi.monthKongWang,
dayKongWang: derived.ganzhi.dayKongWang,
timeKongWang: derived.ganzhi.timeKongWang,
yueJian: derived.ganzhi.yueJian,
riChen: derived.ganzhi.riChen,
yuePo: derived.ganzhi.yuePo,
riChong: derived.ganzhi.riChong,
),
wuXingStatus: derived.wuXingStatuses,
yaoLines: derived.yaoInfoList
.map((line) => line.toViewModel())
.toList(growable: false),
targetYaoLines: derived.targetYaoInfoList
.map((line) => line.toViewModel())
.toList(growable: false),
);
}
String _asBullet(List<String> lines) {
if (lines.isEmpty) {
return '';
}
return List<String>.generate(
lines.length,
(i) => '${i + 1}. ${lines[i]}',
growable: false,
).join('\n');
}
}
class DerivedDivinationData {
const DerivedDivinationData({
required this.question,
required this.questionType,
required this.divinationMethod,
required this.divinationTime,
required this.binaryCode,
required this.changedBinaryCode,
required this.guaName,
required this.upperName,
required this.lowerName,
required this.targetGuaName,
required this.worldPosition,
required this.responsePosition,
required this.hasChangingYao,
required this.ganzhi,
required this.wuXingStatuses,
required this.yaoInfoList,
required this.targetYaoInfoList,
});
factory DerivedDivinationData.fromJson(Map<String, dynamic> json) {
final wuXingRaw = _requiredMap(json, 'wuXingStatuses');
return DerivedDivinationData(
question: _requiredString(json, 'question'),
questionType: _requiredString(json, 'questionType'),
divinationMethod: _requiredString(json, 'divinationMethod'),
divinationTime: _requiredString(json, 'divinationTime'),
binaryCode: _requiredString(json, 'binaryCode'),
changedBinaryCode: _requiredString(json, 'changedBinaryCode'),
guaName: _requiredString(json, 'guaName'),
upperName: _requiredString(json, 'upperName'),
lowerName: _requiredString(json, 'lowerName'),
targetGuaName: _requiredString(json, 'targetGuaName'),
worldPosition: _requiredInt(json, 'worldPosition'),
responsePosition: _requiredInt(json, 'responsePosition'),
hasChangingYao: _requiredBool(json, 'hasChangingYao'),
ganzhi: GanzhiBackend.fromJson(_requiredMap(json, 'ganzhi')),
wuXingStatuses: wuXingRaw.map(
(key, value) => MapEntry(key, value.toString()),
),
yaoInfoList: _parseYaoList(json['yaoInfoList']),
targetYaoInfoList: _parseYaoList(json['targetYaoInfoList']),
);
}
final String question;
final String questionType;
final String divinationMethod;
final String divinationTime;
final String binaryCode;
final String changedBinaryCode;
final String guaName;
final String upperName;
final String lowerName;
final String targetGuaName;
final int worldPosition;
final int responsePosition;
final bool hasChangingYao;
final GanzhiBackend ganzhi;
final Map<String, String> wuXingStatuses;
final List<YaoBackendLine> yaoInfoList;
final List<YaoBackendLine> targetYaoInfoList;
static List<YaoBackendLine> _parseYaoList(Object? raw) {
final list = raw as List<dynamic>?;
if (list == null) {
throw const FormatException(
'Missing required list: yaoInfoList/targetYaoInfoList',
);
}
return list
.map((item) {
if (item is! Map<String, dynamic>) {
throw const FormatException('Invalid yao line item');
}
return YaoBackendLine.fromJson(item);
})
.toList(growable: false);
}
}
class GanzhiBackend {
const GanzhiBackend({
required this.yearGanZhi,
required this.monthGanZhi,
required this.dayGanZhi,
required this.timeGanZhi,
required this.yearKongWang,
required this.monthKongWang,
required this.dayKongWang,
required this.timeKongWang,
required this.yueJian,
required this.riChen,
required this.yuePo,
required this.riChong,
});
factory GanzhiBackend.fromJson(Map<String, dynamic> json) {
return GanzhiBackend(
yearGanZhi: _requiredString(json, 'yearGanZhi'),
monthGanZhi: _requiredString(json, 'monthGanZhi'),
dayGanZhi: _requiredString(json, 'dayGanZhi'),
timeGanZhi: _requiredString(json, 'timeGanZhi'),
yearKongWang: _requiredString(json, 'yearKongWang'),
monthKongWang: _requiredString(json, 'monthKongWang'),
dayKongWang: _requiredString(json, 'dayKongWang'),
timeKongWang: _requiredString(json, 'timeKongWang'),
yueJian: _requiredString(json, 'yueJian'),
riChen: _requiredString(json, 'riChen'),
yuePo: _requiredString(json, 'yuePo'),
riChong: _requiredString(json, 'riChong'),
);
}
final String yearGanZhi;
final String monthGanZhi;
final String dayGanZhi;
final String timeGanZhi;
final String yearKongWang;
final String monthKongWang;
final String dayKongWang;
final String timeKongWang;
final String yueJian;
final String riChen;
final String yuePo;
final String riChong;
}
class YaoBackendLine {
const YaoBackendLine({
required this.position,
required this.spiritName,
required this.relationName,
required this.tiganName,
required this.elementName,
required this.isYang,
required this.isChanging,
required this.specialMark,
});
factory YaoBackendLine.fromJson(Map<String, dynamic> json) {
return YaoBackendLine(
position: _requiredInt(json, 'position'),
spiritName: _requiredString(json, 'spiritName'),
relationName: _requiredString(json, 'relationName'),
tiganName: _requiredString(json, 'tiganName'),
elementName: _requiredString(json, 'elementName'),
isYang: _requiredBool(json, 'isYang'),
isChanging: _requiredBool(json, 'isChanging'),
specialMark: _requiredStringAllowEmpty(json, 'specialMark'),
);
}
final int position;
final String spiritName;
final String relationName;
final String tiganName;
final String elementName;
final bool isYang;
final bool isChanging;
final String specialMark;
YaoLineData toViewModel() {
final type = switch ((isYang, isChanging)) {
(true, false) => YaoType.youngYang,
(false, false) => YaoType.youngYin,
(true, true) => YaoType.oldYang,
(false, true) => YaoType.oldYin,
};
return YaoLineData(
index: position - 1,
spirit: spiritName,
relation: relationName,
branch: tiganName,
element: elementName,
type: type,
mark: specialMark,
);
}
}
String _requiredStringAllowEmpty(Map<String, dynamic> json, String key) {
if (!json.containsKey(key)) {
throw FormatException('Invalid or missing field: $key');
}
final value = json[key];
if (value is! String) {
throw FormatException('Invalid or missing field: $key');
}
return value;
}
String _requiredString(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is! String || value.isEmpty) {
throw FormatException('Invalid or missing field: $key');
}
return value;
}
int _requiredInt(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is! num) {
throw FormatException('Invalid or missing field: $key');
}
return value.toInt();
}
bool _requiredBool(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is! bool) {
throw FormatException('Invalid or missing field: $key');
}
return value;
}
Map<String, dynamic> _requiredMap(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is! Map<String, dynamic>) {
throw FormatException('Invalid or missing field: $key');
}
return value;
}
@@ -85,16 +85,3 @@ class DivinationParams {
.join();
}
}
class DivinationMockData {
static DivinationParams initial() {
return DivinationParams(
method: DivinationMethod.manual,
questionType: QuestionType.career,
question: '',
divinationTime: DateTime.now(),
coinBalance: 8,
userId: 'mock_user_10001',
);
}
}
@@ -1,222 +0,0 @@
import '../models/divination_params.dart';
import '../models/divination_result.dart';
class DivinationResultBuilder {
DivinationResultData build({
required DivinationParams params,
required List<YaoType> yaoStates,
}) {
final binaryCode = params.toBinary(yaoStates);
final changedBinaryCode = params.toChangedBinary(yaoStates);
final baseHexagram = _hexagramMap[binaryCode];
final changedHexagram = _hexagramMap[changedBinaryCode];
if (baseHexagram == null || changedHexagram == null) {
throw StateError(
'Unknown hexagram mapping for binary=$binaryCode changed=$changedBinaryCode',
);
}
final signType = _signByStates(yaoStates);
final content = _mockContent(
params.questionType,
params.question,
signType,
);
final lineData = _buildYaoLines(yaoStates, false);
final targetStates = _toChangedStates(yaoStates);
final targetLineData = _buildYaoLines(targetStates, true);
return DivinationResultData(
params: params,
binaryCode: binaryCode,
changedBinaryCode: changedBinaryCode,
guaName: baseHexagram.name,
targetGuaName: changedHexagram.name,
upperName: baseHexagram.upper,
lowerName: baseHexagram.lower,
signType: signType,
keywords: content.keywords,
conclusion: content.conclusion,
analysis: content.analysis,
suggestion: content.suggestion,
ganzhi: const GanzhiData(
yearGanZhi: '丙午',
monthGanZhi: '甲辰',
dayGanZhi: '辛亥',
timeGanZhi: '乙巳',
yearKongWang: '子丑',
monthKongWang: '申酉',
dayKongWang: '寅卯',
timeKongWang: '午未',
yueJian: '',
riChen: '',
yuePo: '',
riChong: '',
),
wuXingStatus: const {'': '', '': '', '': '', '': '', '': ''},
yaoLines: lineData,
targetYaoLines: targetLineData,
);
}
List<YaoLineData> _buildYaoLines(List<YaoType> states, bool target) {
const spirits = ['', '', '', '', '', ''];
const relations = ['父母', '兄弟', '官鬼', '妻财', '子孙', '父母'];
const branches = ['', '', '', '', '', ''];
const elements = ['', '', '', '', '', ''];
return List<YaoLineData>.generate(6, (idx) {
final mark = switch (idx) {
1 => '',
4 => '',
_ => '',
};
return YaoLineData(
index: idx,
spirit: spirits[idx],
relation: relations[idx],
branch: branches[idx],
element: elements[idx],
type: states[idx],
mark: target ? '' : mark,
);
});
}
List<YaoType> _toChangedStates(List<YaoType> source) {
return source.map((state) {
return switch (state) {
YaoType.oldYang => YaoType.youngYin,
YaoType.oldYin => YaoType.youngYang,
_ => state,
};
}).toList();
}
String _signByStates(List<YaoType> states) {
final dynamicCount = states
.where((e) => e == YaoType.oldYang || e == YaoType.oldYin)
.length;
if (dynamicCount <= 1) {
return '上上签';
}
if (dynamicCount <= 3) {
return '中上签';
}
return '中下签';
}
_MockContent _mockContent(
QuestionType type,
String question,
String signType,
) {
final domain = switch (type) {
QuestionType.career || QuestionType.study => '事业与成长',
QuestionType.love => '关系与情感',
QuestionType.wealth => '财富与资源',
QuestionType.fortune => '阶段运势',
QuestionType.dream => '潜意识信号',
QuestionType.health => '身心节律',
QuestionType.search => '寻物线索',
QuestionType.other => '综合事项',
};
return _MockContent(
keywords: '$signType · $domain',
conclusion: '这个卦象的结果为$signType。你关注的“$question”处于可推进阶段,当前节奏重在稳步而行,不宜急进。',
analysis:
'本卦显示外在条件逐步成形,内在决心也在增强。若短期遇到反复,通常是资源重组与信息修正,并非方向错误。建议将目标拆分为可验证的小节点,持续复盘。',
suggestion:
'建议一:先定三周内可执行动作并按日推进。\n建议二:重要决定留有缓冲期,避免情绪化判断。\n建议三:遇到阻滞先调整节奏,再补关键资源。',
);
}
}
class _MockContent {
const _MockContent({
required this.keywords,
required this.conclusion,
required this.analysis,
required this.suggestion,
});
final String keywords;
final String conclusion;
final String analysis;
final String suggestion;
}
class _HexagramShort {
const _HexagramShort(this.name, this.upper, this.lower);
final String name;
final String upper;
final String lower;
}
const Map<String, _HexagramShort> _hexagramMap = {
'111111': _HexagramShort('乾为天', '', ''),
'011111': _HexagramShort('天风姤', '', ''),
'001111': _HexagramShort('天山遁', '', ''),
'000111': _HexagramShort('天地否', '', ''),
'000011': _HexagramShort('风地观', '', ''),
'000001': _HexagramShort('山地剥', '', ''),
'000101': _HexagramShort('火地晋', '', ''),
'111101': _HexagramShort('火天大有', '', ''),
'010010': _HexagramShort('坎为水', '', ''),
'110010': _HexagramShort('水泽节', '', ''),
'100010': _HexagramShort('水雷屯', '', ''),
'101010': _HexagramShort('水火既济', '', ''),
'101110': _HexagramShort('泽火革', '', ''),
'101100': _HexagramShort('雷火丰', '', ''),
'101000': _HexagramShort('地火明夷', '', ''),
'010000': _HexagramShort('地水师', '', ''),
'001001': _HexagramShort('艮为山', '', ''),
'101001': _HexagramShort('山火贲', '', ''),
'111001': _HexagramShort('山天大畜', '', ''),
'110001': _HexagramShort('山泽损', '', ''),
'110101': _HexagramShort('火泽睽', '', ''),
'110111': _HexagramShort('天泽履', '', ''),
'110011': _HexagramShort('风泽中孚', '', ''),
'001011': _HexagramShort('风山渐', '', ''),
'100100': _HexagramShort('震为雷', '', ''),
'000100': _HexagramShort('雷地豫', '', ''),
'010100': _HexagramShort('雷水解', '', ''),
'011100': _HexagramShort('雷风恒', '', ''),
'011000': _HexagramShort('地风升', '', ''),
'011010': _HexagramShort('水风井', '', ''),
'011110': _HexagramShort('泽风大过', '', ''),
'100110': _HexagramShort('泽雷随', '', ''),
'011011': _HexagramShort('巽为风', '', ''),
'111011': _HexagramShort('风天小畜', '', ''),
'101011': _HexagramShort('风火家人', '', ''),
'100011': _HexagramShort('风雷益', '', ''),
'100111': _HexagramShort('天雷无妄', '', ''),
'100101': _HexagramShort('火雷噬嗑', '', ''),
'100001': _HexagramShort('山雷颐', '', ''),
'011001': _HexagramShort('山风蛊', '', ''),
'101101': _HexagramShort('离为火', '', ''),
'001101': _HexagramShort('火山旅', '', ''),
'011101': _HexagramShort('火风鼎', '', ''),
'010101': _HexagramShort('火水未济', '', ''),
'010001': _HexagramShort('山水蒙', '', ''),
'010011': _HexagramShort('风水涣', '', ''),
'010111': _HexagramShort('天水讼', '', ''),
'101111': _HexagramShort('天火同人', '', ''),
'000000': _HexagramShort('坤为地', '', ''),
'100000': _HexagramShort('地雷复', '', ''),
'110000': _HexagramShort('地泽临', '', ''),
'111000': _HexagramShort('地天泰', '', ''),
'111100': _HexagramShort('雷天大壮', '', ''),
'111110': _HexagramShort('泽天夬', '', ''),
'111010': _HexagramShort('水天需', '', ''),
'000010': _HexagramShort('水地比', '', ''),
'110110': _HexagramShort('兑为泽', '', ''),
'010110': _HexagramShort('泽水困', '', ''),
'000110': _HexagramShort('泽地萃', '', ''),
'001110': _HexagramShort('泽山咸', '', ''),
'001010': _HexagramShort('水山蹇', '', ''),
'001000': _HexagramShort('地山谦', '', ''),
'001100': _HexagramShort('雷山小过', '', ''),
'110100': _HexagramShort('雷泽归妹', '', ''),
};
@@ -0,0 +1,147 @@
import 'dart:math';
import '../../../../core/logging/logger.dart';
import '../../../../core/network/api_problem.dart';
import '../apis/divination_api.dart';
import '../models/divination_backend_models.dart';
import '../models/divination_params.dart';
class DivinationRunService {
const DivinationRunService({required DivinationApi api}) : _api = api;
final DivinationApi _api;
static final Logger _logger = getLogger('features.divination.run_service');
Future<DivinationRunAggregate> run({
required DivinationParams params,
required List<YaoType> yaoStates,
void Function()? onDerived,
void Function()? onTextMessageEnd,
}) async {
final threadId = _uuidV4();
final runId = 'run_${DateTime.now().millisecondsSinceEpoch}';
await _api.enqueueRun(
params: params,
yaoStates: yaoStates,
threadId: threadId,
runId: runId,
);
DerivedDivinationData? derived;
String signLevel = '';
String summary = '';
List<String> conclusion = const <String>[];
List<String> focusPoints = const <String>[];
List<String> advice = const <String>[];
List<String> keywords = const <String>[];
String answer = '';
await for (final event in _api.streamEvents(
threadId: threadId,
runId: runId,
)) {
final type = event['type'] as String? ?? '';
_logger.debug(
message: 'Received run SSE event',
extra: <String, dynamic>{
'threadId': threadId,
'runId': runId,
'type': type,
},
);
if (type == 'DIVINATION_DERIVED') {
final payload = event['divination'];
if (payload is! Map<String, dynamic>) {
throw const FormatException('DIVINATION_DERIVED 缺少 divination 结构');
}
derived = DerivedDivinationData.fromJson(payload);
onDerived?.call();
continue;
}
if (type == 'TEXT_MESSAGE_END') {
signLevel = _requiredString(event, 'sign_level');
summary = _requiredString(event, 'summary');
conclusion = _requiredStringList(event, 'conclusion');
focusPoints = _requiredStringList(event, 'focus_points');
advice = _requiredStringList(event, 'advice');
keywords = _requiredStringList(event, 'keywords');
answer = _requiredString(event, 'answer');
onTextMessageEnd?.call();
continue;
}
if (type == 'RUN_ERROR') {
_logger.warning(
message: 'Run ended with RUN_ERROR event',
extra: <String, dynamic>{
'threadId': threadId,
'runId': runId,
'code': event['code'],
'detail': event['detail'],
'message': event['message'],
},
);
throw ApiProblem(
status: 500,
title: 'Run failed',
detail: event['detail'] as String? ?? '解卦失败',
code: event['code'] as String?,
);
}
if (type == 'RUN_FINISHED') {
break;
}
}
if (derived == null) {
_logger.warning(
message: 'Missing DIVINATION_DERIVED before RUN_FINISHED',
extra: <String, dynamic>{'threadId': threadId, 'runId': runId},
);
throw const FormatException('未收到 DIVINATION_DERIVED 事件');
}
return DivinationRunAggregate(
derived: derived,
signLevel: signLevel,
summary: summary,
conclusion: conclusion,
focusPoints: focusPoints,
advice: advice,
keywords: keywords,
answer: answer,
);
}
String _requiredString(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is! String || value.isEmpty) {
throw FormatException('缺少字段: $key');
}
return value;
}
List<String> _requiredStringList(Map<String, dynamic> json, String key) {
final raw = json[key];
if (raw is! List<dynamic>) {
throw FormatException('缺少列表字段: $key');
}
return raw
.map((item) {
if (item is! String || item.isEmpty) {
throw FormatException('字段 $key 存在非法项');
}
return item;
})
.toList(growable: false);
}
String _uuidV4() {
final random = Random.secure();
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
String toHex(int b) => b.toRadixString(16).padLeft(2, '0');
final b = bytes.map(toHex).toList(growable: false);
return '${b[0]}${b[1]}${b[2]}${b[3]}-${b[4]}${b[5]}-${b[6]}${b[7]}-${b[8]}${b[9]}-${b[10]}${b[11]}${b[12]}${b[13]}${b[14]}${b[15]}';
}
}
@@ -7,8 +7,6 @@ import 'package:intl/intl.dart';
import 'package:sensors_plus/sensors_plus.dart';
import 'package:vibration/vibration.dart';
import '../../../../core/network/api_problem.dart';
import '../../../../core/network/api_problem_mapper.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/divination/divination_shared_widgets.dart';
@@ -16,11 +14,9 @@ import '../../../../shared/widgets/divination/divination_terms.dart';
import '../../../../shared/widgets/divination/yao_legend.dart';
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 '../../data/models/divination_params.dart';
import '../../data/services/divination_run_service.dart';
import 'divination_result_screen.dart';
import 'divination_processing_screen.dart';
class AutoDivinationScreen extends StatefulWidget {
const AutoDivinationScreen({
@@ -220,35 +216,22 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
}
Future<void> _submitRun() async {
final l10n = AppLocalizations.of(context)!;
setState(() {
_submitting = true;
});
try {
final aggregate = await widget.runService.run(
if (!mounted) {
return;
}
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => DivinationProcessingScreen(
params: widget.params.copyWith(divinationTime: _selectedTime),
yaoStates: _yaoStates,
);
if (!mounted) {
return;
}
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DivinationResultScreen(
data: aggregate.toViewData(
widget.params.copyWith(divinationTime: _selectedTime),
),
runService: widget.runService,
),
),
);
} catch (error) {
if (!mounted) {
return;
}
final message = error is ApiProblem
? mapApiProblemToMessage(error, l10n)
: l10n.errorRequestGeneric;
Toast.show(context, message, type: ToastType.error);
} finally {
if (!mounted) {
return;
@@ -0,0 +1,184 @@
import 'package:flutter/material.dart';
import '../../../../core/logging/logger.dart';
import '../../../../core/network/api_problem.dart';
import '../../../../core/network/api_problem_mapper.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../data/models/divination_params.dart';
import '../../data/models/divination_result.dart';
import '../../data/services/divination_run_service.dart';
import 'divination_result_screen.dart';
enum _ProcessingStep { preparing, deriving, done }
class DivinationProcessingScreen extends StatefulWidget {
const DivinationProcessingScreen({
super.key,
required this.params,
required this.yaoStates,
required this.runService,
});
final DivinationParams params;
final List<YaoType> yaoStates;
final DivinationRunService runService;
@override
State<DivinationProcessingScreen> createState() =>
_DivinationProcessingScreenState();
}
class _DivinationProcessingScreenState
extends State<DivinationProcessingScreen> {
static final Logger _logger = getLogger(
'features.divination.processing_screen',
);
_ProcessingStep _step = _ProcessingStep.preparing;
DivinationResultData? _resultData;
String? _errorMessage;
@override
void initState() {
super.initState();
_startRun();
}
Future<void> _startRun() async {
try {
final aggregate = await widget.runService.run(
params: widget.params,
yaoStates: widget.yaoStates,
onDerived: () {
if (!mounted) {
return;
}
setState(() {
_step = _ProcessingStep.deriving;
});
},
onTextMessageEnd: () {
if (!mounted) {
return;
}
setState(() {
_step = _ProcessingStep.done;
});
},
);
if (!mounted) {
return;
}
setState(() {
_resultData = aggregate.toViewData(widget.params);
_step = _ProcessingStep.done;
});
} catch (error, stackTrace) {
_logger.error(
message: 'Divination processing failed while waiting result events',
error: error,
stackTrace: stackTrace,
extra: <String, dynamic>{
'step': _step.name,
'method': widget.params.method.name,
'questionType': widget.params.questionType.name,
},
);
if (!mounted) {
return;
}
final l10n = AppLocalizations.of(context)!;
final message = error is ApiProblem
? mapApiProblemToMessage(error, l10n)
: l10n.errorRequestGeneric;
setState(() {
_errorMessage = message;
});
Toast.show(context, message, type: ToastType.error);
}
}
void _openResult() {
final data = _resultData;
if (data == null) {
return;
}
Navigator.of(context).pushReplacement(
MaterialPageRoute<void>(
builder: (_) => DivinationResultScreen(data: data),
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
final text = switch (_step) {
_ProcessingStep.preparing => l10n.transitionPreparing,
_ProcessingStep.deriving => l10n.transitionDeriving,
_ProcessingStep.done => l10n.transitionDone,
};
final canContinue = _step == _ProcessingStep.done && _resultData != null;
return Scaffold(
backgroundColor: colors.surface,
body: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.xl),
child: _errorMessage == null
? GestureDetector(
onTap: canContinue ? _openResult : null,
child: Container(
width: 220,
height: 320,
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(
color: colors.primary.withValues(alpha: 0.2),
),
boxShadow: [
BoxShadow(
color: colors.shadow.withValues(alpha: 0.25),
blurRadius: 22,
offset: const Offset(0, 12),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
canContinue ? Icons.visibility : Icons.auto_awesome,
color: colors.primary,
size: 34,
),
const SizedBox(height: AppSpacing.md),
Text(
text,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
),
)
: Text(
_errorMessage!,
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(color: colors.error),
),
),
),
),
);
}
}
@@ -1,5 +1,4 @@
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -25,41 +24,30 @@ class DivinationResultScreen extends StatefulWidget {
State<DivinationResultScreen> createState() => _DivinationResultScreenState();
}
enum _ResultTransitionStep { preparing, deriving, done }
class _DivinationResultScreenState extends State<DivinationResultScreen> {
_ResultTransitionStep _step = _ResultTransitionStep.preparing;
bool _showOverlay = true;
bool _showIntro = true;
bool _introCollapsed = false;
@override
void initState() {
super.initState();
_playSequence();
_playIntro();
}
Future<void> _playSequence() async {
await Future<void>.delayed(const Duration(milliseconds: 420));
Future<void> _playIntro() async {
await Future<void>.delayed(const Duration(milliseconds: 120));
if (!mounted) {
return;
}
setState(() {
_step = _ResultTransitionStep.deriving;
_introCollapsed = true;
});
await Future<void>.delayed(const Duration(milliseconds: 820));
await Future<void>.delayed(const Duration(milliseconds: 760));
if (!mounted) {
return;
}
setState(() {
_step = _ResultTransitionStep.done;
});
}
void _dismissOverlay() {
if (_step != _ResultTransitionStep.done) {
return;
}
setState(() {
_showOverlay = false;
_showIntro = false;
});
}
@@ -78,7 +66,10 @@ class _DivinationResultScreenState extends State<DivinationResultScreen> {
),
body: Stack(
children: [
SingleChildScrollView(
AnimatedOpacity(
opacity: _showIntro ? 0 : 1,
duration: const Duration(milliseconds: 260),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.lg,
@@ -152,99 +143,30 @@ class _DivinationResultScreenState extends State<DivinationResultScreen> {
],
),
),
if (_showOverlay)
_ResultTransitionOverlay(step: _step, onTapDone: _dismissOverlay),
],
),
);
}
}
class _ResultTransitionOverlay extends StatelessWidget {
const _ResultTransitionOverlay({required this.step, required this.onTapDone});
final _ResultTransitionStep step;
final VoidCallback onTapDone;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
final cardText = switch (step) {
_ResultTransitionStep.preparing => l10n.transitionPreparing,
_ResultTransitionStep.deriving => l10n.transitionDeriving,
_ResultTransitionStep.done => l10n.transitionDone,
};
return Positioned.fill(
if (_showIntro)
Positioned.fill(
child: Material(
color: colors.surface,
child: Center(
child: GestureDetector(
key: const Key('result_transition_overlay_tap'),
onTap: onTapDone,
child: TweenAnimationBuilder<double>(
key: ValueKey<_ResultTransitionStep>(step),
tween: Tween<double>(begin: pi / 2, end: 0),
duration: const Duration(milliseconds: 460),
curve: Curves.easeOutCubic,
builder: (context, angle, child) {
final opacity = (1 - angle / (pi / 2)).clamp(0.0, 1.0);
return Opacity(
opacity: opacity,
child: Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..rotateY(angle),
child: child,
child: SafeArea(
child: AnimatedAlign(
duration: const Duration(milliseconds: 760),
curve: Curves.easeInOutCubic,
alignment: _introCollapsed
? const Alignment(0, -0.86)
: Alignment.center,
child: AnimatedContainer(
duration: const Duration(milliseconds: 760),
curve: Curves.easeInOutCubic,
width: _introCollapsed ? 150 : 290,
child: _SignCard(signType: widget.data.signType),
),
);
},
child: Container(
width: 220,
height: 320,
decoration: BoxDecoration(
color: colors.surface,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(
color: colors.primary.withValues(alpha: 0.2),
),
boxShadow: [
BoxShadow(
color: colors.shadow.withValues(alpha: 0.25),
blurRadius: 22,
offset: const Offset(0, 12),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
step == _ResultTransitionStep.done
? Icons.visibility
: Icons.auto_awesome,
color: colors.primary,
size: 34,
),
const SizedBox(height: AppSpacing.md),
Text(
cardText,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
height: 1.4,
),
),
],
),
),
),
),
),
),
);
}
}
@@ -1,17 +1,30 @@
import 'package:flutter/material.dart';
import '../../../../app/di/injection.dart';
import '../../../../core/auth/session_store.dart';
import '../../../../data/network/api_client.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/app_color_palette.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/divination/divination_shared_widgets.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../data/apis/divination_api.dart';
import '../../data/models/divination_params.dart';
import '../../data/services/divination_run_service.dart';
import 'auto_divination_screen.dart';
import 'manual_divination_screen.dart';
class DivinationScreen extends StatefulWidget {
const DivinationScreen({super.key});
const DivinationScreen({
super.key,
required this.sessionStore,
required this.userId,
this.runServiceOverride,
});
final SessionStore sessionStore;
final String userId;
final DivinationRunService? runServiceOverride;
@override
State<DivinationScreen> createState() => _DivinationScreenState();
@@ -20,11 +33,26 @@ class DivinationScreen extends StatefulWidget {
class _DivinationScreenState extends State<DivinationScreen> {
late DivinationParams _params;
final TextEditingController _questionController = TextEditingController();
late final DivinationRunService _runService;
@override
void initState() {
super.initState();
_params = DivinationMockData.initial();
final apiClient = ApiClient(
baseUrl: appDependencies.backendUrl,
tokenProvider: widget.sessionStore.getToken,
);
_runService =
widget.runServiceOverride ??
DivinationRunService(api: DivinationApi(apiClient: apiClient));
_params = DivinationParams(
method: DivinationMethod.manual,
questionType: QuestionType.career,
question: '',
divinationTime: DateTime.now(),
coinBalance: 0,
userId: widget.userId,
);
_questionController.addListener(_syncQuestion);
}
@@ -58,7 +86,6 @@ class _DivinationScreenState extends State<DivinationScreen> {
}
Widget _buildBody(BuildContext context, AppLocalizations l10n) {
final palette = Theme.of(context).extension<AppColorPalette>()!;
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: SingleChildScrollView(
@@ -106,15 +133,6 @@ class _DivinationScreenState extends State<DivinationScreen> {
_QuestionTextField(controller: _questionController, l10n: l10n),
const SizedBox(height: AppSpacing.xl),
_StartButton(onPressed: _onStart, l10n: l10n),
const SizedBox(height: AppSpacing.sm),
Center(
child: Text(
l10n.divinationCoinBalance(_params.coinBalance),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: palette.warning),
),
),
],
),
),
@@ -132,16 +150,14 @@ class _DivinationScreenState extends State<DivinationScreen> {
return;
}
if (_params.coinBalance <= 0) {
Toast.show(context, l10n.toastCoinInsufficient, type: ToastType.warning);
return;
}
if (_params.method == DivinationMethod.manual) {
final nextParams = _params.copyWith(divinationTime: DateTime.now());
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ManualDivinationScreen(params: nextParams),
builder: (_) => ManualDivinationScreen(
params: nextParams,
runService: _runService,
),
),
);
return;
@@ -150,7 +166,8 @@ class _DivinationScreenState extends State<DivinationScreen> {
final nextParams = _params.copyWith(divinationTime: DateTime.now());
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => AutoDivinationScreen(params: nextParams),
builder: (_) =>
AutoDivinationScreen(params: nextParams, runService: _runService),
),
);
}
@@ -2,20 +2,16 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import '../../../../core/network/api_problem.dart';
import '../../../../core/network/api_problem_mapper.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/divination/divination_shared_widgets.dart';
import '../../../../shared/widgets/divination/divination_terms.dart';
import '../../../../shared/widgets/divination/yao_legend.dart';
import '../../../../shared/widgets/divination/yao_line_row.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart';
import '../../data/models/divination_params.dart';
import '../../data/services/divination_run_service.dart';
import 'divination_result_screen.dart';
import 'divination_processing_screen.dart';
class ManualDivinationScreen extends StatefulWidget {
const ManualDivinationScreen({
@@ -126,12 +122,7 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
builder: (_) {
return DivinationGuideDialog(
title: l10n.manualSelectYaoTitle,
guideImages: const [
'assets/images/qigua/lc2.jpg',
'assets/images/qigua/lc3.jpg',
'assets/images/qigua/lc4.jpg',
'assets/images/qigua/lc5.jpg',
],
guideImages: const ['lc2.jpg', 'lc3.jpg', 'lc4.jpg', 'lc5.jpg'],
instructionText: l10n.manualYaoTipContent,
);
},
@@ -180,35 +171,22 @@ class _ManualDivinationScreenState extends State<ManualDivinationScreen>
}
Future<void> _submitRun() async {
final l10n = AppLocalizations.of(context)!;
setState(() {
_submitting = true;
});
try {
final aggregate = await widget.runService.run(
if (!mounted) {
return;
}
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => DivinationProcessingScreen(
params: widget.params.copyWith(divinationTime: _selectedTime),
yaoStates: _selectedYaos.cast<YaoType>(),
);
if (!mounted) {
return;
}
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DivinationResultScreen(
data: aggregate.toViewData(
widget.params.copyWith(divinationTime: _selectedTime),
),
runService: widget.runService,
),
),
);
} catch (error) {
if (!mounted) {
return;
}
final message = error is ApiProblem
? mapApiProblemToMessage(error, l10n)
: l10n.errorRequestGeneric;
Toast.show(context, message, type: ToastType.error);
} finally {
if (!mounted) {
return;
@@ -373,70 +351,206 @@ class _YaoSelectionCard extends StatelessWidget {
void Function(int, YaoType) onSelect,
) {
final l10n = AppLocalizations.of(context)!;
final options = <(String, YaoType)>[
final colors = Theme.of(context).colorScheme;
final options = <(String, YaoType, String)>[
(
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.youngYang]} (\u2014)\uFF1A\u82B1\u5B57\u5B57',
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.youngYang]}${DivinationTerms.youngYangSymbol}',
YaoType.youngYang,
'字字字',
),
(
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.youngYin]} (--)\uFF1A\u5B57\u82B1\u82B1',
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.youngYin]}${DivinationTerms.youngYinSymbol}',
YaoType.youngYin,
'花花花',
),
(
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.oldYang]} (\u2014\u25CB)\uFF1A\u82B1\u82B1\u82B1',
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.oldYang]}${DivinationTerms.oldYangSymbol}',
YaoType.oldYang,
'字字字',
),
(
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.oldYin]} (--\u00D7)\uFF1A\u5B57\u5B57\u5B57',
'${DivinationTerms.yaoTypeLabels[YaoTypeLabel.oldYin]}${DivinationTerms.oldYinSymbol}',
YaoType.oldYin,
'花花花',
),
];
return showDialog<void>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(l10n.manualSelectYaoTitle),
content: SingleChildScrollView(
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final option in options)
Padding(
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.manualSelectYaoTitle,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const SizedBox(height: AppSpacing.md),
...options.map((option) {
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
child: OutlinedButton(
onPressed: () {
child: _YaoOptionCard(
label: option.$1,
pattern: option.$3,
isSelected: false,
onTap: () {
onSelect(yaoIndex, option.$2);
Navigator.of(context).pop();
},
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(44),
),
child: Text(option.$1),
);
}),
const SizedBox(height: AppSpacing.md),
Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.coinFaceGuideTitle,
style: Theme.of(context).textTheme.titleSmall
?.copyWith(
color: colors.onSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: AppSpacing.sm),
const Align(
alignment: Alignment.centerLeft,
child: Text('字花图片说明:'),
),
const SizedBox(height: AppSpacing.sm),
Image.asset(
ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Image.asset(
'assets/images/qigua/zihua.jpg',
width: double.infinity,
height: 180,
height: 120,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => Container(
height: 120,
color: colors.errorContainer,
child: Center(
child: Text(
l10n.divinationClose,
style: TextStyle(
color: colors.onErrorContainer,
),
),
),
),
),
),
const SizedBox(height: AppSpacing.xs),
Text(
l10n.coinFaceGuideDescription,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colors.onSurfaceVariant),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.divinationClose),
),
],
),
),
),
);
},
);
}
}
class _YaoOptionCard extends StatelessWidget {
const _YaoOptionCard({
required this.label,
required this.pattern,
required this.isSelected,
required this.onTap,
});
final String label;
final String pattern;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Material(
color: isSelected ? colors.primaryContainer : colors.surface,
borderRadius: BorderRadius.circular(AppRadius.md),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.md),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm + 4,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(
color: isSelected ? colors.primary : colors.outlineVariant,
width: isSelected ? 2 : 1,
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: isSelected
? colors.onPrimaryContainer
: colors.onSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
pattern,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: isSelected
? colors.onPrimaryContainer.withValues(alpha: 0.7)
: colors.onSurfaceVariant,
),
),
],
),
),
Icon(
Icons.chevron_right,
color: isSelected
? colors.onPrimaryContainer
: colors.onSurfaceVariant,
),
],
),
),
),
);
}
}
@@ -278,9 +278,14 @@ class _HomeScreenState extends State<HomeScreen> {
}
void _onStartDivination() {
Navigator.of(
context,
).push(MaterialPageRoute<void>(builder: (_) => const DivinationScreen()));
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DivinationScreen(
sessionStore: widget.sessionStore,
userId: widget.account,
),
),
);
}
void _showSnack(BuildContext context, String message) {
@@ -4,7 +4,6 @@ import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../data/models/profile_settings.dart';
import 'language_settings_screen.dart';
import 'settings_placeholder_screen.dart';
import '../widgets/settings_section_widgets.dart';
class GeneralSettingsScreen extends StatefulWidget {
@@ -70,50 +69,8 @@ class _GeneralSettingsScreenState extends State<GeneralSettingsScreen> {
),
tint: colors.primary,
background: colors.surfaceContainerHighest,
onTap: _openLanguageSettings,
),
SettingsMenuTile(
icon: Icons.auto_awesome_rounded,
title: l10n.settingsAiLanguage,
subtitle: displayLanguageLabel(
l10n,
_settings.preferences.aiLanguage,
),
tint: colors.secondary,
background: colors.surfaceContainerHighest,
onTap: () => _openPlaceholder(
title: l10n.settingsAiLanguage,
value: displayLanguageLabel(
l10n,
_settings.preferences.aiLanguage,
),
description: l10n.settingsAiLanguageHint,
),
),
SettingsMenuTile(
icon: Icons.public_rounded,
title: l10n.settingsTimezone,
subtitle: _settings.preferences.timezone,
tint: colors.primary,
background: colors.surfaceContainerHighest,
onTap: () => _openPlaceholder(
title: l10n.settingsTimezone,
value: _settings.preferences.timezone,
description: l10n.settingsTimezoneHint,
),
),
SettingsMenuTile(
icon: Icons.flag_outlined,
title: l10n.settingsCountry,
subtitle: _settings.preferences.country,
tint: colors.primary,
background: colors.surfaceContainerHighest,
showDivider: false,
onTap: () => _openPlaceholder(
title: l10n.settingsCountry,
value: _settings.preferences.country,
description: l10n.settingsCountryHint,
),
onTap: _openLanguageSettings,
),
],
),
@@ -144,20 +101,4 @@ class _GeneralSettingsScreenState extends State<GeneralSettingsScreen> {
);
});
}
Future<void> _openPlaceholder({
required String title,
required String value,
required String description,
}) async {
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => SettingsPlaceholderScreen(
title: title,
value: value,
description: description,
),
),
);
}
}
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
@@ -15,9 +16,23 @@ class LegalDocumentScreen extends StatelessWidget {
final String title;
final String assetPath;
Future<String> _loadAsset() async {
try {
final data = await rootBundle.loadString(assetPath);
if (data.isEmpty) {
throw Exception('Asset file is empty: $assetPath');
}
return data;
} catch (e) {
debugPrint('Failed to load asset: $assetPath, error: $e');
rethrow;
}
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
return Scaffold(
backgroundColor: colors.surfaceContainerLow,
appBar: AppBar(
@@ -27,8 +42,43 @@ class LegalDocumentScreen extends StatelessWidget {
surfaceTintColor: colors.surfaceContainerLow,
),
body: FutureBuilder<String>(
future: rootBundle.loadString(assetPath),
future: _loadAsset(),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: colors.error),
const SizedBox(height: AppSpacing.md),
Text(
l10n.legalDocumentLoadFailedTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: AppSpacing.sm),
Text(
'${l10n.legalDocumentLoadFailedPathPrefix}: $assetPath',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: colors.error),
textAlign: TextAlign.center,
),
const SizedBox(height: AppSpacing.xs),
Text(
'${l10n.legalDocumentLoadFailedErrorPrefix}: ${snapshot.error}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: colors.error),
textAlign: TextAlign.center,
),
],
),
),
);
}
if (!snapshot.hasData) {
return const Center(
child: AppLoadingIndicator(variant: AppLoadingVariant.surface),
@@ -1,14 +1,12 @@
import 'package:flutter/material.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/app_color_palette.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../data/models/profile_settings.dart';
import '../widgets/settings_section_widgets.dart';
import 'coin_center_screen.dart';
import 'general_settings_screen.dart';
import 'legal_center_screen.dart';
import 'privacy_notification_settings_screen.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({
@@ -44,7 +42,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
final palette = Theme.of(context).extension<AppColorPalette>()!;
return Scaffold(
backgroundColor: colors.surfaceContainerLow,
@@ -62,10 +59,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
AppSpacing.xl,
),
children: [
ProfileHeaderCard(
account: widget.account,
version: _settings.version,
),
ProfileHeaderCard(account: widget.account),
const SizedBox(height: AppSpacing.lg),
WalletHeroCard(
balance: widget.coinBalance,
@@ -76,39 +70,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
SectionLabel(text: l10n.settingsSectionQuickAccess),
SettingsGroupCard(
children: [
SettingsMenuTile(
icon: Icons.toll_rounded,
title: l10n.settingsCoinCenterTitle,
subtitle: l10n.settingsCoinCenterSubtitle(widget.coinBalance),
tint: palette.historyGoldText,
background: palette.historyGoldBg,
onTap: _openCoinCenter,
),
SettingsMenuTile(
icon: Icons.tune_rounded,
title: l10n.settingsGeneralTitle,
subtitle: l10n.settingsGeneralSubtitle(
displayLanguageLabel(
l10n,
_settings.preferences.interfaceLanguage,
),
),
tint: colors.primary,
background: colors.surfaceContainerHighest,
onTap: _openGeneralSettings,
),
SettingsMenuTile(
icon: Icons.privacy_tip_outlined,
title: l10n.settingsPrivacyAndNotificationTitle,
subtitle: l10n.settingsPrivacyAndNotificationSubtitle,
tint: palette.warning,
background: palette.warningContainer,
onTap: _openPrivacyAndNotification,
),
SettingsMenuTile(
icon: Icons.description_outlined,
title: l10n.settingsLegalCenterTitle,
subtitle: l10n.settingsLegalCenterSubtitle,
tint: colors.secondary,
background: colors.surfaceContainerHighest,
showDivider: false,
@@ -117,21 +88,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
],
),
const SizedBox(height: AppSpacing.xl),
SectionLabel(text: l10n.settingsSectionAccount),
SettingsGroupCard(
children: [
SettingsMenuTile(
icon: Icons.logout_rounded,
title: l10n.logout,
subtitle: l10n.settingsLogoutSubtitle,
tint: colors.error,
background: colors.error.withValues(alpha: 0.08),
showDivider: false,
onTap: _confirmLogout,
),
],
),
const SizedBox(height: AppSpacing.xl),
FilledButton(
onPressed: _isLoggingOut ? null : _confirmLogout,
style: FilledButton.styleFrom(
@@ -175,14 +131,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
});
}
Future<void> _openPrivacyAndNotification() async {
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => PrivacyNotificationSettingsScreen(settings: _settings),
),
);
}
Future<void> _openLegalCenter() async {
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(builder: (_) => const LegalCenterScreen()),
@@ -55,18 +55,18 @@ class SettingsMenuTile extends StatelessWidget {
super.key,
required this.icon,
required this.title,
required this.subtitle,
required this.tint,
required this.background,
required this.onTap,
this.showDivider = true,
this.showChevron = true,
this.trailing,
this.subtitle,
});
final IconData icon;
final String title;
final String subtitle;
final String? subtitle;
final Color tint;
final Color background;
final VoidCallback onTap;
@@ -95,9 +95,11 @@ class SettingsMenuTile extends StatelessWidget {
child: Icon(icon, color: tint),
),
title: Text(title),
subtitle: Padding(
subtitle: subtitle == null
? null
: Padding(
padding: const EdgeInsets.only(top: AppSpacing.xs),
child: Text(subtitle),
child: Text(subtitle!),
),
trailing:
trailing ??
@@ -118,18 +120,12 @@ class SettingsMenuTile extends StatelessWidget {
}
class ProfileHeaderCard extends StatelessWidget {
const ProfileHeaderCard({
super.key,
required this.account,
required this.version,
});
const ProfileHeaderCard({super.key, required this.account});
final String account;
final int version;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
return Card(
margin: EdgeInsets.zero,
@@ -153,14 +149,10 @@ class ProfileHeaderCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(account, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: AppSpacing.xs),
Text(
'${l10n.settingsVersionLabel}: v$version',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
Icon(Icons.edit_outlined, color: colors.outline, size: 20),
],
),
),
@@ -185,17 +177,18 @@ class WalletHeroCard extends StatelessWidget {
final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
final palette = Theme.of(context).extension<AppColorPalette>()!;
return InkWell(
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.xl),
child: Ink(
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.xl),
gradient: LinearGradient(
colors: [colors.primary, palette.accentPurple],
),
),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.xl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -213,6 +206,13 @@ class WalletHeroCard extends StatelessWidget {
color: colors.onPrimary,
),
),
const SizedBox(width: AppSpacing.lg),
Text(
l10n.settingsCoinBalanceValue(balance),
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: colors.onPrimary,
),
),
const Spacer(),
Icon(
Icons.chevron_right_rounded,
@@ -220,20 +220,16 @@ class WalletHeroCard extends StatelessWidget {
),
],
),
const SizedBox(height: AppSpacing.lg),
Text(
l10n.settingsCoinBalanceValue(balance),
style: Theme.of(
context,
).textTheme.headlineMedium?.copyWith(color: colors.onPrimary),
),
const SizedBox(height: AppSpacing.xs),
Text(
const SizedBox(height: AppSpacing.md),
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
subtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colors.onPrimary.withValues(alpha: 0.92),
),
),
),
],
),
),
+6 -1
View File
@@ -24,6 +24,9 @@
"privacyPolicySubtitle": "Learn how we protect user privacy",
"termsOfService": "Terms of Service",
"termsOfServiceSubtitle": "Learn the service agreement for users",
"legalDocumentLoadFailedTitle": "Failed to load document",
"legalDocumentLoadFailedPathPrefix": "Path",
"legalDocumentLoadFailedErrorPrefix": "Error",
"disclaimer": "Disclaimer",
"icp": "Yue ICP 2025428416-1A",
"invalidPhone": "Please enter a valid phone number",
@@ -306,5 +309,7 @@
"timeTab": "Time",
"confirm": "Confirm",
"cancel": "Cancel",
"autoSelectTime": "Select Time"
"autoSelectTime": "Select Time",
"coinFaceGuideTitle": "Coin Face Guide",
"coinFaceGuideDescription": "字 = side with inscription\n花 = side with pattern"
}
+30
View File
@@ -200,6 +200,24 @@ abstract class AppLocalizations {
/// **'了解用户服务协议'**
String get termsOfServiceSubtitle;
/// No description provided for @legalDocumentLoadFailedTitle.
///
/// In zh, this message translates to:
/// **'文档加载失败'**
String get legalDocumentLoadFailedTitle;
/// No description provided for @legalDocumentLoadFailedPathPrefix.
///
/// In zh, this message translates to:
/// **'路径'**
String get legalDocumentLoadFailedPathPrefix;
/// No description provided for @legalDocumentLoadFailedErrorPrefix.
///
/// In zh, this message translates to:
/// **'错误'**
String get legalDocumentLoadFailedErrorPrefix;
/// No description provided for @disclaimer.
///
/// In zh, this message translates to:
@@ -1429,6 +1447,18 @@ abstract class AppLocalizations {
/// In zh, this message translates to:
/// **'取消'**
String get cancel;
/// No description provided for @coinFaceGuideTitle.
///
/// In zh, this message translates to:
/// **'字花图片说明'**
String get coinFaceGuideTitle;
/// No description provided for @coinFaceGuideDescription.
///
/// In zh, this message translates to:
/// **'字=铜钱有字的一面\n花=铜钱有花纹的一面'**
String get coinFaceGuideDescription;
}
class _AppLocalizationsDelegate
+16
View File
@@ -62,6 +62,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get termsOfServiceSubtitle => 'Learn the service agreement for users';
@override
String get legalDocumentLoadFailedTitle => 'Failed to load document';
@override
String get legalDocumentLoadFailedPathPrefix => 'Path';
@override
String get legalDocumentLoadFailedErrorPrefix => 'Error';
@override
String get disclaimer => 'Disclaimer';
@@ -731,4 +740,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get cancel => 'Cancel';
@override
String get coinFaceGuideTitle => 'Coin Face Guide';
@override
String get coinFaceGuideDescription =>
'字 = side with inscription\n花 = side with pattern';
}
+15
View File
@@ -61,6 +61,15 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get termsOfServiceSubtitle => '了解用户服务协议';
@override
String get legalDocumentLoadFailedTitle => '文档加载失败';
@override
String get legalDocumentLoadFailedPathPrefix => '路径';
@override
String get legalDocumentLoadFailedErrorPrefix => '错误';
@override
String get disclaimer => '免责声明';
@@ -712,4 +721,10 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get cancel => '取消';
@override
String get coinFaceGuideTitle => '字花图片说明';
@override
String get coinFaceGuideDescription => '字=铜钱有字的一面\n花=铜钱有花纹的一面';
}
+6 -1
View File
@@ -24,6 +24,9 @@
"privacyPolicySubtitle": "了解用户隐私保护政策",
"termsOfService": "服务条款",
"termsOfServiceSubtitle": "了解用户服务协议",
"legalDocumentLoadFailedTitle": "文档加载失败",
"legalDocumentLoadFailedPathPrefix": "路径",
"legalDocumentLoadFailedErrorPrefix": "错误",
"disclaimer": "免责声明",
"icp": "粤ICP备2025428416号-1A",
"invalidPhone": "请输入正确的手机号码",
@@ -306,5 +309,7 @@
"timeTab": "时间",
"confirm": "确认",
"cancel": "取消",
"autoSelectTime": "选择时间"
"autoSelectTime": "选择时间",
"coinFaceGuideTitle": "字花图片说明",
"coinFaceGuideDescription": "字=铜钱有字的一面\n花=铜钱有花纹的一面"
}
@@ -19,6 +19,12 @@ abstract final class DivinationTerms {
static const changeMarkOldYang = '';
static const changeMarkOldYin = '×';
static const yinYangSymbol = {true: '', false: '--'};
static const youngYangSymbol = '';
static const youngYinSymbol = '--';
static const oldYangSymbol = '—○';
static const oldYinSymbol = '--×';
static const signBest = '上上签';
static const signGood = '中上签';
static const signNormal = '中下签';
+2 -1
View File
@@ -79,7 +79,8 @@ flutter:
assets:
- assets/images/logo.png
- assets/images/qigua/
- assets/legal/
- assets/legal/en/
- assets/legal/zh/
# To add assets to your application, add an assets section, like this:
# assets:
@@ -0,0 +1,108 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meeyao_qianwen/features/divination/data/apis/divination_api.dart';
import 'package:meeyao_qianwen/features/divination/data/models/divination_params.dart';
void main() {
test('buildDivinationRunPayload contains required AG-UI fields', () {
final params = DivinationParams(
method: DivinationMethod.auto,
questionType: QuestionType.career,
question: '什么时候找到工作',
divinationTime: DateTime(2026, 4, 3, 18, 0, 2),
coinBalance: 0,
userId: 'u_test',
);
final payload = buildDivinationRunPayload(
params: params,
yaoStates: const <YaoType>[
YaoType.youngYang,
YaoType.youngYang,
YaoType.youngYang,
YaoType.oldYang,
YaoType.oldYin,
YaoType.youngYang,
],
threadId: 'de44f2fb-de0a-46b9-bbf2-e99ee36f2a2d',
runId: 'run_1775210431957',
clientNow: DateTime(2026, 4, 3, 18, 0, 31, 958),
);
expect(payload['state'], isA<Map<String, dynamic>>());
expect(payload['tools'], isA<List<dynamic>>());
expect(payload['context'], isA<List<dynamic>>());
final forwardedProps = payload['forwardedProps'] as Map<String, dynamic>;
expect(forwardedProps['runtime_mode'], 'chat');
final clientTime = forwardedProps['client_time'] as Map<String, dynamic>;
expect((clientTime['client_now_iso'] as String).endsWith('Z'), isTrue);
final divinationPayload =
forwardedProps['divinationPayload'] as Map<String, dynamic>;
expect(
(divinationPayload['divinationTimeIso'] as String).endsWith('Z'),
isTrue,
);
expect((divinationPayload['yaoLines'] as List<dynamic>).length, 6);
final messages = payload['messages'] as List<dynamic>;
expect(messages.length, 1);
final userMessage = messages.first as Map<String, dynamic>;
expect(userMessage['id'], isNotEmpty);
expect(userMessage['role'], 'user');
expect(userMessage['content'], '什么时候找到工作');
});
test('buildDivinationRunPayload throws when yaoStates length is not 6', () {
final params = DivinationParams(
method: DivinationMethod.auto,
questionType: QuestionType.career,
question: '测试',
divinationTime: DateTime(2026, 4, 3, 18, 0, 2),
coinBalance: 0,
userId: 'u_test',
);
expect(
() => buildDivinationRunPayload(
params: params,
yaoStates: const <YaoType>[YaoType.youngYang],
threadId: 'de44f2fb-de0a-46b9-bbf2-e99ee36f2a2d',
runId: 'run_1775210431957',
clientNow: DateTime(2026, 4, 3, 18, 0, 31, 958),
),
throwsArgumentError,
);
});
test(
'buildDivinationRunPayload throws when yaoStates contains undetermined',
() {
final params = DivinationParams(
method: DivinationMethod.auto,
questionType: QuestionType.career,
question: '测试',
divinationTime: DateTime(2026, 4, 3, 18, 0, 2),
coinBalance: 0,
userId: 'u_test',
);
expect(
() => buildDivinationRunPayload(
params: params,
yaoStates: const <YaoType>[
YaoType.youngYang,
YaoType.youngYang,
YaoType.youngYang,
YaoType.oldYang,
YaoType.oldYin,
YaoType.undetermined,
],
threadId: 'de44f2fb-de0a-46b9-bbf2-e99ee36f2a2d',
runId: 'run_1775210431957',
clientNow: DateTime(2026, 4, 3, 18, 0, 31, 958),
),
throwsArgumentError,
);
},
);
}
@@ -0,0 +1,19 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meeyao_qianwen/features/divination/data/models/divination_backend_models.dart';
void main() {
test('YaoBackendLine accepts empty specialMark', () {
final line = YaoBackendLine.fromJson(<String, dynamic>{
'position': 1,
'spiritName': '',
'relationName': '父母',
'tiganName': '',
'elementName': '',
'isYang': true,
'isChanging': false,
'specialMark': '',
});
expect(line.specialMark, '');
});
}
@@ -2,8 +2,15 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:meeyao_qianwen/features/divination/data/models/divination_params.dart';
void main() {
test('mock data contains valid defaults', () {
final params = DivinationMockData.initial();
test('params contains valid fields', () {
final params = DivinationParams(
method: DivinationMethod.manual,
questionType: QuestionType.career,
question: '测试问题',
divinationTime: DateTime(2026, 4, 3, 10, 30),
coinBalance: 8,
userId: 'u_test',
);
expect(params.method, DivinationMethod.manual);
expect(params.questionType, QuestionType.career);
@@ -30,7 +37,14 @@ void main() {
});
test('toBinary and toChangedBinary mappings are correct', () {
final params = DivinationMockData.initial();
final params = DivinationParams(
method: DivinationMethod.manual,
questionType: QuestionType.career,
question: '测试问题',
divinationTime: DateTime(2026, 4, 3, 10, 30),
coinBalance: 8,
userId: 'u_test',
);
final states = <YaoType>[
YaoType.oldYin,
YaoType.youngYang,
@@ -1,36 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meeyao_qianwen/features/divination/data/models/divination_params.dart';
import 'package:meeyao_qianwen/features/divination/data/services/divination_result_builder.dart';
void main() {
final builder = DivinationResultBuilder();
test('build returns result with hexagram names and section text', () {
final params = DivinationMockData.initial().copyWith(
method: DivinationMethod.auto,
question: '近期工作是否会有突破',
questionType: QuestionType.career,
);
final result = builder.build(
params: params,
yaoStates: const [
YaoType.youngYang,
YaoType.youngYin,
YaoType.oldYang,
YaoType.youngYin,
YaoType.oldYin,
YaoType.youngYang,
],
);
expect(result.guaName, isNotEmpty);
expect(result.targetGuaName, isNotEmpty);
expect(result.binaryCode, hasLength(6));
expect(result.changedBinaryCode, hasLength(6));
expect(result.keywords, contains(''));
expect(result.conclusion, contains('这个卦象的结果为'));
expect(result.yaoLines.length, 6);
expect(result.targetYaoLines.length, 6);
});
}
@@ -1,43 +1,180 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meeyao_qianwen/app/app_theme.dart';
import 'package:meeyao_qianwen/features/divination/data/models/divination_params.dart';
import 'package:meeyao_qianwen/features/divination/data/services/divination_result_builder.dart';
import 'package:meeyao_qianwen/features/divination/data/models/divination_result.dart';
import 'package:meeyao_qianwen/features/divination/presentation/screens/divination_result_screen.dart';
import 'package:meeyao_qianwen/l10n/app_localizations.dart';
void main() {
testWidgets('result screen shows key sections', (tester) async {
final params = DivinationMockData.initial().copyWith(
final params = DivinationParams(
method: DivinationMethod.auto,
questionType: QuestionType.health,
question: '近期状态是否平稳',
divinationTime: DateTime(2026, 4, 3, 20, 30),
coinBalance: 10,
userId: 'u_test',
);
final data = DivinationResultBuilder().build(
final data = DivinationResultData(
params: params,
yaoStates: const [
YaoType.oldYin,
YaoType.youngYang,
YaoType.youngYin,
YaoType.oldYang,
YaoType.youngYang,
YaoType.oldYin,
binaryCode: '101001',
changedBinaryCode: '100001',
guaName: '山火贲',
targetGuaName: '山雷颐',
upperName: '',
lowerName: '',
signType: '中上签',
keywords: '稳中求进、审时度势、蓄势待发',
conclusion: '1. 方向可行\n2. 节奏宜稳',
analysis: '当前阶段需先稳住节奏,再做关键推进。',
suggestion: '1. 控节奏\n2. 重复盘',
ganzhi: GanzhiData(
yearGanZhi: '丙午',
monthGanZhi: '辛卯',
dayGanZhi: '丁未',
timeGanZhi: '庚戌',
yearKongWang: '子丑',
monthKongWang: '戌亥',
dayKongWang: '寅卯',
timeKongWang: '寅卯',
yueJian: '卯木',
riChen: '未土',
yuePo: '酉金',
riChong: '丑土',
),
wuXingStatus: {'': '', '': '', '': '', '': '', '': ''},
yaoLines: [
YaoLineData(
index: 0,
spirit: '',
relation: '父母',
branch: '',
element: '',
type: YaoType.oldYin,
mark: '',
),
YaoLineData(
index: 1,
spirit: '',
relation: '兄弟',
branch: '',
element: '',
type: YaoType.youngYang,
mark: '',
),
YaoLineData(
index: 2,
spirit: '',
relation: '妻财',
branch: '',
element: '',
type: YaoType.youngYin,
mark: '',
),
YaoLineData(
index: 3,
spirit: '',
relation: '妻财',
branch: '',
element: '',
type: YaoType.oldYang,
mark: '',
),
YaoLineData(
index: 4,
spirit: '',
relation: '子孙',
branch: '',
element: '',
type: YaoType.youngYang,
mark: '',
),
YaoLineData(
index: 5,
spirit: '',
relation: '兄弟',
branch: '',
element: '',
type: YaoType.oldYin,
mark: '',
),
],
targetYaoLines: [
YaoLineData(
index: 0,
spirit: '',
relation: '父母',
branch: '',
element: '',
type: YaoType.youngYang,
mark: '',
),
YaoLineData(
index: 1,
spirit: '',
relation: '兄弟',
branch: '',
element: '',
type: YaoType.youngYang,
mark: '',
),
YaoLineData(
index: 2,
spirit: '',
relation: '妻财',
branch: '',
element: '',
type: YaoType.youngYin,
mark: '',
),
YaoLineData(
index: 3,
spirit: '',
relation: '妻财',
branch: '',
element: '',
type: YaoType.youngYin,
mark: '',
),
YaoLineData(
index: 4,
spirit: '',
relation: '子孙',
branch: '',
element: '',
type: YaoType.youngYang,
mark: '',
),
YaoLineData(
index: 5,
spirit: '',
relation: '兄弟',
branch: '',
element: '',
type: YaoType.youngYang,
mark: '',
),
],
);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
locale: const Locale('zh'),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: DivinationResultScreen(data: data),
),
);
await tester.pump();
expect(find.text('天机推演中'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 450));
expect(find.text('正在解卦'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 850));
expect(find.text('解卦完成\n点击查看'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 1000));
await tester.pumpAndSettle();
expect(find.text('解卦结果'), findsOneWidget);
expect(find.text('AI解卦'), findsOneWidget);
@@ -1,15 +1,42 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meeyao_qianwen/app/app_theme.dart';
import 'package:meeyao_qianwen/core/auth/session_store.dart';
import 'package:meeyao_qianwen/data/network/api_client.dart';
import 'package:meeyao_qianwen/data/storage/local_kv_store.dart';
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/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/l10n/app_localizations.dart';
void main() {
final runService = DivinationRunService(
api: DivinationApi(apiClient: ApiClient(baseUrl: 'http://localhost:5775')),
);
final sessionStore = SessionStore(LocalKvStore());
testWidgets('divination screen navigates to auto screen', (tester) async {
await tester.pumpWidget(
MaterialApp(theme: AppTheme.light(), home: const DivinationScreen()),
MaterialApp(
theme: AppTheme.light(),
locale: const Locale('zh'),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: DivinationScreen(
sessionStore: sessionStore,
userId: 'user_test',
runServiceOverride: runService,
),
),
);
await tester.tap(find.text('自动起卦'));
@@ -25,15 +52,27 @@ void main() {
testWidgets('auto screen keeps resolve button disabled initially', (
tester,
) async {
final params = DivinationMockData.initial().copyWith(
final params = DivinationParams(
method: DivinationMethod.auto,
questionType: QuestionType.career,
question: '测试问题',
divinationTime: DateTime(2026, 4, 3, 20, 30),
coinBalance: 9,
userId: 'user_test',
);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: AutoDivinationScreen(params: params),
locale: const Locale('zh'),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: AutoDivinationScreen(params: params, runService: runService),
),
);
@@ -49,7 +88,22 @@ void main() {
tester,
) async {
await tester.pumpWidget(
MaterialApp(theme: AppTheme.light(), home: const DivinationScreen()),
MaterialApp(
theme: AppTheme.light(),
locale: const Locale('zh'),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: DivinationScreen(
sessionStore: sessionStore,
userId: 'user_test',
runServiceOverride: runService,
),
),
);
await tester.enterText(find.byType(TextField), '近期感情是否稳定');
@@ -1,20 +1,41 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meeyao_qianwen/app/app_theme.dart';
import 'package:meeyao_qianwen/data/network/api_client.dart';
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/l10n/app_localizations.dart';
void main() {
testWidgets('manual screen shows yao legend', (tester) async {
final params = DivinationMockData.initial().copyWith(
final params = DivinationParams(
method: DivinationMethod.manual,
questionType: QuestionType.career,
question: '测试问题',
divinationTime: DateTime(2026, 4, 3, 20, 30),
coinBalance: 9,
userId: 'user_test',
);
final runService = DivinationRunService(
api: DivinationApi(
apiClient: ApiClient(baseUrl: 'http://localhost:5775'),
),
);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: ManualDivinationScreen(params: params),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: ManualDivinationScreen(params: params, runService: runService),
),
);
@@ -111,7 +111,7 @@ def upgrade() -> None:
lifetime_spent,
version
)
values (new.id, 100, 0, 100, 0, 0)
values (new.id, 60, 0, 60, 0, 0)
on conflict (user_id) do nothing;
insert into public.points_ledger (
@@ -131,8 +131,8 @@ def upgrade() -> None:
v_ledger_id,
new.id,
1,
100,
100,
60,
60,
'register',
null,
null,
@@ -271,7 +271,7 @@ def downgrade() -> None:
lifetime_spent,
version
)
values (new.id, 100, 0, 100, 0, 0)
values (new.id, 60, 0, 60, 0, 0)
on conflict (user_id) do nothing;
insert into public.points_ledger (
@@ -291,8 +291,8 @@ def downgrade() -> None:
v_ledger_id,
new.id,
1,
100,
100,
60,
60,
'register',
null,
null,
@@ -17,36 +17,38 @@ from schemas.agent.ui_hints import UiHintsPayload
if TYPE_CHECKING:
pass
_INTERNAL_TO_AGUI: dict[str, EventType] = {
"run.started": EventType.RUN_STARTED,
"run.finished": EventType.RUN_FINISHED,
"run.error": EventType.RUN_ERROR,
"step.start": EventType.STEP_STARTED,
"step.finish": EventType.STEP_FINISHED,
"text.end": EventType.TEXT_MESSAGE_END,
"tool.start": EventType.TOOL_CALL_START,
"tool.args": EventType.TOOL_CALL_ARGS,
"tool.end": EventType.TOOL_CALL_END,
"tool.result": EventType.TOOL_CALL_RESULT,
"state.snapshot": EventType.STATE_SNAPSHOT,
"messages.snapshot": EventType.MESSAGES_SNAPSHOT,
_INTERNAL_TO_AGUI: dict[str, str] = {
"run.started": EventType.RUN_STARTED.value,
"run.finished": EventType.RUN_FINISHED.value,
"run.error": EventType.RUN_ERROR.value,
"step.start": EventType.STEP_STARTED.value,
"step.finish": EventType.STEP_FINISHED.value,
"text.end": EventType.TEXT_MESSAGE_END.value,
"tool.start": EventType.TOOL_CALL_START.value,
"tool.args": EventType.TOOL_CALL_ARGS.value,
"tool.end": EventType.TOOL_CALL_END.value,
"tool.result": EventType.TOOL_CALL_RESULT.value,
"state.snapshot": EventType.STATE_SNAPSHOT.value,
"messages.snapshot": EventType.MESSAGES_SNAPSHOT.value,
}
_ALLOWED_WIRE_TYPES: set[str] = {event_type.value for event_type in EventType}
_ALLOWED_WIRE_TYPES.add("DIVINATION_DERIVED")
def _convert_to_agui_type(internal_type: str) -> EventType:
def _convert_to_agui_type(internal_type: str) -> str:
mapped = _INTERNAL_TO_AGUI.get(internal_type)
if mapped is not None:
return mapped
return EventType(internal_type.upper().replace(".", "_"))
candidate = internal_type.upper().replace(".", "_")
if candidate in _ALLOWED_WIRE_TYPES:
return candidate
raise ValueError(f"unsupported ag-ui event type: {internal_type}")
def _is_agui_event(event: dict[str, Any]) -> bool:
event_type = event.get("type", "")
try:
EventType(event_type)
return True
except ValueError:
return False
event_type = str(event.get("type", "")).strip().upper()
return event_type in _ALLOWED_WIRE_TYPES
def _sanitize_agui_event(event: dict[str, Any]) -> dict[str, Any]:
@@ -153,7 +155,7 @@ def to_agui_wire_event(event: dict[str, Any] | BaseEvent) -> dict[str, Any]:
if internal_type == "text.end" and isinstance(data, dict):
text_end_payload: dict[str, Any] = {
"type": _convert_to_agui_type(internal_type).value,
"type": _convert_to_agui_type(internal_type),
}
if isinstance(thread_id, str) and thread_id:
text_end_payload["threadId"] = thread_id
@@ -174,7 +176,7 @@ def to_agui_wire_event(event: dict[str, Any] | BaseEvent) -> dict[str, Any]:
if internal_type == "tool.result" and isinstance(data, dict):
tool_result_payload: dict[str, Any] = {
"type": _convert_to_agui_type(internal_type).value,
"type": _convert_to_agui_type(internal_type),
}
if isinstance(thread_id, str) and thread_id:
tool_result_payload["threadId"] = thread_id
@@ -203,7 +205,7 @@ def to_agui_wire_event(event: dict[str, Any] | BaseEvent) -> dict[str, Any]:
wire_type = _convert_to_agui_type(internal_type)
payload: dict[str, Any] = {
"type": wire_type.value,
"type": wire_type,
}
if isinstance(thread_id, str) and thread_id:
payload["threadId"] = thread_id
@@ -0,0 +1,18 @@
from __future__ import annotations
from schemas.domain.divination import DerivedDivinationData
def build_divination_user_prompt(*, derived: DerivedDivinationData) -> str:
structured_json = derived.model_dump_json(
by_alias=True,
exclude_none=True,
ensure_ascii=False,
)
return (
f"用户问题:{derived.question}\n"
f"问题类型:{derived.question_type}\n"
"以下是后端推导后的六爻结构化数据(JSON):\n"
f"{structured_json}\n"
"请仅基于以上六爻数据做专业解读。"
)
+53 -18
View File
@@ -13,11 +13,14 @@ from agentscope.message import Msg
from agentscope.tool import Toolkit
from agentscope.model import OpenAIChatModel
from core.agentscope.prompts.system_prompt import build_system_prompt
from core.agentscope.prompts.user_prompt import build_divination_user_prompt
from core.agentscope.schemas.agui_input import extract_latest_user_payload
from core.divination import derive_divination
from core.agentscope.runtime.json_react_agent import JsonReActAgent
from core.agentscope.runtime.model_tracking import TrackingChatModel
from core.agentscope.runtime.stage_emitter import PipelineStageEmitter
from core.agentscope.utils import patch_agentscope_json_repair_compat
from core.agentscope.utils.json_finalize import finalize_json_response
from core.config.settings import config
from core.db.session import AsyncSessionLocal
from models.llm import Llm
@@ -26,9 +29,11 @@ from models.system_agents import SystemAgents
from schemas.agent.forwarded_props import (
ClientTimeContext,
RuntimeMode,
parse_forwarded_props_divination_payload,
parse_forwarded_props_client_time,
parse_forwarded_props_runtime_mode,
)
from schemas.domain.divination import DerivedDivinationData
from schemas.agent.runtime_models import (
WorkerAgentOutputLite,
resolve_worker_output_model,
@@ -97,6 +102,21 @@ class AgentScopeRunner:
worker_toolkit = self._build_toolkit()
if cancel_checker is not None and await cancel_checker():
raise asyncio.CancelledError("run canceled by user")
derived_divination = self._resolve_derived_divination(
run_input=run_input
)
await self._emit_step_event(
pipeline=pipeline,
run_input=run_input,
step_name="divination",
event_type="DIVINATION_DERIVED",
runtime_mode=runtime_mode,
extra_event={
"divination": derived_divination.model_dump(
mode="json", by_alias=True, exclude_none=True
)
},
)
worker_output = await self._execute_worker_step(
pipeline=pipeline,
run_input=run_input,
@@ -106,6 +126,7 @@ class AgentScopeRunner:
stage_config=worker_config,
runtime_client_time=runtime_client_time,
runtime_mode=runtime_mode,
derived_divination=derived_divination,
)
return {
"worker": worker_output.model_dump(mode="json", exclude_none=True),
@@ -187,6 +208,7 @@ class AgentScopeRunner:
stage_config: SystemAgentRuntimeConfig,
runtime_client_time: ClientTimeContext | None,
runtime_mode: RuntimeMode,
derived_divination: DerivedDivinationData,
) -> WorkerAgentOutputLite:
worker_output_model = resolve_worker_output_model()
await self._emit_step_event(
@@ -201,6 +223,7 @@ class AgentScopeRunner:
input_messages=self._build_worker_input_messages(
context_messages=context_messages,
run_input=run_input,
derived_divination=derived_divination,
),
toolkit=toolkit,
run_input=run_input,
@@ -234,6 +257,7 @@ class AgentScopeRunner:
runtime_mode: RuntimeMode,
) -> StageExecutionResult:
tracking_model = self._build_model(stage_config=stage_config)
formatter = OpenAIChatFormatter()
emitter = PipelineStageEmitter(
pipeline=pipeline,
session_id=run_input.thread_id,
@@ -243,8 +267,6 @@ class AgentScopeRunner:
emit_text_events=True,
emit_tool_events=False,
)
agent = self._build_agent(
agent_name=stage_config.agent_type.value,
system_prompt = build_system_prompt(
agent_type=stage_config.agent_type,
llm_config=stage_config.llm_config,
@@ -253,22 +275,16 @@ class AgentScopeRunner:
runtime_client_time=runtime_client_time,
extra_context=stage_config.extra_context,
tools=None,
),
toolkit=toolkit,
)
_, worker_payload_raw = await finalize_json_response(
model=tracking_model,
emitter=emitter,
formatter=formatter,
base_messages=[Msg("system", system_prompt, "system"), *input_messages],
output_model=worker_output_model,
retries=2,
)
async with self._active_agent_lock:
self._active_agent = agent
try:
response_msg = await agent.reply_json(
input_messages, output_model=worker_output_model
)
finally:
async with self._active_agent_lock:
if self._active_agent is agent:
self._active_agent = None
worker_payload = worker_output_model.model_validate(response_msg.metadata or {})
worker_payload = worker_output_model.model_validate(worker_payload_raw)
response_metadata = self._llm_pricing_service.build_usage_metadata(
model=stage_config.model_code,
usage_summary=tracking_model.usage_summary(),
@@ -278,7 +294,12 @@ class AgentScopeRunner:
response_metadata=response_metadata,
)
return StageExecutionResult(
message=response_msg,
message=Msg(
name=stage_config.agent_type.value,
role="assistant",
content=worker_payload.answer,
metadata=worker_payload.model_dump(mode="json", exclude_none=True),
),
payload=worker_payload.model_dump(mode="json", exclude_none=True),
response_metadata=response_metadata,
)
@@ -288,13 +309,16 @@ class AgentScopeRunner:
*,
context_messages: list[Msg],
run_input: RunAgentInput,
derived_divination: DerivedDivinationData,
) -> list[Msg]:
if context_messages:
last = context_messages[-1]
if last.role == "user":
return context_messages
user_text, user_blocks = extract_latest_user_payload(run_input)
_, _ = extract_latest_user_payload(run_input)
user_text = build_divination_user_prompt(derived=derived_divination)
user_blocks = [{"type": "text", "text": user_text}]
if (
user_blocks
and isinstance(user_blocks[0], dict)
@@ -307,6 +331,17 @@ class AgentScopeRunner:
user_msg = Msg(name="user", role="user", content=content)
return [*context_messages, user_msg]
@staticmethod
def _resolve_derived_divination(
*, run_input: RunAgentInput
) -> DerivedDivinationData:
payload = parse_forwarded_props_divination_payload(
getattr(run_input, "forwarded_props", None)
)
if payload is None:
raise ValueError("forwardedProps.divinationPayload is required")
return derive_divination(payload)
def _build_model(
self, *, stage_config: SystemAgentRuntimeConfig
) -> TrackingChatModel:
+3
View File
@@ -0,0 +1,3 @@
from core.divination.derivation import derive_divination
__all__ = ["derive_divination"]
+281
View File
@@ -0,0 +1,281 @@
from __future__ import annotations
from datetime import datetime
from lunar_python import Solar
from core.divination.gua_catalog_loader import GuaCatalogItem, load_gua_catalog
from schemas.domain.divination import (
DerivedDivinationData,
DivinationPayload,
FushenDetail,
GanzhiDetail,
SpecialMark,
YaoDetail,
YaoType,
)
_DI_ZHI_ORDER = ("", "", "", "", "", "", "", "", "", "", "", "")
_TIAN_GAN_ORDER = ("", "", "", "", "", "", "", "", "", "")
def _yao_to_bit(yao: YaoType) -> str:
if yao in (YaoType.SHAO_YANG, YaoType.LAO_YANG):
return "1"
return "0"
def _yao_to_changed_bit(yao: YaoType) -> str:
if yao == YaoType.SHAO_YANG:
return "1"
if yao == YaoType.SHAO_YIN:
return "0"
if yao == YaoType.LAO_YANG:
return "0"
return "1"
def _yao_is_yang(yao: YaoType) -> bool:
return yao in (YaoType.SHAO_YANG, YaoType.LAO_YANG)
def _yao_is_changing(yao: YaoType) -> bool:
return yao in (YaoType.LAO_YANG, YaoType.LAO_YIN)
def _resolve_liu_shou(day_gan: str) -> tuple[str, ...]:
start = {
"": 0,
"": 0,
"": 1,
"": 1,
"": 2,
"": 3,
"": 4,
"": 4,
"": 5,
"": 5,
}.get(day_gan, 0)
base = ("", "", "", "", "", "")
return tuple(base[(start + i) % 6] for i in range(6))
def _di_zhi_wu_xing(di_zhi: str) -> str:
if di_zhi in ("", ""):
return ""
if di_zhi in ("", ""):
return ""
if di_zhi in ("", ""):
return ""
if di_zhi in ("", ""):
return ""
return ""
def _chong_di_zhi(di_zhi: str) -> str:
return {
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
}.get(di_zhi, "")
def _wu_xing_status(month_di_zhi: str, wu_xing: str) -> str:
table = {
"": {"": "", "": "", "": "", "": "", "": ""},
"": {"": "", "": "", "": "", "": "", "": ""},
"": {"": "", "": "", "": "", "": "", "": ""},
"": {"": "", "": "", "": "", "": "", "": ""},
"": {"": "", "": "", "": "", "": "", "": ""},
"": {"": "", "": "", "": "", "": "", "": ""},
"": {"": "", "": "", "": "", "": "", "": ""},
"": {"": "", "": "", "": "", "": "", "": ""},
"": {"": "", "": "", "": "", "": "", "": ""},
"": {"": "", "": "", "": "", "": "", "": ""},
"": {"": "", "": "", "": "", "": "", "": ""},
"": {"": "", "": "", "": "", "": "", "": ""},
}
return table.get(month_di_zhi, {}).get(wu_xing, "")
def _get_kong_wang(gan_zhi: str) -> str:
tian_gan = gan_zhi[0]
di_zhi = gan_zhi[1]
tian_gan_value = _TIAN_GAN_ORDER.index(tian_gan) + 1
di_zhi_value = _DI_ZHI_ORDER.index(di_zhi) + 1
start = di_zhi_value - tian_gan_value - 1
if start < 0:
start += 12
start %= 12
if start == 0:
start = 12
first = _DI_ZHI_ORDER[start - 1]
second = _DI_ZHI_ORDER[start % 12]
return f"{first}{second}"
def _get_all_wu_xing_status(month_gan_zhi: str) -> dict[str, str]:
month_di_zhi = month_gan_zhi[1]
return {
"": _wu_xing_status(month_di_zhi, ""),
"": _wu_xing_status(month_di_zhi, ""),
"": _wu_xing_status(month_di_zhi, ""),
"": _wu_xing_status(month_di_zhi, ""),
"": _wu_xing_status(month_di_zhi, ""),
}
def _resolve_special_mark(
*, index: int, world_position: int, response_position: int
) -> SpecialMark:
position = index + 1
if position == world_position:
return SpecialMark.SHI
if position == response_position:
return SpecialMark.YING
return SpecialMark.NONE
def _build_target_relation_names(
base_item: GuaCatalogItem, target_item: GuaCatalogItem
) -> tuple[str, ...]:
gua_gong_wu_xing = {
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
}.get(base_item.upper_name, "")
def calculate(yao_wu_xing: str) -> str:
if gua_gong_wu_xing == yao_wu_xing:
return "兄弟"
table = {
"": {"": "子孙", "": "妻财", "": "官鬼", "": "父母"},
"": {"": "子孙", "": "妻财", "": "官鬼", "": "父母"},
"": {"": "子孙", "": "妻财", "": "官鬼", "": "父母"},
"": {"": "子孙", "": "妻财", "": "官鬼", "": "父母"},
"": {"": "子孙", "": "妻财", "": "官鬼", "": "父母"},
}
return table.get(gua_gong_wu_xing, {}).get(yao_wu_xing, "兄弟")
return tuple(calculate(element) for element in target_item.yao_elements)
def derive_divination(payload: DivinationPayload) -> DerivedDivinationData:
catalog = load_gua_catalog()
binary_code = "".join(_yao_to_bit(yao) for yao in payload.yao_lines)
changed_binary_code = "".join(_yao_to_changed_bit(yao) for yao in payload.yao_lines)
base_item = catalog[binary_code]
target_item = catalog[changed_binary_code]
has_changing_yao = any(_yao_is_changing(yao) for yao in payload.yao_lines)
dt = datetime.fromisoformat(payload.divination_time_iso.replace("Z", "+00:00"))
solar = Solar.fromYmdHms(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second)
lunar = solar.getLunar()
year_gan_zhi = lunar.getYearInGanZhi()
month_gan_zhi = lunar.getMonthInGanZhi()
day_gan_zhi = lunar.getDayInGanZhi()
time_gan_zhi = lunar.getTimeInGanZhi()
liu_shou = _resolve_liu_shou(day_gan_zhi[0])
target_relations = _build_target_relation_names(base_item, target_item)
yao_info_list = [
YaoDetail(
position=index + 1,
spiritName=liu_shou[index],
relationName=base_item.yao_relations[index],
tiganName=base_item.yao_tigan[index],
elementName=base_item.yao_elements[index],
isYang=_yao_is_yang(payload.yao_lines[index]),
isChanging=_yao_is_changing(payload.yao_lines[index]),
specialMark=_resolve_special_mark(
index=index,
world_position=base_item.world_position,
response_position=base_item.response_position,
),
)
for index in range(6)
]
target_yao_info_list = [
YaoDetail(
position=index + 1,
spiritName=liu_shou[index],
relationName=target_relations[index],
tiganName=target_item.yao_tigan[index],
elementName=target_item.yao_elements[index],
isYang=_yao_is_yang(payload.yao_lines[index])
if not _yao_is_changing(payload.yao_lines[index])
else not _yao_is_yang(payload.yao_lines[index]),
isChanging=False,
specialMark=_resolve_special_mark(
index=index,
world_position=target_item.world_position,
response_position=target_item.response_position,
),
)
for index in range(6)
]
month_di_zhi = month_gan_zhi[1]
month_chong = _chong_di_zhi(month_di_zhi)
day_di_zhi = day_gan_zhi[1]
day_chong = _chong_di_zhi(day_di_zhi)
fushen_info_list = [
FushenDetail(
position=base_item.fushen_positions[idx] + 1,
relationName=base_item.fushen_relations[idx],
tiganName=base_item.fushen_tigan[idx],
elementName=base_item.fushen_elements[idx],
)
for idx in range(len(base_item.fushen_positions))
]
return DerivedDivinationData(
question=payload.question,
questionType=payload.question_type,
divinationMethod=payload.divination_method,
divinationTime=dt.strftime("%Y年%m月%d%H:%M"),
binaryCode=binary_code,
changedBinaryCode=changed_binary_code,
guaName=base_item.name,
upperName=base_item.upper_name,
lowerName=base_item.lower_name,
targetGuaName=target_item.name,
worldPosition=base_item.world_position,
responsePosition=base_item.response_position,
hasChangingYao=has_changing_yao,
ganzhi=GanzhiDetail(
yearGanZhi=year_gan_zhi,
monthGanZhi=month_gan_zhi,
dayGanZhi=day_gan_zhi,
timeGanZhi=time_gan_zhi,
yearKongWang=_get_kong_wang(year_gan_zhi),
monthKongWang=_get_kong_wang(month_gan_zhi),
dayKongWang=_get_kong_wang(day_gan_zhi),
timeKongWang=_get_kong_wang(time_gan_zhi),
yueJian=f"{month_di_zhi}{_di_zhi_wu_xing(month_di_zhi)}",
riChen=f"{day_di_zhi}{_di_zhi_wu_xing(day_di_zhi)}",
yuePo=f"{month_chong}{_di_zhi_wu_xing(month_chong)}",
riChong=f"{day_chong}{_di_zhi_wu_xing(day_chong)}",
),
wuXingStatuses=_get_all_wu_xing_status(month_gan_zhi),
yaoInfoList=yao_info_list,
targetYaoInfoList=target_yao_info_list if has_changing_yao else [],
fushenPositions=[item + 1 for item in base_item.fushen_positions],
fushenInfoList=fushen_info_list,
)
@@ -0,0 +1,139 @@
from __future__ import annotations
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
import re
@dataclass(frozen=True)
class GuaCatalogItem:
name: str
binary: str
upper_name: str
lower_name: str
yao_relations: tuple[str, ...]
yao_tigan: tuple[str, ...]
yao_elements: tuple[str, ...]
world_position: int
response_position: int
fushen_positions: tuple[int, ...]
fushen_relations: tuple[str, ...]
fushen_tigan: tuple[str, ...]
fushen_elements: tuple[str, ...]
_ENTRY_HEAD_RE = re.compile(r'put\("([01]{6})",\s*GuaInfo\(', re.MULTILINE)
_STRING_FIELD_RE = re.compile(r'\b%s\s*=\s*"([^"]*)"')
_INT_FIELD_RE = re.compile(r"\b%s\s*=\s*(\d+)")
_LIST_STRING_FIELD_RE = re.compile(r"\b%s\s*=\s*listOf\((.*?)\)", re.DOTALL)
_LIST_INT_FIELD_RE = re.compile(r"\b%s\s*=\s*listOf\((.*?)\)", re.DOTALL)
def _extract_gua_body(source: str, start_idx: int) -> tuple[str, int]:
depth = 1
idx = start_idx
while idx < len(source):
ch = source[idx]
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
return source[start_idx:idx], idx
idx += 1
raise ValueError("invalid Guaxiang.kt structure: unmatched parenthesis")
def _parse_string_field(body: str, field_name: str) -> str:
match = _STRING_FIELD_RE.pattern % re.escape(field_name)
found = re.search(match, body)
if found is None:
raise ValueError(f"missing field: {field_name}")
return found.group(1)
def _parse_int_field(body: str, field_name: str) -> int:
match = _INT_FIELD_RE.pattern % re.escape(field_name)
found = re.search(match, body)
if found is None:
raise ValueError(f"missing field: {field_name}")
return int(found.group(1))
def _parse_list_of_strings(
body: str, field_name: str, *, optional: bool = False
) -> tuple[str, ...]:
if f"{field_name} = emptyList()" in body:
return ()
match = _LIST_STRING_FIELD_RE.pattern % re.escape(field_name)
found = re.search(match, body)
if found is None:
if optional:
return ()
raise ValueError(f"missing list field: {field_name}")
inner = found.group(1)
values = re.findall(r'"([^"]+)"', inner)
return tuple(values)
def _parse_list_of_ints(
body: str, field_name: str, *, optional: bool = False
) -> tuple[int, ...]:
if f"{field_name} = emptyList()" in body:
return ()
match = _LIST_INT_FIELD_RE.pattern % re.escape(field_name)
found = re.search(match, body)
if found is None:
if optional:
return ()
raise ValueError(f"missing list field: {field_name}")
inner = found.group(1)
values = [int(item.strip()) for item in inner.split(",") if item.strip()]
return tuple(values)
def _resolve_guaxiang_file() -> Path:
current = Path(__file__).resolve()
root = current.parents[4]
target = (
root / "old/app/src/main/java/com/example/eryaoapp/screens/result/Guaxiang.kt"
)
if not target.exists():
raise FileNotFoundError(f"Guaxiang.kt not found: {target}")
return target
@lru_cache(maxsize=1)
def load_gua_catalog() -> dict[str, GuaCatalogItem]:
source = _resolve_guaxiang_file().read_text(encoding="utf-8")
result: dict[str, GuaCatalogItem] = {}
for head in _ENTRY_HEAD_RE.finditer(source):
binary = head.group(1)
body, _ = _extract_gua_body(source, head.end())
item = GuaCatalogItem(
name=_parse_string_field(body, "name"),
binary=_parse_string_field(body, "binary"),
upper_name=_parse_string_field(body, "upperName"),
lower_name=_parse_string_field(body, "lowerName"),
yao_relations=_parse_list_of_strings(body, "yaoRelations"),
yao_tigan=_parse_list_of_strings(body, "yaoTiGan"),
yao_elements=_parse_list_of_strings(body, "yaoElements"),
world_position=_parse_int_field(body, "worldPosition"),
response_position=_parse_int_field(body, "responsePosition"),
fushen_positions=_parse_list_of_ints(
body, "fushenPositions", optional=True
),
fushen_relations=_parse_list_of_strings(
body, "fushenRelations", optional=True
),
fushen_tigan=_parse_list_of_strings(body, "fushenTiGan", optional=True),
fushen_elements=_parse_list_of_strings(
body, "fushenElements", optional=True
),
)
result[binary] = item
if len(result) != 64:
raise ValueError(f"invalid gua catalog size: {len(result)}")
return result
@@ -14,6 +14,8 @@ from pydantic import (
field_validator,
)
from ..domain.divination import DivinationPayload
_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})$"
)
@@ -69,6 +71,9 @@ class ForwardedPropsPayload(BaseModel):
runtime_mode: RuntimeMode
client_time: ClientTimeContext | None = None
divination_payload: DivinationPayload | None = Field(
default=None, alias="divinationPayload"
)
def parse_forwarded_props(forwarded_props: object) -> ForwardedPropsPayload:
@@ -90,3 +95,10 @@ def parse_forwarded_props_client_time(
def parse_forwarded_props_runtime_mode(forwarded_props: object) -> RuntimeMode:
payload = parse_forwarded_props(forwarded_props)
return payload.runtime_mode
def parse_forwarded_props_divination_payload(
forwarded_props: object,
) -> DivinationPayload | None:
payload = parse_forwarded_props(forwarded_props)
return payload.divination_payload
+114
View File
@@ -0,0 +1,114 @@
from __future__ import annotations
from datetime import datetime
from enum import Enum
from pydantic import BaseModel, ConfigDict, Field, field_validator
class DivinationMethod(str, Enum):
MANUAL = "手动起卦"
AUTO = "自动起卦"
class YaoType(str, Enum):
SHAO_YANG = "少阳"
SHAO_YIN = "少阴"
LAO_YANG = "老阳"
LAO_YIN = "老阴"
class SpecialMark(str, Enum):
SHI = ""
YING = ""
NONE = ""
class YaoDetail(BaseModel):
model_config = ConfigDict(extra="forbid")
position: int = Field(ge=1, le=6)
spirit_name: str = Field(alias="spiritName", min_length=1)
relation_name: str = Field(alias="relationName", min_length=1)
tigan_name: str = Field(alias="tiganName", min_length=1)
element_name: str = Field(alias="elementName", min_length=1)
is_yang: bool = Field(alias="isYang")
is_changing: bool = Field(alias="isChanging")
special_mark: SpecialMark = Field(alias="specialMark", default=SpecialMark.NONE)
class FushenDetail(BaseModel):
model_config = ConfigDict(extra="forbid")
position: int = Field(ge=1, le=6)
relation_name: str = Field(alias="relationName", min_length=1)
tigan_name: str = Field(alias="tiganName", min_length=1)
element_name: str = Field(alias="elementName", min_length=1)
class GanzhiDetail(BaseModel):
model_config = ConfigDict(extra="forbid")
year_gan_zhi: str = Field(alias="yearGanZhi", min_length=2, max_length=2)
month_gan_zhi: str = Field(alias="monthGanZhi", min_length=2, max_length=2)
day_gan_zhi: str = Field(alias="dayGanZhi", min_length=2, max_length=2)
time_gan_zhi: str = Field(alias="timeGanZhi", min_length=2, max_length=2)
year_kong_wang: str = Field(alias="yearKongWang", min_length=2, max_length=2)
month_kong_wang: str = Field(alias="monthKongWang", min_length=2, max_length=2)
day_kong_wang: str = Field(alias="dayKongWang", min_length=2, max_length=2)
time_kong_wang: str = Field(alias="timeKongWang", min_length=2, max_length=2)
yue_jian: str = Field(alias="yueJian", min_length=2)
ri_chen: str = Field(alias="riChen", min_length=2)
yue_po: str = Field(alias="yuePo", min_length=2)
ri_chong: str = Field(alias="riChong", min_length=2)
class DivinationPayload(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
divination_method: DivinationMethod = Field(alias="divinationMethod")
question_type: str = Field(alias="questionType", min_length=1, max_length=32)
question: str = Field(min_length=1, max_length=300)
divination_time_iso: str = Field(alias="divinationTimeIso", min_length=20)
yao_lines: list[YaoType] = Field(alias="yaoLines", min_length=6, max_length=6)
@field_validator("divination_time_iso")
@classmethod
def validate_divination_time_iso(cls, value: str) -> str:
normalized = value.replace("Z", "+00:00")
dt = datetime.fromisoformat(normalized)
if dt.tzinfo is None:
raise ValueError("divinationTimeIso must include timezone")
return value
class DerivedDivinationData(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
question: str = Field(min_length=1)
question_type: str = Field(alias="questionType", min_length=1)
divination_method: DivinationMethod = Field(alias="divinationMethod")
divination_time: str = Field(alias="divinationTime", min_length=1)
binary_code: str = Field(alias="binaryCode", min_length=6, max_length=6)
changed_binary_code: str = Field(
alias="changedBinaryCode", min_length=6, max_length=6
)
gua_name: str = Field(alias="guaName", min_length=2)
upper_name: str = Field(alias="upperName", min_length=1)
lower_name: str = Field(alias="lowerName", min_length=1)
target_gua_name: str = Field(alias="targetGuaName", min_length=2)
world_position: int = Field(alias="worldPosition", ge=1, le=6)
response_position: int = Field(alias="responsePosition", ge=1, le=6)
has_changing_yao: bool = Field(alias="hasChangingYao")
ganzhi: GanzhiDetail
wu_xing_statuses: dict[str, str] = Field(alias="wuXingStatuses")
yao_info_list: list[YaoDetail] = Field(
alias="yaoInfoList", min_length=6, max_length=6
)
target_yao_info_list: list[YaoDetail] = Field(
alias="targetYaoInfoList", default_factory=list
)
fushen_positions: list[int] = Field(alias="fushenPositions", default_factory=list)
fushen_info_list: list[FushenDetail] = Field(
alias="fushenInfoList", default_factory=list
)
+12
View File
@@ -17,6 +17,7 @@ from core.agentscope.schemas.agui_input import extract_latest_user_payload
from core.config.settings import config
from core.logging import get_logger
from schemas.agent.forwarded_props import (
parse_forwarded_props_divination_payload,
parse_forwarded_props_runtime_mode,
RuntimeMode,
)
@@ -93,11 +94,22 @@ class AgentService:
forwarded_props = getattr(run_input, "forwarded_props", None)
try:
runtime_mode = parse_forwarded_props_runtime_mode(forwarded_props)
divination_payload = parse_forwarded_props_divination_payload(
forwarded_props
)
except ValueError as exc:
raise ApiProblemError(
status_code=422,
detail=problem_payload(code="AGENT_PAYLOAD_INVALID", detail=str(exc)),
) from exc
if divination_payload is None:
raise ApiProblemError(
status_code=422,
detail=problem_payload(
code="AGENT_DIVINATION_PAYLOAD_REQUIRED",
detail="forwardedProps.divinationPayload is required",
),
)
if runtime_config is None:
from v1.agent.system_agents_config import (
+12
View File
@@ -0,0 +1,12 @@
from __future__ import annotations
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from core.db import get_db
from v1.points.repository import PointsRepository
from v1.points.service import PointsService
def get_points_service(session: AsyncSession = Depends(get_db)) -> PointsService:
return PointsService(repository=PointsRepository(session))
+11
View File
@@ -56,3 +56,14 @@ class PointsRepository:
)
self._session.add(entry)
await self._session.flush()
async def get_user_points(self, *, user_id: UUID) -> UserPoints:
insert_stmt = (
insert(UserPoints)
.values(user_id=user_id)
.on_conflict_do_nothing(index_elements=[UserPoints.user_id])
)
await self._session.execute(insert_stmt)
stmt = select(UserPoints).where(UserPoints.user_id == user_id)
return (await self._session.execute(stmt)).scalar_one()
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends
from core.auth.models import CurrentUser
from v1.points.dependencies import get_points_service
from v1.points.schemas import PointsBalanceResponse
from v1.points.service import PointsService
from v1.users.dependencies import get_current_user
router = APIRouter(prefix="/points", tags=["points"])
@router.get("/balance", response_model=PointsBalanceResponse)
async def get_points_balance(
service: Annotated[PointsService, Depends(get_points_service)],
current_user: Annotated[CurrentUser, Depends(get_current_user)],
) -> PointsBalanceResponse:
result = await service.get_points_balance(user_id=current_user.id)
return PointsBalanceResponse(
balance=result.balance,
frozenBalance=result.frozen_balance,
availableBalance=result.available_balance,
runCost=result.run_cost,
canRun=result.can_run,
)
+13
View File
@@ -0,0 +1,13 @@
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
class PointsBalanceResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
balance: int = Field(ge=0)
frozen_balance: int = Field(alias="frozenBalance", ge=0)
available_balance: int = Field(alias="availableBalance", ge=0)
run_cost: int = Field(alias="runCost", gt=0)
can_run: bool = Field(alias="canRun")
+26
View File
@@ -22,6 +22,15 @@ class RunChargeResult:
event_id: str
@dataclass(frozen=True)
class PointsBalanceResult:
balance: int
frozen_balance: int
available_balance: int
run_cost: int
can_run: bool
class PointsService:
def __init__(self, repository: PointsRepository) -> None:
self._repository = repository
@@ -51,6 +60,23 @@ class PointsService:
)
return available
async def get_points_balance(
self,
*,
user_id: UUID,
) -> PointsBalanceResult:
account = await self._repository.get_user_points(user_id=user_id)
balance = int(account.balance)
frozen_balance = int(account.frozen_balance)
available = max(balance - frozen_balance, 0)
return PointsBalanceResult(
balance=balance,
frozen_balance=frozen_balance,
available_balance=available,
run_cost=RUN_POINTS_COST,
can_run=available >= RUN_POINTS_COST,
)
async def consume_successful_run_points(
self,
*,
+2
View File
@@ -4,8 +4,10 @@ from fastapi import APIRouter
from v1.agent.router import router as agent_router
from v1.auth.router import router as auth_router
from v1.points.router import router as points_router
router = APIRouter(prefix="/api/v1")
router.include_router(auth_router)
router.include_router(agent_router)
router.include_router(points_router)
+48
View File
@@ -0,0 +1,48 @@
from __future__ import annotations
from core.agentscope.events.agui_codec import to_agui_wire_event
def test_to_agui_wire_event_supports_custom_internal_type() -> None:
event = {
"type": "DIVINATION_DERIVED",
"threadId": "t1",
"runId": "r1",
"divination": {"guaName": "山火贲"},
}
wire = to_agui_wire_event(event)
assert wire["type"] == "DIVINATION_DERIVED"
assert wire["threadId"] == "t1"
assert wire["runId"] == "r1"
assert wire["divination"] == {"guaName": "山火贲"}
def test_to_agui_wire_event_rejects_unknown_type() -> None:
event = {
"type": "UNCONTROLLED_CUSTOM_EVENT",
"threadId": "t1",
"runId": "r1",
}
try:
to_agui_wire_event(event)
except ValueError as exc:
assert "unsupported ag-ui event type" in str(exc)
return
raise AssertionError("expected ValueError for unsupported event type")
def test_to_agui_wire_event_maps_known_internal_type() -> None:
event = {
"type": "run.started",
"threadId": "t1",
"runId": "r1",
}
wire = to_agui_wire_event(event)
assert wire["type"] == "RUN_STARTED"
assert wire["threadId"] == "t1"
assert wire["runId"] == "r1"
@@ -0,0 +1,76 @@
# 摇卦页面日期时间选择器优化设计
## 1. 现状问题
1. **硬编码日期格式**`DateFormat('yyyy年MM月dd日 HH:mm')` 在3处硬编码,未做 l10n
- `auto_divination_screen.dart:353`
- `manual_divination_screen.dart:271`
- `divination_result_screen.dart:455`
2. **原生 picker 样式简陋**:使用 Material `showDatePicker` + `showTimePicker`,交互体验差
## 2. 优化方案
### 2.1 自定义底部弹层时间选择器
- 使用 `CupertinoDatePicker`(iOS 滚轮样式)替代原生 Material picker
- 底部弹层,带半透明遮罩和圆角动画
- 日期/时间在同一个 picker 内通过 SegmentedControl 切换
### 2.2 Locale-aware 日期格式化
使用 `intl` 包实现:
- 中文 locale`DateFormat.yMd('zh_CN').add_Hm()``2026年4月3日 14:30`
- 英文 locale`DateFormat.yMd('en').add_Hm()``4/3/2026 14:30`
### 2.3 新增 l10n 键值
已有键值:
- `autoSelectTime`: "选择起卦时间" / "Select time"
- `manualSelectTime`: "选择起卦时间" / "Select time"
- `divinationModify`: "修改" / "Modify"
无需新增键值,日期格式完全由 `intl` 包根据 locale 自动处理。
## 3. 组件结构
```
apps/lib/shared/widgets/
└── date_time_picker/
└── date_time_picker_bottom_sheet.dart # 弹层容器
```
**DateTimePickerBottomSheet** 接口:
```dart
Future<DateTime?> showDateTimePickerBottomSheet({
required BuildContext context,
required DateTime initialDateTime,
DateTime? minDateTime,
DateTime? maxDateTime,
});
```
## 4. 交互流程
1. 用户点击"修改"按钮
2. 底部弹出 `DateTimePickerBottomSheet`
3. SegmentedControl 切换"日期"/"时间"tab
4. Cupertino 滚轮选择值
5. 点击"确认"关闭弹层并更新状态
## 5. 涉及的改动文件
### 新建
- `apps/lib/shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart`
### 修改
- `apps/lib/features/divination/presentation/screens/auto_divination_screen.dart`
- `apps/lib/features/divination/presentation/screens/manual_divination_screen.dart`
- `apps/lib/features/divination/presentation/screens/divination_result_screen.dart`
## 6. 验收标准
1. 日期格式跟随系统语言:中文环境显示中文格式,英文环境显示英文格式
2. 选择器使用 iOS 滚轮样式
3. 底部弹层带遮罩动画
4. 原硬编码格式完全移除
@@ -0,0 +1,341 @@
# 日期时间选择器优化实现计划
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 将摇卦页面的日期时间选择器改为 iOS 滚轮样式,并实现 locale-aware 格式化
**Architecture:** 创建共享的 `DateTimePickerBottomSheet` 组件,封装 `CupertinoDatePicker` 和底部弹层交互,替换现有的 `showDatePicker` + `showTimePicker` 调用
**Tech Stack:** Flutter, Cupertino widgets, intl package
---
## Task 1: 创建 DateTimePickerBottomSheet 组件
**Files:**
- Create: `apps/lib/shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart`
**Step 1: 创建文件结构和基础代码**
```dart
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:intl/intl.dart';
class DateTimePickerBottomSheet extends StatefulWidget {
const DateTimePickerBottomSheet({
super.key,
required this.initialDateTime,
this.minDateTime,
this.maxDateTime,
});
final DateTime initialDateTime;
final DateTime? minDateTime;
final DateTime? maxDateTime;
@override
State<DateTimePickerBottomSheet> createState() => _DateTimePickerBottomSheetState();
}
class _DateTimePickerBottomSheetState extends State<DateTimePickerBottomSheet> {
late DateTime _selectedDateTime;
int _selectedTab = 0; // 0=日期, 1=时间
@override
void initState() {
super.initState();
_selectedDateTime = widget.initialDateTime;
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context);
return Container(
height: 400,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
),
child: Column(
children: [
// 顶部栏
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.cancel),
),
Text(
l10n.autoSelectTime,
style: Theme.of(context).textTheme.titleMedium,
),
TextButton(
onPressed: () => Navigator.pop(context, _selectedDateTime),
child: Text(l10n.confirm),
),
],
),
),
// SegmentedControl 切换日期/时间
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: CupertinoSlidingSegmentedControl<int>(
groupValue: _selectedTab,
children: {
0: Text(l10n.dateTab),
1: Text(l10n.timeTab),
},
onValueChanged: (value) => setState(() => _selectedTab = value ?? 0),
),
),
const SizedBox(height: 16),
// CupertinoDatePicker
Expanded(
child: CupertinoDatePicker(
mode: _selectedTab == 0
? CupertinoDatePickerMode.date
: CupertinoDatePickerMode.time,
initialDateTime: _selectedDateTime,
minimumDate: widget.minDateTime,
maximumDate: widget.maxDateTime,
onDateTimeChanged: (DateTime newDateTime) {
setState(() => _selectedDateTime = newDateTime);
},
),
),
],
),
);
}
}
Future<DateTime?> showDateTimePickerBottomSheet({
required BuildContext context,
required DateTime initialDateTime,
DateTime? minDateTime,
DateTime? maxDateTime,
}) {
return showModalBottomSheet<DateTime>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => DateTimePickerBottomSheet(
initialDateTime: initialDateTime,
minDateTime: minDateTime,
maxDateTime: maxDateTime,
),
);
}
```
**Step 2: 添加 l10n 键值**
`apps/lib/l10n/app_zh.arb` 添加:
```json
"dateTab": "日期",
"timeTab": "时间",
"confirm": "确认",
"cancel": "取消"
```
`apps/lib/l10n/app_en.arb` 添加:
```json
"dateTab": "Date",
"timeTab": "Time",
"confirm": "Confirm",
"cancel": "Cancel"
```
运行 `flutter gen-l10n` 生成代码
**Step 3: Commit**
```bash
git add apps/lib/shared/widgets/date_time_picker/ apps/lib/l10n/
git commit -m "feat(divination): add DateTimePickerBottomSheet with iOS wheel style"
```
---
## Task 2: 修改 auto_divination_screen.dart 使用新选择器
**Files:**
- Modify: `apps/lib/features/divination/presentation/screens/auto_divination_screen.dart:208-230`
- Modify: `apps/lib/features/divination/presentation/screens/auto_divination_screen.dart:353`
**Step 1: 添加 import**
在文件顶部添加:
```dart
import 'package:shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart';
```
**Step 2: 修改 _pickTime 方法**
将:
```dart
Future<void> _pickTime() async {
final date = await showDatePicker(
context: context,
initialDate: _selectedTime,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (date == null || !mounted) return;
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(_selectedTime),
);
if (time == null || !mounted) return;
setState(() {
_selectedTime = DateTime(
date.year,
date.month,
date.day,
time.hour,
time.minute,
);
});
}
```
替换为:
```dart
Future<void> _pickTime() async {
final result = await showDateTimePickerBottomSheet(
context: context,
initialDateTime: _selectedTime,
minDateTime: DateTime(2000),
maxDateTime: DateTime(2100),
);
if (result == null || !mounted) return;
setState(() {
_selectedTime = result;
});
}
```
**Step 3: 修改日期显示格式**
将:
```dart
DateFormat('yyyy年MM月dd日 HH:mm').format(selectedTime)
```
替换为:
```dart
DateFormat.yMd(Localizations.localeOf(context).toString()).add_Hm().format(selectedTime)
```
需要添加 import:
```dart
import 'package:intl/intl.dart';
```
**Step 4: Commit**
```bash
git add apps/lib/features/divination/presentation/screens/auto_divination_screen.dart
git commit -m "feat(divination): use DateTimePickerBottomSheet in auto_divination_screen"
```
---
## Task 3: 修改 manual_divination_screen.dart 使用新选择器
**Files:**
- Modify: `apps/lib/features/divination/presentation/screens/manual_divination_screen.dart:142-168`
- Modify: `apps/lib/features/divination/presentation/screens/manual_divination_screen.dart:271`
**Step 1: 添加 import**
```dart
import 'package:shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart';
import 'package:intl/intl.dart';
```
**Step 2: 修改 _pickTime 方法和日期显示格式**
同 Task 2 的修改方式
**Step 3: Commit**
```bash
git add apps/lib/features/divination/presentation/screens/manual_divination_screen.dart
git commit -m "feat(divination): use DateTimePickerBottomSheet in manual_divination_screen"
```
---
## Task 4: 修改 divination_result_screen.dart 的日期格式
**Files:**
- Modify: `apps/lib/features/divination/presentation/screens/divination_result_screen.dart:455-457`
**Step 1: 添加 import**
```dart
import 'package:intl/intl.dart';
```
**Step 2: 修改日期格式**
将:
```dart
DateFormat(
'yyyy年MM月dd日 HH:mm',
).format(data.params.divinationTime),
```
替换为:
```dart
DateFormat.yMd(Localizations.localeOf(context).toString()).add_Hm().format(data.params.divinationTime),
```
**Step 3: Commit**
```bash
git add apps/lib/features/divination/presentation/screens/divination_result_screen.dart
git commit -m "refactor(divination): use locale-aware date format in divination_result_screen"
```
---
## Task 5: 运行验证
**Step 1: 生成 l10n**
```bash
cd apps && flutter gen-l10n
```
**Step 2: 运行静态分析**
```bash
cd apps && flutter analyze
```
预期: 无错误
**Step 3: 运行相关测试**
```bash
cd apps && flutter test test/features/divination/
```
---
**Plan complete.** Two execution options:
**1. Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration
**2. Parallel Session (separate)** - Open new session with executing-plans, batch execution with checkpoints
**Which approach?**
@@ -24,6 +24,7 @@ This document is the source of truth for backend RFC7807 `code` values consumed
| code | status | meaning | frontend handling |
|---|---:|---|---|
| `AGENT_SESSION_RUN_LIMIT_EXCEEDED` | 409 | Session already reached max run count (start + 3 follow-ups) | Show run-limit message and require starting a new session |
| `AGENT_DIVINATION_PAYLOAD_REQUIRED` | 422 | Missing required `forwardedProps.divinationPayload` in run request | Prompt user to restart casting flow and resubmit |
Compatibility strategy:
@@ -0,0 +1,172 @@
# Divination Run Protocol (Frontend <-> Backend)
This document defines the structured contract for divination run input, backend hexagram derivation, and run event output.
Protocol verification status:
- Backend route source: `backend/src/v1/agent/router.py`
- Backend derivation source: `backend/src/core/divination/derivation.py`
- Runtime payload schema source: `backend/src/schemas/domain/divination.py`
## Compatibility strategy
- Current strategy: additive evolution only.
- Existing required fields cannot be removed or renamed without migration notes.
- Canonical divination terminology values must remain Chinese.
## Route overview
- Submit run: `POST /api/v1/agent/runs`
- Stream events: `GET /api/v1/agent/runs/{threadId}/events?runId=...`
## Run request contract
`RunAgentInput` uses AG-UI shape. This protocol constrains two sections:
1) `messages[0].content` (question text)
2) `forwardedProps.divinationPayload` (structured divination input)
### Required request shape
```json
{
"threadId": "uuid",
"runId": "run_20260403_xxx",
"state": {},
"messages": [
{
"id": "msg_run_20260403_xxx_user_0",
"role": "user",
"content": "我最近换工作是否合适?"
}
],
"tools": [],
"context": [],
"forwardedProps": {
"runtime_mode": "chat",
"client_time": {
"device_timezone": "Asia/Shanghai",
"client_now_iso": "2026-04-03T20:30:00+08:00",
"client_epoch_ms": 1775219400000
},
"divinationPayload": {
"divinationMethod": "手动起卦",
"questionType": "事业",
"question": "我最近换工作是否合适?",
"divinationTimeIso": "2026-04-03T20:30:00+08:00",
"yaoLines": [
"少阳",
"少阴",
"老阳",
"少阴",
"少阳",
"老阴"
]
}
}
}
```
### AG-UI required base fields
- `state`: required object, frontend sends `{}` by default.
- `messages[0].id`: required string id for user message.
- `tools`: required array, frontend sends empty array when no tools requested.
- `context`: required array, frontend sends empty array when no extra context.
### `divinationPayload` strict rules
- `divinationMethod`: enum, allowed values `手动起卦 | 自动起卦`
- `questionType`: non-empty string, recommended Chinese category labels
- `question`: non-empty string
- `divinationTimeIso`: RFC3339 datetime with timezone offset
- `yaoLines`: exactly 6 items, order is `初爻 -> 上爻`
- `yaoLines` item enum: `少阳 | 少阴 | 老阳 | 老阴`
- Additional fields are forbidden.
## Event output contract
During run streaming, backend emits standard AG-UI lifecycle events and two divination-relevant payload events:
### 1) `DIVINATION_DERIVED`
- Emitted once after backend derives hexagram data.
- Payload field: `divination` (strict object).
`divination` object:
```json
{
"question": "我最近换工作是否合适?",
"questionType": "事业",
"divinationMethod": "手动起卦",
"divinationTime": "2026年04月03日 20:30",
"binaryCode": "101001",
"changedBinaryCode": "100001",
"guaName": "山火贲",
"upperName": "艮",
"lowerName": "离",
"targetGuaName": "山雷颐",
"worldPosition": 1,
"responsePosition": 4,
"hasChangingYao": true,
"ganzhi": {
"yearGanZhi": "丙午",
"monthGanZhi": "壬辰",
"dayGanZhi": "辛亥",
"timeGanZhi": "乙巳",
"yearKongWang": "子丑",
"monthKongWang": "午未",
"dayKongWang": "寅卯",
"timeKongWang": "戌亥",
"yueJian": "辰土",
"riChen": "亥水",
"yuePo": "戌土",
"riChong": "巳火"
},
"wuXingStatuses": {
"木": "囚",
"火": "休",
"土": "旺",
"金": "相",
"水": "死"
},
"yaoInfoList": [
{
"position": 1,
"spiritName": "虎",
"relationName": "官鬼",
"tiganName": "卯",
"elementName": "木",
"isYang": true,
"isChanging": false,
"specialMark": "世"
}
],
"targetYaoInfoList": [],
"fushenPositions": [2],
"fushenInfoList": [
{
"position": 2,
"relationName": "父母",
"tiganName": "午",
"elementName": "火"
}
]
}
```
### 2) `TEXT_MESSAGE_END`
- Standard final answer event.
- Existing fields remain canonical: `sign_level`, `summary`, `conclusion`, `focus_points`, `advice`, `keywords`, `answer`.
Frontend should combine:
- structural divination data from `DIVINATION_DERIVED`
- interpretation text from `TEXT_MESSAGE_END`
## Error contract linkage
- All errors use RFC7807 with extension `code` and optional `params`.
- Error code registry source: `docs/protocols/common/http-error-codes.md`.
@@ -0,0 +1,48 @@
# Points Balance Protocol (Frontend <-> Backend)
This document defines the read-only points balance contract for authenticated users.
Protocol verification status:
- Backend route source: `backend/src/v1/points/router.py`
- Backend service source: `backend/src/v1/points/service.py`
- Response schema source: `backend/src/v1/points/schemas.py`
## Compatibility strategy
- Additive evolution only.
- Existing response fields are stable and must remain backward-compatible.
## Route
- `GET /api/v1/points/balance`
## Authorization
- Requires authenticated session.
- User identity is derived from verified backend auth context.
## Response (200)
```json
{
"balance": 120,
"frozenBalance": 20,
"availableBalance": 100,
"runCost": 20,
"canRun": true
}
```
Field rules:
- `balance`: integer `>= 0`
- `frozenBalance`: integer `>= 0`
- `availableBalance`: integer `>= 0`, computed as `max(balance - frozenBalance, 0)`
- `runCost`: integer `> 0`, current value `20`
- `canRun`: boolean, equivalent to `availableBalance >= runCost`
## Error contract linkage
- RFC7807 + extension `code`, optional `params`.
- Shared registry: `docs/protocols/common/http-error-codes.md`.
+4
View File
@@ -12,6 +12,7 @@ dependencies = [
"dashscope>=1.25.15",
"email-validator==2.3.0",
"fastapi==0.135.1",
"lunar-python>=1.4.8",
"pydantic==2.12.5",
"pydantic-settings==2.13.1",
"pyjwt==2.11.0",
@@ -49,3 +50,6 @@ dev = [
"basedpyright==1.38.2",
"pre-commit==4.5.1",
]
[tool.basedpyright]
reportImplicitRelativeImport = "warning"