7b8865e256
- 新增 stepStarted/stepFinished 事件类型支持 - 前端实现图片附件上传和预览功能 - 后端增强工具结果存储和事件处理 - 完善相关单元测试和集成测试
777 lines
23 KiB
Dart
777 lines
23 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:math';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:dio/dio.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'package:social_app/core/api/i_api_client.dart';
|
|
import 'package:social_app/core/api/mock_api_client.dart';
|
|
|
|
import '../ai/ai_decision_engine.dart';
|
|
import '../models/ag_ui_event.dart';
|
|
import '../tools/tool_registry.dart';
|
|
import 'mock_history_service.dart';
|
|
|
|
typedef EventCallback = void Function(AgUiEvent event);
|
|
|
|
/// ID 前缀常量
|
|
const _runIdPrefix = 'run_';
|
|
const _messageIdPrefix = 'msg_';
|
|
const _toolCallIdPrefix = 'tc_';
|
|
|
|
class AgUiService {
|
|
final IApiClient _apiClient;
|
|
EventCallback onEvent;
|
|
final AiDecisionEngine _decisionEngine;
|
|
final MockHistoryService _historyService;
|
|
final Map<String, List<String>> _mockSseLinesByThread = {};
|
|
final Map<String, String> _lastEventIdByThread = {};
|
|
int _activeStreamToken = 0;
|
|
|
|
String? _threadId;
|
|
bool _hasMoreHistory = false;
|
|
bool _mockApiConfigured = false;
|
|
|
|
AgUiService({EventCallback? onEvent, IApiClient? apiClient})
|
|
: onEvent = onEvent ?? ((_) {}),
|
|
_apiClient = apiClient ?? MockApiClient(),
|
|
_decisionEngine = AiDecisionEngine(),
|
|
_historyService = MockHistoryService() {
|
|
if (_apiClient is MockApiClient) {
|
|
_configureMockAgentApi(_apiClient);
|
|
}
|
|
}
|
|
|
|
Future<void> sendMessage(String content, {List<XFile>? images}) async {
|
|
final streamToken = ++_activeStreamToken;
|
|
final runInput = await _buildRunInput(content: content, images: images);
|
|
final response = await _apiClient.post<Map<String, dynamic>>(
|
|
'/api/v1/agent/runs',
|
|
data: runInput,
|
|
);
|
|
final payload = response.data;
|
|
if (payload is! Map<String, dynamic>) {
|
|
throw StateError('Invalid /agent/runs response');
|
|
}
|
|
final threadId = payload['threadId'] as String?;
|
|
if (threadId == null || threadId.isEmpty) {
|
|
throw StateError('Missing threadId in /agent/runs response');
|
|
}
|
|
_threadId = threadId;
|
|
await _streamEventsFromApi(threadId, streamToken: streamToken);
|
|
}
|
|
|
|
Future<void> loadHistory({DateTime? beforeDate}) async {
|
|
final path = _buildHistoryPath(beforeDate: beforeDate);
|
|
final response = await _apiClient.get<Map<String, dynamic>>(path);
|
|
final payload = response.data;
|
|
if (payload is! Map<String, dynamic>) {
|
|
throw StateError('Invalid /agent/history response');
|
|
}
|
|
final event = AgUiEvent.fromJson(payload);
|
|
if (event is StateSnapshotEvent) {
|
|
final snapshot = event.snapshot;
|
|
final threadIdFromSnapshot = snapshot['threadId'] as String?;
|
|
if (threadIdFromSnapshot != null && threadIdFromSnapshot.isNotEmpty) {
|
|
_threadId = threadIdFromSnapshot;
|
|
}
|
|
_hasMoreHistory = snapshot['hasMore'] == true;
|
|
}
|
|
onEvent(event);
|
|
}
|
|
|
|
Future<Uint8List> fetchAttachmentPreview(String previewPath) async {
|
|
final response = await _apiClient.get<List<int>>(
|
|
previewPath,
|
|
options: Options(responseType: ResponseType.bytes),
|
|
);
|
|
final payload = response.data;
|
|
if (payload is List<int>) {
|
|
return Uint8List.fromList(payload);
|
|
}
|
|
throw StateError('Invalid attachment payload');
|
|
}
|
|
|
|
Future<String> transcribeAudio(String filePath) async {
|
|
final formData = FormData.fromMap({
|
|
'audio': await MultipartFile.fromFile(
|
|
filePath,
|
|
filename: 'recording.wav',
|
|
contentType: DioMediaType('audio', 'wav'),
|
|
),
|
|
});
|
|
final response = await _apiClient.post<Map<String, dynamic>>(
|
|
'/api/v1/agent/transcribe',
|
|
data: formData,
|
|
);
|
|
final payload = response.data;
|
|
if (payload is! Map<String, dynamic>) {
|
|
throw StateError('Invalid /agent/transcribe response');
|
|
}
|
|
final transcript = payload['transcript'];
|
|
if (transcript is! String) {
|
|
throw StateError('Missing transcript in /agent/transcribe response');
|
|
}
|
|
return transcript;
|
|
}
|
|
|
|
Future<void> approveToolCall({
|
|
required String toolCallId,
|
|
required String toolName,
|
|
required Map<String, dynamic> args,
|
|
}) async {
|
|
final streamToken = ++_activeStreamToken;
|
|
final threadId = _threadId;
|
|
if (threadId == null || threadId.isEmpty) {
|
|
throw StateError('Missing threadId for resume');
|
|
}
|
|
ToolRegistry.initialize();
|
|
final nonce = args['__nonce'];
|
|
if (nonce is! String || nonce.isEmpty) {
|
|
throw StateError('Missing tool nonce for resume');
|
|
}
|
|
final localResult = await ToolRegistry.execute(toolName, args);
|
|
if (localResult['ok'] != true) {
|
|
throw StateError('Frontend tool execution failed');
|
|
}
|
|
final runInput = {
|
|
'threadId': threadId,
|
|
'runId': _nextId(_runIdPrefix),
|
|
'state': <String, dynamic>{},
|
|
'messages': [
|
|
{
|
|
'id': _nextId('tool_'),
|
|
'role': 'tool',
|
|
'toolCallId': toolCallId,
|
|
'content': jsonEncode({
|
|
'toolName': toolName,
|
|
'toolArgs': args,
|
|
'nonce': nonce,
|
|
'result': localResult,
|
|
}),
|
|
},
|
|
],
|
|
'tools': _buildTools(),
|
|
'context': <Map<String, dynamic>>[],
|
|
'forwardedProps': <String, dynamic>{},
|
|
};
|
|
final response = await _apiClient.post<Map<String, dynamic>>(
|
|
'/api/v1/agent/runs/$threadId/resume',
|
|
data: runInput,
|
|
);
|
|
final payload = response.data;
|
|
if (payload is Map<String, dynamic>) {
|
|
final responseThreadId = payload['threadId'];
|
|
if (responseThreadId is String && responseThreadId.isNotEmpty) {
|
|
_threadId = responseThreadId;
|
|
}
|
|
}
|
|
await _streamEventsFromApi(threadId, streamToken: streamToken);
|
|
}
|
|
|
|
bool hasEarlierHistory(DateTime fromDate) {
|
|
// 历史是否还有更多由后端 history snapshot 的 hasMore 驱动。
|
|
// 参数保留是为了兼容 ChatBloc 现有调用签名。
|
|
final _ = fromDate;
|
|
return _hasMoreHistory;
|
|
}
|
|
|
|
Future<void> cancelCurrentRun() async {
|
|
_activeStreamToken += 1;
|
|
}
|
|
|
|
Future<void> _streamEventsFromApi(
|
|
String threadId, {
|
|
required int streamToken,
|
|
}) async {
|
|
final lastEventId = _lastEventIdByThread[threadId];
|
|
final headers = <String, String>{'Accept': 'text/event-stream'};
|
|
if (lastEventId != null && lastEventId.isNotEmpty) {
|
|
headers['Last-Event-ID'] = lastEventId;
|
|
}
|
|
final sseLines = await _apiClient.getSseLines(
|
|
'/api/v1/agent/runs/$threadId/events',
|
|
headers: headers,
|
|
);
|
|
|
|
String? eventType;
|
|
String? eventId;
|
|
final dataBuffer = StringBuffer();
|
|
await for (final line in sseLines) {
|
|
if (streamToken != _activeStreamToken) {
|
|
break;
|
|
}
|
|
if (line.isEmpty) {
|
|
if (dataBuffer.isNotEmpty) {
|
|
final raw = dataBuffer.toString();
|
|
dataBuffer.clear();
|
|
try {
|
|
final decoded = jsonDecode(raw);
|
|
if (decoded is Map<String, dynamic>) {
|
|
final event = AgUiEvent.fromJson(decoded);
|
|
if (event is StateSnapshotEvent) {
|
|
_hasMoreHistory = event.snapshot['hasMore'] == true;
|
|
}
|
|
onEvent(event);
|
|
}
|
|
} catch (_) {
|
|
// Ignore malformed SSE payload and keep stream alive.
|
|
}
|
|
final currentEventId = eventId;
|
|
if (currentEventId != null && currentEventId.isNotEmpty) {
|
|
_lastEventIdByThread[threadId] = currentEventId;
|
|
}
|
|
if (eventType == AgUiEventTypeWire.runFinished ||
|
|
eventType == AgUiEventTypeWire.runError) {
|
|
break;
|
|
}
|
|
}
|
|
eventType = null;
|
|
eventId = null;
|
|
continue;
|
|
}
|
|
if (line.startsWith(':')) {
|
|
continue;
|
|
}
|
|
if (line.startsWith('id:')) {
|
|
eventId = line.substring(3).trim();
|
|
continue;
|
|
}
|
|
if (line.startsWith('event:')) {
|
|
eventType = line.substring(6).trim();
|
|
continue;
|
|
}
|
|
if (line.startsWith('data:')) {
|
|
final fragment = line.substring(5).trim();
|
|
if (dataBuffer.isNotEmpty) {
|
|
dataBuffer.write('\n');
|
|
}
|
|
dataBuffer.write(fragment);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<Map<String, dynamic>> _buildRunInput({
|
|
required String content,
|
|
List<XFile>? images,
|
|
}) async {
|
|
final threadId = _threadId ?? _newUuid();
|
|
final runId = _nextId(_runIdPrefix);
|
|
|
|
final contentBlocks = <Map<String, dynamic>>[];
|
|
final attachmentMetadata = <Map<String, dynamic>>[];
|
|
|
|
if (content.isNotEmpty) {
|
|
contentBlocks.add({'type': 'text', 'text': content});
|
|
}
|
|
|
|
if (images != null && images.isNotEmpty) {
|
|
final uploadedAttachments = await _uploadAttachments(
|
|
threadId: threadId,
|
|
images: images,
|
|
);
|
|
for (final attachment in uploadedAttachments) {
|
|
contentBlocks.add({
|
|
'type': 'binary',
|
|
'mimeType': attachment['mimeType'],
|
|
'url': attachment['url'],
|
|
});
|
|
attachmentMetadata.add({
|
|
'bucket': attachment['bucket'],
|
|
'path': attachment['path'],
|
|
'mimeType': attachment['mimeType'],
|
|
});
|
|
}
|
|
}
|
|
|
|
final dynamic messageContent;
|
|
if (contentBlocks.isEmpty) {
|
|
messageContent = '';
|
|
} else if (contentBlocks.length == 1 &&
|
|
contentBlocks[0]['type'] == 'text') {
|
|
messageContent = contentBlocks[0]['text'];
|
|
} else {
|
|
messageContent = contentBlocks;
|
|
}
|
|
|
|
return {
|
|
'threadId': threadId,
|
|
'runId': runId,
|
|
'state': <String, dynamic>{},
|
|
'messages': [
|
|
{'id': _nextId('user_'), 'role': 'user', 'content': messageContent},
|
|
],
|
|
'tools': _buildTools(),
|
|
'context': <Map<String, dynamic>>[],
|
|
'forwardedProps': {
|
|
if (attachmentMetadata.isNotEmpty) 'attachments': attachmentMetadata,
|
|
},
|
|
};
|
|
}
|
|
|
|
Future<List<Map<String, dynamic>>> _uploadAttachments({
|
|
required String threadId,
|
|
required List<XFile> images,
|
|
}) async {
|
|
final attachments = <Map<String, dynamic>>[];
|
|
for (final image in images) {
|
|
final mimeType = image.mimeType ?? 'image/jpeg';
|
|
final fileBytes = await image.readAsBytes();
|
|
final formData = FormData.fromMap({
|
|
'threadId': threadId,
|
|
'file': MultipartFile.fromBytes(
|
|
fileBytes,
|
|
filename: image.name,
|
|
contentType: DioMediaType.parse(mimeType),
|
|
),
|
|
});
|
|
final response = await _apiClient.post<Map<String, dynamic>>(
|
|
'/api/v1/agent/attachments',
|
|
data: formData,
|
|
);
|
|
final payload = response.data;
|
|
if (payload is! Map<String, dynamic>) {
|
|
throw StateError('Invalid /agent/attachments response');
|
|
}
|
|
final attachment = payload['attachment'];
|
|
if (attachment is! Map<String, dynamic>) {
|
|
throw StateError('Missing attachment in /agent/attachments response');
|
|
}
|
|
final bucket = attachment['bucket'];
|
|
final path = attachment['path'];
|
|
final uploadedMime = attachment['mimeType'];
|
|
final url = attachment['url'];
|
|
if (bucket is! String ||
|
|
path is! String ||
|
|
uploadedMime is! String ||
|
|
url is! String ||
|
|
bucket.isEmpty ||
|
|
path.isEmpty ||
|
|
uploadedMime.isEmpty ||
|
|
url.isEmpty) {
|
|
throw StateError('Invalid attachment reference');
|
|
}
|
|
attachments.add({
|
|
'bucket': bucket,
|
|
'path': path,
|
|
'mimeType': uploadedMime,
|
|
'url': url,
|
|
});
|
|
}
|
|
return attachments;
|
|
}
|
|
|
|
List<Map<String, dynamic>> _buildTools() {
|
|
return [
|
|
{
|
|
'name': 'front.navigate_to_route',
|
|
'description': 'Navigate user to a route in the mobile app.',
|
|
'parameters': {
|
|
'type': 'object',
|
|
'properties': {
|
|
'target': {'type': 'string', 'description': 'Route path target'},
|
|
'replace': {
|
|
'type': 'boolean',
|
|
'description': 'Use replace navigation',
|
|
},
|
|
},
|
|
'required': ['target'],
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
String _buildHistoryPath({DateTime? beforeDate}) {
|
|
final query = <String>[];
|
|
if (_threadId != null && _threadId!.isNotEmpty) {
|
|
query.add('threadId=$_threadId');
|
|
}
|
|
if (beforeDate != null) {
|
|
final day = DateTime(beforeDate.year, beforeDate.month, beforeDate.day);
|
|
query.add('before=${day.toIso8601String().substring(0, 10)}');
|
|
}
|
|
if (query.isEmpty) {
|
|
return '/api/v1/agent/history';
|
|
}
|
|
return '/api/v1/agent/history?${query.join('&')}';
|
|
}
|
|
|
|
String _nextId(String prefix) =>
|
|
'$prefix${DateTime.now().millisecondsSinceEpoch}';
|
|
|
|
String _newUuid() {
|
|
final random = Random();
|
|
String hex(int len) => List<String>.generate(
|
|
len,
|
|
(_) => random.nextInt(16).toRadixString(16),
|
|
).join();
|
|
const variant = ['8', '9', 'a', 'b'];
|
|
return '${hex(8)}-${hex(4)}-4${hex(3)}-${variant[random.nextInt(4)]}${hex(3)}-${hex(12)}';
|
|
}
|
|
|
|
void _configureMockAgentApi(MockApiClient client) {
|
|
if (_mockApiConfigured) {
|
|
return;
|
|
}
|
|
_mockApiConfigured = true;
|
|
|
|
client.registerHandler('/api/v1/agent/runs', 'POST', _handleMockRun);
|
|
client.registerPatternHandler(
|
|
RegExp(r'^/api/v1/agent/runs/[^/]+/resume$'),
|
|
'POST',
|
|
_handleMockResume,
|
|
);
|
|
client.registerPatternHandler(
|
|
RegExp(r'^/api/v1/agent/history(?:\?.*)?$'),
|
|
'GET',
|
|
_handleMockHistory,
|
|
);
|
|
client.registerPatternHandler(
|
|
RegExp(r'^/api/v1/agent/runs/[^/]+/events$'),
|
|
'SSE',
|
|
_handleMockSse,
|
|
);
|
|
client.registerHandler(
|
|
'/api/v1/agent/attachments',
|
|
'POST',
|
|
_handleMockUploadAttachment,
|
|
);
|
|
client.registerHandler(
|
|
'/api/v1/agent/transcribe',
|
|
'POST',
|
|
_handleMockTranscribe,
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> _handleMockTranscribe(MockRequest request) {
|
|
return {'transcript': '这是模拟语音转写'};
|
|
}
|
|
|
|
Map<String, dynamic> _handleMockUploadAttachment(MockRequest request) {
|
|
final payload = request.data;
|
|
final threadId = payload is Map<String, dynamic>
|
|
? (payload['threadId'] as String?)
|
|
: null;
|
|
final resolvedThreadId = (threadId != null && threadId.isNotEmpty)
|
|
? threadId
|
|
: (_threadId ?? _newUuid());
|
|
final path =
|
|
'agent-inputs/mock/$resolvedThreadId/${_nextId('upload_')}.png';
|
|
return {
|
|
'attachment': {
|
|
'bucket': 'mock-bucket',
|
|
'path': path,
|
|
'mimeType': 'image/png',
|
|
'url': 'https://mock.local/$path',
|
|
},
|
|
};
|
|
}
|
|
|
|
Map<String, dynamic> _handleMockRun(MockRequest request) {
|
|
final payload = request.data;
|
|
final runInput = payload is Map<String, dynamic>
|
|
? payload
|
|
: <String, dynamic>{};
|
|
final threadId = (runInput['threadId'] as String?) ?? _newUuid();
|
|
final runId = (runInput['runId'] as String?) ?? _nextId(_runIdPrefix);
|
|
_threadId = threadId;
|
|
|
|
final content = _extractLatestUserContent(runInput);
|
|
final events = _buildMockRunEvents(
|
|
threadId: threadId,
|
|
runId: runId,
|
|
userInput: content,
|
|
);
|
|
_mockSseLinesByThread[threadId] = _toSseLines(events);
|
|
return {
|
|
'taskId': _nextId('task_'),
|
|
'threadId': threadId,
|
|
'runId': runId,
|
|
'created': false,
|
|
};
|
|
}
|
|
|
|
Map<String, dynamic> _handleMockResume(MockRequest request) {
|
|
final match = RegExp(
|
|
r'^/api/v1/agent/runs/([^/]+)/resume$',
|
|
).firstMatch(request.path);
|
|
final threadId = match?.group(1) ?? (_threadId ?? _newUuid());
|
|
final payload = request.data;
|
|
final runInput = payload is Map<String, dynamic>
|
|
? payload
|
|
: <String, dynamic>{};
|
|
final runId = (runInput['runId'] as String?) ?? _nextId(_runIdPrefix);
|
|
_threadId = threadId;
|
|
|
|
final toolMessage = _extractLatestToolMessage(runInput);
|
|
final events = <Map<String, dynamic>>[
|
|
{
|
|
'type': AgUiEventTypeWire.runStarted,
|
|
'threadId': threadId,
|
|
'runId': runId,
|
|
},
|
|
{
|
|
'type': AgUiEventTypeWire.toolCallResult,
|
|
'messageId': _nextId(_messageIdPrefix),
|
|
'toolCallId': toolMessage.$1,
|
|
'content': toolMessage.$2,
|
|
},
|
|
{
|
|
'type': AgUiEventTypeWire.textMessageStart,
|
|
'messageId': _nextId(_messageIdPrefix),
|
|
'role': 'assistant',
|
|
},
|
|
{
|
|
'type': AgUiEventTypeWire.textMessageContent,
|
|
'messageId': _nextId(_messageIdPrefix),
|
|
'delta': '已收到你的审批,继续执行完成。',
|
|
},
|
|
{
|
|
'type': AgUiEventTypeWire.textMessageEnd,
|
|
'messageId': _nextId(_messageIdPrefix),
|
|
},
|
|
{
|
|
'type': AgUiEventTypeWire.runFinished,
|
|
'threadId': threadId,
|
|
'runId': runId,
|
|
},
|
|
];
|
|
_mockSseLinesByThread[threadId] = _toSseLines(events);
|
|
return {
|
|
'taskId': _nextId('task_'),
|
|
'threadId': threadId,
|
|
'runId': runId,
|
|
'created': false,
|
|
};
|
|
}
|
|
|
|
Map<String, dynamic> _handleMockHistory(MockRequest request) {
|
|
final uri = Uri.parse(request.path);
|
|
final query = uri.queryParameters;
|
|
final providedThreadId = query['threadId'];
|
|
final threadId = providedThreadId ?? _threadId ?? _newUuid();
|
|
_threadId = threadId;
|
|
|
|
final beforeRaw = query['before'];
|
|
DateTime? beforeDate;
|
|
if (beforeRaw != null && beforeRaw.isNotEmpty) {
|
|
beforeDate = DateTime.tryParse(beforeRaw);
|
|
}
|
|
|
|
DateTime? targetDate;
|
|
if (beforeDate == null) {
|
|
targetDate = _historyService.getLatestHistoryDate();
|
|
} else {
|
|
targetDate = _historyService.getPreviousDay(beforeDate);
|
|
}
|
|
final messages = targetDate == null
|
|
? <SnapshotMessage>[]
|
|
: _historyService.getHistoryForDay(targetDate);
|
|
final hasMore =
|
|
targetDate != null && _historyService.hasEarlierHistory(targetDate);
|
|
_hasMoreHistory = hasMore;
|
|
|
|
return {
|
|
'type': AgUiEventTypeWire.stateSnapshot,
|
|
'threadId': threadId,
|
|
'snapshot': {
|
|
'scope': 'history_day',
|
|
'threadId': threadId,
|
|
'day': targetDate == null
|
|
? null
|
|
: DateTime(
|
|
targetDate.year,
|
|
targetDate.month,
|
|
targetDate.day,
|
|
).toIso8601String().substring(0, 10),
|
|
'hasMore': hasMore,
|
|
'messages': messages.map((item) => item.toJson()).toList(),
|
|
},
|
|
};
|
|
}
|
|
|
|
Stream<String> _handleMockSse(MockRequest request) {
|
|
final match = RegExp(
|
|
r'^/api/v1/agent/runs/([^/]+)/events$',
|
|
).firstMatch(request.path);
|
|
final threadId = match?.group(1);
|
|
if (threadId == null) {
|
|
return const Stream<String>.empty();
|
|
}
|
|
final lines = _mockSseLinesByThread[threadId];
|
|
if (lines == null) {
|
|
return const Stream<String>.empty();
|
|
}
|
|
return Stream<String>.fromIterable(lines);
|
|
}
|
|
|
|
List<Map<String, dynamic>> _buildMockRunEvents({
|
|
required String threadId,
|
|
required String runId,
|
|
required String userInput,
|
|
}) {
|
|
final events = <Map<String, dynamic>>[
|
|
{
|
|
'type': AgUiEventTypeWire.runStarted,
|
|
'threadId': threadId,
|
|
'runId': runId,
|
|
},
|
|
];
|
|
|
|
final forceTrigger = _decisionEngine.tryForceTrigger(userInput);
|
|
Map<String, dynamic>? args;
|
|
String? toolName;
|
|
if (forceTrigger != null) {
|
|
toolName = forceTrigger.toolName;
|
|
args = forceTrigger.args;
|
|
} else if (_looksLikeNavigationIntent(userInput)) {
|
|
toolName = 'front.navigate_to_route';
|
|
args = {'target': _inferNavigationRoute(userInput), 'replace': false};
|
|
}
|
|
|
|
if (toolName != null && args != null) {
|
|
if (toolName == 'front.navigate_to_route') {
|
|
args = {...args, '__nonce': _nextId('nonce_')};
|
|
}
|
|
final toolCallId = _nextId(_toolCallIdPrefix);
|
|
events.add({
|
|
'type': AgUiEventTypeWire.toolCallStart,
|
|
'toolCallId': toolCallId,
|
|
'toolCallName': toolName,
|
|
});
|
|
events.add({
|
|
'type': AgUiEventTypeWire.toolCallArgs,
|
|
'toolCallId': toolCallId,
|
|
'delta': jsonEncode(args),
|
|
});
|
|
events.add({
|
|
'type': AgUiEventTypeWire.toolCallEnd,
|
|
'toolCallId': toolCallId,
|
|
});
|
|
|
|
if (toolName == 'front.navigate_to_route') {
|
|
// 前端工具:等待审批后由 resume 返回 TOOL_CALL_RESULT。
|
|
} else {
|
|
events.add({
|
|
'type': AgUiEventTypeWire.toolCallError,
|
|
'toolCallId': toolCallId,
|
|
'error': 'Unsupported frontend tool in mock mode',
|
|
'code': 'UNSUPPORTED_TOOL',
|
|
});
|
|
}
|
|
}
|
|
|
|
final replies = _generateReplies(userInput);
|
|
for (final reply in replies) {
|
|
final messageId = _nextId(_messageIdPrefix);
|
|
events.add({
|
|
'type': AgUiEventTypeWire.textMessageStart,
|
|
'messageId': messageId,
|
|
'role': 'assistant',
|
|
});
|
|
events.add({
|
|
'type': AgUiEventTypeWire.textMessageContent,
|
|
'messageId': messageId,
|
|
'delta': reply,
|
|
});
|
|
events.add({
|
|
'type': AgUiEventTypeWire.textMessageEnd,
|
|
'messageId': messageId,
|
|
});
|
|
}
|
|
|
|
events.add({
|
|
'type': AgUiEventTypeWire.runFinished,
|
|
'threadId': threadId,
|
|
'runId': runId,
|
|
});
|
|
return events;
|
|
}
|
|
|
|
List<String> _toSseLines(List<Map<String, dynamic>> events) {
|
|
final lines = <String>[];
|
|
for (var i = 0; i < events.length; i++) {
|
|
final event = events[i];
|
|
final eventType = event['type'] as String? ?? 'MESSAGE';
|
|
final eventId = '${i + 1}-0';
|
|
lines.add('id: $eventId');
|
|
lines.add('event: $eventType');
|
|
lines.add('data: ${jsonEncode(event)}');
|
|
lines.add('');
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
String _extractLatestUserContent(Map<String, dynamic> runInput) {
|
|
final messages = runInput['messages'];
|
|
if (messages is! List<dynamic>) {
|
|
return '';
|
|
}
|
|
for (var i = messages.length - 1; i >= 0; i--) {
|
|
final raw = messages[i];
|
|
if (raw is! Map<String, dynamic>) {
|
|
continue;
|
|
}
|
|
if (raw['role'] != 'user') {
|
|
continue;
|
|
}
|
|
final content = raw['content'];
|
|
if (content is String) {
|
|
return content;
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
(String, String) _extractLatestToolMessage(Map<String, dynamic> runInput) {
|
|
final messages = runInput['messages'];
|
|
if (messages is! List<dynamic>) {
|
|
return (_nextId(_toolCallIdPrefix), '{}');
|
|
}
|
|
for (var i = messages.length - 1; i >= 0; i--) {
|
|
final raw = messages[i];
|
|
if (raw is! Map<String, dynamic>) {
|
|
continue;
|
|
}
|
|
if (raw['role'] != 'tool') {
|
|
continue;
|
|
}
|
|
final toolCallId =
|
|
raw['toolCallId'] as String? ?? _nextId(_toolCallIdPrefix);
|
|
final content = raw['content'] as String? ?? '{}';
|
|
return (toolCallId, content);
|
|
}
|
|
return (_nextId(_toolCallIdPrefix), '{}');
|
|
}
|
|
|
|
List<String> _generateReplies(String content) {
|
|
final intent = _decisionEngine.matchIntent(content);
|
|
switch (intent) {
|
|
case Intent.createEvent:
|
|
return ['好的,我已经为您创建了日程安排。'];
|
|
case Intent.searchEvent:
|
|
return ['您今天有以下日程:\n- 10:00 团队会议\n- 14:00 产品评审'];
|
|
case Intent.unknown:
|
|
return ['我理解了您的问题,让我来帮您处理。'];
|
|
}
|
|
}
|
|
|
|
bool _looksLikeNavigationIntent(String input) {
|
|
return input.contains('打开') ||
|
|
input.contains('跳转') ||
|
|
input.toLowerCase().contains('navigate') ||
|
|
input.toLowerCase().contains('open');
|
|
}
|
|
|
|
String _inferNavigationRoute(String input) {
|
|
if (input.contains('设置')) {
|
|
return '/settings';
|
|
}
|
|
if (input.contains('待办')) {
|
|
return '/todo';
|
|
}
|
|
return '/calendar/dayweek';
|
|
}
|
|
}
|