import 'dart:async'; import 'dart:convert'; import 'package:dio/dio.dart'; import '../../../../core/logging/logger.dart'; import '../../../../core/network/api_problem.dart'; import '../../../../data/network/api_client.dart'; import '../models/divination_backend_models.dart'; import '../models/divination_params.dart'; import '../models/divination_result.dart'; class DivinationApi { const DivinationApi({required ApiClient apiClient}) : _apiClient = apiClient; final ApiClient _apiClient; static final Logger _logger = getLogger('features.divination.api'); Future getPointsBalance() async { final json = await _apiClient.getJson('/api/v1/points/balance'); return PointsBalanceData.fromJson(json); } Future enqueueRun({ required DivinationParams params, required List yaoStates, required String threadId, required String runId, }) async { final payload = buildDivinationRunPayload( params: params, yaoStates: yaoStates, threadId: threadId, runId: runId, clientNow: DateTime.now(), ); final json = await _apiClient.postJson('/api/v1/agent/runs', data: payload); return RunAcceptedData.fromJson(json); } Future> getHistoryRecords({ required String userId, }) async { final json = await _apiClient.getJson('/api/v1/agent/history'); final messagesRaw = json['messages']; if (messagesRaw is! List) { return const []; } final records = []; for (final raw in messagesRaw) { if (raw is! Map) { continue; } if (raw['role'] != 'assistant') { continue; } final agentOutputRaw = raw['agent_output']; if (agentOutputRaw is! Map) { continue; } final derivedRaw = agentOutputRaw['divination_derived']; if (derivedRaw is! Map) { continue; } try { final derived = DerivedDivinationData.fromJson(derivedRaw); final divinationTime = _resolveHistoryTime(raw, derived); final params = DivinationParams( method: _methodFromText(derived.divinationMethod), questionType: _questionTypeFromText(derived.questionType), question: derived.question, divinationTime: divinationTime, coinBalance: 0, userId: userId, ); final aggregate = DivinationRunAggregate( derived: derived, signLevel: _asString(agentOutputRaw['sign_level']), conclusion: _asStringList(agentOutputRaw['conclusion']), focusPoints: _asStringList(agentOutputRaw['focus_points']), advice: _asStringList(agentOutputRaw['advice']), keywords: _asStringList(agentOutputRaw['keywords']), answer: _asString(agentOutputRaw['answer']), ); records.add(aggregate.toViewData(params)); } catch (error, stackTrace) { _logger.warning( message: 'Skip malformed history assistant message', extra: { 'error': error.toString(), 'stackTrace': stackTrace.toString(), }, ); continue; } } return records; } Stream> streamEvents({ required String threadId, required String runId, }) async* { Response response; try { response = await _apiClient.rawDio.get( '/api/v1/agent/runs/$threadId/events', queryParameters: {'runId': runId}, options: Options(responseType: ResponseType.stream), ); } on DioException catch (error, stackTrace) { _logger.error( message: 'Failed to open SSE stream for divination run', error: error, stackTrace: stackTrace, extra: {'threadId': threadId, 'runId': runId}, ); throw _mapProblem(error); } final body = response.data; if (body == null) { return; } String buffer = ''; try { await for (final textChunk in utf8.decoder.bind(body.stream)) { buffer += textChunk.replaceAll('\r\n', '\n'); while (true) { final splitAt = buffer.indexOf('\n\n'); if (splitAt < 0) { break; } final frame = buffer.substring(0, splitAt); buffer = buffer.substring(splitAt + 2); final event = _parseSseFrame(frame); if (event != null) { yield event; } } } } on FormatException catch (error, stackTrace) { _logger.error( message: 'Failed to decode SSE stream chunk', error: error, stackTrace: stackTrace, extra: { 'threadId': threadId, 'runId': runId, 'bufferLength': buffer.length, }, ); throw ApiProblem( status: 502, title: 'SSE parse error', detail: error.message, ); } if (buffer.trim().isNotEmpty) { final event = _parseSseFrame(buffer); if (event != null) { yield event; } } } ApiProblem _mapProblem(DioException error) { final status = error.response?.statusCode ?? 500; final data = error.response?.data; if (data is Map) { return ApiProblem( status: status, title: (data['title'] as String?) ?? 'Request failed', detail: (data['detail'] as String?) ?? '', code: data['code'] as String?, ); } return ApiProblem( status: status, title: 'Network error', detail: error.message ?? 'Request failed', ); } Map? _parseSseFrame(String frame) { if (frame.startsWith(':')) { return null; } final lines = frame.split('\n'); String eventType = ''; final dataLines = []; for (final raw in lines) { final line = raw.trimRight(); if (line.startsWith('event:')) { eventType = line.substring(6).trim(); continue; } if (line.startsWith('data:')) { dataLines.add(line.substring(5).trimLeft()); } } if (dataLines.isEmpty) { return null; } final dataText = dataLines.join('\n'); if (dataText.trim().isEmpty) { return null; } final decoded = jsonDecode(dataText); if (decoded is! Map) { return null; } if (!decoded.containsKey('type') && eventType.isNotEmpty) { decoded['type'] = eventType; } return decoded; } } Map buildDivinationRunPayload({ required DivinationParams params, required List yaoStates, required String threadId, required String runId, required DateTime clientNow, }) { if (yaoStates.length != 6) { throw ArgumentError('yaoStates must contain exactly 6 items'); } return { 'threadId': threadId, 'runId': runId, 'state': {}, 'messages': [ {'id': 'msg_${runId}_user_0', 'role': 'user', 'content': params.question}, ], 'tools': const >[], 'context': const >[], 'forwardedProps': { 'runtime_mode': 'chat', 'client_time': { 'device_timezone': 'Asia/Shanghai', 'client_now_iso': _toRfc3339Utc(clientNow), 'client_epoch_ms': clientNow.millisecondsSinceEpoch, }, 'divinationPayload': { 'divinationMethod': params.method == DivinationMethod.manual ? '手动起卦' : '自动起卦', 'questionType': _questionTypeToText(params.questionType), 'question': params.question, 'divinationTimeIso': _toRfc3339Utc(params.divinationTime), 'yaoLines': yaoStates.map(_yaoTypeToText).toList(growable: false), }, }, }; } String _toRfc3339Utc(DateTime value) { return value.toUtc().toIso8601String(); } String _questionTypeToText(QuestionType type) { return switch (type) { QuestionType.career => '事业', QuestionType.love => '情感', QuestionType.wealth => '财富', QuestionType.fortune => '运势', QuestionType.dream => '解梦', QuestionType.health => '健康', QuestionType.study => '学业', QuestionType.search => '寻物', QuestionType.other => '其他', }; } QuestionType _questionTypeFromText(String raw) { return switch (raw) { '事业' => QuestionType.career, '情感' => QuestionType.love, '财富' => QuestionType.wealth, '运势' => QuestionType.fortune, '解梦' => QuestionType.dream, '健康' => QuestionType.health, '学业' => QuestionType.study, '寻物' => QuestionType.search, _ => QuestionType.other, }; } DivinationMethod _methodFromText(String raw) { return raw == '自动起卦' ? DivinationMethod.auto : DivinationMethod.manual; } DateTime _resolveHistoryTime( Map message, DerivedDivinationData derived, ) { final timestamp = message['timestamp']; if (timestamp is String) { final parsed = DateTime.tryParse(timestamp); if (parsed != null) { return parsed.toLocal(); } } final derivedTime = DateTime.tryParse(derived.divinationTime); if (derivedTime != null) { return derivedTime.toLocal(); } return DateTime.now(); } String _asString(Object? value) { return value is String ? value : ''; } List _asStringList(Object? value) { if (value is! List) { return const []; } return value.whereType().toList(growable: false); } String _yaoTypeToText(YaoType type) { return switch (type) { YaoType.youngYang => '少阳', YaoType.youngYin => '少阴', YaoType.oldYang => '老阳', YaoType.oldYin => '老阴', YaoType.undetermined => throw ArgumentError( 'yaoStates contains undetermined line', ), }; }