import 'dart:async'; import 'dart:convert'; import 'dart:math'; import 'package:dio/dio.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> _mockSseLinesByThread = {}; final Map _lastEventIdByThread = {}; 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 sendMessage(String content) async { final runInput = _buildRunInput(content: content); final response = await _apiClient.post>( '/api/v1/agent/runs', data: runInput, ); final payload = response.data; if (payload is! Map) { 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); } Future loadHistory({DateTime? beforeDate}) async { final path = _buildHistoryPath(beforeDate: beforeDate); final response = await _apiClient.get>(path); final payload = response.data; if (payload is! Map) { 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 transcribeAudio(String filePath) async { final formData = FormData.fromMap({ 'audio': await MultipartFile.fromFile( filePath, filename: 'recording.wav', ), }); final response = await _apiClient.post>( '/api/v1/agent/transcribe', data: formData, ); final payload = response.data; if (payload is! Map) { 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 approveToolCall({ required String toolCallId, required String toolName, required Map 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': {}, 'messages': [ { 'id': _nextId('tool_'), 'role': 'tool', 'toolCallId': toolCallId, 'content': jsonEncode({ 'toolName': toolName, 'toolArgs': args, 'nonce': nonce, 'result': localResult, }), }, ], 'tools': _buildTools(), 'context': >[], 'forwardedProps': {}, }; final response = await _apiClient.post>( '/api/v1/agent/runs/$threadId/resume', data: runInput, ); final payload = response.data; if (payload is Map) { final responseThreadId = payload['threadId']; if (responseThreadId is String && responseThreadId.isNotEmpty) { _threadId = responseThreadId; } } await _streamEventsFromApi(threadId); } bool hasEarlierHistory(DateTime fromDate) { // 历史是否还有更多由后端 history snapshot 的 hasMore 驱动。 // 参数保留是为了兼容 ChatBloc 现有调用签名。 final _ = fromDate; return _hasMoreHistory; } Future _streamEventsFromApi(String threadId) async { final lastEventId = _lastEventIdByThread[threadId]; final headers = {'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 (line.isEmpty) { if (dataBuffer.isNotEmpty) { final raw = dataBuffer.toString(); dataBuffer.clear(); try { final decoded = jsonDecode(raw); if (decoded is Map) { 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); } } } Map _buildRunInput({required String content}) { final threadId = _threadId ?? _newUuid(); final runId = _nextId(_runIdPrefix); return { 'threadId': threadId, 'runId': runId, 'state': {}, 'messages': [ {'id': _nextId('user_'), 'role': 'user', 'content': content}, ], 'tools': _buildTools(), 'context': >[], 'forwardedProps': {}, }; } List> _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 = []; 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.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/transcribe', 'POST', _handleMockTranscribe, ); } Map _handleMockTranscribe(MockRequest request) { return {'transcript': '这是模拟语音转写'}; } Map _handleMockRun(MockRequest request) { final payload = request.data; final runInput = payload is Map ? payload : {}; 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 _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 ? payload : {}; final runId = (runInput['runId'] as String?) ?? _nextId(_runIdPrefix); _threadId = threadId; final toolMessage = _extractLatestToolMessage(runInput); final events = >[ { '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 _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 ? [] : _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 _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.empty(); } final lines = _mockSseLinesByThread[threadId]; if (lines == null) { return const Stream.empty(); } return Stream.fromIterable(lines); } List> _buildMockRunEvents({ required String threadId, required String runId, required String userInput, }) { final events = >[ { 'type': AgUiEventTypeWire.runStarted, 'threadId': threadId, 'runId': runId, }, ]; final forceTrigger = _decisionEngine.tryForceTrigger(userInput); Map? 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 _toSseLines(List> events) { final lines = []; 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 runInput) { final messages = runInput['messages']; if (messages is! List) { return ''; } for (var i = messages.length - 1; i >= 0; i--) { final raw = messages[i]; if (raw is! Map) { continue; } if (raw['role'] != 'user') { continue; } final content = raw['content']; if (content is String) { return content; } } return ''; } (String, String) _extractLatestToolMessage(Map runInput) { final messages = runInput['messages']; if (messages is! List) { return (_nextId(_toolCallIdPrefix), '{}'); } for (var i = messages.length - 1; i >= 0; i--) { final raw = messages[i]; if (raw is! Map) { 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 _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'; } }