Files
eryao/apps/lib/features/divination/data/apis/divination_api.dart
T

342 lines
9.8 KiB
Dart

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<PointsBalanceData> getPointsBalance() async {
final json = await _apiClient.getJson('/api/v1/points/balance');
return PointsBalanceData.fromJson(json);
}
Future<RunAcceptedData> enqueueRun({
required DivinationParams params,
required List<YaoType> yaoStates,
required String threadId,
required String runId,
}) async {
final payload = buildDivinationRunPayload(
params: params,
yaoStates: yaoStates,
threadId: threadId,
runId: runId,
clientNow: DateTime.now(),
);
final json = await _apiClient.postJson('/api/v1/agent/runs', data: payload);
return RunAcceptedData.fromJson(json);
}
Future<List<DivinationResultData>> getHistoryRecords({
required String userId,
}) async {
final json = await _apiClient.getJson('/api/v1/agent/history');
final messagesRaw = json['messages'];
if (messagesRaw is! List<dynamic>) {
return const <DivinationResultData>[];
}
final records = <DivinationResultData>[];
for (final raw in messagesRaw) {
if (raw is! Map<String, dynamic>) {
continue;
}
if (raw['role'] != 'assistant') {
continue;
}
final agentOutputRaw = raw['agent_output'];
if (agentOutputRaw is! Map<String, dynamic>) {
continue;
}
final derivedRaw = agentOutputRaw['divination_derived'];
if (derivedRaw is! Map<String, dynamic>) {
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']),
summary: _asString(agentOutputRaw['summary']),
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: <String, dynamic>{
'error': error.toString(),
'stackTrace': stackTrace.toString(),
},
);
continue;
}
}
return records;
}
Stream<Map<String, dynamic>> streamEvents({
required String threadId,
required String runId,
}) async* {
Response<ResponseBody> response;
try {
response = await _apiClient.rawDio.get<ResponseBody>(
'/api/v1/agent/runs/$threadId/events',
queryParameters: <String, dynamic>{'runId': runId},
options: Options(responseType: ResponseType.stream),
);
} on DioException catch (error, stackTrace) {
_logger.error(
message: 'Failed to open SSE stream for divination run',
error: error,
stackTrace: stackTrace,
extra: <String, dynamic>{'threadId': threadId, 'runId': runId},
);
throw _mapProblem(error);
}
final body = response.data;
if (body == null) {
return;
}
String buffer = '';
try {
await for (final textChunk in utf8.decoder.bind(body.stream)) {
buffer += textChunk.replaceAll('\r\n', '\n');
while (true) {
final splitAt = buffer.indexOf('\n\n');
if (splitAt < 0) {
break;
}
final frame = buffer.substring(0, splitAt);
buffer = buffer.substring(splitAt + 2);
final event = _parseSseFrame(frame);
if (event != null) {
yield event;
}
}
}
} on FormatException catch (error, stackTrace) {
_logger.error(
message: 'Failed to decode SSE stream chunk',
error: error,
stackTrace: stackTrace,
extra: <String, dynamic>{
'threadId': threadId,
'runId': runId,
'bufferLength': buffer.length,
},
);
throw ApiProblem(
status: 502,
title: 'SSE parse error',
detail: error.message,
);
}
if (buffer.trim().isNotEmpty) {
final event = _parseSseFrame(buffer);
if (event != null) {
yield event;
}
}
}
ApiProblem _mapProblem(DioException error) {
final status = error.response?.statusCode ?? 500;
final data = error.response?.data;
if (data is Map<String, dynamic>) {
return ApiProblem(
status: status,
title: (data['title'] as String?) ?? 'Request failed',
detail: (data['detail'] as String?) ?? '',
code: data['code'] as String?,
);
}
return ApiProblem(
status: status,
title: 'Network error',
detail: error.message ?? 'Request failed',
);
}
Map<String, dynamic>? _parseSseFrame(String frame) {
if (frame.startsWith(':')) {
return null;
}
final lines = frame.split('\n');
String eventType = '';
final dataLines = <String>[];
for (final raw in lines) {
final line = raw.trimRight();
if (line.startsWith('event:')) {
eventType = line.substring(6).trim();
continue;
}
if (line.startsWith('data:')) {
dataLines.add(line.substring(5).trimLeft());
}
}
if (dataLines.isEmpty) {
return null;
}
final dataText = dataLines.join('\n');
if (dataText.trim().isEmpty) {
return null;
}
final decoded = jsonDecode(dataText);
if (decoded is! Map<String, dynamic>) {
return null;
}
if (!decoded.containsKey('type') && eventType.isNotEmpty) {
decoded['type'] = eventType;
}
return decoded;
}
}
Map<String, dynamic> buildDivinationRunPayload({
required DivinationParams params,
required List<YaoType> yaoStates,
required String threadId,
required String runId,
required DateTime clientNow,
}) {
if (yaoStates.length != 6) {
throw ArgumentError('yaoStates must contain exactly 6 items');
}
return <String, dynamic>{
'threadId': threadId,
'runId': runId,
'state': <String, dynamic>{},
'messages': [
{'id': 'msg_${runId}_user_0', 'role': 'user', 'content': params.question},
],
'tools': const <Map<String, dynamic>>[],
'context': const <Map<String, dynamic>>[],
'forwardedProps': {
'runtime_mode': 'chat',
'client_time': {
'device_timezone': 'Asia/Shanghai',
'client_now_iso': _toRfc3339Utc(clientNow),
'client_epoch_ms': clientNow.millisecondsSinceEpoch,
},
'divinationPayload': {
'divinationMethod': params.method == DivinationMethod.manual
? '手动起卦'
: '自动起卦',
'questionType': _questionTypeToText(params.questionType),
'question': params.question,
'divinationTimeIso': _toRfc3339Utc(params.divinationTime),
'yaoLines': yaoStates.map(_yaoTypeToText).toList(growable: false),
},
},
};
}
String _toRfc3339Utc(DateTime value) {
return value.toUtc().toIso8601String();
}
String _questionTypeToText(QuestionType type) {
return switch (type) {
QuestionType.career => '事业',
QuestionType.love => '情感',
QuestionType.wealth => '财富',
QuestionType.fortune => '运势',
QuestionType.dream => '解梦',
QuestionType.health => '健康',
QuestionType.study => '学业',
QuestionType.search => '寻物',
QuestionType.other => '其他',
};
}
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<String, dynamic> 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<String> _asStringList(Object? value) {
if (value is! List<dynamic>) {
return const <String>[];
}
return value.whereType<String>().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',
),
};
}