From e80a82bef4990795866636e27c46fb5f7086ef11 Mon Sep 17 00:00:00 2001 From: qzl Date: Wed, 8 Apr 2026 17:23:02 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=8C=E5=88=A0=E9=99=A4=E5=BA=9F=E5=BC=83?= =?UTF-8?q?=E8=AE=A1=E5=88=92=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新 http-error-codes, user-points-chat-data-protocol - 更新 divination-run-protocol, profile-protocol - 删除废弃的后端和前端设计计划文档 --- .opencode/opencode.json | 18 +- apps/android/app/src/main/AndroidManifest.xml | 1 + apps/ios/Runner/Info.plist | 2 + apps/lib/app/app.dart | 54 ++ apps/lib/core/network/api_problem_mapper.dart | 12 + .../divination/data/apis/divination_api.dart | 154 ++++- .../models/divination_backend_models.dart | 3 + .../data/models/divination_result.dart | 4 + .../data/models/follow_up_message.dart | 52 ++ .../data/services/divination_run_service.dart | 3 +- .../data/services/voice_recorder.dart | 64 ++ .../screens/auto_divination_screen.dart | 257 +++++-- .../screens/divination_processing_screen.dart | 9 +- .../screens/divination_result_screen.dart | 146 +++- .../screens/divination_screen.dart | 10 +- .../screens/follow_up_chat_screen.dart | 573 ++++++++++++++++ .../screens/manual_divination_screen.dart | 243 +++++-- .../presentation/screens/home_screen.dart | 416 +++++++----- apps/lib/l10n/app_en.arb | 93 ++- apps/lib/l10n/app_localizations.dart | 312 ++++++++- apps/lib/l10n/app_localizations_en.dart | 204 +++++- apps/lib/l10n/app_localizations_zh.dart | 158 ++++- apps/lib/l10n/app_zh.arb | 59 +- .../divination/divination_summary_card.dart | 144 ++++ .../widgets/divination/divination_terms.dart | 27 + .../shared/widgets/divination/yao_legend.dart | 11 +- apps/lib/shared/widgets/message_composer.dart | 249 +++++++ apps/pubspec.yaml | 2 + .../src/core/agentscope/events/agui_codec.py | 16 +- backend/src/core/agentscope/events/store.py | 77 +-- backend/src/core/agentscope/runtime/runner.py | 86 ++- .../core/agentscope/runtime/stage_emitter.py | 20 +- backend/src/core/agentscope/runtime/tasks.py | 85 ++- .../core/agentscope/runtime/ui_compiler.py | 638 ------------------ backend/src/schemas/agent/__init__.py | 14 - backend/src/schemas/agent/forwarded_props.py | 1 + backend/src/schemas/agent/runtime_models.py | 21 +- backend/src/schemas/agent/ui_hints.py | 349 ---------- backend/src/schemas/domain/chat_message.py | 5 +- backend/src/v1/agent/repository.py | 84 ++- backend/src/v1/agent/router.py | 14 +- backend/src/v1/agent/schemas.py | 17 +- backend/src/v1/agent/service.py | 148 ++-- backend/src/v1/agent/utils.py | 20 +- .../tests/unit/test_history_message_schema.py | 29 + .../unit/test_runtime_models_worker_output.py | 32 +- docs/bugs/2026-04-08-followup-entry-bug.md | 89 +++ ...26-04-08-followup-loading-and-voice-bug.md | 261 +++++++ ...-04-08-followup-sign-level-regeneration.md | 200 ++++++ ...6-04-08-home-history-only-shows-4-items.md | 90 +++ ...ion-history-profile-backend-source-plan.md | 241 ------- ...-05-divination-history-profile-eng-plan.md | 403 ----------- docs/protocols/common/http-error-codes.md | 9 +- .../common/user-points-chat-data-protocol.md | 2 +- .../divination/divination-run-protocol.md | 104 ++- docs/protocols/profile/profile-protocol.md | 46 +- infra/docker/supabase/docker-compose.yml | 5 +- 57 files changed, 4117 insertions(+), 2269 deletions(-) create mode 100644 apps/lib/features/divination/data/models/follow_up_message.dart create mode 100644 apps/lib/features/divination/data/services/voice_recorder.dart create mode 100644 apps/lib/features/divination/presentation/screens/follow_up_chat_screen.dart create mode 100644 apps/lib/shared/widgets/divination/divination_summary_card.dart create mode 100644 apps/lib/shared/widgets/message_composer.dart delete mode 100644 backend/src/core/agentscope/runtime/ui_compiler.py delete mode 100644 backend/src/schemas/agent/ui_hints.py create mode 100644 backend/tests/unit/test_history_message_schema.py create mode 100644 docs/bugs/2026-04-08-followup-entry-bug.md create mode 100644 docs/bugs/2026-04-08-followup-loading-and-voice-bug.md create mode 100644 docs/bugs/2026-04-08-followup-sign-level-regeneration.md create mode 100644 docs/bugs/2026-04-08-home-history-only-shows-4-items.md delete mode 100644 docs/plans/2026-04-05-divination-history-profile-backend-source-plan.md delete mode 100644 docs/plans/2026-04-05-divination-history-profile-eng-plan.md diff --git a/.opencode/opencode.json b/.opencode/opencode.json index 12c787f..0b8a85c 100644 --- a/.opencode/opencode.json +++ b/.opencode/opencode.json @@ -1,10 +1,14 @@ { - "$schema": "https://opencode.ai/config.json", - "mcp": { - "supabase": { - "type": "remote", - "enabled": true, - "url": "http://localhost:8001/mcp" + "$schema": "https://opencode.ai/config.json", + "instructions": [ + "./.opencode/memory/current.md", + "./.opencode/memory/durable.md" + ], + "mcp": { + "supabase": { + "type": "remote", + "enabled": true, + "url": "http://localhost:8001/mcp" + } } - } } diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index d530288..0c1152b 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + 需要访问您的相册以选择并上传头像 NSPhotoLibraryAddUsageDescription 需要将头像处理结果保存到您的相册 + NSMicrophoneUsageDescription + 需要麦克风权限用于语音追问 UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/apps/lib/app/app.dart b/apps/lib/app/app.dart index bbd0aba..e53e215 100644 --- a/apps/lib/app/app.dart +++ b/apps/lib/app/app.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; @@ -181,6 +183,56 @@ class _EryaoAppState extends State { } } + Future _handleHistorySessionDeleted(String threadId) async { + final user = _authBloc.state.user; + if (user == null) { + return; + } + + final rollback = List.from(_historyRecords); + if (!mounted) { + return; + } + setState(() { + _historyRecords = _historyRecords + .where((item) => item.threadId != threadId) + .toList(growable: false); + _loadedHistoryUserEmail = user.email; + }); + + unawaited( + _deleteHistorySessionRemote( + threadId: threadId, + userEmail: user.email, + rollback: rollback, + ), + ); + } + + Future _deleteHistorySessionRemote({ + required String threadId, + required String userEmail, + required List rollback, + }) async { + try { + await _divinationApi.deleteSession(threadId: threadId); + } catch (error, stackTrace) { + _logger.error( + message: 'Failed to delete history session', + error: error, + stackTrace: stackTrace, + extra: {'threadId': threadId}, + ); + if (!mounted) { + return; + } + setState(() { + _historyRecords = rollback; + _loadedHistoryUserEmail = userEmail; + }); + } + } + List _mergeAndSortHistory( List input, ) { @@ -355,11 +407,13 @@ class _EryaoAppState extends State { profileSettings: _profileSettings, historyRecords: _historyRecords, coinBalance: _creditsBalance, + divinationApi: _divinationApi, onLocaleChanged: _handleInterfaceLanguageChanged, onProfileSettingsChanged: _saveProfileSettings, onSaveProfile: _saveProfile, onUploadAvatar: _uploadAvatar, onDivinationCompleted: _handleDivinationCompleted, + onDeleteHistorySession: _handleHistorySessionDeleted, onLogout: _authBloc.logout, ); } diff --git a/apps/lib/core/network/api_problem_mapper.dart b/apps/lib/core/network/api_problem_mapper.dart index 14a61b5..2297ae5 100644 --- a/apps/lib/core/network/api_problem_mapper.dart +++ b/apps/lib/core/network/api_problem_mapper.dart @@ -15,8 +15,20 @@ String mapApiProblemToMessage(ApiProblem problem, AppLocalizations l10n) { return l10n.toastCoinInsufficient; case 'AGENT_SESSION_RUN_LIMIT_EXCEEDED': return l10n.errorRunLimitExceeded; + case 'AGENT_RUNTIME_MODE_INVALID': + return l10n.errorRequestGeneric; case 'AGENT_DIVINATION_PAYLOAD_REQUIRED': return l10n.errorDivinationPayloadRequired; + case 'AGENT_SESSION_NOT_FOUND': + return l10n.errorRequestGeneric; + case 'AGENT_AUDIO_UNSUPPORTED_FORMAT': + return l10n.errorAudioUnsupportedFormat; + case 'AGENT_AUDIO_TOO_LARGE': + return l10n.errorAudioTooLarge; + case 'AGENT_AUDIO_EMPTY': + return l10n.errorAudioEmpty; + case 'AGENT_ASR_UNAVAILABLE': + return l10n.errorAsrUnavailable; default: break; } diff --git a/apps/lib/features/divination/data/apis/divination_api.dart b/apps/lib/features/divination/data/apis/divination_api.dart index 00ac152..a2a2cb2 100644 --- a/apps/lib/features/divination/data/apis/divination_api.dart +++ b/apps/lib/features/divination/data/apis/divination_api.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:dio/dio.dart'; @@ -7,6 +8,7 @@ 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/follow_up_message.dart'; import '../models/divination_params.dart'; import '../models/divination_result.dart'; @@ -55,6 +57,14 @@ class DivinationApi { if (raw['role'] != 'assistant') { continue; } + final threadId = raw['threadId']; + if (threadId is! String || threadId.trim().isEmpty) { + _logger.warning( + message: 'Skip history item without threadId', + extra: {'messageId': raw['id']}, + ); + continue; + } final agentOutputRaw = raw['agent_output']; if (agentOutputRaw is! Map) { continue; @@ -75,6 +85,7 @@ class DivinationApi { userId: userId, ); final aggregate = DivinationRunAggregate( + threadId: threadId, derived: derived, signLevel: _asString(agentOutputRaw['sign_level']), conclusion: _asStringList(agentOutputRaw['conclusion']), @@ -98,6 +109,105 @@ class DivinationApi { return records; } + Future> getSessionMessages({ + required String threadId, + }) async { + Map json; + try { + final response = await _apiClient.rawDio.get>( + '/api/v1/agent/history', + queryParameters: {'threadId': threadId}, + ); + json = response.data ?? {}; + } on DioException catch (error) { + throw _mapProblem(error); + } + final messagesRaw = json['messages']; + if (messagesRaw is! List) { + return const []; + } + + final messages = []; + for (final raw in messagesRaw) { + if (raw is! Map) { + continue; + } + try { + final message = FollowUpMessage.fromJson(raw); + if (message.role != 'user' && message.role != 'assistant') { + continue; + } + messages.add(message); + } catch (error, stackTrace) { + _logger.warning( + message: 'Skip malformed follow-up history message', + extra: { + 'error': error.toString(), + 'stackTrace': stackTrace.toString(), + }, + ); + continue; + } + } + return messages; + } + + Future enqueueFollowUp({ + required String threadId, + required String runId, + required String question, + required DivinationResultData result, + }) async { + final payload = buildFollowUpRunPayload( + threadId: threadId, + runId: runId, + question: question, + result: result, + clientNow: DateTime.now(), + ); + final json = await _apiClient.postJson('/api/v1/agent/runs', data: payload); + return RunAcceptedData.fromJson(json); + } + + Future deleteSession({required String threadId}) async { + await _apiClient.deleteNoContent('/api/v1/agent/sessions/$threadId'); + } + + Future transcribeAudio(String audioPath) async { + final file = File(audioPath); + if (!await file.exists()) { + throw ApiProblem( + status: 400, + title: 'Audio file missing', + detail: 'Audio file does not exist', + ); + } + + try { + final response = await _apiClient.rawDio.post>( + '/api/v1/agent/transcribe', + data: FormData.fromMap({ + 'audio': await MultipartFile.fromFile( + audioPath, + filename: 'follow_up.wav', + contentType: DioMediaType('audio', 'wav'), + ), + }), + ); + final payload = response.data; + if (payload is! Map) { + throw const FormatException('Invalid transcribe response'); + } + final transcript = payload['transcript']; + if (transcript is! String) { + throw const FormatException('Invalid transcribe response'); + } + return transcript; + } on DioException catch (error) { + throw _mapProblem(error); + } + } + Stream> streamEvents({ required String threadId, required String runId, @@ -107,7 +217,10 @@ class DivinationApi { response = await _apiClient.rawDio.get( '/api/v1/agent/runs/$threadId/events', queryParameters: {'runId': runId}, - options: Options(responseType: ResponseType.stream), + options: Options( + responseType: ResponseType.stream, + receiveTimeout: null, + ), ); } on DioException catch (error, stackTrace) { _logger.error( @@ -260,6 +373,45 @@ Map buildDivinationRunPayload({ }; } +Map buildFollowUpRunPayload({ + required String threadId, + required String runId, + required String question, + required DivinationResultData result, + required DateTime clientNow, +}) { + final yaoStates = result.yaoLines + .map((line) => line.type) + .toList(growable: false); + return { + 'threadId': threadId, + 'runId': runId, + 'state': {}, + 'messages': [ + {'id': 'msg_${runId}_user_0', 'role': 'user', 'content': question}, + ], + 'tools': const >[], + 'context': const >[], + 'forwardedProps': { + 'runtime_mode': 'follow_up', + 'client_time': { + 'device_timezone': 'Asia/Shanghai', + 'client_now_iso': _toRfc3339Utc(clientNow), + 'client_epoch_ms': clientNow.millisecondsSinceEpoch, + }, + 'divinationPayload': { + 'divinationMethod': result.params.method == DivinationMethod.manual + ? '手动起卦' + : '自动起卦', + 'questionType': _questionTypeToText(result.params.questionType), + 'question': result.params.question, + 'divinationTimeIso': _toRfc3339Utc(result.params.divinationTime), + 'yaoLines': yaoStates.map(_yaoTypeToText).toList(growable: false), + }, + }, + }; +} + String _toRfc3339Utc(DateTime value) { return value.toUtc().toIso8601String(); } diff --git a/apps/lib/features/divination/data/models/divination_backend_models.dart b/apps/lib/features/divination/data/models/divination_backend_models.dart index eeaa29a..5813f18 100644 --- a/apps/lib/features/divination/data/models/divination_backend_models.dart +++ b/apps/lib/features/divination/data/models/divination_backend_models.dart @@ -52,6 +52,7 @@ class RunAcceptedData { class DivinationRunAggregate { const DivinationRunAggregate({ + this.threadId, required this.derived, required this.signLevel, required this.conclusion, @@ -62,6 +63,7 @@ class DivinationRunAggregate { }); final DerivedDivinationData derived; + final String? threadId; final String signLevel; final List conclusion; final List focusPoints; @@ -72,6 +74,7 @@ class DivinationRunAggregate { DivinationResultData toViewData(DivinationParams params) { return DivinationResultData( params: params, + threadId: threadId, binaryCode: derived.binaryCode, changedBinaryCode: derived.changedBinaryCode, guaName: derived.guaName, diff --git a/apps/lib/features/divination/data/models/divination_result.dart b/apps/lib/features/divination/data/models/divination_result.dart index 3fdacbe..e545d69 100644 --- a/apps/lib/features/divination/data/models/divination_result.dart +++ b/apps/lib/features/divination/data/models/divination_result.dart @@ -2,6 +2,7 @@ import 'divination_params.dart'; class DivinationResultData { const DivinationResultData({ + this.threadId, required this.params, required this.binaryCode, required this.changedBinaryCode, @@ -22,6 +23,7 @@ class DivinationResultData { }); final DivinationParams params; + final String? threadId; final String binaryCode; final String changedBinaryCode; final String guaName; @@ -44,6 +46,7 @@ class DivinationResultData { Map toJson() { return { 'params': params.toPayload(), + 'threadId': threadId, 'binaryCode': binaryCode, 'changedBinaryCode': changedBinaryCode, 'guaName': guaName, @@ -81,6 +84,7 @@ class DivinationResultData { return DivinationResultData( params: DivinationParams.fromPayload(paramsRaw), + threadId: json['threadId'] as String?, binaryCode: _requiredString(json, 'binaryCode'), changedBinaryCode: _requiredString(json, 'changedBinaryCode'), guaName: _requiredString(json, 'guaName'), diff --git a/apps/lib/features/divination/data/models/follow_up_message.dart b/apps/lib/features/divination/data/models/follow_up_message.dart new file mode 100644 index 0000000..28f732d --- /dev/null +++ b/apps/lib/features/divination/data/models/follow_up_message.dart @@ -0,0 +1,52 @@ +class FollowUpMessage { + const FollowUpMessage({ + required this.id, + required this.seq, + required this.role, + required this.content, + required this.timestamp, + }); + + final String id; + final int seq; + final String role; + final String content; + final DateTime timestamp; + + factory FollowUpMessage.fromJson(Map json) { + return FollowUpMessage( + id: _requiredString(json, 'id'), + seq: _requiredInt(json, 'seq'), + role: _requiredString(json, 'role'), + content: _requiredStringAllowEmpty(json, 'content'), + timestamp: DateTime.parse(_requiredString(json, 'timestamp')).toLocal(), + ); + } +} + +String _requiredString(Map json, String key) { + final value = json[key]; + if (value is! String || value.isEmpty) { + throw FormatException('Missing required string: $key'); + } + return value; +} + +String _requiredStringAllowEmpty(Map json, String key) { + final value = json[key]; + if (value is! String) { + throw FormatException('Missing required string: $key'); + } + return value; +} + +int _requiredInt(Map json, String key) { + final value = json[key]; + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + throw FormatException('Missing required int: $key'); +} diff --git a/apps/lib/features/divination/data/services/divination_run_service.dart b/apps/lib/features/divination/data/services/divination_run_service.dart index 10ce4ec..d28d45f 100644 --- a/apps/lib/features/divination/data/services/divination_run_service.dart +++ b/apps/lib/features/divination/data/services/divination_run_service.dart @@ -24,7 +24,7 @@ class DivinationRunService { }) async { final threadId = _uuidV4(); final runId = 'run_${DateTime.now().millisecondsSinceEpoch}'; - await _api.enqueueRun( + final accepted = await _api.enqueueRun( params: params, yaoStates: yaoStates, threadId: threadId, @@ -103,6 +103,7 @@ class DivinationRunService { } return DivinationRunAggregate( + threadId: accepted.threadId, derived: derived, signLevel: signLevel, conclusion: conclusion, diff --git a/apps/lib/features/divination/data/services/voice_recorder.dart b/apps/lib/features/divination/data/services/voice_recorder.dart new file mode 100644 index 0000000..59f0809 --- /dev/null +++ b/apps/lib/features/divination/data/services/voice_recorder.dart @@ -0,0 +1,64 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:record/record.dart'; + +abstract class VoiceRecorder { + Future start(); + Future stop(); + Future dispose(); +} + +class RecordVoiceRecorder implements VoiceRecorder { + RecordVoiceRecorder({AudioRecorder? recorder}) + : _recorder = recorder ?? AudioRecorder(); + + final AudioRecorder _recorder; + String? _currentPath; + + @override + Future start() async { + bool hasPermission; + try { + hasPermission = await _recorder.hasPermission(); + } on MissingPluginException { + throw StateError('录音能力不可用'); + } + if (!hasPermission) { + throw StateError('录音权限未授权'); + } + + final fileName = + 'voice_${DateTime.now().millisecondsSinceEpoch}_${DateTime.now().microsecond}.wav'; + final path = '${Directory.systemTemp.path}/$fileName'; + _currentPath = path; + + try { + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 16000, + numChannels: 1, + ), + path: path, + ); + } on MissingPluginException { + throw StateError('录音能力不可用'); + } + } + + @override + Future stop() async { + try { + final stoppedPath = await _recorder.stop(); + return stoppedPath ?? _currentPath; + } on MissingPluginException { + throw StateError('录音能力不可用'); + } + } + + @override + Future dispose() async { + await _recorder.dispose(); + } +} diff --git a/apps/lib/features/divination/presentation/screens/auto_divination_screen.dart b/apps/lib/features/divination/presentation/screens/auto_divination_screen.dart index 725b075..1987e5c 100644 --- a/apps/lib/features/divination/presentation/screens/auto_divination_screen.dart +++ b/apps/lib/features/divination/presentation/screens/auto_divination_screen.dart @@ -4,6 +4,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; +import 'package:onboarding_overlay/onboarding_overlay.dart'; import 'package:sensors_plus/sensors_plus.dart'; import 'package:vibration/vibration.dart'; @@ -19,6 +20,7 @@ import '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_shee import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; import '../../data/models/divination_backend_models.dart'; +import '../../data/apis/divination_api.dart'; import '../../data/models/divination_params.dart'; import '../../data/models/divination_result.dart'; import '../../data/services/divination_run_service.dart'; @@ -29,11 +31,13 @@ class AutoDivinationScreen extends StatefulWidget { super.key, required this.params, required this.runService, + this.divinationApi, required this.onCompleted, }); final DivinationParams params; final DivinationRunService runService; + final DivinationApi? divinationApi; final Future Function(DivinationResultData result) onCompleted; @override @@ -48,6 +52,7 @@ class _AutoDivinationScreenState extends State YaoType.undetermined, ); late final AnimationController _spinController; + late final AnimationController _blinkController; StreamSubscription? _accSubscription; DateTime _selectedTime = DateTime.now(); bool _isSpinning = false; @@ -60,6 +65,17 @@ class _AutoDivinationScreenState extends State bool _spinLocked = false; bool _submitting = false; + final GlobalKey _onboardingKey = + GlobalKey(); + final ScrollController _scrollController = ScrollController(); + final GlobalKey _step2TargetKey = GlobalKey(); + final GlobalKey _step3TargetKey = GlobalKey(); + final GlobalKey _step4TargetKey = GlobalKey(); + final FocusNode _step1Focus = FocusNode(); + final FocusNode _step2Focus = FocusNode(); + final FocusNode _step3Focus = FocusNode(); + final FocusNode _step4Focus = FocusNode(); + @override void initState() { super.initState(); @@ -68,13 +84,23 @@ class _AutoDivinationScreenState extends State vsync: this, duration: const Duration(milliseconds: 500), ); + _blinkController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + )..repeat(reverse: true); _listenShake(); } @override void dispose() { _accSubscription?.cancel(); + _scrollController.dispose(); _spinController.dispose(); + _blinkController.dispose(); + _step1Focus.dispose(); + _step2Focus.dispose(); + _step3Focus.dispose(); + _step4Focus.dispose(); super.dispose(); } @@ -96,35 +122,113 @@ class _AutoDivinationScreenState extends State } Widget _buildBody(BuildContext context, AppLocalizations l10n) { - return SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.xl), - child: Column( - children: [ - _InstructionCard(onTap: () => _showGuide(context, l10n)), - const SizedBox(height: AppSpacing.lg), - _TimeSelectorCard(selectedTime: _selectedTime, onPickTime: _pickTime), - const SizedBox(height: AppSpacing.lg), - _YaoPickerCard( - isSpinning: _isSpinning, - coin1Yang: _coin1Yang, - coin2Yang: _coin2Yang, - coin3Yang: _coin3Yang, - spinController: _spinController, - countdown: _countdown, - shakeCount: _shakeCount, - canShake: _canShake, - onStartShake: _startSpin, - buttonText: _buttonText(l10n), - statusText: _statusText(l10n), - ), - const SizedBox(height: AppSpacing.lg), - _HexagramCard(yaoStates: _yaoStates), - const SizedBox(height: AppSpacing.lg), - _ResolveButton( - enabled: _shakeCount >= 6 && !_submitting, - onPressed: _submitRun, - ), - ], + final steps = [ + OnboardingStep( + focusNode: _step1Focus, + titleText: l10n.autoGuideStep1Title, + bodyText: l10n.autoGuideStep1Body, + titleTextColor: Colors.white, + bodyTextColor: Colors.white, + hasArrow: false, + hasLabelBox: true, + fullscreen: true, + overlayColor: Colors.black.withValues(alpha: 0.7), + ), + OnboardingStep( + focusNode: _step2Focus, + titleText: l10n.autoGuideStep2Title, + bodyText: l10n.autoGuideStep2Body, + titleTextColor: Colors.white, + bodyTextColor: Colors.white, + hasArrow: true, + hasLabelBox: true, + arrowPosition: ArrowPosition.top, + overlayColor: Colors.black.withValues(alpha: 0.7), + delay: const Duration(milliseconds: 320), + ), + OnboardingStep( + focusNode: _step3Focus, + titleText: l10n.autoGuideStep3Title, + bodyText: l10n.autoGuideStep3Body, + titleTextColor: Colors.white, + bodyTextColor: Colors.white, + hasArrow: true, + hasLabelBox: true, + arrowPosition: ArrowPosition.top, + overlayColor: Colors.black.withValues(alpha: 0.7), + delay: const Duration(milliseconds: 320), + ), + OnboardingStep( + focusNode: _step4Focus, + titleText: l10n.autoGuideStep4Title, + bodyText: l10n.autoGuideStep4Body, + titleTextColor: Colors.white, + bodyTextColor: Colors.white, + hasArrow: true, + hasLabelBox: true, + arrowPosition: ArrowPosition.top, + overlayColor: Colors.black.withValues(alpha: 0.7), + delay: const Duration(milliseconds: 320), + ), + ]; + + return Onboarding( + key: _onboardingKey, + steps: steps, + onChanged: _onGuideStepChanged, + child: SingleChildScrollView( + controller: _scrollController, + padding: const EdgeInsets.all(AppSpacing.xl), + child: Column( + children: [ + _InstructionCard(onTap: _showGuide), + const SizedBox(height: AppSpacing.lg), + _TimeSelectorCard( + selectedTime: _selectedTime, + onPickTime: _pickTime, + ), + const SizedBox(height: AppSpacing.lg), + Container( + key: _step2TargetKey, + child: Focus( + focusNode: _step2Focus, + child: _YaoPickerCard( + isSpinning: _isSpinning, + coin1Yang: _coin1Yang, + coin2Yang: _coin2Yang, + coin3Yang: _coin3Yang, + spinController: _spinController, + countdown: _countdown, + shakeCount: _shakeCount, + canShake: _canShake, + onStartShake: _startSpin, + buttonText: _buttonText(l10n), + statusText: _statusText(l10n), + ), + ), + ), + const SizedBox(height: AppSpacing.lg), + Container( + key: _step3TargetKey, + child: Focus( + focusNode: _step3Focus, + child: _HexagramCard(yaoStates: _yaoStates), + ), + ), + const SizedBox(height: AppSpacing.lg), + Container( + key: _step4TargetKey, + child: Focus( + focusNode: _step4Focus, + child: _ResolveButton( + enabled: _shakeCount >= 6 && !_submitting, + onPressed: _submitRun, + blinkAnimation: _blinkController, + ), + ), + ), + ], + ), ), ); } @@ -286,6 +390,7 @@ class _AutoDivinationScreenState extends State params: widget.params.copyWith(divinationTime: _selectedTime), yaoStates: _yaoStates, runService: widget.runService, + divinationApi: widget.divinationApi, onCompleted: widget.onCompleted, ), ), @@ -311,24 +416,35 @@ class _AutoDivinationScreenState extends State await HapticFeedback.heavyImpact(); } - Future _showGuide(BuildContext context, AppLocalizations l10n) async { - await showDialog( - context: context, - builder: (context) { - return DivinationGuideDialog( - title: l10n.divinationManualGuideTitle, - guideImages: const [ - ['assets/images/tutorial/tutorial_1.png'], - ['assets/images/tutorial/tutorial_2.png'], - ['assets/images/tutorial/tutorial_3.png'], - ], - instructions: [ - l10n.divinationManualGuideStep1, - l10n.divinationManualGuideStep2, - l10n.divinationManualGuideStep3, - ], - ); - }, + void _showGuide() { + _scrollToGuideStep(0); + Future.delayed(const Duration(milliseconds: 120), () { + if (!mounted) { + return; + } + _onboardingKey.currentState?.show(); + }); + } + + void _onGuideStepChanged(int currentIndex) { + _scrollToGuideStep(currentIndex + 1); + } + + void _scrollToGuideStep(int stepIndex) { + final GlobalKey? targetKey = switch (stepIndex) { + 2 => _step3TargetKey, + 3 => _step4TargetKey, + _ => null, + }; + final targetContext = targetKey?.currentContext; + if (targetContext == null) { + return; + } + Scrollable.ensureVisible( + targetContext, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOut, + alignment: 0.12, ); } } @@ -532,7 +648,7 @@ class _CoinColumn extends StatelessWidget { ), const SizedBox(height: AppSpacing.sm), Text( - DivinationTerms.ziHua[isYang] ?? '', + _coinFaceLabel(context, isYang), style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: colors.onSurface, fontWeight: FontWeight.w700, @@ -541,6 +657,11 @@ class _CoinColumn extends StatelessWidget { ], ); } + + String _coinFaceLabel(BuildContext context, bool isYang) { + final l10n = AppLocalizations.of(context)!; + return isYang ? l10n.autoCoinFaceZi : l10n.autoCoinFaceHua; + } } class _CoinFace extends StatelessWidget { @@ -567,8 +688,8 @@ class _CoinFace extends StatelessWidget { : (isYang ? 0 : 180); final showingYang = isSpinning ? rotationY < 90 : isYang; final image = showingYang - ? 'assets/images/qigua/hua.jpg' - : 'assets/images/qigua/zi.jpg'; + ? 'assets/images/qigua/zi.jpg' + : 'assets/images/qigua/hua.jpg'; return Transform( alignment: Alignment.center, transform: Matrix4.identity() @@ -632,10 +753,11 @@ class _YaoRow extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; return Padding( padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), child: YaoLineRow( - name: DivinationTerms.yaoNames[index], + name: DivinationTerms.yaoName(l10n, index), type: type, showChangeMark: true, lineHeight: 8, @@ -645,21 +767,38 @@ class _YaoRow extends StatelessWidget { } class _ResolveButton extends StatelessWidget { - const _ResolveButton({required this.enabled, required this.onPressed}); + const _ResolveButton({ + required this.enabled, + required this.onPressed, + required this.blinkAnimation, + }); final bool enabled; final VoidCallback onPressed; + final Animation blinkAnimation; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - return SizedBox( - width: double.infinity, - height: 50, - child: FilledButton( - onPressed: enabled ? onPressed : null, - child: Text(l10n.autoStartResolve), - ), + final colors = Theme.of(context).colorScheme; + return AnimatedBuilder( + animation: blinkAnimation, + builder: (context, _) { + final base = colors.primary; + return SizedBox( + width: double.infinity, + height: 50, + child: FilledButton( + onPressed: enabled ? onPressed : null, + style: FilledButton.styleFrom( + backgroundColor: enabled + ? base.withValues(alpha: 0.6 + blinkAnimation.value * 0.4) + : base, + ), + child: Text(l10n.autoStartResolve), + ), + ); + }, ); } } diff --git a/apps/lib/features/divination/presentation/screens/divination_processing_screen.dart b/apps/lib/features/divination/presentation/screens/divination_processing_screen.dart index c3e5a64..3d68cb4 100644 --- a/apps/lib/features/divination/presentation/screens/divination_processing_screen.dart +++ b/apps/lib/features/divination/presentation/screens/divination_processing_screen.dart @@ -7,6 +7,7 @@ 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/apis/divination_api.dart'; import '../../data/models/divination_params.dart'; import '../../data/models/divination_result.dart'; import '../../data/services/divination_run_service.dart'; @@ -20,12 +21,14 @@ class DivinationProcessingScreen extends StatefulWidget { required this.params, required this.yaoStates, required this.runService, + this.divinationApi, required this.onCompleted, }); final DivinationParams params; final List yaoStates; final DivinationRunService runService; + final DivinationApi? divinationApi; final Future Function(DivinationResultData result) onCompleted; @override @@ -148,7 +151,11 @@ class _DivinationProcessingScreenState extends State } Navigator.of(context).pushReplacement( MaterialPageRoute( - builder: (_) => DivinationResultScreen(data: data), + builder: (_) => DivinationResultScreen( + data: data, + divinationApi: data.threadId == null ? null : widget.divinationApi, + enableIntroTransition: true, + ), ), ); } diff --git a/apps/lib/features/divination/presentation/screens/divination_result_screen.dart b/apps/lib/features/divination/presentation/screens/divination_result_screen.dart index a49d60c..b8d42e4 100644 --- a/apps/lib/features/divination/presentation/screens/divination_result_screen.dart +++ b/apps/lib/features/divination/presentation/screens/divination_result_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; +import '../../../../core/logging/logger.dart'; import '../../../../l10n/app_localizations.dart'; import '../../../../shared/theme/app_color_palette.dart'; import '../../../../shared/theme/design_tokens.dart'; @@ -12,24 +13,37 @@ import '../../../../shared/widgets/divination/yao_glyph.dart'; import '../../../../shared/widgets/divination/yao_legend.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/models/divination_result.dart'; +import 'follow_up_chat_screen.dart'; class DivinationResultScreen extends StatefulWidget { - const DivinationResultScreen({super.key, required this.data}); + const DivinationResultScreen({ + super.key, + required this.data, + this.divinationApi, + this.enableIntroTransition = false, + }); final DivinationResultData data; + final DivinationApi? divinationApi; + final bool enableIntroTransition; @override State createState() => _DivinationResultScreenState(); } class _DivinationResultScreenState extends State { - bool _showIntro = true; + static final Logger _logger = getLogger('features.divination.result_screen'); + + bool _showIntro = false; bool _introCollapsed = false; Rect? _introTargetRect; final GlobalKey _stackKey = GlobalKey(); final GlobalKey _finalSignCardKey = GlobalKey(); + bool _followUpEligibilityLoading = false; + bool _canSendFollowUp = true; void _backToHome() { final navigator = Navigator.of(context); @@ -39,9 +53,48 @@ class _DivinationResultScreenState extends State { @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - _prepareIntro(); + if (widget.enableIntroTransition) { + _showIntro = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _prepareIntro(); + }); + } + _loadFollowUpEligibility(); + } + + Future _loadFollowUpEligibility() async { + if (widget.divinationApi == null || widget.data.threadId == null) { + return; + } + setState(() { + _followUpEligibilityLoading = true; }); + try { + final messages = await widget.divinationApi!.getSessionMessages( + threadId: widget.data.threadId!, + ); + final userCount = messages.where((msg) => msg.role == 'user').length; + if (!mounted) { + return; + } + setState(() { + _canSendFollowUp = userCount < 2; + _followUpEligibilityLoading = false; + }); + } catch (error, stackTrace) { + _logger.error( + message: 'Failed to load follow-up eligibility', + error: error, + stackTrace: stackTrace, + ); + if (!mounted) { + return; + } + setState(() { + _canSendFollowUp = false; + _followUpEligibilityLoading = false; + }); + } } Future _prepareIntro() async { @@ -138,6 +191,7 @@ class _DivinationResultScreenState extends State { title: Text(l10n.resultScreenTitle), centerTitle: true, ), + bottomNavigationBar: _buildFollowUpBar(context), body: LayoutBuilder( builder: (context, constraints) { final stackSize = Size(constraints.maxWidth, constraints.maxHeight); @@ -273,6 +327,24 @@ class _DivinationResultScreenState extends State { ), ), ), + Positioned( + left: 0, + right: 0, + bottom: 0, + height: 80, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + colors.surface.withValues(alpha: 0), + colors.surface, + ], + ), + ), + ), + ), ], ); }, @@ -280,6 +352,72 @@ class _DivinationResultScreenState extends State { ), ); } + + Widget? _buildFollowUpBar(BuildContext context) { + if (widget.divinationApi == null || widget.data.threadId == null) { + return null; + } + final l10n = AppLocalizations.of(context)!; + final colors = Theme.of(context).colorScheme; + return SafeArea( + top: false, + child: Container( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.sm, + AppSpacing.md, + AppSpacing.md, + ), + decoration: BoxDecoration(color: colors.surface), + child: Row( + children: [ + Expanded( + child: Text( + _canSendFollowUp + ? l10n.followUpEntryHint + : l10n.followUpQuotaUsed, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + const SizedBox(width: AppSpacing.sm), + FilledButton( + onPressed: _followUpEligibilityLoading + ? null + : () async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => FollowUpChatScreen( + result: widget.data, + api: widget.divinationApi!, + threadId: widget.data.threadId!, + ), + ), + ); + if (!mounted) { + return; + } + await _loadFollowUpEligibility(); + }, + child: _followUpEligibilityLoading + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colors.onPrimary, + ), + ) + : Text( + _canSendFollowUp + ? l10n.followUpEntryAction + : l10n.followUpViewHistory, + ), + ), + ], + ), + ), + ); + } } class _ResultHeader extends StatelessWidget { diff --git a/apps/lib/features/divination/presentation/screens/divination_screen.dart b/apps/lib/features/divination/presentation/screens/divination_screen.dart index 0f18494..63831a8 100644 --- a/apps/lib/features/divination/presentation/screens/divination_screen.dart +++ b/apps/lib/features/divination/presentation/screens/divination_screen.dart @@ -24,6 +24,7 @@ class DivinationScreen extends StatefulWidget { required this.userId, required this.onCompleted, this.runServiceOverride, + this.divinationApiOverride, this.allowVibration = true, }); @@ -31,6 +32,7 @@ class DivinationScreen extends StatefulWidget { final String userId; final Future Function(DivinationResultData result) onCompleted; final DivinationRunService? runServiceOverride; + final DivinationApi? divinationApiOverride; final bool allowVibration; @override @@ -40,6 +42,7 @@ class DivinationScreen extends StatefulWidget { class _DivinationScreenState extends State { late DivinationParams _params; final TextEditingController _questionController = TextEditingController(); + late final DivinationApi _divinationApi; late final DivinationRunService _runService; @override @@ -49,9 +52,10 @@ class _DivinationScreenState extends State { baseUrl: appDependencies.backendUrl, tokenProvider: widget.sessionStore.getToken, ); + _divinationApi = + widget.divinationApiOverride ?? DivinationApi(apiClient: apiClient); _runService = - widget.runServiceOverride ?? - DivinationRunService(api: DivinationApi(apiClient: apiClient)); + widget.runServiceOverride ?? DivinationRunService(api: _divinationApi); _params = DivinationParams( method: DivinationMethod.auto, questionType: QuestionType.career, @@ -164,6 +168,7 @@ class _DivinationScreenState extends State { builder: (_) => ManualDivinationScreen( params: nextParams, runService: _runService, + divinationApi: _divinationApi, onCompleted: widget.onCompleted, ), ), @@ -180,6 +185,7 @@ class _DivinationScreenState extends State { builder: (_) => AutoDivinationScreen( params: nextParams, runService: _runService, + divinationApi: _divinationApi, onCompleted: widget.onCompleted, ), ), diff --git a/apps/lib/features/divination/presentation/screens/follow_up_chat_screen.dart b/apps/lib/features/divination/presentation/screens/follow_up_chat_screen.dart new file mode 100644 index 0000000..17918b1 --- /dev/null +++ b/apps/lib/features/divination/presentation/screens/follow_up_chat_screen.dart @@ -0,0 +1,573 @@ +import 'dart:math'; + +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/app_color_palette.dart'; +import '../../../../shared/theme/design_tokens.dart'; +import '../../../../shared/widgets/divination/divination_summary_card.dart'; +import '../../../../shared/widgets/gua_icon.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/models/divination_result.dart'; +import '../../data/models/follow_up_message.dart'; + +class FollowUpChatScreen extends StatefulWidget { + const FollowUpChatScreen({ + super.key, + required this.result, + required this.api, + required this.threadId, + }); + + final DivinationResultData result; + final DivinationApi api; + final String threadId; + + @override + State createState() => _FollowUpChatScreenState(); +} + +class _FollowUpChatScreenState extends State { + static final Logger _logger = getLogger('features.divination.follow_up_chat'); + + final TextEditingController _inputController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + + List _messages = const []; + bool _loading = true; + bool _sending = false; + + @override + void initState() { + super.initState(); + _loadHistory(); + } + + @override + void dispose() { + _inputController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + bool get _hasFollowUpQuota { + final userCount = _messages.where((item) => item.role == 'user').length; + return userCount < 2; + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final colors = Theme.of(context).colorScheme; + + return Scaffold( + backgroundColor: colors.surface, + appBar: AppBar( + backgroundColor: colors.surface, + surfaceTintColor: colors.surface, + title: Text(l10n.followUpScreenTitle), + ), + body: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + colors.primaryContainer.withValues(alpha: 0.12), + colors.surface, + ], + ), + ), + child: SafeArea( + child: Column( + children: [ + _buildHeader(context, colors), + Expanded(child: _buildTimeline(context, l10n, colors)), + _buildInputArea(context, l10n, colors), + ], + ), + ), + ), + ); + } + + Widget _buildHeader(BuildContext context, ColorScheme colors) { + final l10n = AppLocalizations.of(context)!; + final palette = Theme.of(context).extension()!; + final questionTypeLabel = _localizedQuestionType( + l10n, + widget.result.params.questionType, + ); + + final categoryStyle = switch (widget.result.params.questionType) { + QuestionType.career || QuestionType.study => ( + palette.categoryCareerBg, + palette.categoryCareerText, + ), + QuestionType.love => (palette.categoryLoveBg, palette.categoryLoveText), + _ => (palette.categoryMoneyBg, palette.categoryMoneyText), + }; + + final normalizedSignType = widget.result.signType.trim(); + final isBestSign = normalizedSignType.contains('上上'); + final isGoodSign = !isBestSign && normalizedSignType.contains('中上'); + final isWorstSign = normalizedSignType.contains('下下'); + + final signLabel = _localizedSignType(l10n, widget.result.signType); + final signStyle = isBestSign + ? (palette.historyGoldBg, palette.historyGoldText) + : isGoodSign + ? (colors.surfaceContainerHighest, colors.primary) + : isWorstSign + ? (colors.errorContainer, colors.onErrorContainer) + : (palette.historyGrayBg, palette.historyGrayText); + + return Container( + width: double.infinity, + margin: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.sm, + AppSpacing.md, + AppSpacing.sm, + ), + child: DivinationSummaryCard( + question: widget.result.params.question, + leading: GuaIcon(color: colors.onPrimary, size: 22), + leadingBackgroundColor: palette.accentPurple, + tags: [ + DivinationSummaryTagData( + label: questionTypeLabel, + background: categoryStyle.$1, + foreground: categoryStyle.$2, + ), + DivinationSummaryTagData( + label: widget.result.guaName, + background: palette.historyBlueBg, + foreground: palette.historyBlueText, + ), + DivinationSummaryTagData( + label: signLabel, + background: signStyle.$1, + foreground: signStyle.$2, + ), + ], + ), + ); + } + + String _localizedSignType(AppLocalizations l10n, String rawSignType) { + final normalized = rawSignType.trim(); + if (normalized.contains('上上')) { + return l10n.signTypeShangShang; + } + if (normalized.contains('中上')) { + return l10n.signTypeZhongShang; + } + if (normalized.contains('下下')) { + return l10n.signTypeXiaXia; + } + return l10n.signTypeZhongXia; + } + + String _localizedQuestionType( + AppLocalizations l10n, + QuestionType questionType, + ) { + return switch (questionType) { + QuestionType.career => l10n.questionTypeCareer, + QuestionType.love => l10n.questionTypeLove, + QuestionType.wealth => l10n.questionTypeWealth, + QuestionType.fortune => l10n.questionTypeFortune, + QuestionType.dream => l10n.questionTypeDream, + QuestionType.health => l10n.questionTypeHealth, + QuestionType.study => l10n.questionTypeStudy, + QuestionType.search => l10n.questionTypeSearch, + QuestionType.other => l10n.questionTypeOther, + }; + } + + Widget _buildTimeline( + BuildContext context, + AppLocalizations l10n, + ColorScheme colors, + ) { + if (_loading) { + return Center(child: CircularProgressIndicator(color: colors.primary)); + } + + if (_messages.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl), + child: Text( + l10n.followUpEmpty, + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: colors.onSurfaceVariant), + ), + ), + ); + } + + return ListView.separated( + controller: _scrollController, + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.sm, + AppSpacing.md, + AppSpacing.sm, + ), + itemCount: _messages.length, + separatorBuilder: (_, _) => const SizedBox(height: AppSpacing.sm), + itemBuilder: (context, index) { + final item = _messages[index]; + final isUser = item.role == 'user'; + final bubbleColor = isUser ? colors.primary : colors.surface; + final textColor = isUser ? colors.onPrimary : colors.onSurface; + + return Align( + alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.78, + ), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: bubbleColor, + borderRadius: BorderRadius.circular(AppRadius.md), + border: isUser + ? null + : Border.all( + color: colors.outlineVariant.withValues(alpha: 0.65), + ), + boxShadow: [ + BoxShadow( + color: colors.shadow.withValues( + alpha: isUser ? 0.08 : 0.05, + ), + blurRadius: isUser ? 10 : 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Text( + item.content.isEmpty ? '...' : item.content, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: textColor, height: 1.5), + ), + ), + ), + ); + }, + ); + } + + Widget _buildInputArea( + BuildContext context, + AppLocalizations l10n, + ColorScheme colors, + ) { + final hintText = _hasFollowUpQuota + ? l10n.followUpEntryHint + : l10n.followUpQuotaUsed; + + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.xs, + AppSpacing.md, + AppSpacing.sm, + ), + color: colors.surface, + child: Row( + children: [ + Expanded( + child: TextField( + controller: _inputController, + minLines: 1, + maxLines: 4, + enabled: !_sending && _hasFollowUpQuota, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colors.onSurface, + fontSize: 15, + height: 1.35, + ), + decoration: InputDecoration( + hintText: hintText, + hintStyle: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + fontSize: 12, + height: 1.35, + fontWeight: FontWeight.w400, + ), + filled: false, + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.full), + borderSide: BorderSide(color: colors.outlineVariant), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.full), + borderSide: BorderSide(color: colors.outlineVariant), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.full), + borderSide: BorderSide(color: colors.primary, width: 1.4), + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.full), + borderSide: BorderSide( + color: _hasFollowUpQuota + ? colors.outlineVariant + : colors.error.withValues(alpha: 0.4), + ), + ), + ), + textInputAction: TextInputAction.send, + onSubmitted: (_) => _submitText(), + ), + ), + const SizedBox(width: AppSpacing.sm), + ValueListenableBuilder( + valueListenable: _inputController, + builder: (context, value, _) { + final hasText = value.text.trim().isNotEmpty; + final canSend = !_sending && _hasFollowUpQuota && hasText; + return AnimatedScale( + scale: canSend ? 1 : 0.94, + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: canSend + ? [ + BoxShadow( + color: colors.primary.withValues(alpha: 0.2), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ] + : const [], + ), + child: IconButton.filled( + onPressed: canSend ? _submitText : null, + style: IconButton.styleFrom( + backgroundColor: canSend + ? colors.primary + : colors.surfaceContainerHighest, + foregroundColor: canSend + ? colors.onPrimary + : colors.onSurfaceVariant, + ), + icon: AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: _sending + ? SizedBox( + key: const ValueKey('follow_up_sending'), + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colors.onPrimary, + ), + ) + : Icon( + Icons.send_rounded, + key: ValueKey('follow_up_send_$canSend'), + ), + ), + ), + ), + ); + }, + ), + ], + ), + ); + } + + Future _loadHistory() async { + try { + final messages = await widget.api.getSessionMessages( + threadId: widget.threadId, + ); + if (!mounted) { + return; + } + setState(() { + _messages = messages; + _loading = false; + }); + _scrollToBottom(); + } catch (error, stackTrace) { + _logger.error( + message: 'Failed to load follow-up history', + error: error, + stackTrace: stackTrace, + ); + if (!mounted) { + return; + } + setState(() { + _loading = false; + }); + final l10n = AppLocalizations.of(context)!; + final message = error is ApiProblem + ? mapApiProblemToMessage(error, l10n) + : l10n.errorRequestGeneric; + Toast.show(context, message, type: ToastType.error); + } + } + + Future _submitText() async { + final text = _inputController.text.trim(); + if (text.isEmpty || _sending || !_hasFollowUpQuota) { + return; + } + + _inputController.clear(); + final l10n = AppLocalizations.of(context)!; + final runId = + 'run_${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(99999)}'; + final now = DateTime.now(); + final localUser = FollowUpMessage( + id: 'local_user_$runId', + seq: _messages.length + 1, + role: 'user', + content: text, + timestamp: now, + ); + final localAssistant = FollowUpMessage( + id: 'local_assistant_$runId', + seq: _messages.length + 2, + role: 'assistant', + content: '', + timestamp: now, + ); + + setState(() { + _sending = true; + _messages = [..._messages, localUser, localAssistant]; + }); + _scrollToBottom(); + + String answer = ''; + try { + await widget.api.enqueueFollowUp( + threadId: widget.threadId, + runId: runId, + question: text, + result: widget.result, + ); + + await for (final event in widget.api.streamEvents( + threadId: widget.threadId, + runId: runId, + )) { + final type = event['type'] as String? ?? ''; + if (type == 'TEXT_MESSAGE_END') { + answer = (event['answer'] as String?) ?? ''; + continue; + } + if (type == 'RUN_ERROR') { + throw ApiProblem( + status: 500, + title: 'Run failed', + detail: event['detail'] as String? ?? l10n.errorRequestGeneric, + code: event['code'] as String?, + ); + } + if (type == 'RUN_FINISHED') { + break; + } + } + + if (!mounted) { + return; + } + setState(() { + _messages = _messages + .map( + (item) => item.id == localAssistant.id + ? FollowUpMessage( + id: item.id, + seq: item.seq, + role: item.role, + content: answer, + timestamp: item.timestamp, + ) + : item, + ) + .toList(growable: false); + }); + _scrollToBottom(); + + await _loadHistory(); + } catch (error, stackTrace) { + _logger.error( + message: 'Failed to submit follow-up text', + error: error, + stackTrace: stackTrace, + ); + if (!mounted) { + return; + } + final message = error is ApiProblem + ? mapApiProblemToMessage(error, l10n) + : l10n.errorRequestGeneric; + Toast.show(context, message, type: ToastType.error); + await _loadHistory(); + } finally { + if (mounted) { + setState(() { + _sending = false; + }); + } + } + } + + void _scrollToBottom([int retry = 0]) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollController.hasClients) { + return; + } + final position = _scrollController.position; + if (!position.hasContentDimensions) { + if (retry < 3) { + _scrollToBottom(retry + 1); + } + return; + } + final target = position.maxScrollExtent; + if (!target.isFinite) { + return; + } + _scrollController.animateTo( + target, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOut, + ); + }); + } +} diff --git a/apps/lib/features/divination/presentation/screens/manual_divination_screen.dart b/apps/lib/features/divination/presentation/screens/manual_divination_screen.dart index d8d7cb5..def922a 100644 --- a/apps/lib/features/divination/presentation/screens/manual_divination_screen.dart +++ b/apps/lib/features/divination/presentation/screens/manual_divination_screen.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; +import 'package:onboarding_overlay/onboarding_overlay.dart'; import '../../../../l10n/app_localizations.dart'; import '../../../../shared/theme/design_tokens.dart'; @@ -16,6 +17,7 @@ import '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_shee import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; import '../../data/models/divination_backend_models.dart'; +import '../../data/apis/divination_api.dart'; import '../../data/models/divination_params.dart'; import '../../data/models/divination_result.dart'; import '../../data/services/divination_run_service.dart'; @@ -26,11 +28,13 @@ class ManualDivinationScreen extends StatefulWidget { super.key, required this.params, required this.runService, + this.divinationApi, required this.onCompleted, }); final DivinationParams params; final DivinationRunService runService; + final DivinationApi? divinationApi; final Future Function(DivinationResultData result) onCompleted; @override @@ -43,6 +47,16 @@ class _ManualDivinationScreenState extends State final List _selectedYaos = List.filled(6, null); late final AnimationController _blinkController; bool _submitting = false; + final GlobalKey _onboardingKey = + GlobalKey(); + final ScrollController _scrollController = ScrollController(); + final GlobalKey _timeCardKey = GlobalKey(); + final GlobalKey _yaoCardKey = GlobalKey(); + final GlobalKey _analyzeButtonKey = GlobalKey(); + final FocusNode _guideStep1Focus = FocusNode(); + final FocusNode _guideStep2Focus = FocusNode(); + final FocusNode _guideStep3Focus = FocusNode(); + final FocusNode _guideStep4Focus = FocusNode(); @override void initState() { @@ -56,7 +70,12 @@ class _ManualDivinationScreenState extends State @override void dispose() { + _scrollController.dispose(); _blinkController.dispose(); + _guideStep1Focus.dispose(); + _guideStep2Focus.dispose(); + _guideStep3Focus.dispose(); + _guideStep4Focus.dispose(); super.dispose(); } @@ -66,6 +85,55 @@ class _ManualDivinationScreenState extends State Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; + final guideSteps = [ + OnboardingStep( + focusNode: _guideStep1Focus, + titleText: l10n.manualGuideStep1Title, + bodyText: l10n.manualGuideStep1Body, + titleTextColor: Colors.white, + bodyTextColor: Colors.white, + hasArrow: false, + hasLabelBox: true, + fullscreen: true, + overlayColor: Colors.black.withValues(alpha: 0.7), + ), + OnboardingStep( + focusNode: _guideStep2Focus, + titleText: l10n.manualGuideStep2Title, + bodyText: l10n.manualGuideStep2Body, + titleTextColor: Colors.white, + bodyTextColor: Colors.white, + hasArrow: true, + hasLabelBox: true, + arrowPosition: ArrowPosition.top, + delay: const Duration(milliseconds: 320), + overlayColor: Colors.black.withValues(alpha: 0.7), + ), + OnboardingStep( + focusNode: _guideStep3Focus, + titleText: l10n.manualGuideStep3Title, + bodyText: l10n.manualGuideStep3Body, + titleTextColor: Colors.white, + bodyTextColor: Colors.white, + hasArrow: true, + hasLabelBox: true, + arrowPosition: ArrowPosition.top, + delay: const Duration(milliseconds: 320), + overlayColor: Colors.black.withValues(alpha: 0.7), + ), + OnboardingStep( + focusNode: _guideStep4Focus, + titleText: l10n.manualGuideStep4Title, + bodyText: l10n.manualGuideStep4Body, + titleTextColor: Colors.white, + bodyTextColor: Colors.white, + hasArrow: true, + hasLabelBox: true, + arrowPosition: ArrowPosition.top, + delay: const Duration(milliseconds: 320), + overlayColor: Colors.black.withValues(alpha: 0.7), + ), + ]; return Scaffold( backgroundColor: colors.surface, appBar: AppBar( @@ -74,49 +142,80 @@ class _ManualDivinationScreenState extends State backgroundColor: colors.surface, surfaceTintColor: colors.surface, ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(AppSpacing.xl), - child: Column( - children: [ - _buildInstruction(), - const SizedBox(height: AppSpacing.lg), - _TimeCard(selectedTime: _selectedTime, onPickTime: _pickTime), - const SizedBox(height: AppSpacing.lg), - _YaoSelectionCard( - selectedYaos: _selectedYaos, - blinkAnimation: _blinkController, - onSelect: _onSelectYao, - onNeedTip: _showOrderTip, - ), - const SizedBox(height: AppSpacing.xl), - AnimatedBuilder( - animation: _blinkController, - builder: (context, _) { - final base = colors.primary; - return SizedBox( - width: double.infinity, - height: 50, - child: FilledButton( - onPressed: _allSelected && !_submitting ? _submitRun : null, - style: FilledButton.styleFrom( - backgroundColor: _allSelected - ? base.withValues( - alpha: 0.6 + _blinkController.value * 0.4, - ) - : base, - ), - child: _submitting - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Text(l10n.manualStartResolve), + body: Onboarding( + key: _onboardingKey, + steps: guideSteps, + onChanged: _onGuideStepChanged, + child: SingleChildScrollView( + controller: _scrollController, + padding: const EdgeInsets.all(AppSpacing.xl), + child: Column( + children: [ + _buildInstruction(), + const SizedBox(height: AppSpacing.lg), + Container( + key: _timeCardKey, + child: Focus( + focusNode: _guideStep2Focus, + child: _TimeCard( + selectedTime: _selectedTime, + onPickTime: _pickTime, ), - ); - }, - ), - ], + ), + ), + const SizedBox(height: AppSpacing.lg), + Container( + key: _yaoCardKey, + child: Focus( + focusNode: _guideStep3Focus, + child: _YaoSelectionCard( + selectedYaos: _selectedYaos, + blinkAnimation: _blinkController, + onSelect: _onSelectYao, + onNeedTip: _showOrderTip, + ), + ), + ), + const SizedBox(height: AppSpacing.xl), + Container( + key: _analyzeButtonKey, + child: Focus( + focusNode: _guideStep4Focus, + child: AnimatedBuilder( + animation: _blinkController, + builder: (context, _) { + final base = colors.primary; + return SizedBox( + width: double.infinity, + height: 50, + child: FilledButton( + onPressed: _allSelected && !_submitting + ? _submitRun + : null, + style: FilledButton.styleFrom( + backgroundColor: _allSelected + ? base.withValues( + alpha: 0.6 + _blinkController.value * 0.4, + ) + : base, + ), + child: _submitting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Text(l10n.manualStartResolve), + ), + ); + }, + ), + ), + ), + ], + ), ), ), ); @@ -126,26 +225,40 @@ class _ManualDivinationScreenState extends State final l10n = AppLocalizations.of(context)!; return DivinationInstructionCard( text: l10n.manualYaoInstruction, - onTap: () { - showDialog( - context: context, - builder: (_) { - return DivinationGuideDialog( - title: l10n.manualSelectYaoTitle, - guideImages: const [ - ['assets/images/tutorial/tutorial_1.png'], - ['assets/images/tutorial/tutorial_2.png'], - ['assets/images/tutorial/tutorial_3.png'], - ], - instructions: [ - l10n.divinationManualGuideStep1, - l10n.divinationManualGuideStep2, - l10n.divinationManualGuideStep3, - ], - ); - }, - ); - }, + onTap: _showGuide, + ); + } + + void _showGuide() { + _scrollToGuideStep(0); + Future.delayed(const Duration(milliseconds: 120), () { + if (!mounted) { + return; + } + _onboardingKey.currentState?.show(); + }); + } + + void _onGuideStepChanged(int currentIndex) { + _scrollToGuideStep(currentIndex + 1); + } + + void _scrollToGuideStep(int stepIndex) { + final GlobalKey? targetKey = switch (stepIndex) { + 1 => _timeCardKey, + 2 => _yaoCardKey, + 3 => _analyzeButtonKey, + _ => null, + }; + final targetContext = targetKey?.currentContext; + if (targetContext == null) { + return; + } + Scrollable.ensureVisible( + targetContext, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOut, + alignment: 0.12, ); } @@ -253,6 +366,7 @@ class _ManualDivinationScreenState extends State params: widget.params.copyWith(divinationTime: _selectedTime), yaoStates: _selectedYaos.cast(), runService: widget.runService, + divinationApi: widget.divinationApi, onCompleted: widget.onCompleted, ), ), @@ -337,7 +451,10 @@ class _YaoSelectionCard extends StatelessWidget { Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; - final rowNames = DivinationTerms.yaoNames.reversed.toList(); + final rowNames = List.generate( + 6, + (i) => DivinationTerms.yaoName(l10n, i), + ).reversed.toList(); return Card( margin: EdgeInsets.zero, color: colors.surface, diff --git a/apps/lib/features/home/presentation/screens/home_screen.dart b/apps/lib/features/home/presentation/screens/home_screen.dart index 68518c8..a69b08d 100644 --- a/apps/lib/features/home/presentation/screens/home_screen.dart +++ b/apps/lib/features/home/presentation/screens/home_screen.dart @@ -1,8 +1,11 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import '../../../../core/auth/session_store.dart'; import '../../../divination/presentation/screens/divination_screen.dart'; import '../../../divination/presentation/screens/divination_result_screen.dart'; +import '../../../divination/data/apis/divination_api.dart'; import '../../../divination/data/models/divination_params.dart'; import '../../../divination/data/models/divination_result.dart'; import '../../../settings/data/models/profile_settings.dart'; @@ -11,6 +14,7 @@ import '../../../../l10n/app_localizations.dart'; import '../../../../shared/theme/app_color_palette.dart'; import '../../../../shared/theme/design_tokens.dart'; import '../../../../shared/widgets/bottom_nav_bar.dart'; +import '../../../../shared/widgets/divination/divination_summary_card.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; @@ -23,11 +27,13 @@ class HomeScreen extends StatefulWidget { required this.profileSettings, required this.historyRecords, required this.coinBalance, + required this.divinationApi, required this.onLocaleChanged, required this.onProfileSettingsChanged, required this.onSaveProfile, required this.onUploadAvatar, required this.onDivinationCompleted, + required this.onDeleteHistorySession, required this.onLogout, }); @@ -37,6 +43,7 @@ class HomeScreen extends StatefulWidget { final ProfileSettingsV1 profileSettings; final List historyRecords; final int coinBalance; + final DivinationApi divinationApi; final Future Function(String languageTag) onLocaleChanged; final Future Function(ProfileSettingsV1 settings) onProfileSettingsChanged; @@ -45,6 +52,7 @@ class HomeScreen extends StatefulWidget { final Future Function(String filePath) onUploadAvatar; final Future Function(DivinationResultData result) onDivinationCompleted; + final Future Function(String threadId) onDeleteHistorySession; final Future Function() onLogout; @override @@ -94,7 +102,9 @@ class _HomeScreenState extends State { historyItems: historyItems, sessionStore: widget.sessionStore, userId: widget.account, + divinationApi: widget.divinationApi, onDivinationCompleted: widget.onDivinationCompleted, + onDeleteHistorySession: widget.onDeleteHistorySession, allowVibration: widget.profileSettings.notification.allowVibration, ), _ProfileTab( @@ -138,15 +148,19 @@ class _HomeTab extends StatelessWidget { required this.historyItems, required this.sessionStore, required this.userId, + required this.divinationApi, required this.onDivinationCompleted, + required this.onDeleteHistorySession, required this.allowVibration, }); final List historyItems; final SessionStore sessionStore; final String userId; + final DivinationApi divinationApi; final Future Function(DivinationResultData result) onDivinationCompleted; + final Future Function(String threadId) onDeleteHistorySession; final bool allowVibration; @override @@ -253,9 +267,29 @@ class _HomeTab extends StatelessWidget { const SizedBox(height: AppSpacing.xl), Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), - child: Text( - l10n.historyTitle, - style: Theme.of(context).textTheme.titleMedium, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + l10n.historyTitle, + style: Theme.of(context).textTheme.titleMedium, + ), + if (historyItems.length > 4) + TextButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DivinationHistoryScreen( + initialItems: historyItems, + divinationApi: divinationApi, + onDeleteHistorySession: onDeleteHistorySession, + ), + ), + ); + }, + child: Text(l10n.more), + ), + ], ), ), const SizedBox(height: AppSpacing.md), @@ -279,22 +313,66 @@ class _HomeTab extends StatelessWidget { Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: historyItems.take(4).map((item) { + final threadId = item.threadId; return Padding( padding: const EdgeInsets.only( left: AppSpacing.md, right: AppSpacing.md, bottom: AppSpacing.md, ), - child: _HistoryCard( - item: item, - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => DivinationResultScreen(data: item), + child: threadId == null + ? _HistoryCard( + item: item, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DivinationResultScreen( + data: item, + divinationApi: null, + enableIntroTransition: false, + ), + ), + ); + }, + ) + : Dismissible( + key: ValueKey('home-history-$threadId'), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + ), + decoration: BoxDecoration( + color: colors.errorContainer, + borderRadius: BorderRadius.circular( + AppRadius.md, + ), + ), + child: Icon( + Icons.delete_outline, + color: colors.onErrorContainer, + ), + ), + confirmDismiss: (_) async => true, + onDismissed: (_) { + unawaited(onDeleteHistorySession(threadId)); + }, + child: _HistoryCard( + item: item, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DivinationResultScreen( + data: item, + divinationApi: divinationApi, + enableIntroTransition: false, + ), + ), + ); + }, + ), ), - ); - }, - ), ); }).toList(), ), @@ -305,6 +383,118 @@ class _HomeTab extends StatelessWidget { } } +class DivinationHistoryScreen extends StatefulWidget { + const DivinationHistoryScreen({ + super.key, + required this.initialItems, + required this.divinationApi, + required this.onDeleteHistorySession, + }); + + final List initialItems; + final DivinationApi divinationApi; + final Future Function(String threadId) onDeleteHistorySession; + + @override + State createState() => + _DivinationHistoryScreenState(); +} + +class _DivinationHistoryScreenState extends State { + late List _items; + + @override + void initState() { + super.initState(); + _items = List.from( + widget.initialItems, + growable: true, + ); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final colors = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar(title: Text(l10n.historyTitle)), + backgroundColor: colors.surfaceContainerLow, + body: _items.isEmpty + ? Center(child: Text(l10n.noRecords)) + : ListView.builder( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), + itemCount: _items.length, + itemBuilder: (context, index) { + final item = _items[index]; + final threadId = item.threadId; + return Padding( + padding: const EdgeInsets.only( + left: AppSpacing.md, + right: AppSpacing.md, + bottom: AppSpacing.md, + ), + child: threadId == null + ? _HistoryCard( + item: item, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DivinationResultScreen( + data: item, + divinationApi: null, + enableIntroTransition: false, + ), + ), + ); + }, + ) + : Dismissible( + key: ValueKey('history-$threadId'), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + ), + decoration: BoxDecoration( + color: colors.errorContainer, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Icon( + Icons.delete_outline, + color: colors.onErrorContainer, + ), + ), + confirmDismiss: (_) async => true, + onDismissed: (_) { + setState(() { + _items.removeAt(index); + }); + unawaited(widget.onDeleteHistorySession(threadId)); + }, + child: _HistoryCard( + item: item, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DivinationResultScreen( + data: item, + divinationApi: widget.divinationApi, + enableIntroTransition: false, + ), + ), + ); + }, + ), + ), + ); + }, + ), + ); + } +} + class _ProfileTab extends StatelessWidget { const _ProfileTab({ required this.account, @@ -355,9 +545,15 @@ class _HistoryCard extends StatelessWidget { final palette = Theme.of(context).extension()!; final categoryLabel = switch (item.params.questionType) { - QuestionType.career || QuestionType.study => l10n.categoryCareer, - QuestionType.love => l10n.categoryLove, - _ => l10n.categoryMoney, + QuestionType.career => l10n.questionTypeCareer, + QuestionType.love => l10n.questionTypeLove, + QuestionType.wealth => l10n.questionTypeWealth, + QuestionType.fortune => l10n.questionTypeFortune, + QuestionType.dream => l10n.questionTypeDream, + QuestionType.health => l10n.questionTypeHealth, + QuestionType.study => l10n.questionTypeStudy, + QuestionType.search => l10n.questionTypeSearch, + QuestionType.other => l10n.questionTypeOther, }; final categoryStyle = switch (item.params.questionType) { @@ -390,177 +586,39 @@ class _HistoryCard extends StatelessWidget { ? (colors.errorContainer, colors.onErrorContainer) : (palette.historyGrayBg, palette.historyGrayText); - return Material( - color: colors.surface.withValues(alpha: 0), - child: InkWell( - borderRadius: BorderRadius.circular(AppRadius.md), + return SizedBox( + width: double.infinity, + child: DivinationSummaryCard( + question: item.params.question, onTap: onTap, - child: Card( - margin: EdgeInsets.zero, - color: colors.surface, - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Padding( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.params.question, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: AppSpacing.sm), - Wrap( - spacing: AppSpacing.sm, - runSpacing: AppSpacing.sm, - children: [ - _Tag( - label: categoryLabel, - background: categoryStyle.$1, - foreground: categoryStyle.$2, - ), - _Tag( - label: item.guaName, - background: palette.historyBlueBg, - foreground: palette.historyBlueText, - ), - _Tag( - label: signLabel, - background: signStyle.$1, - foreground: signStyle.$2, - ), - ], - ), - ], - ), - ), + leading: Icon( + Icons.auto_awesome, + color: palette.historyBlueText, + size: 22, ), + leadingBackgroundColor: palette.historyBlueBg, + tags: [ + DivinationSummaryTagData( + label: categoryLabel, + background: categoryStyle.$1, + foreground: categoryStyle.$2, + ), + DivinationSummaryTagData( + label: item.guaName, + background: palette.historyBlueBg, + foreground: palette.historyBlueText, + ), + DivinationSummaryTagData( + label: signLabel, + background: signStyle.$1, + foreground: signStyle.$2, + ), + ], ), ); } } -class _Tag extends StatelessWidget { - const _Tag({ - required this.label, - required this.background, - required this.foreground, - }); - - final String label; - final Color background; - final Color foreground; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: AppSpacing.xs, - ), - decoration: BoxDecoration( - color: background, - borderRadius: BorderRadius.circular(AppRadius.sm), - ), - child: Text( - label, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: foreground), - ), - ); - } -} - -class _HistoryRecordsScreen extends StatelessWidget { - const _HistoryRecordsScreen({ - required this.records, - required this.onOpenResult, - }); - - final List records; - final ValueChanged onOpenResult; - - String _itemKey(DivinationResultData item) { - return '${item.guaName}_${item.binaryCode}_${item.params.question}'; - } - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final colors = Theme.of(context).colorScheme; - return Scaffold( - backgroundColor: colors.surfaceContainerLow, - appBar: AppBar( - title: Text(l10n.historyTitle), - centerTitle: true, - backgroundColor: colors.surfaceContainerLow, - surfaceTintColor: colors.surfaceContainerLow, - ), - body: records.isEmpty - ? Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - l10n.noRecords, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: AppSpacing.sm), - Text(l10n.noRecordsSubtitle), - ], - ), - ) - : ListView.separated( - padding: EdgeInsets.only( - top: AppSpacing.md, - bottom: AppSpacing.md, - ), - itemBuilder: (context, index) { - final item = records[index]; - return Dismissible( - key: ValueKey(_itemKey(item)), - direction: DismissDirection.endToStart, - background: Container( - alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: AppSpacing.lg), - decoration: BoxDecoration( - color: colors.error, - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Icon( - Icons.delete_outline_rounded, - color: colors.onError, - ), - ), - confirmDismiss: (direction) async { - return true; - }, - onDismissed: (direction) { - // TODO: implement delete - }, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - ), - child: _HistoryCard( - item: item, - onTap: () => onOpenResult(item), - ), - ), - ); - }, - separatorBuilder: (_, _) => const SizedBox(height: AppSpacing.md), - itemCount: records.length, - ), - ); - } -} - class _WelcomeDialog extends StatefulWidget { const _WelcomeDialog({required this.onDone}); diff --git a/apps/lib/l10n/app_en.arb b/apps/lib/l10n/app_en.arb index 6506e75..9398c11 100644 --- a/apps/lib/l10n/app_en.arb +++ b/apps/lib/l10n/app_en.arb @@ -52,7 +52,7 @@ "noRecordsSubtitle": "You have not saved any records", "homeTab": "Home", "profileTab": "Me", - "notify": "Notifications", + "notify": "Message Notifications", "featurePending": "This feature is not connected yet", "logout": "Logout", "defaultUserName": "User", @@ -69,11 +69,8 @@ "warningTitle": "Important Notice", "warningBody": "All interpretations are AI-generated for entertainment only. Do not use them as professional advice for business, medical, or legal decisions.", "scrollHint": "Scroll down to read all", - "understood": "I Understand", + "understood": "Got It", "readAllFirst": "Please read all first", - "categoryCareer": "Career/Study", - "categoryLove": "Love/Marriage", - "categoryMoney": "Wealth/Investment", "signBest": "Supremely Auspicious", "signGood": "Auspicious", "signNormal": "Cautionary", @@ -84,7 +81,7 @@ "settingsSectionQuickAccess": "Primary Menu", "settingsSectionAccount": "Account", "settingsSectionPrivacy": "Privacy", - "settingsSectionNotification": "Notifications", + "settingsSectionNotification": "Notification Settings", "settingsInterfaceLanguage": "Interface Language", "settingsAiLanguage": "AI Response Language", "settingsNotificationAllow": "Allow Notifications", @@ -195,7 +192,7 @@ "privacyContent": "Dear user,\nWelcome to MeiYao Divination. We understand that your privacy is critically important, and we take the protection of your personal information seriously. This policy explains how we collect, use, store, and share your information, as well as how you can access and manage it.\n\n1. Information We Collect\nWe may collect information you actively provide, including account registration details, profile information, and divination-related inputs and results. We may also collect device information and log data automatically to support security, compatibility, and service improvement.\n\n2. How We Use Information\nWe use your information to provide and improve divination services, manage accounts, protect account security, send service notifications, and respond to feedback or support requests.\n\n3. Storage of Information\nInformation collected in China is generally stored on servers located within China. We only retain personal information for as long as needed to meet legal obligations and service purposes, after which it will be deleted or anonymized.\n\n4. Sharing of Information\nWe do not share personal information with third parties except when you give clear consent, when we work with service providers under proper safeguards, when required by law, or in connection with mergers, acquisitions, restructuring, or bankruptcy.\n\n5. Your Rights\nYou may request access to, correction of, or deletion of your personal information, and you may request account cancellation. Please note that cancelling an account may make related data unrecoverable.\n\n6. Protection of Minors\nIf you are under the age of 14, please use the service under the guidance of a parent or legal guardian and obtain their prior consent.\n\n7. Security of Personal Information\nWe use reasonable organizational and technical measures, including encryption, access control, auditing, and monitoring, to protect personal information from unauthorized access, disclosure, use, modification, damage, or loss.\n\n8. Policy Updates\nWe may update this privacy policy from time to time because of legal, business, or service changes. Material changes will be communicated in a prominent way.\n\n9. Contact Us\nIf you have questions or suggestions about this privacy policy, please contact us at xuyunlong@xunmee.com.\n\nXunmee Technology (Shenzhen) Co., Ltd.\nJune 1, 2025", "termsContent": "Chapter 1 General\nWelcome to MeiYao Divination. The app is developed, operated, and maintained by Xunmee Technology (Shenzhen) Co., Ltd. By downloading, installing, registering, signing in, or otherwise using the app, you confirm that you have read, understood, and accepted these terms.\n\nChapter 2 Service Description\nMeiYao Divination provides AI-based divination interpretation services, including manual and automatic casting flows. Service interruption caused by maintenance, failure, force majeure, or other reasonable causes does not constitute a breach.\n\nChapter 3 User Accounts and Information Security\nUsers must have proper legal capacity, provide true and valid registration information, and keep account credentials secure. Necessary personal information may be collected and processed according to the privacy policy.\n\nChapter 4 Intellectual Property\nAll content of MeiYao Divination, including software, text, images, audio, video, charts, trademarks, and domains, is protected by law. Reverse engineering, decompilation, disassembly, or any attempt to obtain source code without written permission is strictly prohibited.\n\nChapter 5 User Conduct\nUsers may not publish unlawful content, infringe on the rights of others, disrupt normal service operation, or conduct unauthorized commercial activity. The app may warn, restrict, suspend, or ban accounts that violate these rules and may pursue legal liability.\n\nChapter 6 Liability and Disclaimer\nUsers are responsible for losses caused by their own violations of these terms. AI-generated divination results are for reference only and must not be treated as the sole basis for real-world decisions. Users assume the related risks.\n\nChapter 7 Dispute Resolution\nThese terms are governed by the laws of the People's Republic of China. Disputes should first be resolved through friendly consultation. If consultation fails, either party may bring the dispute to the competent court where Xunmee Technology is registered.\n\nChapter 8 Miscellaneous\nNotices may be delivered through contact information, system messages, internal messages, or announcements. If you need to contact Xunmee Technology, please email xuyunlong@xunmee.com.\n\nXunmee Technology (Shenzhen) Co., Ltd.\nJune 1, 2025", "disclaimerContent": "Placeholder content for disclaimer.", - "toastLabelInfo": "Info", + "toastLabelInfo": "Tip", "toastLabelSuccess": "Success", "toastLabelWarning": "Warning", "toastLabelError": "Error", @@ -209,12 +206,12 @@ "errorDivinationPayloadRequired": "Missing divination payload. Please cast again.", "divinationScreenTitle": "Cast Hexagram", "divinationSelectMethod": "Select divination method", - "divinationManualMethod": "Manual", - "divinationAutoMethod": "Auto", + "divinationManualMethod": "Manual Casting", + "divinationAutoMethod": "Auto Casting", "divinationQuestionTypePrompt": "Select question type", - "divinationQuestionInputPrompt": "Enter your question", + "divinationQuestionInputPrompt": "Please enter your question", "divinationQuestionInputHint": "Describe your question in detail for more accurate reading", - "divinationStartButton": "Start Casting", + "divinationStartButton": "Start Divination", "divinationCoinBalance": "Available coins: {balance}", "@divinationCoinBalance": { "placeholders": { @@ -233,6 +230,37 @@ "divinationManualGuideStep1": "Left: Pattern side. Right: Inscription side. Prepare three identical coins or similar tokens.", "divinationManualGuideStep2": "Hold the coins in both hands, think about your question, then toss them onto a table. Record how many inscription sides and pattern sides appear.", "divinationManualGuideStep3": "Record each result by whether the inscription side or pattern side faces up. Repeat 6 times, recording from bottom to top.", + "autoGuideStep1Title": "Auto Casting", + "autoGuideStep1Body": "No coins needed. Simply shake your phone or tap the button to cast. Each shake automatically rotates three coins and shows the result.", + "autoGuideStep2Title": "Start Casting", + "autoGuideStep2Body": "Tap the \"Start Casting\" button or shake your phone. The coins will rotate automatically for 3 seconds.", + "autoGuideStep3Title": "Auto Recording", + "autoGuideStep3Body": "Each shake automatically records the corresponding yao position. Repeat 6 times to complete all six yao.", + "autoGuideStep4Title": "Analyze Hexagram", + "autoGuideStep4Body": "After 6 shakes, the \"Analyze Hexagram\" button will blink. Tap it to view the hexagram interpretation.", + "manualGuideStep1Title": "Manual Casting", + "manualGuideStep1Body": "Prepare three identical coins. Record one yao at a time, from bottom to top, until all six yao are complete.", + "manualGuideStep2Title": "Confirm Time", + "manualGuideStep2Body": "Check the casting time first. Tap \"Modify\" on the right if you need to adjust it.", + "manualGuideStep3Title": "Fill Six Yao in Order", + "manualGuideStep3Body": "Start from the first yao and select one row at a time. The next row stays locked until the current row is completed.", + "manualGuideStep4Title": "Start Analysis", + "manualGuideStep4Body": "After all six yao are filled, the \"Analyze Hexagram\" button will blink. Tap it to start interpretation.", + "yaoNameFirst": "First Yao", + "yaoNameSecond": "Second Yao", + "yaoNameThird": "Third Yao", + "yaoNameFourth": "Fourth Yao", + "yaoNameFifth": "Fifth Yao", + "yaoNameTop": "Top Yao", + "yaoYin": "Yin", + "yaoYang": "Yang", + "yaoYoungYin": "Young Yin", + "yaoYoungYang": "Young Yang", + "yaoOldYin": "Old Yin", + "yaoOldYang": "Old Yang", + "yaoMovingSuffix": "(moving)", + "autoCoinFaceZi": "Inscription", + "autoCoinFaceHua": "Pattern", "divinationIAcknowledge": "I Understand", "divinationClose": "Close", "divinationModify": "Modify", @@ -259,7 +287,7 @@ } } }, - "divinationCostDialogConfirm": "Start", + "divinationCostDialogConfirm": "Confirm Analysis", "toastContentCopied": "Content copied", "toastContentCopiedWithTitle": "{title} copied", "@toastContentCopiedWithTitle": { @@ -278,18 +306,43 @@ "resultAnalysis": "Analysis", "resultSuggestion": "Suggestion", "resultDivinationInfo": "Divination Info", - "resultDivinationTime": "Time", + "resultDivinationTime": "Casting Time", "resultDivinationMethod": "Method", "resultQuestionType": "Type", "resultQuestion": "Question", - "resultAutoMethod": "Auto", - "resultManualMethod": "Manual", + "resultAutoMethod": "Auto Casting", + "resultManualMethod": "Manual Casting", "signTypeShangShang": "Supremely Auspicious", "signTypeZhongShang": "Auspicious", "signTypeZhongXia": "Cautionary", "signTypeXiaXia": "Inauspicious", "resultCopy": "Copy", - "resultWarning": "All interpretations are AI-generated for entertainment only. Do not use them as professional advice.", + "resultWarning": "All interpretations are AI-generated for entertainment only. Do not use them as professional advice for business, medical, or legal decisions.", + "followUpEntryHint": "You can ask one follow-up question for this reading", + "followUpEntryAction": "Ask Follow-up", + "followUpViewHistory": "View history", + "followUpScreenTitle": "Continue Follow-up", + "followUpEmpty": "No messages yet", + "followUpQuotaUsed": "Follow-up limit reached for this session", + "followUpInputHint": "Type your follow-up question", + "followUpHoldToSpeak": "Hold to speak", + "followUpRecording": "Release to send", + "followUpRecordingHint": "Slide up to cancel", + "followUpTranscribing": "Transcribing voice...", + "followUpGenerating": "Generating reply...", + "followUpStepWorker": "Analyzing divination and generating reply", + "followUpStepGeneric": "Processing: {stepName}", + "@followUpStepGeneric": { + "placeholders": { + "stepName": { + "type": "String" + } + } + }, + "errorAudioUnsupportedFormat": "Unsupported audio format. Please use wav", + "errorAudioTooLarge": "Audio file too large. Please record a shorter clip", + "errorAudioEmpty": "No valid voice detected. Please try again", + "errorAsrUnavailable": "Voice transcription service is unavailable now", "transitionPreparing": "Deriving...", "transitionDeriving": "Analyzing...", "transitionDone": "Complete\nTap to view", @@ -323,7 +376,7 @@ "manualScreenTitle": "Manual Casting", "manualSelectTime": "Select time", "manualSpecifyYaoCombo": "Select coin combination", - "manualStartResolve": "Start Analysis", + "manualStartResolve": "Start Interpretation", "manualSelectYaoTitle": "Select Yao", "manualYaoInstruction": "Tap to view casting method and coin combination guide", "manualYaoTipTitle": "Tip", @@ -334,11 +387,11 @@ "autoCoinDivination": "Coin Casting", "autoHexagramForming": "Forming Hexagram", "autoShakeInstruction": "Tap to view auto casting method", - "autoStartShake": "Start", + "autoStartShake": "Start Casting", "autoContinueShake": "Continue", "autoFinishShake": "Finish", "autoShaking": "Casting...", - "autoStartResolve": "Start Analysis", + "autoStartResolve": "Start Interpretation", "autoShakeCountdown": "Stopping in {seconds}s", "@autoShakeCountdown": { "placeholders": { @@ -368,7 +421,7 @@ "autoGuideTitle": "Auto Casting Tutorial", "autoGuideInstruction": "Shake your phone or tap the button, cast 6 times to form a complete hexagram.", "dateTab": "Date", - "timeTab": "Time", + "timeTab": "Time Picker", "confirm": "Confirm", "cancel": "Cancel", "autoSelectTime": "Select Time", diff --git a/apps/lib/l10n/app_localizations.dart b/apps/lib/l10n/app_localizations.dart index 56b8758..6cf06d3 100644 --- a/apps/lib/l10n/app_localizations.dart +++ b/apps/lib/l10n/app_localizations.dart @@ -440,24 +440,6 @@ abstract class AppLocalizations { /// **'请先阅读完整内容'** String get readAllFirst; - /// No description provided for @categoryCareer. - /// - /// In zh, this message translates to: - /// **'事业学业'** - String get categoryCareer; - - /// No description provided for @categoryLove. - /// - /// In zh, this message translates to: - /// **'情感婚姻'** - String get categoryLove; - - /// No description provided for @categoryMoney. - /// - /// In zh, this message translates to: - /// **'财富投资'** - String get categoryMoney; - /// No description provided for @signBest. /// /// In zh, this message translates to: @@ -1154,6 +1136,192 @@ abstract class AppLocalizations { /// **'记录每次结果,按照「字面在上还是花面在上」记录。重复六次,从下往上记录。'** String get divinationManualGuideStep3; + /// No description provided for @autoGuideStep1Title. + /// + /// In zh, this message translates to: + /// **'自动起卦'** + String get autoGuideStep1Title; + + /// No description provided for @autoGuideStep1Body. + /// + /// In zh, this message translates to: + /// **'无需铜钱,摇动手机或点击按钮即可完成起卦。每次摇动后,三枚铜币将自动旋转并显示结果。'** + String get autoGuideStep1Body; + + /// No description provided for @autoGuideStep2Title. + /// + /// In zh, this message translates to: + /// **'开始摇卦'** + String get autoGuideStep2Title; + + /// No description provided for @autoGuideStep2Body. + /// + /// In zh, this message translates to: + /// **'点击「开始摇卦」按钮,或直接摇动手机。铜币将自动旋转3秒后停止。'** + String get autoGuideStep2Body; + + /// No description provided for @autoGuideStep3Title. + /// + /// In zh, this message translates to: + /// **'自动记录'** + String get autoGuideStep3Title; + + /// No description provided for @autoGuideStep3Body. + /// + /// In zh, this message translates to: + /// **'每摇一次,对应的爻位会自动记录。重复六次,即可完成全部六爻。'** + String get autoGuideStep3Body; + + /// No description provided for @autoGuideStep4Title. + /// + /// In zh, this message translates to: + /// **'分析卦象'** + String get autoGuideStep4Title; + + /// No description provided for @autoGuideStep4Body. + /// + /// In zh, this message translates to: + /// **'六次完成后,「分析卦象」按钮将闪烁提示。点击即可查看卦象解读。'** + String get autoGuideStep4Body; + + /// No description provided for @manualGuideStep1Title. + /// + /// In zh, this message translates to: + /// **'手动起卦'** + String get manualGuideStep1Title; + + /// No description provided for @manualGuideStep1Body. + /// + /// In zh, this message translates to: + /// **'准备三枚相同的钱币。每次记录一爻,按从下往上的顺序共记录六爻。'** + String get manualGuideStep1Body; + + /// No description provided for @manualGuideStep2Title. + /// + /// In zh, this message translates to: + /// **'确认时间'** + String get manualGuideStep2Title; + + /// No description provided for @manualGuideStep2Body. + /// + /// In zh, this message translates to: + /// **'先确认起卦时间。如需调整,点击右侧「修改」。'** + String get manualGuideStep2Body; + + /// No description provided for @manualGuideStep3Title. + /// + /// In zh, this message translates to: + /// **'依次录入六爻'** + String get manualGuideStep3Title; + + /// No description provided for @manualGuideStep3Body. + /// + /// In zh, this message translates to: + /// **'从初爻开始逐条选择,未完成前下一爻不可点。每条会弹出三枚钱币选择面板。'** + String get manualGuideStep3Body; + + /// No description provided for @manualGuideStep4Title. + /// + /// In zh, this message translates to: + /// **'开始分析'** + String get manualGuideStep4Title; + + /// No description provided for @manualGuideStep4Body. + /// + /// In zh, this message translates to: + /// **'六爻都填完后,下方「分析卦象」按钮会闪烁提示,点击即可解卦。'** + String get manualGuideStep4Body; + + /// No description provided for @yaoNameFirst. + /// + /// In zh, this message translates to: + /// **'初爻'** + String get yaoNameFirst; + + /// No description provided for @yaoNameSecond. + /// + /// In zh, this message translates to: + /// **'二爻'** + String get yaoNameSecond; + + /// No description provided for @yaoNameThird. + /// + /// In zh, this message translates to: + /// **'三爻'** + String get yaoNameThird; + + /// No description provided for @yaoNameFourth. + /// + /// In zh, this message translates to: + /// **'四爻'** + String get yaoNameFourth; + + /// No description provided for @yaoNameFifth. + /// + /// In zh, this message translates to: + /// **'五爻'** + String get yaoNameFifth; + + /// No description provided for @yaoNameTop. + /// + /// In zh, this message translates to: + /// **'上爻'** + String get yaoNameTop; + + /// No description provided for @yaoYin. + /// + /// In zh, this message translates to: + /// **'阴'** + String get yaoYin; + + /// No description provided for @yaoYang. + /// + /// In zh, this message translates to: + /// **'阳'** + String get yaoYang; + + /// No description provided for @yaoYoungYin. + /// + /// In zh, this message translates to: + /// **'少阴'** + String get yaoYoungYin; + + /// No description provided for @yaoYoungYang. + /// + /// In zh, this message translates to: + /// **'少阳'** + String get yaoYoungYang; + + /// No description provided for @yaoOldYin. + /// + /// In zh, this message translates to: + /// **'老阴'** + String get yaoOldYin; + + /// No description provided for @yaoOldYang. + /// + /// In zh, this message translates to: + /// **'老阳'** + String get yaoOldYang; + + /// No description provided for @yaoMovingSuffix. + /// + /// In zh, this message translates to: + /// **'(变)'** + String get yaoMovingSuffix; + + /// No description provided for @autoCoinFaceZi. + /// + /// In zh, this message translates to: + /// **'字'** + String get autoCoinFaceZi; + + /// No description provided for @autoCoinFaceHua. + /// + /// In zh, this message translates to: + /// **'花'** + String get autoCoinFaceHua; + /// No description provided for @divinationIAcknowledge. /// /// In zh, this message translates to: @@ -1394,6 +1562,114 @@ abstract class AppLocalizations { /// **'卦象解读结果均由AI生成,仅供娱乐参考,切不可作为商业、医疗等专业领域的决策依据。理性看待卦象,自由掌握人生。'** String get resultWarning; + /// No description provided for @followUpEntryHint. + /// + /// In zh, this message translates to: + /// **'可针对本次解卦继续追问 1 次'** + String get followUpEntryHint; + + /// No description provided for @followUpEntryAction. + /// + /// In zh, this message translates to: + /// **'追问'** + String get followUpEntryAction; + + /// No description provided for @followUpViewHistory. + /// + /// In zh, this message translates to: + /// **'查看历史记录'** + String get followUpViewHistory; + + /// No description provided for @followUpScreenTitle. + /// + /// In zh, this message translates to: + /// **'继续追问'** + String get followUpScreenTitle; + + /// No description provided for @followUpEmpty. + /// + /// In zh, this message translates to: + /// **'暂无消息'** + String get followUpEmpty; + + /// No description provided for @followUpQuotaUsed. + /// + /// In zh, this message translates to: + /// **'本次会话追问次数已用完'** + String get followUpQuotaUsed; + + /// No description provided for @followUpInputHint. + /// + /// In zh, this message translates to: + /// **'输入你想继续追问的问题'** + String get followUpInputHint; + + /// No description provided for @followUpHoldToSpeak. + /// + /// In zh, this message translates to: + /// **'按住说话'** + String get followUpHoldToSpeak; + + /// No description provided for @followUpRecording. + /// + /// In zh, this message translates to: + /// **'松开发送'** + String get followUpRecording; + + /// No description provided for @followUpRecordingHint. + /// + /// In zh, this message translates to: + /// **'上滑取消'** + String get followUpRecordingHint; + + /// No description provided for @followUpTranscribing. + /// + /// In zh, this message translates to: + /// **'语音转文字中...'** + String get followUpTranscribing; + + /// No description provided for @followUpGenerating. + /// + /// In zh, this message translates to: + /// **'正在生成回复...'** + String get followUpGenerating; + + /// No description provided for @followUpStepWorker. + /// + /// In zh, this message translates to: + /// **'正在分析卦象并生成回复'** + String get followUpStepWorker; + + /// No description provided for @followUpStepGeneric. + /// + /// In zh, this message translates to: + /// **'正在处理:{stepName}'** + String followUpStepGeneric(String stepName); + + /// No description provided for @errorAudioUnsupportedFormat. + /// + /// In zh, this message translates to: + /// **'音频格式不支持,请使用 wav'** + String get errorAudioUnsupportedFormat; + + /// No description provided for @errorAudioTooLarge. + /// + /// In zh, this message translates to: + /// **'音频文件过大,请缩短录音时长'** + String get errorAudioTooLarge; + + /// No description provided for @errorAudioEmpty. + /// + /// In zh, this message translates to: + /// **'未检测到有效语音,请重试'** + String get errorAudioEmpty; + + /// No description provided for @errorAsrUnavailable. + /// + /// In zh, this message translates to: + /// **'语音识别服务暂不可用,请稍后重试'** + String get errorAsrUnavailable; + /// No description provided for @transitionPreparing. /// /// In zh, this message translates to: diff --git a/apps/lib/l10n/app_localizations_en.dart b/apps/lib/l10n/app_localizations_en.dart index dca457e..3fc9b60 100644 --- a/apps/lib/l10n/app_localizations_en.dart +++ b/apps/lib/l10n/app_localizations_en.dart @@ -128,7 +128,7 @@ class AppLocalizationsEn extends AppLocalizations { String get profileTab => 'Me'; @override - String get notify => 'Notifications'; + String get notify => 'Message Notifications'; @override String get featurePending => 'This feature is not connected yet'; @@ -184,20 +184,11 @@ class AppLocalizationsEn extends AppLocalizations { String get scrollHint => 'Scroll down to read all'; @override - String get understood => 'I Understand'; + String get understood => 'Got It'; @override String get readAllFirst => 'Please read all first'; - @override - String get categoryCareer => 'Career/Study'; - - @override - String get categoryLove => 'Love/Marriage'; - - @override - String get categoryMoney => 'Wealth/Investment'; - @override String get signBest => 'Supremely Auspicious'; @@ -229,7 +220,7 @@ class AppLocalizationsEn extends AppLocalizations { String get settingsSectionPrivacy => 'Privacy'; @override - String get settingsSectionNotification => 'Notifications'; + String get settingsSectionNotification => 'Notification Settings'; @override String get settingsInterfaceLanguage => 'Interface Language'; @@ -479,7 +470,7 @@ class AppLocalizationsEn extends AppLocalizations { String get disclaimerContent => 'Placeholder content for disclaimer.'; @override - String get toastLabelInfo => 'Info'; + String get toastLabelInfo => 'Tip'; @override String get toastLabelSuccess => 'Success'; @@ -525,23 +516,23 @@ class AppLocalizationsEn extends AppLocalizations { String get divinationSelectMethod => 'Select divination method'; @override - String get divinationManualMethod => 'Manual'; + String get divinationManualMethod => 'Manual Casting'; @override - String get divinationAutoMethod => 'Auto'; + String get divinationAutoMethod => 'Auto Casting'; @override String get divinationQuestionTypePrompt => 'Select question type'; @override - String get divinationQuestionInputPrompt => 'Enter your question'; + String get divinationQuestionInputPrompt => 'Please enter your question'; @override String get divinationQuestionInputHint => 'Describe your question in detail for more accurate reading'; @override - String get divinationStartButton => 'Start Casting'; + String get divinationStartButton => 'Start Divination'; @override String divinationCoinBalance(int balance) { @@ -585,6 +576,107 @@ class AppLocalizationsEn extends AppLocalizations { String get divinationManualGuideStep3 => 'Record each result by whether the inscription side or pattern side faces up. Repeat 6 times, recording from bottom to top.'; + @override + String get autoGuideStep1Title => 'Auto Casting'; + + @override + String get autoGuideStep1Body => + 'No coins needed. Simply shake your phone or tap the button to cast. Each shake automatically rotates three coins and shows the result.'; + + @override + String get autoGuideStep2Title => 'Start Casting'; + + @override + String get autoGuideStep2Body => + 'Tap the \"Start Casting\" button or shake your phone. The coins will rotate automatically for 3 seconds.'; + + @override + String get autoGuideStep3Title => 'Auto Recording'; + + @override + String get autoGuideStep3Body => + 'Each shake automatically records the corresponding yao position. Repeat 6 times to complete all six yao.'; + + @override + String get autoGuideStep4Title => 'Analyze Hexagram'; + + @override + String get autoGuideStep4Body => + 'After 6 shakes, the \"Analyze Hexagram\" button will blink. Tap it to view the hexagram interpretation.'; + + @override + String get manualGuideStep1Title => 'Manual Casting'; + + @override + String get manualGuideStep1Body => + 'Prepare three identical coins. Record one yao at a time, from bottom to top, until all six yao are complete.'; + + @override + String get manualGuideStep2Title => 'Confirm Time'; + + @override + String get manualGuideStep2Body => + 'Check the casting time first. Tap \"Modify\" on the right if you need to adjust it.'; + + @override + String get manualGuideStep3Title => 'Fill Six Yao in Order'; + + @override + String get manualGuideStep3Body => + 'Start from the first yao and select one row at a time. The next row stays locked until the current row is completed.'; + + @override + String get manualGuideStep4Title => 'Start Analysis'; + + @override + String get manualGuideStep4Body => + 'After all six yao are filled, the \"Analyze Hexagram\" button will blink. Tap it to start interpretation.'; + + @override + String get yaoNameFirst => 'First Yao'; + + @override + String get yaoNameSecond => 'Second Yao'; + + @override + String get yaoNameThird => 'Third Yao'; + + @override + String get yaoNameFourth => 'Fourth Yao'; + + @override + String get yaoNameFifth => 'Fifth Yao'; + + @override + String get yaoNameTop => 'Top Yao'; + + @override + String get yaoYin => 'Yin'; + + @override + String get yaoYang => 'Yang'; + + @override + String get yaoYoungYin => 'Young Yin'; + + @override + String get yaoYoungYang => 'Young Yang'; + + @override + String get yaoOldYin => 'Old Yin'; + + @override + String get yaoOldYang => 'Old Yang'; + + @override + String get yaoMovingSuffix => '(moving)'; + + @override + String get autoCoinFaceZi => 'Inscription'; + + @override + String get autoCoinFaceHua => 'Pattern'; + @override String get divinationIAcknowledge => 'I Understand'; @@ -636,7 +728,7 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String get divinationCostDialogConfirm => 'Start'; + String get divinationCostDialogConfirm => 'Confirm Analysis'; @override String get toastContentCopied => 'Content copied'; @@ -674,7 +766,7 @@ class AppLocalizationsEn extends AppLocalizations { String get resultDivinationInfo => 'Divination Info'; @override - String get resultDivinationTime => 'Time'; + String get resultDivinationTime => 'Casting Time'; @override String get resultDivinationMethod => 'Method'; @@ -686,10 +778,10 @@ class AppLocalizationsEn extends AppLocalizations { String get resultQuestion => 'Question'; @override - String get resultAutoMethod => 'Auto'; + String get resultAutoMethod => 'Auto Casting'; @override - String get resultManualMethod => 'Manual'; + String get resultManualMethod => 'Manual Casting'; @override String get signTypeShangShang => 'Supremely Auspicious'; @@ -708,7 +800,67 @@ class AppLocalizationsEn extends AppLocalizations { @override String get resultWarning => - 'All interpretations are AI-generated for entertainment only. Do not use them as professional advice.'; + 'All interpretations are AI-generated for entertainment only. Do not use them as professional advice for business, medical, or legal decisions.'; + + @override + String get followUpEntryHint => + 'You can ask one follow-up question for this reading'; + + @override + String get followUpEntryAction => 'Ask Follow-up'; + + @override + String get followUpViewHistory => 'View history'; + + @override + String get followUpScreenTitle => 'Continue Follow-up'; + + @override + String get followUpEmpty => 'No messages yet'; + + @override + String get followUpQuotaUsed => 'Follow-up limit reached for this session'; + + @override + String get followUpInputHint => 'Type your follow-up question'; + + @override + String get followUpHoldToSpeak => 'Hold to speak'; + + @override + String get followUpRecording => 'Release to send'; + + @override + String get followUpRecordingHint => 'Slide up to cancel'; + + @override + String get followUpTranscribing => 'Transcribing voice...'; + + @override + String get followUpGenerating => 'Generating reply...'; + + @override + String get followUpStepWorker => 'Analyzing divination and generating reply'; + + @override + String followUpStepGeneric(String stepName) { + return 'Processing: $stepName'; + } + + @override + String get errorAudioUnsupportedFormat => + 'Unsupported audio format. Please use wav'; + + @override + String get errorAudioTooLarge => + 'Audio file too large. Please record a shorter clip'; + + @override + String get errorAudioEmpty => 'No valid voice detected. Please try again'; + + @override + String get errorAsrUnavailable => + 'Voice transcription service is unavailable now'; @override String get transitionPreparing => 'Deriving...'; @@ -818,7 +970,7 @@ class AppLocalizationsEn extends AppLocalizations { String get manualSpecifyYaoCombo => 'Select coin combination'; @override - String get manualStartResolve => 'Start Analysis'; + String get manualStartResolve => 'Start Interpretation'; @override String get manualSelectYaoTitle => 'Select Yao'; @@ -854,7 +1006,7 @@ class AppLocalizationsEn extends AppLocalizations { String get autoShakeInstruction => 'Tap to view auto casting method'; @override - String get autoStartShake => 'Start'; + String get autoStartShake => 'Start Casting'; @override String get autoContinueShake => 'Continue'; @@ -866,7 +1018,7 @@ class AppLocalizationsEn extends AppLocalizations { String get autoShaking => 'Casting...'; @override - String get autoStartResolve => 'Start Analysis'; + String get autoStartResolve => 'Start Interpretation'; @override String autoShakeCountdown(int seconds) { @@ -900,7 +1052,7 @@ class AppLocalizationsEn extends AppLocalizations { String get dateTab => 'Date'; @override - String get timeTab => 'Time'; + String get timeTab => 'Time Picker'; @override String get confirm => 'Confirm'; diff --git a/apps/lib/l10n/app_localizations_zh.dart b/apps/lib/l10n/app_localizations_zh.dart index 16c9b97..51ab4fd 100644 --- a/apps/lib/l10n/app_localizations_zh.dart +++ b/apps/lib/l10n/app_localizations_zh.dart @@ -187,15 +187,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get readAllFirst => '请先阅读完整内容'; - @override - String get categoryCareer => '事业学业'; - - @override - String get categoryLove => '情感婚姻'; - - @override - String get categoryMoney => '财富投资'; - @override String get signBest => '上上签'; @@ -568,6 +559,99 @@ class AppLocalizationsZh extends AppLocalizations { String get divinationManualGuideStep3 => '记录每次结果,按照「字面在上还是花面在上」记录。重复六次,从下往上记录。'; + @override + String get autoGuideStep1Title => '自动起卦'; + + @override + String get autoGuideStep1Body => '无需铜钱,摇动手机或点击按钮即可完成起卦。每次摇动后,三枚铜币将自动旋转并显示结果。'; + + @override + String get autoGuideStep2Title => '开始摇卦'; + + @override + String get autoGuideStep2Body => '点击「开始摇卦」按钮,或直接摇动手机。铜币将自动旋转3秒后停止。'; + + @override + String get autoGuideStep3Title => '自动记录'; + + @override + String get autoGuideStep3Body => '每摇一次,对应的爻位会自动记录。重复六次,即可完成全部六爻。'; + + @override + String get autoGuideStep4Title => '分析卦象'; + + @override + String get autoGuideStep4Body => '六次完成后,「分析卦象」按钮将闪烁提示。点击即可查看卦象解读。'; + + @override + String get manualGuideStep1Title => '手动起卦'; + + @override + String get manualGuideStep1Body => '准备三枚相同的钱币。每次记录一爻,按从下往上的顺序共记录六爻。'; + + @override + String get manualGuideStep2Title => '确认时间'; + + @override + String get manualGuideStep2Body => '先确认起卦时间。如需调整,点击右侧「修改」。'; + + @override + String get manualGuideStep3Title => '依次录入六爻'; + + @override + String get manualGuideStep3Body => '从初爻开始逐条选择,未完成前下一爻不可点。每条会弹出三枚钱币选择面板。'; + + @override + String get manualGuideStep4Title => '开始分析'; + + @override + String get manualGuideStep4Body => '六爻都填完后,下方「分析卦象」按钮会闪烁提示,点击即可解卦。'; + + @override + String get yaoNameFirst => '初爻'; + + @override + String get yaoNameSecond => '二爻'; + + @override + String get yaoNameThird => '三爻'; + + @override + String get yaoNameFourth => '四爻'; + + @override + String get yaoNameFifth => '五爻'; + + @override + String get yaoNameTop => '上爻'; + + @override + String get yaoYin => '阴'; + + @override + String get yaoYang => '阳'; + + @override + String get yaoYoungYin => '少阴'; + + @override + String get yaoYoungYang => '少阳'; + + @override + String get yaoOldYin => '老阴'; + + @override + String get yaoOldYang => '老阳'; + + @override + String get yaoMovingSuffix => '(变)'; + + @override + String get autoCoinFaceZi => '字'; + + @override + String get autoCoinFaceHua => '花'; + @override String get divinationIAcknowledge => '我知道了'; @@ -693,6 +777,62 @@ class AppLocalizationsZh extends AppLocalizations { String get resultWarning => '卦象解读结果均由AI生成,仅供娱乐参考,切不可作为商业、医疗等专业领域的决策依据。理性看待卦象,自由掌握人生。'; + @override + String get followUpEntryHint => '可针对本次解卦继续追问 1 次'; + + @override + String get followUpEntryAction => '追问'; + + @override + String get followUpViewHistory => '查看历史记录'; + + @override + String get followUpScreenTitle => '继续追问'; + + @override + String get followUpEmpty => '暂无消息'; + + @override + String get followUpQuotaUsed => '本次会话追问次数已用完'; + + @override + String get followUpInputHint => '输入你想继续追问的问题'; + + @override + String get followUpHoldToSpeak => '按住说话'; + + @override + String get followUpRecording => '松开发送'; + + @override + String get followUpRecordingHint => '上滑取消'; + + @override + String get followUpTranscribing => '语音转文字中...'; + + @override + String get followUpGenerating => '正在生成回复...'; + + @override + String get followUpStepWorker => '正在分析卦象并生成回复'; + + @override + String followUpStepGeneric(String stepName) { + return '正在处理:$stepName'; + } + + @override + String get errorAudioUnsupportedFormat => '音频格式不支持,请使用 wav'; + + @override + String get errorAudioTooLarge => '音频文件过大,请缩短录音时长'; + + @override + String get errorAudioEmpty => '未检测到有效语音,请重试'; + + @override + String get errorAsrUnavailable => '语音识别服务暂不可用,请稍后重试'; + @override String get transitionPreparing => '天机推演中'; diff --git a/apps/lib/l10n/app_zh.arb b/apps/lib/l10n/app_zh.arb index 231fd78..7a0347b 100644 --- a/apps/lib/l10n/app_zh.arb +++ b/apps/lib/l10n/app_zh.arb @@ -71,9 +71,6 @@ "scrollHint": "请向下滚动阅读全部内容", "understood": "我已了解", "readAllFirst": "请先阅读完整内容", - "categoryCareer": "事业学业", - "categoryLove": "情感婚姻", - "categoryMoney": "财富投资", "signBest": "上上签", "signGood": "中上签", "signNormal": "中下签", @@ -233,6 +230,37 @@ "divinationManualGuideStep1": "左侧为花面,右侧为字面。准备三枚相同的钱币,任何类似款式均可。", "divinationManualGuideStep2": "双手捧起钱币,闭目心中默念所问之事,然后抛掷钱币于桌面,记录字面和花面出现的次数。", "divinationManualGuideStep3": "记录每次结果,按照「字面在上还是花面在上」记录。重复六次,从下往上记录。", + "autoGuideStep1Title": "自动起卦", + "autoGuideStep1Body": "无需铜钱,摇动手机或点击按钮即可完成起卦。每次摇动后,三枚铜币将自动旋转并显示结果。", + "autoGuideStep2Title": "开始摇卦", + "autoGuideStep2Body": "点击「开始摇卦」按钮,或直接摇动手机。铜币将自动旋转3秒后停止。", + "autoGuideStep3Title": "自动记录", + "autoGuideStep3Body": "每摇一次,对应的爻位会自动记录。重复六次,即可完成全部六爻。", + "autoGuideStep4Title": "分析卦象", + "autoGuideStep4Body": "六次完成后,「分析卦象」按钮将闪烁提示。点击即可查看卦象解读。", + "manualGuideStep1Title": "手动起卦", + "manualGuideStep1Body": "准备三枚相同的钱币。每次记录一爻,按从下往上的顺序共记录六爻。", + "manualGuideStep2Title": "确认时间", + "manualGuideStep2Body": "先确认起卦时间。如需调整,点击右侧「修改」。", + "manualGuideStep3Title": "依次录入六爻", + "manualGuideStep3Body": "从初爻开始逐条选择,未完成前下一爻不可点。每条会弹出三枚钱币选择面板。", + "manualGuideStep4Title": "开始分析", + "manualGuideStep4Body": "六爻都填完后,下方「分析卦象」按钮会闪烁提示,点击即可解卦。", + "yaoNameFirst": "初爻", + "yaoNameSecond": "二爻", + "yaoNameThird": "三爻", + "yaoNameFourth": "四爻", + "yaoNameFifth": "五爻", + "yaoNameTop": "上爻", + "yaoYin": "阴", + "yaoYang": "阳", + "yaoYoungYin": "少阴", + "yaoYoungYang": "少阳", + "yaoOldYin": "老阴", + "yaoOldYang": "老阳", + "yaoMovingSuffix": "(变)", + "autoCoinFaceZi": "字", + "autoCoinFaceHua": "花", "divinationIAcknowledge": "我知道了", "divinationClose": "关闭", "divinationModify": "修改", @@ -290,6 +318,31 @@ "signTypeXiaXia": "下下签", "resultCopy": "复制", "resultWarning": "卦象解读结果均由AI生成,仅供娱乐参考,切不可作为商业、医疗等专业领域的决策依据。理性看待卦象,自由掌握人生。", + "followUpEntryHint": "可针对本次解卦继续追问 1 次", + "followUpEntryAction": "追问", + "followUpViewHistory": "查看历史记录", + "followUpScreenTitle": "继续追问", + "followUpEmpty": "暂无消息", + "followUpQuotaUsed": "本次会话追问次数已用完", + "followUpInputHint": "输入你想继续追问的问题", + "followUpHoldToSpeak": "按住说话", + "followUpRecording": "松开发送", + "followUpRecordingHint": "上滑取消", + "followUpTranscribing": "语音转文字中...", + "followUpGenerating": "正在生成回复...", + "followUpStepWorker": "正在分析卦象并生成回复", + "followUpStepGeneric": "正在处理:{stepName}", + "@followUpStepGeneric": { + "placeholders": { + "stepName": { + "type": "String" + } + } + }, + "errorAudioUnsupportedFormat": "音频格式不支持,请使用 wav", + "errorAudioTooLarge": "音频文件过大,请缩短录音时长", + "errorAudioEmpty": "未检测到有效语音,请重试", + "errorAsrUnavailable": "语音识别服务暂不可用,请稍后重试", "transitionPreparing": "天机推演中", "transitionDeriving": "正在解卦", "transitionDone": "解卦完成\n点击查看", diff --git a/apps/lib/shared/widgets/divination/divination_summary_card.dart b/apps/lib/shared/widgets/divination/divination_summary_card.dart new file mode 100644 index 0000000..a97aa8c --- /dev/null +++ b/apps/lib/shared/widgets/divination/divination_summary_card.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; + +import '../../theme/design_tokens.dart'; + +class DivinationSummaryTagData { + const DivinationSummaryTagData({ + required this.label, + required this.background, + required this.foreground, + }); + + final String label; + final Color background; + final Color foreground; +} + +class DivinationSummaryCard extends StatelessWidget { + const DivinationSummaryCard({ + super.key, + required this.question, + required this.leading, + required this.tags, + this.leadingBackgroundColor, + this.onTap, + this.questionMaxLines = 1, + }); + + final String question; + final Widget leading; + final List tags; + final Color? leadingBackgroundColor; + final VoidCallback? onTap; + final int questionMaxLines; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final card = Card( + margin: EdgeInsets.zero, + color: colors.surface, + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: + leadingBackgroundColor ?? + colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: Center(child: leading), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Text( + question, + maxLines: questionMaxLines, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + if (tags.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.sm), + Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: tags + .map( + (tag) => _DivinationSummaryTag( + label: tag.label, + background: tag.background, + foreground: tag.foreground, + ), + ) + .toList(growable: false), + ), + ], + ], + ), + ), + ); + + if (onTap == null) { + return SizedBox(width: double.infinity, child: card); + } + + return SizedBox( + width: double.infinity, + child: Material( + color: colors.surface.withValues(alpha: 0), + child: InkWell( + borderRadius: BorderRadius.circular(AppRadius.md), + onTap: onTap, + child: card, + ), + ), + ); + } +} + +class _DivinationSummaryTag extends StatelessWidget { + const _DivinationSummaryTag({ + required this.label, + required this.background, + required this.foreground, + }); + + final String label; + final Color background; + final Color foreground; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: Text( + label, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: foreground), + ), + ); + } +} diff --git a/apps/lib/shared/widgets/divination/divination_terms.dart b/apps/lib/shared/widgets/divination/divination_terms.dart index b27184b..129bed8 100644 --- a/apps/lib/shared/widgets/divination/divination_terms.dart +++ b/apps/lib/shared/widgets/divination/divination_terms.dart @@ -1,4 +1,5 @@ import '../../../features/divination/data/models/divination_params.dart'; +import '../../../l10n/app_localizations.dart'; abstract final class DivinationTerms { static const yaoNames = ['初爻', '二爻', '三爻', '四爻', '五爻', '上爻']; @@ -45,6 +46,32 @@ abstract final class DivinationTerms { static const yaoXiang = '爻象'; static const qiGua = '起卦'; static const jieGua = '解卦'; + + static String yaoName(AppLocalizations l10n, int index) { + return switch (index) { + 0 => l10n.yaoNameFirst, + 1 => l10n.yaoNameSecond, + 2 => l10n.yaoNameThird, + 3 => l10n.yaoNameFourth, + 4 => l10n.yaoNameFifth, + 5 => l10n.yaoNameTop, + _ => '', + }; + } + + static String yinYangLabel(AppLocalizations l10n, bool isYang) { + return isYang ? l10n.yaoYang : l10n.yaoYin; + } + + static String yaoTypeLabel(AppLocalizations l10n, YaoType type) { + return switch (type) { + YaoType.youngYang => l10n.yaoYoungYang, + YaoType.youngYin => l10n.yaoYoungYin, + YaoType.oldYang => l10n.yaoOldYang, + YaoType.oldYin => l10n.yaoOldYin, + YaoType.undetermined => '', + }; + } } enum YaoTypeLabel { youngYang, youngYin, oldYang, oldYin } diff --git a/apps/lib/shared/widgets/divination/yao_legend.dart b/apps/lib/shared/widgets/divination/yao_legend.dart index 6eee144..55098fc 100644 --- a/apps/lib/shared/widgets/divination/yao_legend.dart +++ b/apps/lib/shared/widgets/divination/yao_legend.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../../../features/divination/data/models/divination_params.dart'; +import '../../../l10n/app_localizations.dart'; import '../../theme/design_tokens.dart'; import 'divination_terms.dart'; @@ -8,6 +10,7 @@ class YaoLegend extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; final style = Theme.of(context).textTheme.bodySmall; final mutedTextColor = Theme.of( context, @@ -17,19 +20,19 @@ class YaoLegend extends StatelessWidget { runSpacing: AppSpacing.xs, children: [ Text( - '\u2014 ${DivinationTerms.yinYang[true]}', + '\u2014 ${DivinationTerms.yinYangLabel(l10n, true)}', style: style?.copyWith(color: mutedTextColor), ), Text( - '-- ${DivinationTerms.yinYang[false]}', + '-- ${DivinationTerms.yinYangLabel(l10n, false)}', style: style?.copyWith(color: mutedTextColor), ), Text( - '${DivinationTerms.changeMarkOldYang} ${DivinationTerms.yaoTypeLabels[YaoTypeLabel.oldYang]}(变)', + '${DivinationTerms.changeMarkOldYang} ${DivinationTerms.yaoTypeLabel(l10n, YaoType.oldYang)}${l10n.yaoMovingSuffix}', style: style?.copyWith(color: mutedTextColor), ), Text( - '${DivinationTerms.changeMarkOldYin} ${DivinationTerms.yaoTypeLabels[YaoTypeLabel.oldYin]}(变)', + '${DivinationTerms.changeMarkOldYin} ${DivinationTerms.yaoTypeLabel(l10n, YaoType.oldYin)}${l10n.yaoMovingSuffix}', style: style?.copyWith(color: mutedTextColor), ), ], diff --git a/apps/lib/shared/widgets/message_composer.dart b/apps/lib/shared/widgets/message_composer.dart new file mode 100644 index 0000000..eed4d84 --- /dev/null +++ b/apps/lib/shared/widgets/message_composer.dart @@ -0,0 +1,249 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import '../theme/design_tokens.dart'; +import 'app_loading_indicator.dart'; + +enum MessageComposerMode { text, holdToSpeak } + +enum MessageComposerProcess { idle, recording, transcribing } + +class MessageComposer extends StatelessWidget { + const MessageComposer({ + super.key, + required this.mode, + required this.process, + required this.hasMessage, + required this.isWaitingAgent, + required this.iconSize, + required this.composerMinHeight, + required this.onTapRightAction, + required this.onHoldToSpeakStart, + required this.onHoldToSpeakEnd, + required this.onHoldToSpeakMoveUpdate, + required this.onHoldToSpeakCancel, + required this.textInputChild, + required this.holdToSpeakText, + required this.recordingText, + required this.transcribingText, + required this.recordingHintText, + this.showRecordingInlineFeedback = true, + }); + + final MessageComposerMode mode; + final MessageComposerProcess process; + final bool hasMessage; + final bool isWaitingAgent; + final double iconSize; + final double composerMinHeight; + final VoidCallback onTapRightAction; + final VoidCallback onHoldToSpeakStart; + final VoidCallback onHoldToSpeakEnd; + final ValueChanged onHoldToSpeakMoveUpdate; + final VoidCallback onHoldToSpeakCancel; + final Widget textInputChild; + final String holdToSpeakText; + final String recordingText; + final String transcribingText; + final String recordingHintText; + final bool showRecordingInlineFeedback; + + bool get _isHoldMode => mode == MessageComposerMode.holdToSpeak; + + bool get _isRecording => process == MessageComposerProcess.recording; + + bool get _isTranscribing => process == MessageComposerProcess.transcribing; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: colorScheme.outlineVariant, width: 0.5), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded(child: _buildCenterArea(colorScheme)), + const SizedBox(width: AppSpacing.sm), + _buildRightAction(colorScheme), + ], + ), + ); + } + + Widget _buildRightAction(ColorScheme colorScheme) { + if (_isTranscribing) { + return SizedBox( + width: iconSize, + height: iconSize, + child: AppLoadingIndicator( + variant: AppLoadingVariant.inline, + size: iconSize, + strokeWidth: AppSpacing.xs / 2, + color: colorScheme.primary, + trackColor: colorScheme.primaryContainer, + ), + ); + } + if (_isRecording) { + return Container( + width: iconSize, + height: iconSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorScheme.error.withValues(alpha: 0.1), + ), + child: Icon( + Icons.fiber_manual_record, + size: iconSize * 0.6, + color: colorScheme.error, + ), + ); + } + return IconButton( + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: BoxConstraints(minWidth: iconSize, minHeight: iconSize), + onPressed: onTapRightAction, + icon: Icon( + _resolveRightIcon(), + size: iconSize, + color: _resolveRightIconColor(colorScheme), + ), + ); + } + + Widget _buildCenterArea(ColorScheme colorScheme) { + return SizedBox( + height: composerMinHeight, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeOut, + child: _isHoldMode + ? _buildHoldToSpeakArea( + key: const ValueKey('hold_mode'), + colorScheme: colorScheme, + ) + : _buildTextInputArea(key: const ValueKey('text_mode')), + ), + ); + } + + Widget _buildTextInputArea({required Key key}) { + return SizedBox(key: key, height: composerMinHeight, child: textInputChild); + } + + Widget _buildHoldToSpeakArea({ + required Key key, + required ColorScheme colorScheme, + }) { + return RawGestureDetector( + behavior: HitTestBehavior.opaque, + gestures: { + LongPressGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => LongPressGestureRecognizer( + duration: const Duration(milliseconds: 120), + ), + (instance) { + instance.onLongPressStart = (details) => onHoldToSpeakStart(); + instance.onLongPressEnd = (details) => onHoldToSpeakEnd(); + instance.onLongPressMoveUpdate = onHoldToSpeakMoveUpdate; + instance.onLongPressCancel = onHoldToSpeakCancel; + }, + ), + }, + child: Container( + key: key, + width: double.infinity, + height: composerMinHeight, + alignment: Alignment.center, + child: _buildHoldToSpeakContent(colorScheme), + ), + ); + } + + Widget _buildHoldToSpeakContent(ColorScheme colorScheme) { + if (_isRecording) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorScheme.error, + ), + ), + const SizedBox(width: AppSpacing.sm), + Text( + recordingText, + style: TextStyle( + color: colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + ), + ], + ); + } + + if (_isTranscribing) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.primary, + ), + ), + const SizedBox(width: AppSpacing.sm), + Text( + transcribingText, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + ], + ); + } + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.mic, size: 16, color: colorScheme.onSurfaceVariant), + const SizedBox(width: AppSpacing.sm), + Text( + holdToSpeakText, + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + ], + ); + } + + IconData _resolveRightIcon() { + if (isWaitingAgent) { + return Icons.stop_rounded; + } + if (hasMessage) { + return Icons.send_rounded; + } + return _isHoldMode ? Icons.keyboard_rounded : Icons.mic_rounded; + } + + Color _resolveRightIconColor(ColorScheme colorScheme) { + if (isWaitingAgent || hasMessage) { + return colorScheme.primary; + } + return colorScheme.onSurfaceVariant; + } +} diff --git a/apps/pubspec.yaml b/apps/pubspec.yaml index 789b8db..2161c8b 100644 --- a/apps/pubspec.yaml +++ b/apps/pubspec.yaml @@ -40,7 +40,9 @@ dependencies: sensors_plus: ^6.1.1 vibration: ^3.1.3 flutter_markdown: ^0.7.7+1 + onboarding_overlay: ^3.2.3 image_picker: ^1.1.2 + record: ^6.1.1 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. diff --git a/backend/src/core/agentscope/events/agui_codec.py b/backend/src/core/agentscope/events/agui_codec.py index e85a29e..115ae50 100644 --- a/backend/src/core/agentscope/events/agui_codec.py +++ b/backend/src/core/agentscope/events/agui_codec.py @@ -11,8 +11,6 @@ from ag_ui.core import ( StepStartedEvent, StepFinishedEvent, ) -from core.agentscope.runtime.ui_compiler import compile as compile_ui_hints -from schemas.agent.ui_hints import UiHintsPayload if TYPE_CHECKING: pass @@ -55,15 +53,6 @@ def _sanitize_agui_event(event: dict[str, Any]) -> dict[str, Any]: payload = dict(event) event_type = str(payload.get("type", "")).strip().upper() if event_type == EventType.TEXT_MESSAGE_END.value: - ui_hints = payload.get("ui_hints") - if ui_hints is not None: - try: - ui_hints_payload = UiHintsPayload.model_validate(ui_hints) - ui_schema = compile_ui_hints(ui_hints_payload) - payload["ui_schema"] = ui_schema - except Exception: - pass - payload.pop("ui_hints", None) for key in ( "inputTokens", "outputTokens", @@ -72,9 +61,6 @@ def _sanitize_agui_event(event: dict[str, Any]) -> dict[str, Any]: "model", ): payload.pop(key, None) - if event_type == EventType.TOOL_CALL_RESULT.value: - payload.pop("ui_hints", None) - payload.pop("ui_schema", None) return payload @@ -182,7 +168,7 @@ def to_agui_wire_event(event: dict[str, Any] | BaseEvent) -> dict[str, Any]: tool_result_payload["threadId"] = thread_id if isinstance(run_id, str) and run_id: tool_result_payload["runId"] = run_id - reserved = {"type", "threadId", "runId", "ui_hints", "ui_schema"} + reserved = {"type", "threadId", "runId"} tool_result_payload.update({k: v for k, v in data.items() if k not in reserved}) return tool_result_payload diff --git a/backend/src/core/agentscope/events/store.py b/backend/src/core/agentscope/events/store.py index 63ecb69..321c894 100644 --- a/backend/src/core/agentscope/events/store.py +++ b/backend/src/core/agentscope/events/store.py @@ -13,7 +13,7 @@ from core.logging import get_logger from schemas.agent.forwarded_props import RuntimeMode from schemas.enums import AgentChatMessageRole, AgentChatSessionStatus from schemas.agent.system_agent import AgentType -from schemas.agent.runtime_models import AgentOutput, ToolAgentOutput +from schemas.agent.runtime_models import AgentOutput, FollowUpOutput, ToolAgentOutput from schemas.agent.visibility import SystemVisibilityBit, bit_mask from schemas.domain.chat_message import AgentChatMessageMetadata @@ -103,8 +103,6 @@ class SqlAlchemyEventStore: session_repo: SessionRepository, message_repo: MessageRepository, ) -> None: - message_id_raw = self._event_value(event, "messageId") - message_id = message_id_raw if isinstance(message_id_raw, str) else "" content_value = self._event_value(event, "answer") content = content_value if isinstance(content_value, str) else "" if not content: @@ -122,17 +120,22 @@ class SqlAlchemyEventStore: if run_id_value is None: return + runtime_mode = self._resolve_runtime_mode(event=event) + worker_output_fields = ( - "status", - "sign_level", - "conclusion", - "focus_points", - "advice", - "keywords", - "answer", - "error", - "divination_derived", - "ui_hints", + ( + "status", + "sign_level", + "conclusion", + "focus_points", + "advice", + "keywords", + "answer", + "error", + "divination_derived", + ) + if runtime_mode == RuntimeMode.CHAT.value + else ("status", "answer", "error") ) worker_output_payload: dict[str, object] = {} for field in worker_output_fields: @@ -143,21 +146,16 @@ class SqlAlchemyEventStore: if not worker_output_payload: return - try: + if runtime_mode == RuntimeMode.CHAT.value: worker_output = AgentOutput.model_validate(worker_output_payload) - agent_type = AgentType.WORKER - metadata_model = AgentChatMessageMetadata( - run_id=run_id_value, - agent_type=agent_type, - agent_output=worker_output, - ) - except Exception: - self._logger.warning( - "invalid worker metadata payload", - run_id=run_id_value, - message_id=message_id, - ) - return + else: + worker_output = FollowUpOutput.model_validate(worker_output_payload) + agent_type = AgentType.WORKER + metadata_model = AgentChatMessageMetadata( + run_id=run_id_value, + agent_type=agent_type, + agent_output=worker_output, + ) role_value = self._event_value(event, "role") if not isinstance(role_value, str): @@ -345,29 +343,22 @@ class SqlAlchemyEventStore: if isinstance(metadata, dict): message_payload["metadata"] = metadata - try: - context_cache = create_context_messages_cache() - await context_cache.append_message( - thread_id=str(session_id), - runtime_mode=self._resolve_runtime_mode(event=event), - visibility_mask=visibility_mask, - message=message_payload, - ) - except Exception as exc: - self._logger.warning( - "Failed to append context cache message from event", - thread_id=str(session_id), - error=str(exc), - ) + context_cache = create_context_messages_cache() + await context_cache.append_message( + thread_id=str(session_id), + runtime_mode=self._resolve_runtime_mode(event=event), + visibility_mask=visibility_mask, + message=message_payload, + ) @staticmethod def _resolve_runtime_mode(*, event: dict[str, Any]) -> str: raw = event.get("runtime_mode") if isinstance(raw, str): normalized = raw.strip().lower() - if normalized: + if normalized in (RuntimeMode.CHAT.value, RuntimeMode.FOLLOW_UP.value): return normalized - return RuntimeMode.CHAT.value + raise ValueError("invalid runtime_mode in event payload") @staticmethod def _resolve_message_timestamp(message: Any) -> str: diff --git a/backend/src/core/agentscope/runtime/runner.py b/backend/src/core/agentscope/runtime/runner.py index 79c37f2..78ac94f 100644 --- a/backend/src/core/agentscope/runtime/runner.py +++ b/backend/src/core/agentscope/runtime/runner.py @@ -35,6 +35,7 @@ from schemas.agent.forwarded_props import ( ) from schemas.domain.divination import DerivedDivinationData from schemas.agent.runtime_models import ( + FollowUpOutput, WorkerAgentOutputLite, resolve_worker_output_model, ) @@ -102,21 +103,23 @@ 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 - ) - }, - ) + derived_divination: DerivedDivinationData | None = None + if runtime_mode == RuntimeMode.CHAT: + 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, @@ -208,9 +211,11 @@ class AgentScopeRunner: stage_config: SystemAgentRuntimeConfig, runtime_client_time: ClientTimeContext | None, runtime_mode: RuntimeMode, - derived_divination: DerivedDivinationData, - ) -> WorkerAgentOutputLite: - worker_output_model = resolve_worker_output_model() + derived_divination: DerivedDivinationData | None, + ) -> WorkerAgentOutputLite | FollowUpOutput: + worker_output_model = resolve_worker_output_model( + runtime_mode=runtime_mode.value + ) await self._emit_step_event( pipeline=pipeline, run_input=run_input, @@ -252,11 +257,11 @@ class AgentScopeRunner: toolkit: Any, run_input: RunAgentInput, stage_config: SystemAgentRuntimeConfig, - worker_output_model: type[WorkerAgentOutputLite], + worker_output_model: type[WorkerAgentOutputLite | FollowUpOutput], pipeline: PipelineLike, runtime_client_time: ClientTimeContext | None, runtime_mode: RuntimeMode, - derived_divination: DerivedDivinationData, + derived_divination: DerivedDivinationData | None, ) -> StageExecutionResult: tracking_model = self._build_model(stage_config=stage_config) formatter = OpenAIChatFormatter() @@ -292,12 +297,11 @@ class AgentScopeRunner: usage_summary=tracking_model.usage_summary(), ) await emitter.emit_final_text_end( - worker_output={ - **worker_payload.model_dump(mode="json", exclude_none=True), - "divination_derived": derived_divination.model_dump( - mode="json", by_alias=True, exclude_none=True - ), - }, + worker_output=self._build_final_worker_output( + worker_payload=worker_payload, + runtime_mode=runtime_mode, + derived_divination=derived_divination, + ), response_metadata=response_metadata, ) return StageExecutionResult( @@ -316,15 +320,18 @@ class AgentScopeRunner: *, context_messages: list[Msg], run_input: RunAgentInput, - derived_divination: DerivedDivinationData, + derived_divination: DerivedDivinationData | None, ) -> list[Msg]: if context_messages: last = context_messages[-1] if last.role == "user": return context_messages - _, _ = extract_latest_user_payload(run_input) - user_text = build_divination_user_prompt(derived=derived_divination) + _, latest_user_text = extract_latest_user_payload(run_input) + if derived_divination is None: + user_text = latest_user_text + else: + user_text = build_divination_user_prompt(derived=derived_divination) user_blocks = [{"type": "text", "text": user_text}] if ( user_blocks @@ -422,12 +429,23 @@ class AgentScopeRunner: @staticmethod def _resolve_runtime_mode(*, run_input: RunAgentInput) -> RuntimeMode: - try: - return parse_forwarded_props_runtime_mode( - getattr(run_input, "forwarded_props", None) + return parse_forwarded_props_runtime_mode( + getattr(run_input, "forwarded_props", None) + ) + + @staticmethod + def _build_final_worker_output( + *, + worker_payload: WorkerAgentOutputLite | FollowUpOutput, + runtime_mode: RuntimeMode, + derived_divination: DerivedDivinationData | None, + ) -> dict[str, Any]: + payload = worker_payload.model_dump(mode="json", exclude_none=True) + if runtime_mode == RuntimeMode.CHAT and derived_divination is not None: + payload["divination_derived"] = derived_divination.model_dump( + mode="json", by_alias=True, exclude_none=True ) - except ValueError: - return RuntimeMode.CHAT + return payload @staticmethod def _resolve_provider_api_key(*, factory_name: str) -> str: diff --git a/backend/src/core/agentscope/runtime/stage_emitter.py b/backend/src/core/agentscope/runtime/stage_emitter.py index f8aaaa3..6231c78 100644 --- a/backend/src/core/agentscope/runtime/stage_emitter.py +++ b/backend/src/core/agentscope/runtime/stage_emitter.py @@ -57,19 +57,21 @@ class PipelineStageEmitter: "role": "assistant", "stage": self._stage, "status": worker_output.get("status"), - "sign_level": worker_output.get("sign_level"), - "conclusion": worker_output.get("conclusion", []), - "focus_points": worker_output.get("focus_points", []), - "advice": worker_output.get("advice", []), - "keywords": worker_output.get("keywords", []), "answer": worker_output.get("answer", ""), "error": worker_output.get("error"), - "divination_derived": worker_output.get("divination_derived"), **response_metadata, } - ui_hints = worker_output.get("ui_hints") - if ui_hints is not None: - payload["ui_hints"] = ui_hints + if self._runtime_mode == "chat": + payload.update( + { + "sign_level": worker_output.get("sign_level"), + "conclusion": worker_output.get("conclusion", []), + "focus_points": worker_output.get("focus_points", []), + "advice": worker_output.get("advice", []), + "keywords": worker_output.get("keywords", []), + "divination_derived": worker_output.get("divination_derived"), + } + ) await self._emit("TEXT_MESSAGE_END", payload) async def _emit_text_events_from_msg(self, msg: Msg) -> None: diff --git a/backend/src/core/agentscope/runtime/tasks.py b/backend/src/core/agentscope/runtime/tasks.py index f9eb7a5..2ca24ae 100644 --- a/backend/src/core/agentscope/runtime/tasks.py +++ b/backend/src/core/agentscope/runtime/tasks.py @@ -6,6 +6,7 @@ from typing import Any, cast from uuid import UUID from agentscope.message import Msg +from pydantic import TypeAdapter from core.agentscope.caches import create_user_context_cache from core.agentscope.caches.attachment_content_cache import ( create_attachment_content_cache, @@ -31,6 +32,7 @@ from schemas.agent.forwarded_props import ( parse_forwarded_props_runtime_mode, ) from schemas.agent.runtime_config import MessageContextConfig, RuntimeConfig +from schemas.agent.runtime_models import RuntimeAgentOutput from schemas.domain.chat_message import ( AgentChatMessageMetadata, extract_user_message_attachments, @@ -46,6 +48,7 @@ from v1.points.service import PointsService logger = get_logger("core.agentscope.runtime.tasks") _MAX_CONTEXT_ATTACHMENTS = 3 +_RUNTIME_AGENT_OUTPUT_ADAPTER = TypeAdapter(RuntimeAgentOutput) def _serialize_tool_agent_output( @@ -75,6 +78,72 @@ def _serialize_tool_agent_output( ) +def _serialize_assistant_context_from_metadata( + *, + metadata: AgentChatMessageMetadata | dict[str, object] | None, + fallback_content: str, +) -> str: + if metadata is None: + return fallback_content + + try: + resolved_metadata = ( + metadata + if isinstance(metadata, AgentChatMessageMetadata) + else AgentChatMessageMetadata.model_validate(metadata) + ) + except Exception: + return fallback_content + + agent_output = resolved_metadata.agent_output + if agent_output is None and isinstance(metadata, dict): + raw = metadata.get("agent_output") + if raw is not None: + try: + agent_output = _RUNTIME_AGENT_OUTPUT_ADAPTER.validate_python(raw) + except Exception: + return fallback_content + + if agent_output is None: + return fallback_content + + payload = agent_output.model_dump(mode="json", by_alias=True, exclude_none=True) + lines: list[str] = ["[assistant_context]"] + + answer = payload.get("answer") + if isinstance(answer, str) and answer: + lines.append(f"answer: {answer}") + + sign_level = payload.get("sign_level") + if isinstance(sign_level, str) and sign_level: + lines.append(f"sign_level: {sign_level}") + + focus_points = payload.get("focus_points") + if isinstance(focus_points, list) and focus_points: + lines.append("focus_points:") + for item in focus_points: + if isinstance(item, str) and item: + lines.append(f"- {item}") + + divination_derived = payload.get("divination_derived") + if isinstance(divination_derived, dict) and divination_derived: + lines.append("divination_derived:") + key_map = ( + ("guaName", "gua_name"), + ("targetGuaName", "target_gua_name"), + ("binaryCode", "binary_code"), + ("changedBinaryCode", "changed_binary_code"), + ) + for key, label in key_map: + value = divination_derived.get(key) + if isinstance(value, str) and value: + lines.append(f" {label}: {value}") + + if len(lines) <= 1: + return fallback_content + return "\n".join(lines) + + def _load_runtime() -> type[Any]: return AgentScopeRuntimeOrchestrator @@ -235,6 +304,12 @@ async def _build_recent_context_messages( continue content = tool_content + if role == "assistant": + content = _serialize_assistant_context_from_metadata( + metadata=metadata, + fallback_content=content, + ) + converted.append( Msg( name=role or "user", @@ -259,13 +334,9 @@ async def run_agentscope_task(command: dict[str, Any]) -> dict[str, object]: raise ValueError("run_input is required") run_input = parse_run_input(run_input_raw) - runtime_mode = RuntimeMode.CHAT - try: - runtime_mode = parse_forwarded_props_runtime_mode( - getattr(run_input, "forwarded_props", None) - ) - except ValueError: - runtime_mode = RuntimeMode.CHAT + runtime_mode = parse_forwarded_props_runtime_mode( + getattr(run_input, "forwarded_props", None) + ) runtime_config = RuntimeConfig.model_validate(runtime_config_raw or {}) thread_id = run_input.thread_id run_id = run_input.run_id diff --git a/backend/src/core/agentscope/runtime/ui_compiler.py b/backend/src/core/agentscope/runtime/ui_compiler.py deleted file mode 100644 index 59e4c1a..0000000 --- a/backend/src/core/agentscope/runtime/ui_compiler.py +++ /dev/null @@ -1,638 +0,0 @@ -""" -UiCompiler - 将描述性 UiHints 编译为渲染性 UiSchemaRenderer - -设计原则: -- 机械转换: 不依赖复杂语义理解 -- 尽量无损: hints 中出现的主要内容字段尽量保留 -- 弱模板: intent 只影响默认布局,不决定字段是否丢失 -""" - -from __future__ import annotations - -from typing import Any - -from schemas.agent.ui_hints import ( - UiHintAction, - UiHintActionTarget, - UiHintIcon, - UiHintIntent, - UiHintKvItem, - UiHintListItem, - UiHintSection, - UiHintsPayload, - UiHintStatus, - UiHintTextFormat, -) - -# ============================================================ -# Helpers -# ============================================================ - - -def _compact(value: Any) -> Any: - """递归移除 dict/list 中的 None,保留 False/0/空字符串/空列表按原样存在。""" - if isinstance(value, dict): - return {k: _compact(v) for k, v in value.items() if v is not None} - if isinstance(value, list): - return [_compact(v) for v in value] - return value - - -def _non_empty(nodes: list[dict[str, Any] | None]) -> list[dict[str, Any]]: - return [node for node in nodes if node is not None] - - -def compile_status(status: UiHintStatus) -> str: - return status.value - - -def _status_badge_needed(intent: UiHintIntent, status: UiHintStatus) -> bool: - return intent == UiHintIntent.STATUS or status != UiHintStatus.INFO - - -def _status_label(status: str) -> str: - normalized = status.strip().lower() - if not normalized: - return "ui.status.info" - return f"ui.status.{normalized}" - - -# ============================================================ -# Action Compilation -# ============================================================ - - -def compile_action(action: UiHintActionTarget) -> dict[str, Any]: - """ - 编译 action target。 - 关键修复: - - 使用 by_alias=True,避免 toolId/successMessage/submitTo 丢失 - """ - action_dict = action.model_dump(by_alias=True, exclude_none=True) - action_type = action_dict["type"] - - if action_type == "navigation": - result: dict[str, Any] = { - "type": "navigation", - "path": action_dict["path"], - } - if "params" in action_dict: - result["params"] = action_dict["params"] - return result - - if action_type == "url": - return { - "type": "url", - "url": action_dict["url"], - "target": action_dict.get("target", "_blank"), - } - - if action_type == "event": - result = { - "type": "event", - "event": action_dict["event"], - } - if "payload" in action_dict: - result["payload"] = action_dict["payload"] - return result - - if action_type == "tool": - result = { - "type": "tool", - "toolId": action_dict["toolId"], - } - if "params" in action_dict: - result["params"] = action_dict["params"] - return result - - if action_type == "copy": - result = { - "type": "copy", - "content": action_dict["content"], - } - if "successMessage" in action_dict: - result["successMessage"] = action_dict["successMessage"] - return result - - if action_type == "payload": - result = { - "type": "payload", - "payload": action_dict["payload"], - } - if "submitTo" in action_dict: - result["submitTo"] = action_dict["submitTo"] - return result - - raise ValueError(f"Unknown action type: {action_type}") - - -def compile_button(action: UiHintAction) -> dict[str, Any]: - return _compact( - { - "type": "button", - "label": action.label, - "style": action.style.value if action.style else "primary", - "disabled": action.disabled, - "action": compile_action(action.action), - } - ) - - -# ============================================================ -# Small Node Compilation -# ============================================================ - - -def compile_icon_spec(icon: UiHintIcon) -> dict[str, Any]: - return _compact( - { - "source": icon.source.value, - "value": icon.value, - "color": icon.color, - "size": icon.size, - } - ) - - -def compile_icon_node(icon: UiHintIcon) -> dict[str, Any]: - return _compact( - { - "type": "icon", - "source": icon.source.value, - "value": icon.value, - "color": icon.color, - "size": icon.size, - } - ) - - -def compile_text( - content: str, - *, - role: str = "body", - format: UiHintTextFormat | str = UiHintTextFormat.PLAIN, - status: str | None = None, -) -> dict[str, Any]: - fmt = format.value if isinstance(format, UiHintTextFormat) else format - return _compact( - { - "type": "text", - "content": content, - "format": fmt, - "role": role, - "status": status, - } - ) - - -def compile_badge(label: str, status: str) -> dict[str, Any]: - return { - "type": "badge", - "label": label, - "status": status, - } - - -def compile_kv_item(item: UiHintKvItem) -> dict[str, Any]: - return _compact( - { - "key": item.key, - "label": item.label, - "value": item.value, - "copyable": item.copyable, - } - ) - - -def compile_kv(items: list[UiHintKvItem], columns: int = 1) -> dict[str, Any]: - return { - "type": "kv", - "items": [compile_kv_item(i) for i in items], - "columns": columns, - } - - -def compile_divider() -> dict[str, Any]: - return {"type": "divider", "inset": 0} - - -# ============================================================ -# Layout Compilation -# ============================================================ - - -def compile_stack( - children: list[dict[str, Any]], - *, - direction: str = "vertical", - gap: int = 12, - appearance: str = "plain", - align: str | None = None, - justify: str | None = None, - wrap: bool | None = None, - status: str | None = None, - node_id: str | None = None, -) -> dict[str, Any]: - return _compact( - { - "type": "stack", - "id": node_id, - "direction": direction, - "gap": gap, - "appearance": appearance, - "align": align, - "justify": justify, - "wrap": wrap, - "status": status, - "children": children, - } - ) - - -def compile_grid( - children: list[dict[str, Any]], - *, - columns: int, - gap: int = 12, - appearance: str = "plain", - status: str | None = None, - node_id: str | None = None, -) -> dict[str, Any]: - return _compact( - { - "type": "grid", - "id": node_id, - "columns": columns, - "gap": gap, - "appearance": appearance, - "status": status, - "children": children, - } - ) - - -def compile_card( - children: list[dict[str, Any]], *, status: str | None = None -) -> dict[str, Any]: - return compile_stack( - children, - direction="vertical", - gap=12, - appearance="card", - status=status, - ) - - -def compile_section( - *, - title: str | None = None, - description: str | None = None, - icon: UiHintIcon | None = None, - children: list[dict[str, Any]], - status: str | None = None, -) -> dict[str, Any]: - section_children: list[dict[str, Any]] = [] - - header_row_children: list[dict[str, Any]] = [] - if icon: - header_row_children.append(compile_icon_node(icon)) - if title: - header_row_children.append(compile_text(title, role="title")) - - if header_row_children: - section_children.append( - compile_stack( - header_row_children, - direction="horizontal", - gap=8, - align="center", - ) - ) - - if description: - section_children.append(compile_text(description, role="caption")) - - section_children.extend(children) - - return compile_stack( - section_children, - direction="vertical", - gap=12, - appearance="section", - status=status, - ) - - -# ============================================================ -# Block Compilation -# ============================================================ - - -def compile_action_row(actions: list[UiHintAction]) -> dict[str, Any] | None: - if not actions: - return None - buttons = [compile_button(a) for a in actions] - return compile_stack( - buttons, - direction="horizontal", - gap=8, - align="center", - wrap=True, - ) - - -def compile_list_item(item: UiHintListItem) -> dict[str, Any]: - lead_children: list[dict[str, Any]] = [compile_text(item.title, role="body")] - - if item.subtitle: - lead_children.append(compile_text(item.subtitle, role="caption")) - if item.description: - lead_children.append(compile_text(item.description, role="caption")) - - lead_block = compile_stack( - lead_children, - direction="vertical", - gap=4, - ) - - main_row_children: list[dict[str, Any]] = [] - if item.icon: - main_row_children.append(compile_icon_node(item.icon)) - main_row_children.append(lead_block) - - row = compile_stack( - main_row_children, - node_id=item.id, - direction="horizontal", - gap=8, - align="center", - ) - - trailing_children: list[dict[str, Any]] = [] - if item.status: - trailing_children.append( - compile_badge( - label=_status_label(item.status.value), - status=item.status.value, - ) - ) - - if trailing_children: - header = compile_stack( - [row, compile_stack(trailing_children, direction="horizontal", gap=8)], - direction="horizontal", - gap=8, - justify="space-between", - align="center", - ) - else: - header = row - - children: list[dict[str, Any]] = [header] - action_row = compile_action_row(item.actions) - if action_row: - children.append(action_row) - - return compile_stack( - children, - direction="vertical", - gap=8, - appearance="card" if item.actions or item.description else "plain", - status=item.status.value if item.status else None, - node_id=item.id, - ) - - -def compile_list_block(items: list[UiHintListItem]) -> dict[str, Any]: - return compile_stack( - [compile_list_item(item) for item in items], - direction="vertical", - gap=8, - ) - - -def compile_section_block( - section: UiHintSection, default_status: str -) -> dict[str, Any]: - """ - 修复点: - - 不再先把 title/description 塞进 children 再切片 - - 避免 description 重复输出 - """ - body_children: list[dict[str, Any]] = [] - - if section.content: - body_children.append( - compile_text( - section.content, - role="body", - format=section.content_format, - ) - ) - - if section.items: - body_children.append(compile_kv(section.items)) - - if section.list_items: - body_children.append(compile_list_block(section.list_items)) - - action_row = compile_action_row(section.actions) - if action_row: - body_children.append(action_row) - - if section.title or section.description or section.icon: - return compile_section( - title=section.title, - description=section.description, - icon=section.icon, - children=body_children, - status=default_status, - ) - - return compile_stack( - body_children, - direction="vertical", - gap=12, - ) - - -# ============================================================ -# Top-level Compilation -# ============================================================ - - -def compile_header(hints: UiHintsPayload) -> dict[str, Any] | None: - status = compile_status(hints.status) - - title_row_children: list[dict[str, Any]] = [] - if hints.icon: - title_row_children.append(compile_icon_node(hints.icon)) - if hints.title: - title_row_children.append(compile_text(hints.title, role="title")) - - right_children: list[dict[str, Any]] = [] - if _status_badge_needed(hints.intent, hints.status): - right_children.append(compile_badge(_status_label(status), status)) - - header_children: list[dict[str, Any]] = [] - - if title_row_children and right_children: - header_children.append( - compile_stack( - [ - compile_stack( - title_row_children, - direction="horizontal", - gap=8, - align="center", - ), - compile_stack( - right_children, - direction="horizontal", - gap=8, - align="center", - ), - ], - direction="horizontal", - gap=8, - justify="space-between", - align="center", - ) - ) - elif title_row_children: - header_children.append( - compile_stack( - title_row_children, - direction="horizontal", - gap=8, - align="center", - ) - ) - elif right_children: - header_children.append( - compile_stack( - right_children, - direction="horizontal", - gap=8, - align="center", - ) - ) - - if hints.description: - header_children.append(compile_text(hints.description, role="caption")) - - if not header_children: - return None - - return compile_stack( - header_children, - direction="vertical", - gap=8, - ) - - -def compile_body_blocks(hints: UiHintsPayload) -> list[dict[str, Any]]: - blocks: list[dict[str, Any]] = [] - - if hints.body: - blocks.append( - compile_text( - hints.body, - role="body", - format=hints.body_format, - status=compile_status(hints.status) - if hints.intent == UiHintIntent.STATUS - else None, - ) - ) - - if hints.items: - blocks.append(compile_kv(hints.items)) - - if hints.list_items: - blocks.append(compile_list_block(hints.list_items)) - - if hints.sections: - blocks.extend( - [ - compile_section_block(section, compile_status(hints.status)) - for section in hints.sections - ] - ) - - return blocks - - -def compile_footer(hints: UiHintsPayload) -> dict[str, Any] | None: - return compile_action_row(hints.actions) - - -def _root_appearance(intent: UiHintIntent) -> str: - if intent in {UiHintIntent.DATA, UiHintIntent.STATUS, UiHintIntent.MIXED}: - return "card" - if intent == UiHintIntent.FORM: - return "section" - return "plain" - - -def _root_gap(intent: UiHintIntent) -> int: - if intent == UiHintIntent.FORM: - return 16 - return 12 - - -def compile_root(hints: UiHintsPayload) -> dict[str, Any]: - """ - intent 只影响默认布局风格,不决定字段是否保留。 - """ - children = _non_empty( - [ - compile_header(hints), - *compile_body_blocks(hints), - compile_footer(hints), - ] - ) - - if not children: - children = [compile_text("No content", role="body")] - - return compile_stack( - children, - direction="vertical", - gap=_root_gap(hints.intent), - appearance=_root_appearance(hints.intent), - status=compile_status(hints.status), - ) - - -# ============================================================ -# Public API -# ============================================================ - - -def compile(hints: UiHintsPayload) -> dict[str, Any]: - """ - 将描述性 UiHints 编译为渲染性 UiSchemaRenderer。 - - 保证: - - title / description / body / items / listItems / sections / actions / icon 尽量保留 - - intent 只影响默认包装风格 - - meta 中常用 requestId/toolId/traceId/userId 会透传 - """ - root = compile_root(hints) - - meta_keys = ("requestId", "toolId", "traceId", "userId") - meta = {k: hints.meta.get(k) for k in meta_keys if hints.meta.get(k) is not None} - - result: dict[str, Any] = { - "version": "2.0", - "locale": "zh-CN", - "status": compile_status(hints.status), - "theme": "default", - "root": root, - } - - if meta: - result["meta"] = meta - - return _compact(result) diff --git a/backend/src/schemas/agent/__init__.py b/backend/src/schemas/agent/__init__.py index fe2fcea..f3acdb5 100644 --- a/backend/src/schemas/agent/__init__.py +++ b/backend/src/schemas/agent/__init__.py @@ -11,18 +11,10 @@ from schemas.agent.runtime_models import ( ToolAgentOutput, ToolStatus, WorkerAgentOutputLite, - WorkerAgentOutputRich, resolve_worker_output_model, ) from schemas.agent.system_agent import AgentType, SystemAgentLLMConfig from schemas.agent.visibility import SystemVisibilityBit, VisibilityMask, bit_mask -from schemas.agent.ui_hints import ( - UiHintAction, - UiHintIntent, - UiHintSection, - UiHintStatus, - UiHintsPayload, -) __all__ = [ "AgentType", @@ -35,14 +27,8 @@ __all__ = [ "SystemVisibilityBit", "ToolAgentOutput", "ToolStatus", - "UiHintAction", - "UiHintIntent", - "UiHintSection", - "UiHintStatus", - "UiHintsPayload", "VisibilityMask", "WorkerAgentOutputLite", - "WorkerAgentOutputRich", "bit_mask", "parse_forwarded_props_client_time", "parse_forwarded_props_runtime_mode", diff --git a/backend/src/schemas/agent/forwarded_props.py b/backend/src/schemas/agent/forwarded_props.py index 11db3b8..2aa3a9b 100644 --- a/backend/src/schemas/agent/forwarded_props.py +++ b/backend/src/schemas/agent/forwarded_props.py @@ -64,6 +64,7 @@ class ClientTimeContext(BaseModel): class RuntimeMode(str, Enum): CHAT = "chat" + FOLLOW_UP = "follow_up" class ForwardedPropsPayload(BaseModel): diff --git a/backend/src/schemas/agent/runtime_models.py b/backend/src/schemas/agent/runtime_models.py index 97c7ed9..1ae725a 100644 --- a/backend/src/schemas/agent/runtime_models.py +++ b/backend/src/schemas/agent/runtime_models.py @@ -5,7 +5,6 @@ from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field -from schemas.agent.ui_hints import UiHintsPayload from schemas.domain.divination import DerivedDivinationData @@ -53,18 +52,28 @@ class WorkerAgentOutputLite(BaseModel): error: ErrorInfo | None = None -class WorkerAgentOutputRich(WorkerAgentOutputLite): - ui_hints: UiHintsPayload | None = None +class FollowUpOutput(BaseModel): + model_config = ConfigDict(extra="forbid") + + status: RunStatus = RunStatus.SUCCESS + answer: str = Field(min_length=1, max_length=4000) + error: ErrorInfo | None = None -class AgentOutput(WorkerAgentOutputRich): +class AgentOutput(WorkerAgentOutputLite): model_config = ConfigDict(extra="forbid") divination_derived: DerivedDivinationData | None = None -WorkerAgentOutput = WorkerAgentOutputLite | WorkerAgentOutputRich +WorkerAgentOutput = WorkerAgentOutputLite +RuntimeAgentOutput = AgentOutput | FollowUpOutput -def resolve_worker_output_model() -> type[WorkerAgentOutputLite]: +def resolve_worker_output_model( + *, runtime_mode: str +) -> type[WorkerAgentOutputLite | FollowUpOutput]: + normalized = runtime_mode.strip().lower() + if normalized == "follow_up": + return FollowUpOutput return WorkerAgentOutputLite diff --git a/backend/src/schemas/agent/ui_hints.py b/backend/src/schemas/agent/ui_hints.py deleted file mode 100644 index 54f461b..0000000 --- a/backend/src/schemas/agent/ui_hints.py +++ /dev/null @@ -1,349 +0,0 @@ -""" -UiHints - 描述性 UI 提示 - -设计原则: -- 描述性而非渲染性: 告诉编译器“要展示什么”,而不是“如何渲染” -- 最小化 token: 保持字段简洁 -- 可编译: 可机械转换为 UiSchemaRenderer -- 尽量无损: hints 中的主要内容字段应尽量被保留到 renderer 中 - -Version: 2.1 -""" - -from __future__ import annotations - -from enum import Enum -import re -from typing import Any, ClassVar, Literal - -from pydantic import BaseModel, ConfigDict, Field -from pydantic import field_validator - -_NAVIGATION_PATH_PATTERN = re.compile(r"^/[A-Za-z0-9/_-]*$") -_NAVIGATION_PARAM_KEY_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,31}$") -_MAX_NAVIGATION_PARAMS = 8 - - -# ============================================================ -# Enums -# ============================================================ - - -class UiHintStatus(str, Enum): - INFO = "info" - SUCCESS = "success" - WARNING = "warning" - ERROR = "error" - PENDING = "pending" - - -class UiHintIntent(str, Enum): - """主要展示意图(弱提示,不应决定字段生死)""" - - MESSAGE = "message" # 普通消息/说明 - DATA = "data" # 数据/结果摘要 - LIST = "list" # 列表为主 - STATUS = "status" # 状态结果为主 - FORM = "form" # 结构化内容(当前不表示真实输入表单) - MIXED = "mixed" # 混合内容 - - -class UiHintActionStyle(str, Enum): - PRIMARY = "primary" - SECONDARY = "secondary" - GHOST = "ghost" - DANGER = "danger" - - -class UiHintTextFormat(str, Enum): - PLAIN = "plain" - MARKDOWN = "markdown" - - -class UiHintActionType(str, Enum): - NAVIGATION = "navigation" - URL = "url" - EVENT = "event" - TOOL = "tool" - COPY = "copy" - PAYLOAD = "payload" - - -class UiHintIconSource(str, Enum): - ICON = "icon" - EMOJI = "emoji" - URL = "url" - - -# ============================================================ -# Base Config -# ============================================================ - - -class UiHintBaseModel(BaseModel): - model_config: ClassVar[ConfigDict] = ConfigDict( - extra="forbid", - populate_by_name=True, - ) - - -# ============================================================ -# Action Targets -# ============================================================ - - -class UiHintActionNavigation(UiHintBaseModel): - type: Literal["navigation"] - path: str = Field(..., description="Internal route path.") - params: dict[str, Any] | None = Field(default=None, description="Route params.") - - @field_validator("path") - @classmethod - def validate_navigation_path(cls, value: str) -> str: - path = value.strip() - if not path: - raise ValueError("navigation path must not be empty") - if len(path) > 256: - raise ValueError("navigation path is too long") - if path.startswith("//") or "://" in path: - raise ValueError("navigation path must be internal") - if "?" in path or "#" in path: - raise ValueError("navigation path must not contain query or fragment") - if ":" in path: - raise ValueError("navigation path must be concrete without placeholders") - if _NAVIGATION_PATH_PATTERN.fullmatch(path) is None: - raise ValueError("navigation path contains unsupported characters") - return path - - @field_validator("params") - @classmethod - def validate_navigation_params( - cls, value: dict[str, Any] | None - ) -> dict[str, Any] | None: - if value is None: - return None - if len(value) > _MAX_NAVIGATION_PARAMS: - raise ValueError("navigation params exceed limit") - - normalized: dict[str, Any] = {} - for key, param_value in value.items(): - if _NAVIGATION_PARAM_KEY_PATTERN.fullmatch(key) is None: - raise ValueError("navigation param key is invalid") - if isinstance(param_value, (str, int, float, bool)): - normalized[key] = param_value - continue - raise ValueError("navigation params must be scalar") - return normalized - - -class UiHintActionUrl(UiHintBaseModel): - type: Literal["url"] - url: str = Field(..., description="External URL.") - target: Literal["_self", "_blank"] | None = Field(default=None) - - -class UiHintActionEvent(UiHintBaseModel): - type: Literal["event"] - event: str = Field(..., description="Frontend event name.") - payload: dict[str, Any] | None = Field(default=None) - - -class UiHintActionTool(UiHintBaseModel): - type: Literal["tool"] - tool_id: str = Field(alias="toolId", description="Tool identifier.") - params: dict[str, Any] | None = Field(default=None) - - -class UiHintActionCopy(UiHintBaseModel): - type: Literal["copy"] - content: str = Field(..., description="Content to copy.") - success_message: str | None = Field(alias="successMessage", default=None) - - -class UiHintActionPayload(UiHintBaseModel): - type: Literal["payload"] - payload: dict[str, Any] = Field(..., description="Structured payload.") - submit_to: str | None = Field(alias="submitTo", default=None) - - -UiHintActionTarget = ( - UiHintActionNavigation - | UiHintActionUrl - | UiHintActionEvent - | UiHintActionTool - | UiHintActionCopy - | UiHintActionPayload -) - - -class UiHintAction(UiHintBaseModel): - label: str = Field(..., description="Button label.") - style: UiHintActionStyle | None = Field(default=None, description="Button style.") - disabled: bool = Field(default=False, description="Disabled state.") - action: UiHintActionTarget = Field(..., description="Action to execute.") - - -# ============================================================ -# Small Descriptive Models -# ============================================================ - - -class UiHintIcon(UiHintBaseModel): - source: UiHintIconSource = Field(default=UiHintIconSource.ICON) - value: str = Field(..., description="Icon identifier / emoji / url.") - color: str | None = Field(default=None) - size: int | None = Field(default=None) - - -class UiHintKvItem(UiHintBaseModel): - key: str = Field(..., description="Key identifier.") - label: str | None = Field(default=None, description="Display label.") - value: Any = Field(default=None, description="Value.") - copyable: bool = Field(default=False, description="Allow copy.") - - -class UiHintListItem(UiHintBaseModel): - id: str | None = Field(default=None) - title: str = Field(..., description="Item title.") - subtitle: str | None = Field(default=None) - description: str | None = Field(default=None) - icon: UiHintIcon | None = Field(default=None) - status: UiHintStatus | None = Field(default=None) - actions: list[UiHintAction] = Field(default_factory=list) - - @field_validator("status", mode="before") - @classmethod - def normalize_status(cls, value: object) -> object: - if value is None: - return None - if isinstance(value, dict): - status_type = value.get("type") - if isinstance(status_type, str): - return status_type - status_value = value.get("status") - if isinstance(status_value, str): - return status_value - return value - - -class UiHintSection(UiHintBaseModel): - title: str | None = Field(default=None, description="Section title.") - description: str | None = Field(default=None, description="Section description.") - icon: UiHintIcon | None = Field(default=None, description="Section icon.") - - content: str | None = Field(default=None, description="Main text content.") - content_format: UiHintTextFormat = Field( - default=UiHintTextFormat.PLAIN, - alias="contentFormat", - description="Section content text format.", - ) - - items: list[UiHintKvItem] = Field(default_factory=list, description="KV items.") - list_items: list[UiHintListItem] = Field( - default_factory=list, - alias="listItems", - description="List items.", - ) - actions: list[UiHintAction] = Field(default_factory=list, description="Actions.") - - -# ============================================================ -# Root Payload -# ============================================================ - - -class UiHintsPayload(UiHintBaseModel): - """ - 描述性 UI 提示 - - 设计目标: - - agent 输出尽可能短 - - 不表达布局细节 - - 编译器负责转换为完整 UiSchemaRenderer - """ - - model_config: ClassVar[ConfigDict] = ConfigDict( - extra="forbid", - populate_by_name=True, - json_schema_extra={ - "examples": [ - { - "intent": "status", - "status": "success", - "title": "日程已创建", - "body": "本次创建已成功完成。", - "items": [ - {"key": "title", "label": "主题", "value": "Q1 规划会议"}, - {"key": "time", "label": "时间", "value": "2026-03-15 14:00"}, - ], - "actions": [ - { - "label": "查看详情", - "style": "primary", - "action": { - "type": "navigation", - "path": "/calendar/evt_123", - }, - }, - { - "label": "删除", - "style": "danger", - "action": { - "type": "tool", - "toolId": "calendar.delete", - "params": {"eventId": "evt_123"}, - }, - }, - ], - } - ] - }, - ) - - version: str = Field(default="2.1") - - intent: UiHintIntent = Field( - default=UiHintIntent.MESSAGE, - description="Primary display intent.", - ) - status: UiHintStatus = Field( - default=UiHintStatus.INFO, - description="Overall status.", - ) - - title: str | None = Field(default=None, description="Top-level title.") - description: str | None = Field(default=None, description="Top-level description.") - - body: str | None = Field(default=None, description="Top-level main body text.") - body_format: UiHintTextFormat = Field( - default=UiHintTextFormat.PLAIN, - alias="bodyFormat", - description="Body text format.", - ) - - items: list[UiHintKvItem] = Field( - default_factory=list, - description="Top-level key-value items.", - ) - list_items: list[UiHintListItem] = Field( - default_factory=list, - alias="listItems", - description="Top-level list items.", - ) - sections: list[UiHintSection] = Field( - default_factory=list, - description="Grouped sections.", - ) - actions: list[UiHintAction] = Field( - default_factory=list, - description="Top-level actions.", - ) - - icon: UiHintIcon | None = Field( - default=None, - description="Top-level icon.", - ) - meta: dict[str, Any] = Field( - default_factory=dict, - description="Extra meta, e.g. requestId/toolId/traceId/userId.", - ) diff --git a/backend/src/schemas/domain/chat_message.py b/backend/src/schemas/domain/chat_message.py index b2ee290..f7687f0 100644 --- a/backend/src/schemas/domain/chat_message.py +++ b/backend/src/schemas/domain/chat_message.py @@ -6,7 +6,7 @@ from typing import Any, ClassVar from uuid import UUID from pydantic import BaseModel, ConfigDict, Field -from schemas.agent.runtime_models import AgentOutput +from schemas.agent.runtime_models import AgentOutput, FollowUpOutput from ..agent import AgentType, ToolAgentOutput @@ -25,7 +25,7 @@ class AgentChatMessageMetadata(BaseModel): agent_type: AgentType | None = None user_message_attachments: list[UserMessageAttachment] | None = None tool_agent_output: ToolAgentOutput | None = None - agent_output: AgentOutput | None = None + agent_output: AgentOutput | FollowUpOutput | None = None class AgentChatMessage(BaseModel): @@ -34,6 +34,7 @@ class AgentChatMessage(BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") id: UUID + session_id: UUID seq: int role: str content: str diff --git a/backend/src/v1/agent/repository.py b/backend/src/v1/agent/repository.py index b5dcaa1..6b7f9bc 100644 --- a/backend/src/v1/agent/repository.py +++ b/backend/src/v1/agent/repository.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import date, datetime, time, timedelta, timezone from decimal import Decimal -from typing import Protocol +from typing import Any, Protocol from uuid import UUID, uuid4 from sqlalchemy import Select, func, select @@ -45,8 +45,10 @@ class AgentRepository: detail="Invalid session_id", ) from exc - stmt = select(AgentChatSession.user_id).where( - AgentChatSession.id == session_uuid + stmt = ( + select(AgentChatSession.user_id) + .where(AgentChatSession.id == session_uuid) + .where(AgentChatSession.deleted_at.is_(None)) ) owner_id = (await self._session.execute(stmt)).scalar_one_or_none() if owner_id is None: @@ -103,10 +105,18 @@ class AgentRepository: code="AGENT_SESSION_ID_INVALID", detail="Invalid session_id", ) from exc - session = await self._session.get(AgentChatSession, session_uuid) - if session is not None: - await self._session.delete(session) - await self._session.flush() + stmt = ( + select(AgentChatSession) + .where(AgentChatSession.id == session_uuid) + .with_for_update() + ) + session = (await self._session.execute(stmt)).scalar_one_or_none() + if session is None: + return + if session.deleted_at is not None: + return + session.deleted_at = datetime.now(timezone.utc) + await self._session.flush() async def persist_user_message( self, @@ -263,6 +273,37 @@ class AgentRepository: "messages": snapshot_messages, } + async def get_session_messages( + self, + *, + session_id: str, + visibility_mask: int | None = None, + ) -> list[dict[str, object]]: + try: + session_uuid = UUID(session_id) + except ValueError as exc: + raise ApiProblemError( + status_code=422, + code="AGENT_SESSION_ID_INVALID", + detail="Invalid session_id", + ) from exc + + message_stmt = ( + select(AgentChatMessage) + .where(AgentChatMessage.session_id == session_uuid) + .where(AgentChatMessage.deleted_at.is_(None)) + .order_by(AgentChatMessage.seq.asc()) + ) + message_stmt = self._apply_visibility_filter( + stmt=message_stmt, + visibility_mask=visibility_mask, + ) + messages = (await self._session.execute(message_stmt)).scalars().all() + snapshot_messages: list[dict[str, object]] = [] + for message in messages: + snapshot_messages.append(await self._to_snapshot_message(message)) + return snapshot_messages + async def get_recent_messages_by_user_window( self, *, @@ -371,16 +412,32 @@ class AgentRepository: .where(AgentChatMessage.deleted_at.is_(None)) .where(AgentChatMessage.role == AgentChatMessageRole.ASSISTANT) .order_by(AgentChatMessage.created_at.desc()) - .limit(1) + .limit(20) ) message_stmt = self._apply_visibility_filter( stmt=message_stmt, visibility_mask=visibility_mask, ) - message = (await self._session.execute(message_stmt)).scalar_one_or_none() - if message is None: + candidate_messages = ( + (await self._session.execute(message_stmt)).scalars().all() + ) + if not candidate_messages: continue - snapshots.append(await self._to_snapshot_message(message)) + selected_snapshot: dict[str, object] | None = None + for message in candidate_messages: + snapshot = await self._to_snapshot_message(message) + metadata = snapshot.get("metadata") + if not isinstance(metadata, dict): + continue + agent_output = metadata.get("agent_output") + if not isinstance(agent_output, dict): + continue + derived = agent_output.get("divination_derived") + if isinstance(derived, dict) and derived: + selected_snapshot = snapshot + break + if selected_snapshot is not None: + snapshots.append(selected_snapshot) snapshots.sort( key=lambda item: str(item.get("timestamp") or ""), @@ -416,6 +473,7 @@ class AgentRepository: payload_model = AgentChatMessageSchema.model_validate( { "id": str(message.id), + "session_id": str(message.session_id), "seq": int(message.seq), "role": role, "content": message.content, @@ -434,9 +492,9 @@ class AgentRepository: def _apply_visibility_filter( self, *, - stmt: Select, + stmt: Select[Any], visibility_mask: int | None, - ) -> Select: + ) -> Select[Any]: if visibility_mask is None: return stmt required_mask = max(int(visibility_mask), 0) diff --git a/backend/src/v1/agent/router.py b/backend/src/v1/agent/router.py index 2e460e8..f5a4b2a 100644 --- a/backend/src/v1/agent/router.py +++ b/backend/src/v1/agent/router.py @@ -5,7 +5,6 @@ import os import re import tempfile from collections.abc import AsyncIterator -from datetime import date from typing import Annotated from ag_ui.core import RunAgentInput @@ -25,6 +24,7 @@ from fastapi import ( Form, Header, Query, + Response, Request, UploadFile, status, @@ -297,15 +297,23 @@ async def get_user_history_snapshot( service: Annotated[AgentService, Depends(get_agent_service)], current_user: Annotated[CurrentUser, Depends(get_current_user)], thread_id: str | None = Query(default=None, alias="threadId"), - before: date | None = Query(default=None), ) -> HistorySnapshotResponse: return await service.get_user_history_snapshot( current_user=current_user, thread_id=thread_id, - before=before, ) +@router.delete("/sessions/{thread_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_session( + thread_id: str, + service: Annotated[AgentService, Depends(get_agent_service)], + current_user: Annotated[CurrentUser, Depends(get_current_user)], +) -> Response: + await service.delete_session(thread_id=thread_id, current_user=current_user) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + @router.post( "/attachments", response_model=AttachmentUploadResponse, diff --git a/backend/src/v1/agent/schemas.py b/backend/src/v1/agent/schemas.py index a76d2a7..fbf15ad 100644 --- a/backend/src/v1/agent/schemas.py +++ b/backend/src/v1/agent/schemas.py @@ -7,6 +7,7 @@ from uuid import UUID from pydantic import BaseModel, ConfigDict, Field +from schemas.agent.runtime_models import ErrorInfo from schemas.domain.divination import DerivedDivinationData @@ -21,6 +22,8 @@ class AgentRepositoryLike(Protocol): async def rollback(self) -> None: ... + async def delete_session(self, *, session_id: str) -> None: ... + async def get_history_day( self, *, @@ -29,6 +32,13 @@ class AgentRepositoryLike(Protocol): visibility_mask: int | None = None, ) -> dict[str, object] | None: ... + async def get_session_messages( + self, + *, + session_id: str, + visibility_mask: int | None = None, + ) -> list[dict[str, object]]: ... + async def get_latest_session_id_for_user(self, *, user_id: str) -> str | None: ... async def get_latest_assistant_messages_by_user_sessions( @@ -186,6 +196,7 @@ class HistoryMessage(BaseModel): model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) id: str = Field(description="Message UUID") + thread_id: str = Field(alias="threadId", description="Owning session UUID") seq: int = Field(description="Message sequence number") role: Literal["user", "assistant"] = Field( description="Message role: user | assistant" @@ -213,6 +224,7 @@ class HistoryAgentOutput(BaseModel): advice: list[str] = Field(default_factory=list) keywords: list[str] = Field(default_factory=list) answer: str | None = None + error: ErrorInfo | None = None divination_derived: DerivedDivinationData | None = None @@ -221,7 +233,10 @@ class HistorySnapshotResponse(BaseModel): model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) - scope: str = Field(default="history_day") + scope: str = Field( + default="history_session_full", + description="history_session_full | history_sessions_latest_assistant", + ) thread_id: str | None = Field(default=None, alias="threadId") day: str | None = None has_more: bool = Field(default=False, alias="hasMore") diff --git a/backend/src/v1/agent/service.py b/backend/src/v1/agent/service.py index c868b29..f5efea2 100644 --- a/backend/src/v1/agent/service.py +++ b/backend/src/v1/agent/service.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import date, datetime, timezone +from datetime import datetime, timezone import hashlib from urllib.parse import urlparse @@ -46,7 +46,7 @@ from v1.agent.utils import ( ) logger = get_logger(__name__) -MAX_RUNS_PER_SESSION = 4 +MAX_RUNS_PER_SESSION = 2 def ensure_session_owner(*, owner_id: str, current_user: CurrentUser) -> None: @@ -94,6 +94,15 @@ class AgentService: forwarded_props = getattr(run_input, "forwarded_props", None) try: runtime_mode = parse_forwarded_props_runtime_mode(forwarded_props) + except ValueError as exc: + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="AGENT_RUNTIME_MODE_INVALID", + detail="Invalid forwardedProps.runtime_mode", + ), + ) from exc + try: divination_payload = parse_forwarded_props_divination_payload( forwarded_props ) @@ -123,6 +132,14 @@ class AgentService: except ApiProblemError as exc: if exc.status_code != 404: raise + if runtime_mode == RuntimeMode.FOLLOW_UP: + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="AGENT_SESSION_NOT_FOUND", + detail="Session not found", + ), + ) from exc created = await self._create_session_if_missing( thread_id=thread_id, current_user=current_user, @@ -204,6 +221,22 @@ class AgentService: accepted=True, ) + async def delete_session( + self, + *, + thread_id: str, + current_user: CurrentUser, + ) -> None: + try: + owner = await self._repository.get_session_owner(session_id=thread_id) + except ApiProblemError as exc: + if exc.status_code == 404: + return + raise + ensure_session_owner(owner_id=owner, current_user=current_user) + await self._repository.delete_session(session_id=thread_id) + await self._repository.commit() + async def _append_context_cache_user_message( self, *, @@ -226,30 +259,21 @@ class AgentService: if isinstance(metadata_payload, dict): message_payload["metadata"] = metadata_payload - try: - context_cache = create_context_messages_cache() - await context_cache.append_message( - thread_id=thread_id, - runtime_mode=runtime_mode.value, - visibility_mask=visibility_mask, - message=message_payload, - ) - except Exception as exc: - logger.warning( - "Failed to append user message to context cache", - thread_id=thread_id, - runtime_mode=runtime_mode.value, - error=str(exc), - ) + context_cache = create_context_messages_cache() + await context_cache.append_message( + thread_id=thread_id, + runtime_mode=runtime_mode.value, + visibility_mask=visibility_mask, + message=message_payload, + ) async def _resolve_user_message_visibility_mask( self, *, runtime_mode: RuntimeMode ) -> int: - if runtime_mode == RuntimeMode.CHAT: - return bit_mask(bit=int(SystemVisibilityBit.UI_HISTORY)) | bit_mask( - bit=int(SystemVisibilityBit.CONTEXT_ASSEMBLY) - ) - return 0 + _ = runtime_mode + return bit_mask(bit=int(SystemVisibilityBit.UI_HISTORY)) | bit_mask( + bit=int(SystemVisibilityBit.CONTEXT_ASSEMBLY) + ) async def _prepare_user_message( self, @@ -571,7 +595,6 @@ class AgentService: self, *, thread_id: str, - before: date | None, current_user: CurrentUser, ) -> HistorySnapshotResponse: from schemas.domain.chat_message import AgentChatMessage @@ -580,57 +603,47 @@ class AgentService: owner = await self._repository.get_session_owner(session_id=thread_id) ensure_session_owner(owner_id=owner, current_user=current_user) - day_payload = await self._repository.get_history_day( + raw_messages = await self._repository.get_session_messages( session_id=thread_id, - before=before, visibility_mask=bit_mask(bit=int(SystemVisibilityBit.UI_HISTORY)), ) messages: list[HistoryMessage] = [] - if day_payload: - raw_messages_obj = day_payload.get("messages") - raw_messages = ( - raw_messages_obj if isinstance(raw_messages_obj, list) else [] - ) - for msg_dict in raw_messages: - msg = AgentChatMessage.model_validate(msg_dict) - if msg.role == "tool": - continue + for msg_dict in raw_messages: + msg = AgentChatMessage.model_validate(msg_dict) + if msg.role == "tool": + continue - signed_urls: dict[str, str] = {} - attachments = extract_user_message_attachments(msg.metadata) - if self._attachment_storage and attachments: - expected_prefix = ( - f"agent-inputs/{current_user.id}/{thread_id}/uploads/" + signed_urls: dict[str, str] = {} + attachments = extract_user_message_attachments(msg.metadata) + if self._attachment_storage and attachments: + expected_prefix = f"agent-inputs/{current_user.id}/{thread_id}/uploads/" + for attachment in attachments: + if not is_safe_attachment_path( + attachment.path, + expected_prefix=expected_prefix, + ): + continue + signed_url = await self._attachment_storage.create_signed_url( + bucket=attachment.bucket, + path=attachment.path, + expires_in_seconds=self._SIGNED_URL_EXPIRES_IN_SECONDS, ) - for attachment in attachments: - if not is_safe_attachment_path( - attachment.path, - expected_prefix=expected_prefix, - ): - continue - signed_url = await self._attachment_storage.create_signed_url( - bucket=attachment.bucket, - path=attachment.path, - expires_in_seconds=self._SIGNED_URL_EXPIRES_IN_SECONDS, - ) - key = f"{attachment.bucket}/{attachment.path}" - signed_urls[key] = signed_url + key = f"{attachment.bucket}/{attachment.path}" + signed_urls[key] = signed_url - def _get_signed_url(payload: dict[str, str]) -> str: - key = f"{payload['bucket']}/{payload['path']}" - return signed_urls[key] + def _get_signed_url(payload: dict[str, str]) -> str: + key = f"{payload['bucket']}/{payload['path']}" + return signed_urls[key] - converted = convert_message_to_history(msg, _get_signed_url) - messages.append(HistoryMessage.model_validate(converted)) + converted = convert_message_to_history(msg, _get_signed_url) + messages.append(HistoryMessage.model_validate(converted)) return HistorySnapshotResponse( - scope="history_day", + scope="history_session_full", threadId=thread_id, - day=str(day_payload.get("day")) - if day_payload and day_payload.get("day") - else None, - hasMore=bool(day_payload.get("hasMore")) if day_payload else False, + day=None, + hasMore=False, messages=messages, ) @@ -639,7 +652,6 @@ class AgentService: *, current_user: CurrentUser, thread_id: str | None, - before: date | None, ) -> HistorySnapshotResponse: from schemas.domain.chat_message import AgentChatMessage from v1.agent.utils import convert_message_to_history @@ -648,20 +660,22 @@ class AgentService: if thread_id is not None: return await self.get_history_snapshot( thread_id=thread_id, - before=before, current_user=current_user, ) + summary_limit = 50 raw_messages = ( await self._repository.get_latest_assistant_messages_by_user_sessions( user_id=str(current_user.id), visibility_mask=bit_mask(bit=int(SystemVisibilityBit.UI_HISTORY)), - session_limit=50, + session_limit=summary_limit + 1, ) ) + has_more = len(raw_messages) > summary_limit + visible_messages = raw_messages[:summary_limit] messages: list[HistoryMessage] = [] - for msg_dict in raw_messages: + for msg_dict in visible_messages: msg = AgentChatMessage.model_validate(msg_dict) converted = convert_message_to_history(msg) messages.append(HistoryMessage.model_validate(converted)) @@ -670,7 +684,7 @@ class AgentService: scope="history_sessions_latest_assistant", threadId=None, day=None, - hasMore=False, + hasMore=has_more, messages=messages, ) diff --git a/backend/src/v1/agent/utils.py b/backend/src/v1/agent/utils.py index 519dd8f..b86e397 100644 --- a/backend/src/v1/agent/utils.py +++ b/backend/src/v1/agent/utils.py @@ -7,7 +7,8 @@ from collections.abc import Callable from typing import Any -from schemas.agent.runtime_models import AgentOutput +from pydantic import TypeAdapter +from schemas.agent.runtime_models import RuntimeAgentOutput from schemas.domain.chat_message import ( AgentChatMessage, AgentChatMessageMetadata, @@ -18,6 +19,7 @@ ALLOWED_ATTACHMENT_MIME_TYPES = {"image/png", "image/jpeg", "image/webp"} MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024 MAX_TOTAL_ATTACHMENT_BYTES = 12 * 1024 * 1024 MAX_ATTACHMENTS_PER_MESSAGE = 3 +_RUNTIME_AGENT_OUTPUT_ADAPTER = TypeAdapter(RuntimeAgentOutput) def convert_message_to_history( @@ -46,6 +48,7 @@ def convert_message_to_history( result: dict[str, Any] = { "id": str(message.id), + "threadId": str(message.session_id), "seq": message.seq, "role": role, "content": content, @@ -78,12 +81,9 @@ def _convert_user_attachments( signed_attachments: list[dict[str, str]] = [] for attachment in resolved: - try: - signed_url = get_signed_url_fn( - {"bucket": attachment.bucket, "path": attachment.path} - ) - except Exception: - continue + signed_url = get_signed_url_fn( + {"bucket": attachment.bucket, "path": attachment.path} + ) signed_attachments.append( { "url": signed_url, @@ -106,16 +106,12 @@ def _extract_worker_agent_output( agent_output_data = metadata.get("agent_output") if not agent_output_data: return None - try: - agent_output = AgentOutput.model_validate(agent_output_data) - except Exception: - return None + agent_output = _RUNTIME_AGENT_OUTPUT_ADAPTER.validate_python(agent_output_data) if not agent_output: return None payload = agent_output.model_dump(mode="json", by_alias=True, exclude_none=True) - payload.pop("ui_hints", None) return payload or None diff --git a/backend/tests/unit/test_history_message_schema.py b/backend/tests/unit/test_history_message_schema.py new file mode 100644 index 0000000..f00361e --- /dev/null +++ b/backend/tests/unit/test_history_message_schema.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from v1.agent.schemas import HistoryMessage + + +def test_history_message_accepts_follow_up_error_payload() -> None: + payload = { + "id": "msg-1", + "threadId": "thread-1", + "seq": 2, + "role": "assistant", + "content": "补充回答", + "timestamp": "2026-04-08T07:31:24+00:00", + "agent_output": { + "status": "failed", + "answer": "需要补充信息", + "error": { + "code": "INVALID_INPUT", + "message": "输入内容不完整", + "retryable": True, + "details": {}, + }, + }, + } + + parsed = HistoryMessage.model_validate(payload) + assert parsed.agent_output is not None + assert parsed.agent_output.error is not None + assert parsed.agent_output.error.code == "INVALID_INPUT" diff --git a/backend/tests/unit/test_runtime_models_worker_output.py b/backend/tests/unit/test_runtime_models_worker_output.py index 7015a0f..b43deeb 100644 --- a/backend/tests/unit/test_runtime_models_worker_output.py +++ b/backend/tests/unit/test_runtime_models_worker_output.py @@ -3,7 +3,11 @@ from __future__ import annotations import pytest from pydantic import ValidationError -from schemas.agent.runtime_models import WorkerAgentOutputLite +from schemas.agent.runtime_models import ( + FollowUpOutput, + WorkerAgentOutputLite, + resolve_worker_output_model, +) def test_worker_output_lite_rejects_divination_derived_from_llm() -> None: @@ -20,3 +24,29 @@ def test_worker_output_lite_rejects_divination_derived_from_llm() -> None: with pytest.raises(ValidationError): WorkerAgentOutputLite.model_validate(payload) + + +def test_follow_up_output_accepts_minimal_schema() -> None: + payload = { + "status": "success", + "answer": "追问回答", + } + + parsed = FollowUpOutput.model_validate(payload) + assert parsed.answer == "追问回答" + + +def test_follow_up_output_rejects_chat_only_fields() -> None: + payload = { + "status": "success", + "answer": "追问回答", + "sign_level": "中上签", + } + + with pytest.raises(ValidationError): + FollowUpOutput.model_validate(payload) + + +def test_resolve_worker_output_model_uses_runtime_mode() -> None: + assert resolve_worker_output_model(runtime_mode="chat") is WorkerAgentOutputLite + assert resolve_worker_output_model(runtime_mode="follow_up") is FollowUpOutput diff --git a/docs/bugs/2026-04-08-followup-entry-bug.md b/docs/bugs/2026-04-08-followup-entry-bug.md new file mode 100644 index 0000000..82aa639 --- /dev/null +++ b/docs/bugs/2026-04-08-followup-entry-bug.md @@ -0,0 +1,89 @@ +# Bug: 追问1次后无法进入查看历史记录 + +日期:2026-04-08 +状态:已确认(未修复) + +## 问题描述 + +追问1次之后追问入口关闭,但用户也没法点击进去查看追问的历史记录。 + +## 根因分析 + +`_loadFollowUpEligibility()` 方法(`divination_result_screen.dart:65-83`)将两个概念混为一谈: + +```dart +Future _loadFollowUpEligibility() async { + ... + final messages = await widget.divinationApi!.getSessionMessages( + threadId: widget.data.threadId!, + ); + final userCount = messages.where((msg) => msg.role == 'user').length; + ... + setState(() { + _canFollowUp = userCount < 2; // 同时控制"能追问"和"能进入" + ... + }); +} +``` + +按钮逻辑(`divination_result_screen.dart:367`): +```dart +onPressed: (!_canFollowUp || _followUpEligibilityLoading) + ? null // _canFollowUp = false 时按钮完全禁用 + : () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => FollowUpChatScreen(...), + ), + ); + }, +``` + +**问题**: +- `_canFollowUp = userCount < 2` 控制的是"是否还有追问配额" +- 但 `onPressed` 把它和"是否能进入查看历史"混用了 +- 导致用户追问1次后(userCount=2),`_canFollowUp=false`,按钮被禁用 +- 用户**无法进入追问页面查看历史记录** + +**业务逻辑分析**: +- 每 session 最多2次追问(首问1次 + 追问1次) +- 追问1次后,用户不能再发送新追问 +- 但**应该仍能进入查看历史记录** + +### 正确的逻辑应该是 + +```dart +// 追问配额判断 +_canFollowUp = userCount < 2; + +// 能否进入查看历史(与追问次数无关,只要有历史消息就能进入) +_canEnterFollowUpChat = messages.isNotEmpty; // 或者只要有 threadId 就能进 + +// 按钮文案 +buttonText = _canFollowUp ? l10n.followUpEntryHint : l10n.followUpViewHistory; + +// 按钮是否禁用 +onPressed = (_followUpEligibilityLoading) + ? null // 只在加载中时禁用 + : () { ... } // 始终可点击进入查看 +``` + +## 证据 + +- 结果页代码:`apps/lib/features/divination/presentation/screens/divination_result_screen.dart` + - `_buildFollowUpBar`: 行 338-394 + - `_loadFollowUpEligibility`: 行 65-83 + - 按钮 onPress: 行 367 +- 追问聊天页:`apps/lib/features/divination/presentation/screens/follow_up_chat_screen.dart` + +## 修复方向 + +1. 分离"能否追问"和"能否进入查看历史"的逻辑 +2. 按钮文案根据状态显示不同内容(能追问显示追问提示,已用完显示"查看历史") +3. 只要有 `threadId`,用户就应该能进入查看历史 + +--- + +## 相关文档 + +- 随访工程计划:`docs/plans/2026-04-08-followup-session-history-eng-plan.md` diff --git a/docs/bugs/2026-04-08-followup-loading-and-voice-bug.md b/docs/bugs/2026-04-08-followup-loading-and-voice-bug.md new file mode 100644 index 0000000..65e5230 --- /dev/null +++ b/docs/bugs/2026-04-08-followup-loading-and-voice-bug.md @@ -0,0 +1,261 @@ +# Bug: 追问页面 UI 问题 + +日期:2026-04-08 +状态:已确认(未修复) + +## Bug 1: 追问页发送时顶部和消息下方重复加载UI + +### 问题描述 + +在追问页面,用户输入信息发送后,顶部和消息下方同时出现加载UI,但顶部的加载UI是多余的。 + +### 根因分析 + +**文件**:`apps/lib/features/divination/presentation/screens/follow_up_chat_screen.dart` + +**问题代码**: + +1. 顶部 step 指示器(第 85-124 行): +```dart +if (_sending && _currentStepName != null) + Container( + // 显示 step 进度,如 "解读中..."、"推理中..." + child: Row( + children: [ + CircularProgressIndicator(...), + Text(_stepLabel(_currentStepName!)), + ], + ), + ) +``` + +2. 消息下方 streaming placeholder(第 565-584 行): +```dart +isStreamingPlaceholder + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(...), + Text(l10n.followUpGenerating), // "生成中..." + ], + ) +``` + +**事件触发流程**: + +| 顺序 | 事件 | 顶部指示器 | 消息下方 | +|------|------|----------|---------| +| 1 | `_submitText()` 调用 | 无 | streaming placeholder 显示 "生成中..." | +| 2 | `STEP_STARTED(stepName='divination')` | 显示 "解读中..." | 继续显示 | +| 3 | `STEP_FINISHED` | 消失 | 继续显示 | +| 4 | `STEP_STARTED(stepName='worker')` | 显示 "推理中..." | **同时显示** "生成中..." | +| 5 | `STEP_FINISHED` | 消失 | 继续显示 | +| 6 | `TEXT_MESSAGE_END` | 无 | placeholder 消失,显示真实内容 | + +**问题**:在 worker 阶段(步骤 4),顶部显示 "推理中..." 同时消息下方显示 "生成中...",造成重复反馈。用户只需要一个加载反馈即可。 + +**修复方向**: + +1. **方案A**:只在 divination 阶段显示顶部 step 指示器,worker 阶段隐藏(因为 streaming placeholder 已经提供反馈) + + ```dart + // 第 85 行修改 + if (_sending && _currentStepName != null && _currentStepName != 'worker') + ``` + +2. **方案B**:移除顶部 step 指示器,完全依赖 streaming placeholder + +3. **方案C**:在 worker 阶段隐藏 streaming placeholder,只显示顶部指示器 + +推荐 **方案A**,因为: +- divination 阶段没有 streaming placeholder,需要顶部指示器提供反馈 +- worker 阶段有 streaming placeholder,不需要顶部指示器 +- 改动最小,只加一个条件 + +--- + +## Bug 2: 语音模式录制时缺少动画UI + +### 问题描述 + +根据 social-app 的实现,按住说话模式下录音时应该有动画效果,但 eryao 的实现只有文字,没有动画。 + +### 根因分析 + +**对比**: + +| 功能 | social-app | eryao | +|------|------------|-------| +| 录音中动画 | `recordingAnimation` widget(外部传入) | 只有文字 "录音中..." | +| 录音提示文字 | 带动画的 widget | 只有文字 | +| 录音中隐藏输入区域 | `IgnorePointer` + `Opacity` | 未实现 | + +**eryao 当前实现**(`message_composer.dart:159-172`): +```dart +if (_isRecording) { + if (!showRecordingInlineFeedback) { + return Text(recordingText, ...); + } + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(width: 16, height: 16), // 空白占位 + const SizedBox(height: AppSpacing.xs), + Text(recordingText, ...), // "录音中..." + const SizedBox(height: AppSpacing.xs), + Text(recordingHintText, ...), // "上滑取消" + ], + ); +} +``` + +**social-app 实现**(`message_composer.dart:222-239`): +```dart +if (_isRecording) { + if (!showRecordingInlineFeedback) { + return Text(resolvedRecordingText, ...); + } + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + recordingAnimation, // 动画widget(外部传入) + const SizedBox(height: AppSpacing.xs), + Text(resolvedRecordingText, ...), + const SizedBox(height: AppSpacing.xs), + Text(resolvedRecordingHintText, ...), + ], + ); +} +``` + +**差异**: +1. `recordingAnimation` widget 缺失 - 需要外部传入,eryao 直接用空白占位 +2. 录音中图标/按钮没有动画效果 + +**修复方向**: + +1. 在 `FollowUpChatScreen` 或其父组件中创建 `recordingAnimation` widget +2. 将 `recordingAnimation` 通过 `MessageComposer` 传入 +3. 参考 social-app 的实现,使用脉冲动画或波形动画 + +**参考实现**(需要添加 `recording_animation.dart`): + +```dart +class RecordingAnimation extends StatefulWidget { + final double size; + final Color color; + + @override + State createState() => _RecordingAnimationState(); +} + +class _RecordingAnimationState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _scaleAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 1000), + vsync: this, + )..repeat(reverse: true); + _scaleAnimation = Tween(begin: 1.0, end: 1.2).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _scaleAnimation, + builder: (context, child) { + return Transform.scale( + scale: _scaleAnimation.value, + child: Container( + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: widget.color.withValues(alpha: 0.3), + ), + child: Icon(Icons.mic, color: widget.color, size: widget.size * 0.6), + ), + ); + }, + ); + } +} +``` + +--- + +## Bug 2 & 3: 直接复用 social-app 输入框(不需要 plus 按钮) + +### 需求 + +**直接复用** social-app 的 `MessageComposer`,但**不需要 plus 按钮**。 + +### 复用清单 + +| 功能 | social-app | eryao | 处理 | +|------|------------|-------|------| +| `recordingAnimation` | required Widget | 空白占位 | **需要添加** | +| 双层阴影 | 有 | 无 | **需要添加** | +| `AppRadius.xxl` | 用于圆角 | 无此值 | **需要添加** | +| Plus 按钮 | 有 | 无 | **不需要**,保持无 | +| 图标 | `LucideIcons` | Material Icons | 保持 Material Icons | + +### 实现步骤 + +#### 1. 添加 `AppRadius.xxl` 到 `design_tokens.dart` + +```dart +class AppRadius { + static const double sm = 8; + static const double md = 12; + static const double lg = 16; + static const double xl = 20; + static const double xxl = 32; // 新增 + static const double full = 999; +} +``` + +#### 2. 创建 `RecordingAnimation` widget + +放在 `apps/lib/shared/widgets/` 下,参考 social-app 的脉冲动画效果。 + +#### 3. 修改 `MessageComposer` + +- 添加 `recordingAnimation` 参数(required) +- 添加双层阴影 +- 使用 `AppRadius.xxl` 替代 `AppRadius.full` +- 移除 plus 按钮(eryao 原有的无 plus 逻辑保持不变) +- 保持 Material Icons + +#### 4. 修改 `FollowUpChatScreen` + +- 创建 `RecordingAnimation` 实例 +- 传入 `MessageComposer` + +--- + +## 相关文件 + +- `apps/lib/features/divination/presentation/screens/follow_up_chat_screen.dart` +- `apps/lib/shared/widgets/message_composer.dart` +- `apps/lib/shared/widgets/recording_animation.dart`(新建) +- `apps/lib/shared/theme/design_tokens.dart` +- `/home/qzl/Code/social-app/apps/lib/shared/widgets/message_composer.dart` + +## 修复优先级 + +1. **高优先级**:Bug 1(重复加载UI) +2. **高优先级**:Bug 2 & 3(直接复用 social-app 输入框) diff --git a/docs/bugs/2026-04-08-followup-sign-level-regeneration.md b/docs/bugs/2026-04-08-followup-sign-level-regeneration.md new file mode 100644 index 0000000..99e1e05 --- /dev/null +++ b/docs/bugs/2026-04-08-followup-sign-level-regeneration.md @@ -0,0 +1,200 @@ +# Bug: 追问时 agent_output 被重新生成,导致 sign_level 被覆盖 + +日期:2026-04-08 +状态:已确认根因(未修复) + +## 问题描述 + +首次解卦完成后,用户继续追问时,agent 会重新生成 `agent_output`,重新计算卦的结论和标签。 + +**具体表现**: +- 首次解卦结论:`中下签` +- 追问后结论:`下下签`(同一个卦,结果被重新生成) + +## 根因分析 + +**问题定位**:`backend/src/core/agentscope/runtime/runner.py` + +### 根因链条 + +1. **`runner.py:execute()` 方法(第 70-133 行)** + + 无论 `runtime_mode` 是 `chat` 还是 `follow_up`,都会执行以下逻辑: + + ```python + # 第 105-119 行:始终推导卦象 + 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", # <-- 追问时不应发射此事件 + ... + ) + ``` + +2. **`runner.py:_execute_worker_step()` 方法(第 200-245 行)** + + 始终将 `derived_divination` 传递给 worker: + + ```python + # 第 294-302 行:始终将 divination_derived 放入 worker_output + await emitter.emit_final_text_end( + worker_output={ + **worker_payload.model_dump(mode="json", exclude_none=True), + "divination_derived": derived_divination.model_dump(...), # <-- 追问时不应包含 + }, + ... + ) + ``` + +3. **`stage_emitter.py:emit_final_text_end()` 方法(第 46-73 行)** + + 始终将所有字段放入 `TEXT_MESSAGE_END` 事件: + + ```python + payload = { + "messageId": message_id, + "role": "assistant", + "stage": self._stage, + "status": worker_output.get("status"), + "sign_level": worker_output.get("sign_level"), # <-- 追问时不应有 + "conclusion": worker_output.get("conclusion", []), # <-- 追问时不应有 + "focus_points": worker_output.get("focus_points", []), + "advice": worker_output.get("advice", []), + "keywords": worker_output.get("keywords", []), + "answer": worker_output.get("answer", ""), + "error": worker_output.get("error"), + "divination_derived": worker_output.get("divination_derived"), # <-- 追问时不应有 + ... + } + ``` + +4. **`store.py:_persist_text_message()` 方法(第 125-160 行)** + + 从事件中提取所有字段并完整存储: + + ```python + worker_output_fields = ( + "status", + "sign_level", # <-- 追问时被重新生成并覆盖 + "conclusion", # <-- 追问时被重新生成并覆盖 + "focus_points", + "advice", + "keywords", + "answer", + "error", + "divination_derived", # <-- 追问时被重新生成 + "ui_hints", + ) + ``` + +### 问题本质 + +`runtime_mode=follow_up` 时,系统仍在: +1. 重新推导卦象(调用 `_resolve_derived_divination`) +2. 发射完整的 `DIVINATION_DERIVED` 事件 +3. 生成包含所有结构化字段的 `worker_output` +4. 将所有字段存储到数据库 + +但根据工程计划(`docs/plans/2026-04-08-followup-session-history-eng-plan.md:37-40`),追问时的预期行为是: + +``` +[一次追问] +user -> /agent/runs(runtime_mode=follow_up) + -> assistant(content [+ optional metadata.agent_output.answer]) +``` + +即:追问时只输出 `answer`(内容),不重新生成卦象结构。 + +## 证据 + +### 数据库证据 + +Session `015fe0f9-0500-43ab-911a-4ce8e3160032`: + +| 时间 | 角色 | sign_level | divination_derived | 问题 | +|------|------|------------|-------------------|------| +| 05:21:19 | user | - | - | 首问问题 | +| 05:21:28 | assistant | 中下签 | 完整 | 首答(正确) | +| 05:22:24 | user | - | - | 追问 | +| 05:22:33 | assistant | **下下签** | 完整 | 追问答(sign_level 被重新生成) | + +两个 assistant 消息的 `divination_derived` 完全相同,但 `sign_level` 不同,证明是重新生成的。 + +## 修复方向 + +### 1. `runner.py` + +在 `execute()` 方法中,根据 `runtime_mode` 决定是否推导卦象: + +```python +async def execute(self, ...): + runtime_mode = self._resolve_runtime_mode(run_input=run_input) + + if runtime_mode == RuntimeMode.CHAT: + derived_divination = self._resolve_derived_divination(run_input=run_input) + await self._emit_step_event(...) # DIVINATION_DERIVED + else: + derived_divination = None # follow_up 不推导 +``` + +### 2. `stage_emitter.py` + +`emit_final_text_end()` 根据 `runtime_mode` 决定发送哪些字段: + +```python +async def emit_final_text_end(self, ..., runtime_mode: str): + payload = {"messageId": ..., "role": "assistant", "stage": self._stage} + + if runtime_mode == "chat": + payload.update({ + "status": worker_output.get("status"), + "sign_level": worker_output.get("sign_level"), + "conclusion": worker_output.get("conclusion", []), + "focus_points": worker_output.get("focus_points", []), + "advice": worker_output.get("advice", []), + "keywords": worker_output.get("keywords", []), + "divination_derived": worker_output.get("divination_derived"), + ... + }) + else: # follow_up + payload["answer"] = worker_output.get("answer", "") +``` + +### 3. `store.py` + +`_persist_text_message()` 根据 `runtime_mode` 决定提取哪些字段: + +```python +async def _persist_text_message(self, ...): + runtime_mode = self._resolve_runtime_mode(event=event) + + if runtime_mode == "chat": + worker_output_fields = ("status", "sign_level", "conclusion", ...) + else: # follow_up + worker_output_fields = ("answer",) # 只存储 answer +``` + +### 4. `runtime_models.py` + +`resolve_worker_output_model()` 应根据 `runtime_mode` 返回不同 schema: + +```python +def resolve_worker_output_model(runtime_mode: RuntimeMode = RuntimeMode.CHAT) -> type[WorkerAgentOutputLite]: + if runtime_mode == RuntimeMode.FOLLOW_UP: + return WorkerAgentOutputLite # 只有 answer + return AgentOutput # 完整结构(继承自 WorkerAgentOutputRich) +``` + +## 相关文件 + +- `backend/src/core/agentscope/runtime/runner.py` - 主编排逻辑 +- `backend/src/core/agentscope/runtime/stage_emitter.py` - 事件发射 +- `backend/src/core/agentscope/events/store.py` - 事件持久化 +- `backend/src/schemas/agent/runtime_models.py` - 输出 schema 定义 + +## 相关文档 + +- 工程计划:`docs/plans/2026-04-08-followup-session-history-eng-plan.md` +- 协议文档:`docs/protocols/divination/divination-run-protocol.md` diff --git a/docs/bugs/2026-04-08-home-history-only-shows-4-items.md b/docs/bugs/2026-04-08-home-history-only-shows-4-items.md new file mode 100644 index 0000000..a14986e --- /dev/null +++ b/docs/bugs/2026-04-08-home-history-only-shows-4-items.md @@ -0,0 +1,90 @@ +# Bug: 首页历史 Session 只显示4条,"更多"按钮缺失 + +日期:2026-04-08 +状态:功能缺失(未完成) + +## 问题描述 + +前端主页接收后端传来的历史 session,永远只显示四个,"more"按钮不见了。 + +## 根因分析 + +### 1. 前端硬编码 `take(4)` + +`apps/lib/features/home/presentation/screens/home_screen.dart:287`: +```dart +children: historyItems.take(4).map((item) { +``` + +这是在 commit `6e82053`(重构首页为底部导航栏布局)时**有意添加**的设计选择,用于限制首页展示的历史记录数量。 + +### 2. 前端未实现"更多"按钮 + +- `l10n.more` 本地化字符串存在(`'更多'`/`'More'`),定义于 `apps/lib/l10n/app_localizations_zh.dart:115` 和 `app_localizations_en.dart:116` +- 但搜索整个 Dart 代码库,`l10n.more` **没有任何地方使用它** +- "更多"功能从未被实现 + +### 3. 后端 `hasMore` 硬编码为 `False` + +`backend/src/v1/agent/service.py:655-659`: +```python +return HistorySnapshotResponse( + scope="history_sessions_latest_assistant", + threadId=None, + day=None, + hasMore=False, # 硬编码,未实际计算 + messages=messages, +) +``` + +后端 schema 定义了 `hasMore` 字段(`schemas.py:238`),但 service 层返回时**硬编码为 `False`**,从未实际计算是否还有更多数据。 + +### 4. 后端 API 行为正确 + +`backend/src/v1/agent/service.py:641-646`: +```python +raw_messages = await self._repository.get_latest_assistant_messages_by_user_sessions( + user_id=str(current_user.id), + visibility_mask=bit_mask(bit=int(SystemVisibilityBit.UI_HISTORY)), + session_limit=50, # 后端返回最多50条 +) +``` + +后端正确返回最多 50 条历史 session,前端只是没有利用这些数据。 + +## 问题性质 + +| 层级 | 现象 | 性质 | +|------|------|------| +| 后端 | `hasMore=False` 硬编码 | 缺陷:响应语义不正确 | +| 前端 | `take(4)` 只显示4条 | 设计选择:有意的 UI 限制 | +| 前端 | 无"更多"按钮 | 功能缺失:有 `l10n.more` 但未实现 | + +## 证据 + +- 前端代码:`apps/lib/features/home/presentation/screens/home_screen.dart:287` +- 后端代码:`backend/src/v1/agent/service.py:655` +- 本地化字符串:`apps/lib/l10n/app_localizations_zh.dart:115` +- 提交记录:`6e82053`(feat(home): 重构首页为底部导航栏布局) + +## 修复方向 + +### 方案 A:实现完整分页(推荐) + +1. **后端**:实现真正的 `hasMore` 计算逻辑 + - 添加 `offset` 参数支持分页 + - 实际计算 `hasMore = total_count > offset + limit` + +2. **前端**:实现"更多"按钮或无限滚动 + - 监听滚动位置,滚动到底部时加载更多 + - 或添加"更多"按钮手动触发加载 + +### 方案 B:移除不存在的字符串 + +如果业务确定只需要显示4条,则: +- 移除 `l10n.more` 本地化字符串,避免误导 + +## 相关文档 + +- 工程计划:`docs/plans/2026-04-05-divination-history-profile-eng-plan.md` +- 随访计划:`docs/plans/2026-04-08-followup-session-history-eng-plan.md` diff --git a/docs/plans/2026-04-05-divination-history-profile-backend-source-plan.md b/docs/plans/2026-04-05-divination-history-profile-backend-source-plan.md deleted file mode 100644 index f281e6c..0000000 --- a/docs/plans/2026-04-05-divination-history-profile-backend-source-plan.md +++ /dev/null @@ -1,241 +0,0 @@ -# Eryao 解卦历史与个人档案后端单一数据源改造计划 - -日期:2026-04-05 -状态:评审中(未开始编码) - -## 1. 背景与目标 - -当前移动端存在两类不符合目标架构的问题: - -1. 个人档案(昵称、简介、头像)仍有前端本地状态路径,非后端权威数据源。 -2. 首页历史解卦无法稳定由后端快照直接重建结果页,前端被迫做本地兜底。 - -本计划目标: - -- 实现“后端为唯一数据源,前端仅缓存”。 -- 将 `DIVINATION_DERIVED` 的完整结构进入消息 `metadata.agent_output` 并持久化。 -- 历史接口返回可被前端直接解析的结构化 assistant 输出(不再依赖 `ui_schema`)。 -- 个人档案全链路后端化,头像使用 `avatars` bucket。 - -非目标: - -- 本计划不直接提交代码实现。 -- 本计划不包含 UI 视觉细节改稿。 - -## 2. 现状核对(基于仓库代码) - -### 2.1 历史接口与消息转换 - -- 历史接口:`GET /api/v1/agent/history`,定义于 `backend/src/v1/agent/router.py`。 -- 当前转换逻辑在 `backend/src/v1/agent/utils.py`: - - `user` 消息主要输出 `content` 与 `attachments`。 - - `assistant` 消息默认走 `ui_hints -> ui_schema` 编译路径。 -- 历史响应结构 `HistoryMessage` 当前包含 `ui_schema`,不直接暴露结构化 `agent_output`。 - -### 2.2 DIVINATION_DERIVED 与落库断点 - -- 运行时会发出 `DIVINATION_DERIVED`(见 `backend/src/core/agentscope/runtime/runner.py`)。 -- 消息落库由 `backend/src/core/agentscope/events/store.py` 负责。 -- 当前 `TEXT_MESSAGE_END` 持久化字段包含 `sign_level/summary/.../ui_hints`,未包含 `divination` 结构。 -- 结果:历史快照难以完整重建结果页结构。 - -### 2.3 Profile 与头像 - -- 后端配置已有 `storage.avatar.bucket`,默认 `avatars`(`backend/src/core/config/settings.py`)。 -- 当前 `v1` 仅挂载 `auth/agent/points` 路由(`backend/src/v1/router.py`),尚无 profile 专用路由。 - -## 3. 核心设计决策 - -### 决策 A:把 `divination_derived` 放入 `metadata.agent_output` - -- 在 `AgentOutput` 增加字段 `divination_derived`(强类型,禁止裸 `dict`)。 -- 事件落库时把 `DIVINATION_DERIVED` 内容并入 assistant 的 `metadata.agent_output.divination_derived`。 -- 与 `sign_level/summary/advice/...` 同时持久化,形成一条可回放的 assistant 结构化输出。 - -理由: - -- 最小改动复用现有消息表,不新增历史结果表即可满足回放需求。 -- 前端可直接从历史响应解析结果页,避免本地拼装。 - -### 决策 B:历史接口返回 `assistant.agent_output`,移除 `ui_schema` - -- `HistoryMessage` 改为: - - `user`: `content + attachments` - - `assistant`: `content + agent_output` -- `ui_schema` 从接口协议中移除(迁移自通用模块的历史遗留,不在本项目范围)。 - -理由: - -- 减少中间编译层,契约更稳定、语义更清晰。 -- 前端直接消费业务数据,不依赖通用 UI 编译器。 - -### 决策 C:Profile 全后端化 + 头像对象存储 - -- 新增 users/profile API,前端只保留缓存层。 -- 头像上传走预签名 URL,bucket 固定 `avatars`,路径按用户隔离。 - -## 4. 协议与接口计划(先文档,后实现) - -## 4.1 新增/修改协议文档 - -按“协议先行”更新以下文档: - -1. `docs/protocols/divination/divination-run-protocol.md` - - 增补:历史回放时 assistant `agent_output.divination_derived` 的字段契约。 - - 标记:`ui_schema` 已废弃并移除。 -2. 新增:`docs/protocols/profile/profile-protocol.md` - - 定义 profile 读写与头像上传签名协议。 -3. 如涉及错误码新增,更新: - - `docs/protocols/common/http-error-codes.md` - -### 4.2 后端 API 契约(目标) - -#### A. 历史快照(改造) - -- `GET /api/v1/agent/history` -- 响应中 assistant 消息新增(或替换为)`agent_output`: - - `sign_level` - - `summary` - - `conclusion` - - `focus_points` - - `advice` - - `keywords` - - `answer` - - `divination_derived`(完整卦象结构) - -#### B. Profile(新增) - -- `GET /api/v1/users/me/profile` -- `PATCH /api/v1/users/me/profile` -- `POST /api/v1/users/me/avatar/upload-url` -- (可选)`GET /api/v1/users/me/avatar/signed-url` - -#### C. 头像上传约束 - -- bucket 固定:`config.storage.avatar.bucket` -- 路径前缀建议:`avatars/{user_id}/...` -- 文件类型:`image/png|image/jpeg|image/webp` -- 体积上限:`config.storage.avatar.max_size_mb` - -## 5. 数据模型改造计划 - -### 5.1 Runtime 模型 - -- 文件:`backend/src/schemas/agent/runtime_models.py` -- 变更:`AgentOutput` 增加 `divination_derived` 字段(类型复用 `schemas/domain/divination.py`)。 -- 规则:保持 `extra="forbid"`,禁止无类型漂移。 - -### 5.2 事件到落库链路 - -- 文件:`backend/src/core/agentscope/runtime/stage_emitter.py` - - `TEXT_MESSAGE_END` payload 带上 `divination_derived`。 -- 文件:`backend/src/core/agentscope/events/store.py` - - `worker_output_fields` 纳入 `divination_derived` 并写入 `metadata.agent_output`。 - -### 5.3 历史响应转换 - -- 文件:`backend/src/v1/agent/utils.py` - - 删除 `ui_hints -> ui_schema` 编译路径。 - - assistant 消息改为抽取并返回受控 `agent_output`。 -- 文件:`backend/src/v1/agent/schemas.py` - - `HistoryMessage` 改字段定义(去 `ui_schema`,加 `agent_output`)。 - -## 6. 前端消费与缓存策略 - -### 6.1 历史与结果页 - -- 历史列表数据源改为后端 `agent/history`。 -- 点开历史项时: - - 直接解析 `assistant.agent_output.divination_derived` + 解释文本字段。 - - 本地仅做缓存,不做真源 fallback。 - -### 6.2 Profile - -- 设置页资料读取改为 `GET /users/me/profile`。 -- 编辑资料写入 `PATCH /users/me/profile`。 -- 头像更新走 upload-url + 上传 + profile 更新引用路径。 - -### 6.3 点数 - -- 保持后端余额接口作为权威数据源(现有已接)。 -- 前端只做短期缓存,解卦完成后强制 refresh。 - -## 7. 代码清理边界(你关心的“删除通用遗留”) - -原则:先去引用,再删定义,最后删文件,避免误删。 - -分三步: - -1. 第一阶段(本次改造内) - - 删除 `agent/history` 对 `ui_schema` 的输出与依赖。 - - 删除前端对 `ui_schema` 的消费路径(若存在)。 -2. 第二阶段(安全清理) - - 搜索 `schemas/domain` 与 `schemas/agent/ui_hints` 的实际引用。 - - 对“零引用 + 非协议字段”进行清理。 -3. 第三阶段(文档与测试补齐) - - 更新协议文档、错误码、回归测试。 - -备注: - -- 不建议在同一 PR 里“功能改造 + 大规模 schema 删除”,建议拆成两个 PR,降低回归风险。 - -## 8. 测试计划(必须项) - -### 8.1 后端单元/集成 - -1. `TEXT_MESSAGE_END` 持久化:`metadata.agent_output.divination_derived` 落库断言。 -2. `GET /api/v1/agent/history`:assistant 返回 `agent_output`,且不再返回 `ui_schema`。 -3. 历史分页与 owner 校验不回退。 -4. profile API:读写、权限、字段约束、头像路径安全性。 -5. 头像签名 URL:bucket/path/mime/size 约束。 - -### 8.2 前端 - -1. 历史列表从后端数据渲染。 -2. 点击历史项成功进入结果页,字段一致性校验。 -3. profile 页面读写闭环(昵称/简介/头像)。 -4. 点数刷新与缓存失效策略验证。 - -## 9. 风险与回滚 - -主要风险: - -- 历史消息中旧数据可能没有 `divination_derived`,前端需兼容空值。 -- `ui_schema` 下线后,若有隐藏调用方会断。 - -回滚策略: - -- 协议层采用短期双读兼容窗口(仅过渡期): - - 新字段优先;旧字段仅用于读,不再写。 -- 若线上异常,先回滚 history 响应变更,再保持落库新增字段不删。 - -## 10. 实施顺序(最小风险) - -1. 协议文档更新并评审通过。 -2. 后端:`AgentOutput` + 事件落库 + history 响应新增 `agent_output`(先加后切)。 -3. 前端:改消费到 `agent_output`,移除本地真源。 -4. 后端:移除 `ui_schema` 输出。 -5. profile API + 前端接入头像上传。 -6. 清理无用 schema(独立 PR)。 - -## 11. 验收标准(DoD) - -全部满足才算完成: - -1. 解卦后写入的 assistant 消息在 DB 中可见 `metadata.agent_output.divination_derived`。 -2. 首页历史完全来自后端,清空本地缓存后仍可正确展示。 -3. 历史详情可完整还原结果页,不依赖 `ui_schema`。 -4. profile 读写走后端,头像实际落 `avatars` bucket。 -5. 前端不再把 profile/history 作为本地权威数据源。 -6. 协议文档与实现一致,相关测试通过。 - -## 12. GSTACK REVIEW REPORT - -| Review | Trigger | Why | Runs | Status | Findings | -|--------|---------|-----|------|--------|----------| -| Eng Review | `/plan-eng-review` | 锁定架构、契约、测试闭环 | 1 | Done | 确认后端单一数据源方向;建议分阶段移除 `ui_schema` 并将 schema 清理拆分独立 PR | -| CEO Review | `/plan-ceo-review` | 范围与优先级 | 0 | — | — | -| Design Review | `/plan-design-review` | UI/UX 风险 | 0 | — | — | -| DX Review | `/plan-devex-review` | 开发体验风险 | 0 | — | — | - -VERDICT:可以进入实现阶段,但必须先完成协议文档更新并冻结字段契约。 diff --git a/docs/plans/2026-04-05-divination-history-profile-eng-plan.md b/docs/plans/2026-04-05-divination-history-profile-eng-plan.md deleted file mode 100644 index 9bacc36..0000000 --- a/docs/plans/2026-04-05-divination-history-profile-eng-plan.md +++ /dev/null @@ -1,403 +0,0 @@ -# Eryao 工程计划:历史解卦与个人档案后端化(单一数据源) - -日期:2026-04-05 -状态:规划中(Planning Only) - -## 0. 约束与决策前提 - -本计划基于已确认前提: - -1. 当前无生产兼容压力,旧字段可直接不兼容。 -2. 前端只做缓存层,不做权威数据源。 -3. `ui_schema` 属于通用迁移遗留,不在本项目范围,目标是移除。 -4. 头像存储必须使用 `avatars` bucket(`config.storage.avatar.bucket`)。 - ---- - -## 1. 目标 - -在不引入额外业务表的前提下,完成以下工程目标: - -1. assistant 消息落库时,`metadata.agent_output` 持久化完整 `divination_derived`。 -2. `GET /api/v1/agent/history` 返回前端可直接消费的 `assistant.agent_output`(移除 `ui_schema`)。 -3. 新增 profile 后端 API,前端设置页改为后端读写。 -4. 头像上传改为预签名 + `avatars` bucket,后端校验路径和类型。 - ---- - -## 2. 系统边界与职责 - -### 2.1 边界图 - -```text -[Flutter App] - | Auth Token - v -[API Router v1] - |---- /agent/runs + /agent/history - |---- /users/me/profile + /users/me/avatar/upload-url - v -[Service Layer] - |---- AgentService: 会话、历史、消息转换 - |---- UserProfileService: 档案读写、头像签名 - v -[Repository Layer] - |---- sessions/messages/profiles CRUD - v -[Postgres + Supabase Storage] - |---- messages.metadata_json - |---- profiles - |---- bucket: avatars -``` - -### 2.2 分层职责 - -- Router:参数校验、鉴权入口、RFC7807 错误转换。 -- Service:业务规则与信任边界控制。 -- Repository:纯查询和写入,不做鉴权决策。 -- Schema:协议强类型、禁止松散 dict 漂移。 - ---- - -## 3. 数据流设计 - -## 3.1 解卦写入链路(新增 `divination_derived`) - -```text -POST /agent/runs - -> Runner emit DIVINATION_DERIVED(divination) - -> StageEmitter merge into TEXT_MESSAGE_END payload - -> EventStore picks worker_output_fields - -> metadata.agent_output.divination_derived persisted - -> messages.metadata_json -``` - -### 关键点 - -1. `AgentOutput` 增加 `divination_derived` 强类型字段。 -2. `EventStore` 字段白名单纳入 `divination_derived`。 -3. `extra="forbid"` 保留,防止脏字段入库。 - -## 3.2 历史读取链路(移除 `ui_schema`) - -```text -GET /agent/history - -> AgentService.get_history_snapshot - -> convert_message_to_history - user -> content + attachments - assistant -> content + agent_output - -> HistoryMessage response -``` - -### 关键点 - -1. 停止 `ui_hints -> ui_schema` 编译。 -2. assistant 返回受控 `agent_output` 子集,不透传任意 metadata。 -3. 前端结果页以 `agent_output.divination_derived` 为主数据源。 - -## 3.3 Profile 与头像链路 - -```text -GET /users/me/profile - -> read profiles - -PATCH /users/me/profile - -> validate payload - -> update profiles - -POST /users/me/avatar/upload-url - -> validate mime/size/path - -> create signed upload url (bucket=avatars) -``` - ---- - -## 4. API 契约(冻结版) - -## 4.1 History 响应(目标结构) - -```json -{ - "scope": "history_day", - "threadId": "uuid", - "day": "2026-04-05", - "hasMore": false, - "messages": [ - { - "id": "uuid", - "seq": 12, - "role": "assistant", - "content": "...", - "timestamp": "2026-04-05T12:34:56Z", - "agent_output": { - "sign_level": "中上签", - "summary": "...", - "conclusion": ["..."], - "focus_points": ["..."], - "advice": ["..."], - "keywords": ["..."], - "answer": "...", - "divination_derived": { - "binaryCode": "101001", - "changedBinaryCode": "100001", - "guaName": "...", - "targetGuaName": "...", - "ganzhi": {}, - "yaoInfoList": [] - } - } - } - ] -} -``` - -说明: - -- 本接口不再返回 `ui_schema`。 -- user 消息仍可返回 `attachments`。 - -## 4.2 Profile API - -### `GET /api/v1/users/me/profile` - -```json -{ - "user_id": "uuid", - "display_name": "string", - "bio": "string", - "avatar_path": "avatars/{user_id}/...", - "avatar_url": "https://...", - "updated_at": "..." -} -``` - -### `PATCH /api/v1/users/me/profile` - -请求: - -```json -{ - "display_name": "string<=30", - "bio": "string<=200", - "avatar_path": "avatars/{user_id}/..." -} -``` - -### `POST /api/v1/users/me/avatar/upload-url` - -请求: - -```json -{ - "mime_type": "image/png", - "file_size": 123456, - "ext": "png" -} -``` - -响应: - -```json -{ - "bucket": "avatars", - "path": "avatars/{user_id}/{uuid}.png", - "upload_url": "https://...", - "expires_in": 600 -} -``` - ---- - -## 5. 信任边界与安全规则 - -1. `user_id` 只能取 JWT `sub`,禁止客户端传 owner。 -2. 头像 path 必须前缀匹配:`avatars/{current_user.id}/`。 -3. bucket 必须等于 `config.storage.avatar.bucket`。 -4. mime 白名单:`image/png|image/jpeg|image/webp`。 -5. size 上限:`config.storage.avatar.max_size_mb`。 -6. history 读取严格校验 session owner。 -7. 错误统一 RFC7807 + `code`。 - ---- - -## 6. 失败模式与处理 - -## 6.1 消息落库阶段 - -1. `divination_derived` 校验失败 - - 行为:拒绝写入该字段并记录结构化日志。 - - 错误码:`AGENT_OUTPUT_DIVINATION_INVALID`(新)。 -2. TEXT_MESSAGE_END 缺失关键字段 - - 行为:整条 assistant 消息按失败路径处理,不写半残对象。 - -## 6.2 history 读取阶段 - -1. `agent_output` 缺失或损坏 - - 行为:assistant 消息返回 `content`,并标记 `agent_output=null`。 - - 前端:展示“历史记录不完整”提示,不崩溃。 -2. 非 owner 访问 - - 行为:403,`code=AGENT_SESSION_FORBIDDEN`。 - -## 6.3 头像上传阶段 - -1. bucket/path 越权 - - 422,`AVATAR_PATH_SCOPE_INVALID`。 -2. mime/size 非法 - - 422,`AVATAR_FILE_INVALID`。 -3. storage 签名失败 - - 502,`AVATAR_SIGNED_URL_FAILED`。 - ---- - -## 7. 关键边缘场景 - -1. 用户连续点击“保存资料”两次: - - 以后端最后一次写入为准,前端按钮防抖。 -2. 上传头像成功但 profile 更新失败: - - 前端重试 profile PATCH,不重复上传。 -3. history 返回空列表: - - 前端展示空态,不触发本地假数据。 -4. 助手消息存在但缺 `divination_derived`: - - 卡片可展示摘要,不允许进入完整结果页。 -5. 解卦完成后 history 立即读取: - - 允许短暂读到旧快照,前端做一次重拉。 - ---- - -## 8. 技术取舍 - -### 方案 A(推荐):在现有 messages.metadata 扩展 - -- 优点: - - 最小变更,不新增表。 - - 复用当前会话与历史体系。 -- 缺点: - - metadata 体积增大,需要关注单条消息大小。 - -### 方案 B:新增 `divination_results` 独立表 - -- 优点: - - 结构更纯,查询更明确。 -- 缺点: - - 迁移、回写、关联复杂度明显增加。 - -结论: - -- 当前阶段选 A,满足速度与复杂度平衡。 - ---- - -## 9. 实施切片(按风险顺序) - -### Slice 1:协议与 schema - -1. 更新协议文档:history + profile + 错误码。 -2. 更新 `AgentOutput` 模型字段。 - -### Slice 2:写链路改造 - -1. runner/emitter/store 打通 `divination_derived` 落库。 -2. 增加单元测试与集成测试。 - -### Slice 3:读链路改造 - -1. history 转换改为返回 `agent_output`。 -2. 移除 `ui_schema` 响应字段。 - -### Slice 4:profile API + 头像 - -1. users 路由、service、schema。 -2. 头像 upload-url 接口。 - -### Slice 5:前端切换 - -1. 历史列表/详情改消费后端 `agent_output`。 -2. 设置页改 profile 接口。 -3. 清理本地真源。 - ---- - -## 10. 测试覆盖计划 - -## 10.1 后端测试矩阵 - -### A. AgentOutput 落库 - -1. `divination_derived` 正常写入。 -2. `divination_derived` 非法结构拒绝写入。 - -### B. history 接口 - -1. assistant 返回 `agent_output`。 -2. 响应不含 `ui_schema`。 -3. 非 owner 403。 -4. 空历史返回空数组。 - -### C. profile 接口 - -1. GET 返回当前用户档案。 -2. PATCH 字段边界(空、超长、非法字符)。 -3. 并发 PATCH 最终一致性。 - -### D. avatar upload-url - -1. 合法 mime/size/path 成功签名。 -2. bucket/path 越权失败。 -3. mime/size 超限失败。 -4. storage 异常返回 502 问题体。 - -## 10.2 前端测试矩阵 - -1. history 列表从接口渲染。 -2. 点击历史项进入结果页并解析 `divination_derived`。 -3. profile 读写回显。 -4. 头像上传后刷新显示。 -5. 异常提示(网络失败、数据缺失)不崩溃。 - ---- - -## 11. 可观测性 - -新增日志字段建议: - -1. history 响应统计:`thread_id`, `message_count`, `assistant_with_agent_output_count`。 -2. profile 更新:`user_id`, `updated_fields`。 -3. avatar 签名:`user_id`, `mime_type`, `file_size`, `success/failure_code`。 - -指标建议: - -1. `history_agent_output_missing_rate`。 -2. `avatar_upload_url_failure_rate`。 -3. `profile_patch_error_rate`。 - ---- - -## 12. 风险与回滚 - -### 风险 - -1. 单条 metadata 变大,可能影响查询性能。 -2. 前端解析新结构时存在字段名误配风险。 - -### 回滚 - -1. 若读链路异常,先回滚 history 输出层(保持落库不回滚)。 -2. profile 接口异常时,可临时只读禁写,保护账户信息。 - ---- - -## 13. 验收标准(Done) - -1. 新产生 assistant 消息均含 `metadata.agent_output.divination_derived`。 -2. history 接口返回 `agent_output`,且不再返回 `ui_schema`。 -3. 前端历史页与结果页不依赖本地真源。 -4. profile 读写和头像上传全走后端。 -5. 测试矩阵项全部落地并通过。 - ---- - -## 14. NOT in Scope - -1. 大规模清理 `backend/src/schemas/domain/**`。 -2. 历史数据回填脚本。 -3. 新增独立 `divination_results` 表。 diff --git a/docs/protocols/common/http-error-codes.md b/docs/protocols/common/http-error-codes.md index c5d3fd8..48dc933 100644 --- a/docs/protocols/common/http-error-codes.md +++ b/docs/protocols/common/http-error-codes.md @@ -23,9 +23,16 @@ 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_SESSION_RUN_LIMIT_EXCEEDED` | 409 | Session already reached max run count (start + 1 follow-up) | Show run-limit message and require starting a new session | +| `AGENT_RUNTIME_MODE_INVALID` | 422 | Missing or invalid `forwardedProps.runtime_mode` in run request | Show invalid-request message and retry from current page | | `AGENT_DIVINATION_PAYLOAD_REQUIRED` | 422 | Missing required `forwardedProps.divinationPayload` in run request | Prompt user to restart casting flow and resubmit | | `AGENT_OUTPUT_DIVINATION_INVALID` | 422 | Worker output contains invalid `divination_derived` payload shape | Show generic history parse error and suggest retrying latest run | +| `AGENT_SESSION_ID_INVALID` | 422 | Invalid session/thread id format | Show invalid-session message and force refresh history | +| `AGENT_SESSION_NOT_FOUND` | 404 | Session does not exist (including follow-up on non-existing thread) | Show session-not-found message and refresh history | +| `AGENT_AUDIO_UNSUPPORTED_FORMAT` | 400 | Audio format is not accepted by transcribe endpoint | Show format hint and ask user to retry with wav audio | +| `AGENT_AUDIO_TOO_LARGE` | 400 | Audio file exceeds transcribe size limit | Show size-limit message and ask user to shorten audio | +| `AGENT_AUDIO_EMPTY` | 400 | Uploaded audio payload is empty | Show retry hint and keep input unchanged | +| `AGENT_ASR_UNAVAILABLE` | 502 | Upstream ASR service unavailable | Show retry message and allow fallback to text input | ## Profile diff --git a/docs/protocols/common/user-points-chat-data-protocol.md b/docs/protocols/common/user-points-chat-data-protocol.md index 9d93763..d00796e 100644 --- a/docs/protocols/common/user-points-chat-data-protocol.md +++ b/docs/protocols/common/user-points-chat-data-protocol.md @@ -28,7 +28,7 @@ Protocol verification status: - Charge timing: deduct after worker run succeeds (`RUN_FINISHED` path). - Failure behavior: failed/canceled runs do not deduct points. - Precheck: before accepting a run, backend must verify `available = balance - frozen_balance >= 20`. -- Session follow-up cap: one session allows at most 4 user runs total (initial divination + 3 follow-ups). +- Session follow-up cap: one session allows at most 2 user runs total (initial divination + 1 follow-up). - Billing idempotency key for per-run consume: `chat.run.success:{session_id}:{run_id}`. ## Table contract diff --git a/docs/protocols/divination/divination-run-protocol.md b/docs/protocols/divination/divination-run-protocol.md index 0e1b520..b15d16c 100644 --- a/docs/protocols/divination/divination-run-protocol.md +++ b/docs/protocols/divination/divination-run-protocol.md @@ -10,7 +10,8 @@ Protocol verification status: ## Compatibility strategy -- Current strategy: additive evolution only. +- Run/events contract: `backward-compatible` additive evolution. +- History contract (`GET /agent/history`) currently `requires-migration` (see migration notes in this document). - Existing required fields cannot be removed or renamed without migration notes. - Canonical divination terminology values must remain Chinese. @@ -19,6 +20,8 @@ Protocol verification status: - Submit run: `POST /api/v1/agent/runs` - Stream events: `GET /api/v1/agent/runs/{threadId}/events?runId=...` - History snapshot: `GET /api/v1/agent/history` +- Delete session: `DELETE /api/v1/agent/sessions/{threadId}` +- Audio transcribe: `POST /api/v1/agent/transcribe` ## Run request contract @@ -85,14 +88,36 @@ Protocol verification status: - `yaoLines` item enum: `少阳 | 少阴 | 老阳 | 老阴` - Additional fields are forbidden. +### `runtime_mode` rules + +- Allowed values: `chat | follow_up`. +- `chat`: first run for a session. +- `follow_up`: follow-up run in existing session. +- Missing or invalid `runtime_mode` MUST return `422` with code `AGENT_RUNTIME_MODE_INVALID`. +- Current backend behavior still requires `forwardedProps.divinationPayload` in both modes. + +### Follow-up request note + +- Follow-up submit still uses `POST /api/v1/agent/runs`. +- Required differences from first run: + - `threadId` must be existing session id. + - `forwardedProps.runtime_mode` must be `follow_up`. + - `messages[0].content` is follow-up question text. + - If `threadId` does not exist, backend returns `404` (`AGENT_SESSION_NOT_FOUND`). + ## Event output contract During run streaming, backend emits standard AG-UI lifecycle events and two divination-relevant payload events: +- Lifecycle: `RUN_STARTED`, `RUN_FINISHED` or `RUN_ERROR`. +- Step events: `STEP_STARTED`, `STEP_FINISHED` with `stepName` (for example: `worker`). +- Payload events: `DIVINATION_DERIVED`, `TEXT_MESSAGE_END`. + ### 1) `DIVINATION_DERIVED` - Emitted once after backend derives hexagram data. - Payload field: `divination` (strict object). +- Emitted only when `runtime_mode=chat`. `divination` object: @@ -160,7 +185,9 @@ During run streaming, backend emits standard AG-UI lifecycle events and two divi ### 2) `TEXT_MESSAGE_END` - Standard final answer event. -- Existing fields remain canonical: `sign_level`, `conclusion`, `focus_points`, `advice`, `keywords`, `answer`. +- `runtime_mode=chat` fields: `status`, `sign_level`, `conclusion`, `focus_points`, `advice`, `keywords`, `answer`, `error`, `divination_derived`. +- `runtime_mode=follow_up` fields: `status`, `answer`, `error`. +- `runtime_mode=follow_up` MUST NOT include `sign_level`, `conclusion`, `focus_points`, `advice`, `keywords`, `divination_derived`. - Language rule: `conclusion`, `focus_points`, `advice`, `keywords`, `answer` should follow user `ai_language` preference unless user explicitly requests otherwise. - Canonical six-yao terms remain Chinese in protocol text (for example: 世爻、应爻、动爻、静爻、六亲、六神、伏神、月建、日辰、月破、日冲、空亡、五行旺衰). @@ -173,17 +200,22 @@ Frontend should combine: `GET /api/v1/agent/history` is the canonical replay source for frontend history list and result reconstruction. +- When `threadId` is provided, backend returns full session messages ordered by `seq asc`. +- When `threadId` is omitted, backend returns one latest assistant message per session for history list summary. +- `threadId` is the session identifier (same value as AG-UI run/events path parameter). + ### Required response shape ```json { - "scope": "history_day", + "scope": "history_session_full", "threadId": "uuid|null", - "day": "2026-04-05|null", + "day": null, "hasMore": false, "messages": [ { "id": "uuid", + "threadId": "session-uuid", "seq": 12, "role": "assistant", "content": "...", @@ -205,6 +237,7 @@ Frontend should combine: }, { "id": "uuid", + "threadId": "session-uuid", "seq": 11, "role": "user", "content": "我最近换工作是否合适?", @@ -222,17 +255,74 @@ Frontend should combine: Rules: +- `scope=history_session_full` means full-thread replay with `messages` ordered by `seq`. +- `scope=history_sessions_latest_assistant` means cross-session summary list. +- Each `messages[i].threadId` is required and points to the owning session. Frontend must use this value to open detail replay and follow-up. +- `day` and `hasMore` are retained for compatibility with old clients, but in `history_session_full` mode backend currently returns `day=null` and `hasMore=false`. +- In `history_sessions_latest_assistant`, backend computes `hasMore` from `session_limit + 1` query. - `assistant` message MUST provide `agent_output` when backend has valid worker output metadata. - `agent_output.divination_derived` uses the same shape as `DIVINATION_DERIVED.divination` payload. - Frontend reconstructs divination result page from `agent_output` data, not from local mock data. - `agent_output.sign_level` allowed values: `上上签` / `中上签` / `中下签` / `下下签`. -### Breaking change note +## Migration note (`requires-migration`) -- `ui_schema` is removed from history response and is no longer part of this project protocol. -- This repository currently accepts non-backward-compatible protocol evolution (no production compatibility burden). +### Change set + +1. `GET /agent/history?threadId=...` now returns full session replay (`scope=history_session_full`) instead of day-window snapshot semantics. +2. `messages[i].threadId` is now required. +3. `ui_schema` is removed from history payload. +4. `runtime_mode=follow_up` uses minimal `TEXT_MESSAGE_END` schema and no longer emits `DIVINATION_DERIVED`. +5. Added `DELETE /api/v1/agent/sessions/{threadId}` with idempotent `204 No Content` (already deleted or not found also returns 204). + +### Frontend migration steps + +1. Parse history as ordered message stream by `messages[].seq`. +2. Use `messages[].threadId` as session id for opening follow-up. +3. Do not depend on `ui_schema` in history payload. +4. Keep `day`/`hasMore` optional-read only; do not use them for pagination in thread replay mode. + +### Rollback notes + +- If backend rolls back to day-window semantics, frontend must switch detail replay back to day-based load logic and stop requiring `messages[].threadId`. +- Keep legacy parser branch behind feature flag during staged rollout. ## Error contract linkage - All errors use RFC7807 with extension `code` and optional `params`. - Error code registry source: `docs/protocols/common/http-error-codes.md`. + +## Session delete contract + +### `DELETE /api/v1/agent/sessions/{threadId}` + +- Authorization: current user must own the session. +- Semantics: soft delete session (`deleted_at`), history reads filter deleted sessions by default. +- Success: `204 No Content`. +- Idempotent: already deleted or not found also returns `204 No Content`. + +## Transcribe contract + +### `POST /api/v1/agent/transcribe` + +Request: + +- `multipart/form-data` +- field name: `audio` +- allowed content types: `audio/wav`, `audio/x-wav`, `audio/wave` +- max payload bytes: `10MB` + +Response: + +```json +{ + "transcript": "我今天适合出门谈合作吗?" +} +``` + +Error codes (see common registry): + +- `AGENT_AUDIO_UNSUPPORTED_FORMAT` +- `AGENT_AUDIO_TOO_LARGE` +- `AGENT_AUDIO_EMPTY` +- `AGENT_ASR_UNAVAILABLE` diff --git a/docs/protocols/profile/profile-protocol.md b/docs/protocols/profile/profile-protocol.md index 9304a9b..80476ff 100644 --- a/docs/protocols/profile/profile-protocol.md +++ b/docs/protocols/profile/profile-protocol.md @@ -4,19 +4,23 @@ This document defines the canonical backend contract for user profile read/write Protocol verification status: -- Backend model source: `backend/src/models/profile.py` +- Backend route source: `backend/src/v1/users/router.py` +- Backend schema source: `backend/src/v1/users/schemas.py` +- Backend service source: `backend/src/v1/users/service.py` +- Frontend mapping source: `apps/lib/features/settings/data/apis/profile_api.dart` - Storage config source: `backend/src/core/config/settings.py` -- Current status: planned +- Current status: aligned ## Compatibility strategy -- Current strategy: breaking changes allowed during implementation phase (no production compatibility burden). -- Once production compatibility is required, switch to additive-only evolution. +- Current strategy: additive evolution (`backward-compatible`). +- Breaking change requires explicit migration + rollback notes (`requires-migration`). ## Route overview - Get profile: `GET /api/v1/users/me/profile` - Update profile: `PATCH /api/v1/users/me/profile` +- Update settings: `PATCH /api/v1/users/me/settings` - Create avatar upload url: `POST /api/v1/users/me/avatar/upload-url` - Upload avatar directly: `POST /api/v1/users/me/avatar` (multipart) @@ -84,6 +88,40 @@ Response: - Returns the same shape as `GET /users/me/profile`. +## Settings update contract + +### `PATCH /api/v1/users/me/settings` + +Request: + +```json +{ + "settings": { + "version": 1, + "preferences": { + "interface_language": "zh-CN", + "ai_language": "zh-CN", + "timezone": "Asia/Shanghai", + "country": "CN" + }, + "privacy": {}, + "notification": { + "allow_notifications": true, + "allow_vibration": true + } + } +} +``` + +Rules: + +- `settings` must conform to `ProfileSettingsV1`. +- Additional fields are forbidden. + +Response: + +- Returns the same shape as `GET /users/me/profile`. + ## Avatar upload signing contract ### `POST /api/v1/users/me/avatar/upload-url` diff --git a/infra/docker/supabase/docker-compose.yml b/infra/docker/supabase/docker-compose.yml index 15307d1..dd99eb5 100644 --- a/infra/docker/supabase/docker-compose.yml +++ b/infra/docker/supabase/docker-compose.yml @@ -165,10 +165,12 @@ services: SUPABASE_ANON_KEY: ${ERYAO_SUPABASE__ANON_KEY} SUPABASE_SERVICE_KEY: ${ERYAO_SUPABASE__SERVICE_ROLE_KEY} AUTH_JWT_SECRET: ${ERYAO_SUPABASE__JWT_SECRET} - EDGE_FUNCTIONS_MANAGEMENT_FOLDER: /tmp/functions + EDGE_FUNCTIONS_MANAGEMENT_FOLDER: /var/lib/functions LOGFLARE_API_KEY: local-logflare-public-token LOGFLARE_URL: http://localhost:4000 NEXT_PUBLIC_ENABLE_LOGS: "false" + volumes: + - studio-functions:/var/lib/functions kong: container_name: supabase-kong @@ -215,3 +217,4 @@ services: volumes: db-config: storage-data: + studio-functions: