e80a82bef4
- 更新 http-error-codes, user-points-chat-data-protocol - 更新 divination-run-protocol, profile-protocol - 删除废弃的后端和前端设计计划文档
493 lines
14 KiB
Dart
493 lines
14 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
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/follow_up_message.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 threadId = raw['threadId'];
|
|
if (threadId is! String || threadId.trim().isEmpty) {
|
|
_logger.warning(
|
|
message: 'Skip history item without threadId',
|
|
extra: <String, dynamic>{'messageId': raw['id']},
|
|
);
|
|
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(
|
|
threadId: threadId,
|
|
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: <String, dynamic>{
|
|
'error': error.toString(),
|
|
'stackTrace': stackTrace.toString(),
|
|
},
|
|
);
|
|
continue;
|
|
}
|
|
}
|
|
return records;
|
|
}
|
|
|
|
Future<List<FollowUpMessage>> getSessionMessages({
|
|
required String threadId,
|
|
}) async {
|
|
Map<String, dynamic> json;
|
|
try {
|
|
final response = await _apiClient.rawDio.get<Map<String, dynamic>>(
|
|
'/api/v1/agent/history',
|
|
queryParameters: <String, dynamic>{'threadId': threadId},
|
|
);
|
|
json = response.data ?? <String, dynamic>{};
|
|
} on DioException catch (error) {
|
|
throw _mapProblem(error);
|
|
}
|
|
final messagesRaw = json['messages'];
|
|
if (messagesRaw is! List<dynamic>) {
|
|
return const <FollowUpMessage>[];
|
|
}
|
|
|
|
final messages = <FollowUpMessage>[];
|
|
for (final raw in messagesRaw) {
|
|
if (raw is! Map<String, dynamic>) {
|
|
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: <String, dynamic>{
|
|
'error': error.toString(),
|
|
'stackTrace': stackTrace.toString(),
|
|
},
|
|
);
|
|
continue;
|
|
}
|
|
}
|
|
return messages;
|
|
}
|
|
|
|
Future<RunAcceptedData> 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<void> deleteSession({required String threadId}) async {
|
|
await _apiClient.deleteNoContent('/api/v1/agent/sessions/$threadId');
|
|
}
|
|
|
|
Future<String> 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<Map<String, dynamic>>(
|
|
'/api/v1/agent/transcribe',
|
|
data: FormData.fromMap(<String, dynamic>{
|
|
'audio': await MultipartFile.fromFile(
|
|
audioPath,
|
|
filename: 'follow_up.wav',
|
|
contentType: DioMediaType('audio', 'wav'),
|
|
),
|
|
}),
|
|
);
|
|
final payload = response.data;
|
|
if (payload is! Map<String, dynamic>) {
|
|
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<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,
|
|
receiveTimeout: null,
|
|
),
|
|
);
|
|
} 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),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
Map<String, dynamic> 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 <String, dynamic>{
|
|
'threadId': threadId,
|
|
'runId': runId,
|
|
'state': <String, dynamic>{},
|
|
'messages': [
|
|
{'id': 'msg_${runId}_user_0', 'role': 'user', 'content': question},
|
|
],
|
|
'tools': const <Map<String, dynamic>>[],
|
|
'context': const <Map<String, dynamic>>[],
|
|
'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();
|
|
}
|
|
|
|
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',
|
|
),
|
|
};
|
|
}
|