docs: 更新协议文档,删除废弃计划文档
- 更新 http-error-codes, user-points-chat-data-protocol - 更新 divination-run-protocol, profile-protocol - 删除废弃的后端和前端设计计划文档
This commit is contained in:
@@ -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: <String, dynamic>{'messageId': raw['id']},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
final agentOutputRaw = raw['agent_output'];
|
||||
if (agentOutputRaw is! Map<String, dynamic>) {
|
||||
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<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,
|
||||
@@ -107,7 +217,10 @@ class DivinationApi {
|
||||
response = await _apiClient.rawDio.get<ResponseBody>(
|
||||
'/api/v1/agent/runs/$threadId/events',
|
||||
queryParameters: <String, dynamic>{'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<String, dynamic> buildDivinationRunPayload({
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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<String> conclusion;
|
||||
final List<String> 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,
|
||||
|
||||
@@ -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<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'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'),
|
||||
|
||||
@@ -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<String, dynamic> 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<String, dynamic> json, String key) {
|
||||
final value = json[key];
|
||||
if (value is! String || value.isEmpty) {
|
||||
throw FormatException('Missing required string: $key');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
String _requiredStringAllowEmpty(Map<String, dynamic> json, String key) {
|
||||
final value = json[key];
|
||||
if (value is! String) {
|
||||
throw FormatException('Missing required string: $key');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
int _requiredInt(Map<String, dynamic> 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');
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:record/record.dart';
|
||||
|
||||
abstract class VoiceRecorder {
|
||||
Future<void> start();
|
||||
Future<String?> stop();
|
||||
Future<void> dispose();
|
||||
}
|
||||
|
||||
class RecordVoiceRecorder implements VoiceRecorder {
|
||||
RecordVoiceRecorder({AudioRecorder? recorder})
|
||||
: _recorder = recorder ?? AudioRecorder();
|
||||
|
||||
final AudioRecorder _recorder;
|
||||
String? _currentPath;
|
||||
|
||||
@override
|
||||
Future<void> 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<String?> stop() async {
|
||||
try {
|
||||
final stoppedPath = await _recorder.stop();
|
||||
return stoppedPath ?? _currentPath;
|
||||
} on MissingPluginException {
|
||||
throw StateError('录音能力不可用');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
await _recorder.dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user