feat: AG-UI 协议对齐与路由导航功能
- 前端: 添加 SSE 流式支持、stateSnapshot 事件、路由导航工具 - 前端: 实现工具调用审批流程,支持 pending 状态展示 - 后端: Agent 状态管理与会话持久化相关重构 - 文档: 新增 agent-agui-full-alignance 设计文档 - 测试: 补充相关单元测试和集成测试
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
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';
|
||||
@@ -9,185 +11,625 @@ import '../models/tool_result.dart';
|
||||
import '../tools/tool_registry.dart';
|
||||
import 'mock_history_service.dart';
|
||||
|
||||
/// Mock ID 前缀常量
|
||||
const _threadIdPrefix = 'thread_';
|
||||
const _runIdPrefix = 'run_';
|
||||
const _toolCallIdPrefix = 'tc_';
|
||||
const _messageIdPrefix = 'msg_';
|
||||
|
||||
/// 流式输出延迟 (毫秒)
|
||||
const _streamChunkDelayMs = 50;
|
||||
|
||||
/// 文本块大小
|
||||
const _textChunkSize = 10;
|
||||
|
||||
typedef EventCallback = void Function(AgUiEvent event);
|
||||
|
||||
/// ID 前缀常量
|
||||
const _runIdPrefix = 'run_';
|
||||
const _messageIdPrefix = 'msg_';
|
||||
const _toolCallIdPrefix = 'tc_';
|
||||
|
||||
class AgUiService {
|
||||
final IApiClient? _apiClient;
|
||||
final IApiClient _apiClient;
|
||||
EventCallback onEvent;
|
||||
final AiDecisionEngine _decisionEngine;
|
||||
final MockHistoryService _historyService;
|
||||
final Map<String, List<String>> _mockSseLinesByThread = {};
|
||||
final Map<String, String> _lastEventIdByThread = {};
|
||||
|
||||
String? _threadId;
|
||||
bool _hasMoreHistory = false;
|
||||
bool _mockApiConfigured = false;
|
||||
|
||||
AgUiService({EventCallback? onEvent, IApiClient? apiClient})
|
||||
: onEvent = onEvent ?? ((_) {}),
|
||||
_apiClient = apiClient,
|
||||
_apiClient = apiClient ?? MockApiClient(),
|
||||
_decisionEngine = AiDecisionEngine(),
|
||||
_historyService = MockHistoryService();
|
||||
_historyService = MockHistoryService() {
|
||||
if (_apiClient is MockApiClient) {
|
||||
_configureMockAgentApi(_apiClient as MockApiClient);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendMessage(String content) async {
|
||||
if (_apiClient != null) {
|
||||
throw UnimplementedError('Real API not implemented');
|
||||
final runInput = _buildRunInput(content: content);
|
||||
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');
|
||||
}
|
||||
await _mockEventStream(content);
|
||||
final threadId = payload['threadId'] as String?;
|
||||
if (threadId == null || threadId.isEmpty) {
|
||||
throw StateError('Missing threadId in /agent/runs response');
|
||||
}
|
||||
_threadId = threadId;
|
||||
await _streamEventsFromApi(threadId);
|
||||
}
|
||||
|
||||
Future<void> loadHistory({DateTime? beforeDate}) async {
|
||||
if (_apiClient != null) {
|
||||
throw UnimplementedError('Real API not implemented');
|
||||
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');
|
||||
}
|
||||
await _mockLoadHistory(beforeDate: beforeDate);
|
||||
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<void> approveToolCall({
|
||||
required String toolCallId,
|
||||
required String toolName,
|
||||
required Map<String, dynamic> args,
|
||||
}) async {
|
||||
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);
|
||||
}
|
||||
|
||||
bool hasEarlierHistory(DateTime fromDate) {
|
||||
return _historyService.hasEarlierHistory(fromDate);
|
||||
// 历史是否还有更多由后端 history snapshot 的 hasMore 驱动。
|
||||
// 参数保留是为了兼容 ChatBloc 现有调用签名。
|
||||
final _ = fromDate;
|
||||
return _hasMoreHistory;
|
||||
}
|
||||
|
||||
Future<void> _mockLoadHistory({DateTime? beforeDate}) async {
|
||||
final threadId = '$_threadIdPrefix${DateTime.now().millisecondsSinceEpoch}';
|
||||
final runId = '$_runIdPrefix${DateTime.now().millisecondsSinceEpoch}';
|
||||
Future<void> _streamEventsFromApi(String threadId) 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,
|
||||
);
|
||||
|
||||
onEvent(RunStartedEvent(threadId: threadId, runId: runId));
|
||||
await Future.delayed(const Duration(milliseconds: 10));
|
||||
|
||||
// Determine target date, end early if no earlier history
|
||||
final DateTime targetDate;
|
||||
if (beforeDate != null) {
|
||||
final prevDate = _historyService.getPreviousDay(beforeDate);
|
||||
if (prevDate == null) {
|
||||
onEvent(RunFinishedEvent(threadId: threadId, runId: runId));
|
||||
return;
|
||||
String? eventType;
|
||||
String? eventId;
|
||||
final dataBuffer = StringBuffer();
|
||||
await for (final line in sseLines) {
|
||||
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);
|
||||
}
|
||||
targetDate = prevDate;
|
||||
} else {
|
||||
targetDate = _historyService.getLatestHistoryDate() ?? DateTime.now();
|
||||
}
|
||||
|
||||
final messages = _historyService.getHistoryForDay(targetDate);
|
||||
onEvent(MessagesSnapshotEvent(messages: messages));
|
||||
await Future.delayed(const Duration(milliseconds: 10));
|
||||
onEvent(RunFinishedEvent(threadId: threadId, runId: runId));
|
||||
}
|
||||
|
||||
Future<void> _mockEventStream(String content) async {
|
||||
final threadId = '$_threadIdPrefix${DateTime.now().millisecondsSinceEpoch}';
|
||||
final runId = '$_runIdPrefix${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
onEvent(RunStartedEvent(threadId: threadId, runId: runId));
|
||||
|
||||
final forceTrigger = _decisionEngine.tryForceTrigger(content);
|
||||
if (forceTrigger != null) {
|
||||
await _mockToolCallFlowWithArgs(forceTrigger.toolName, forceTrigger.args);
|
||||
} else if (_decisionEngine.shouldTriggerToolCall(content)) {
|
||||
await _mockToolCallFlow(content);
|
||||
}
|
||||
|
||||
final replies = _generateReplies(content);
|
||||
if (replies.isNotEmpty) {
|
||||
await _mockTextMessageStream(replies);
|
||||
}
|
||||
|
||||
onEvent(RunFinishedEvent(threadId: threadId, runId: runId));
|
||||
Map<String, dynamic> _buildRunInput({required String content}) {
|
||||
final threadId = _threadId ?? _newUuid();
|
||||
final runId = _nextId(_runIdPrefix);
|
||||
return {
|
||||
'threadId': threadId,
|
||||
'runId': runId,
|
||||
'state': <String, dynamic>{},
|
||||
'messages': [
|
||||
{
|
||||
'id': _nextId('user_'),
|
||||
'role': 'user',
|
||||
'content': content,
|
||||
},
|
||||
],
|
||||
'tools': _buildTools(),
|
||||
'context': <Map<String, dynamic>>[],
|
||||
'forwardedProps': <String, dynamic>{},
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _mockToolCallFlow(String content) async {
|
||||
final args = _decisionEngine.getToolCallArgs(content);
|
||||
if (args == null) return;
|
||||
|
||||
await _mockToolCallFlowWithArgs('create_calendar_event', args);
|
||||
List<Map<String, dynamic>> _buildTools() {
|
||||
return [
|
||||
{
|
||||
'name': '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'],
|
||||
},
|
||||
},
|
||||
{
|
||||
'name': 'create_calendar_event',
|
||||
'description': 'Create a calendar schedule event.',
|
||||
'parameters': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'title': {'type': 'string'},
|
||||
'description': {'type': 'string'},
|
||||
'startAt': {'type': 'string', 'format': 'date-time'},
|
||||
'endAt': {'type': 'string', 'format': 'date-time'},
|
||||
'timezone': {'type': 'string'},
|
||||
'location': {'type': 'string'},
|
||||
},
|
||||
'required': ['title', 'startAt'],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _mockToolCallFlowWithArgs(
|
||||
String toolName,
|
||||
Map<String, dynamic> args,
|
||||
) async {
|
||||
final toolCallId =
|
||||
'$_toolCallIdPrefix${DateTime.now().millisecondsSinceEpoch}';
|
||||
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('&')}';
|
||||
}
|
||||
|
||||
onEvent(ToolCallStartEvent(toolCallId: toolCallId, toolCallName: toolName));
|
||||
String _nextId(String prefix) => '$prefix${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
onEvent(ToolCallArgsEvent(toolCallId: toolCallId, delta: jsonEncode(args)));
|
||||
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)}';
|
||||
}
|
||||
|
||||
onEvent(ToolCallEndEvent(toolCallId: toolCallId));
|
||||
|
||||
final validation = ToolRegistry.validateArgs(toolName, args);
|
||||
if (!validation.ok) {
|
||||
onEvent(
|
||||
ToolCallErrorEvent(
|
||||
toolCallId: toolCallId,
|
||||
error: validation.error ?? 'Validation failed',
|
||||
code: 'VALIDATION_ERROR',
|
||||
),
|
||||
);
|
||||
void _configureMockAgentApi(MockApiClient client) {
|
||||
if (_mockApiConfigured) {
|
||||
return;
|
||||
}
|
||||
_mockApiConfigured = true;
|
||||
|
||||
try {
|
||||
ToolRegistry.initialize();
|
||||
final result = await ToolRegistry.execute(toolName, args);
|
||||
final ui = _buildUiCard(toolName, result);
|
||||
final messageId =
|
||||
'$_messageIdPrefix${DateTime.now().millisecondsSinceEpoch}';
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
onEvent(
|
||||
ToolCallResultEvent(
|
||||
messageId: messageId,
|
||||
toolCallId: toolCallId,
|
||||
result: result,
|
||||
ui: ui,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
onEvent(
|
||||
ToolCallErrorEvent(
|
||||
toolCallId: toolCallId,
|
||||
error: e.toString(),
|
||||
code: 'EXECUTION_ERROR',
|
||||
),
|
||||
);
|
||||
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 = 'navigate_to_route';
|
||||
args = {'target': _inferNavigationRoute(userInput), 'replace': false};
|
||||
} else if (_decisionEngine.shouldTriggerToolCall(userInput)) {
|
||||
toolName = 'create_calendar_event';
|
||||
args = _decisionEngine.getToolCallArgs(userInput);
|
||||
}
|
||||
|
||||
if (toolName != null && args != null) {
|
||||
if (toolName == '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 == 'navigate_to_route') {
|
||||
// 前端工具:等待审批后由 resume 返回 TOOL_CALL_RESULT。
|
||||
} else {
|
||||
final validation = ToolRegistry.validateArgs(toolName, args);
|
||||
if (!validation.ok) {
|
||||
events.add({
|
||||
'type': AgUiEventTypeWire.toolCallError,
|
||||
'toolCallId': toolCallId,
|
||||
'error': validation.error ?? 'Validation failed',
|
||||
'code': 'VALIDATION_ERROR',
|
||||
});
|
||||
} else {
|
||||
final result = _mockCalendarResult(args);
|
||||
final ui = _buildUiCard(toolName, result);
|
||||
events.add({
|
||||
'type': AgUiEventTypeWire.toolCallResult,
|
||||
'messageId': _nextId(_messageIdPrefix),
|
||||
'toolCallId': toolCallId,
|
||||
'content': jsonEncode({
|
||||
'result': result,
|
||||
if (ui != null) 'ui': ui.toJson(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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), '{}');
|
||||
}
|
||||
|
||||
Map<String, dynamic> _mockCalendarResult(Map<String, dynamic> args) {
|
||||
final eventId = 'evt_${DateTime.now().millisecondsSinceEpoch}';
|
||||
return {
|
||||
'eventId': eventId,
|
||||
'ok': true,
|
||||
'message': '日程已创建',
|
||||
'title': args['title'],
|
||||
'description': args['description'],
|
||||
'startAt': args['startAt'],
|
||||
'endAt': args['endAt'],
|
||||
'timezone': args['timezone'] ?? 'Asia/Shanghai',
|
||||
'location': args['location'],
|
||||
'color': '#4F46E5',
|
||||
'sourceType': 'agentGenerated',
|
||||
};
|
||||
}
|
||||
|
||||
UiCard? _buildUiCard(String toolName, Map<String, dynamic> result) {
|
||||
if (toolName == 'create_calendar_event') {
|
||||
return UiCard(
|
||||
cardType: 'calendar',
|
||||
data: CalendarCardData(
|
||||
id: result['eventId'] ?? '',
|
||||
title: result['title'] ?? '',
|
||||
description: result['description'],
|
||||
startAt: result['startAt'] ?? '',
|
||||
endAt: result['endAt'],
|
||||
timezone: result['timezone'],
|
||||
location: result['location'],
|
||||
color: result['color'],
|
||||
sourceType: result['sourceType'],
|
||||
).toJson(),
|
||||
actions: [
|
||||
CardAction(
|
||||
type: 'link',
|
||||
label: '查看详情',
|
||||
target: '/calendar/${result['eventId']}',
|
||||
),
|
||||
],
|
||||
);
|
||||
if (toolName != 'create_calendar_event') {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
return UiCard(
|
||||
cardType: 'calendar_card.v1',
|
||||
data: CalendarCardData(
|
||||
id: result['eventId'] ?? '',
|
||||
title: result['title'] ?? '',
|
||||
description: result['description'],
|
||||
startAt: result['startAt'] ?? '',
|
||||
endAt: result['endAt'],
|
||||
timezone: result['timezone'],
|
||||
location: result['location'],
|
||||
color: result['color'],
|
||||
sourceType: result['sourceType'],
|
||||
).toJson(),
|
||||
actions: [
|
||||
CardAction(
|
||||
type: 'link',
|
||||
label: '查看详情',
|
||||
target: '/calendar/events/${result['eventId']}',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _generateReplies(String content) {
|
||||
final intent = _decisionEngine.matchIntent(content);
|
||||
|
||||
switch (intent) {
|
||||
case Intent.createEvent:
|
||||
return ['好的,我已经为您创建了日程安排。'];
|
||||
@@ -198,25 +640,20 @@ class AgUiService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _mockTextMessageStream(List<String> replies) async {
|
||||
for (final reply in replies) {
|
||||
final messageId =
|
||||
'$_messageIdPrefix${DateTime.now().millisecondsSinceEpoch}';
|
||||
bool _looksLikeNavigationIntent(String input) {
|
||||
return input.contains('打开') ||
|
||||
input.contains('跳转') ||
|
||||
input.toLowerCase().contains('navigate') ||
|
||||
input.toLowerCase().contains('open');
|
||||
}
|
||||
|
||||
onEvent(TextMessageStartEvent(messageId: messageId, role: 'assistant'));
|
||||
|
||||
for (var i = 0; i < reply.length; i += _textChunkSize) {
|
||||
final end = (i + _textChunkSize < reply.length)
|
||||
? i + _textChunkSize
|
||||
: reply.length;
|
||||
final chunk = reply.substring(i, end);
|
||||
|
||||
onEvent(TextMessageContentEvent(messageId: messageId, delta: chunk));
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: _streamChunkDelayMs));
|
||||
}
|
||||
|
||||
onEvent(TextMessageEndEvent(messageId: messageId));
|
||||
String _inferNavigationRoute(String input) {
|
||||
if (input.contains('设置')) {
|
||||
return '/settings';
|
||||
}
|
||||
if (input.contains('待办')) {
|
||||
return '/todo';
|
||||
}
|
||||
return '/calendar/dayweek';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user