import 'dart:async'; import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'auth_provider.dart'; import 'data_providers.dart'; import '../utils/sse_handler.dart'; enum MessageType { text, dataConfirm, agentWelcome, taskCard } class ChatMessage { final String id; final String role; String content; final DateTime createdAt; MessageType type; Map? metadata; bool confirmed; ChatMessage({ required this.id, required this.role, required this.content, required this.createdAt, this.type = MessageType.text, this.metadata, this.confirmed = false, }); bool get isUser => role == 'user'; } enum ActiveAgent { default_, consultation, health, diet, medication, report, exercise, } class ChatState { final ActiveAgent activeAgent; final List messages; final String? conversationId; final bool isStreaming; final String? thinkingText; const ChatState({ this.activeAgent = ActiveAgent.default_, this.messages = const [], this.conversationId, this.isStreaming = false, this.thinkingText, }); ChatState copyWith({ ActiveAgent? activeAgent, List? messages, String? conversationId, bool? isStreaming, String? thinkingText, }) => ChatState( activeAgent: activeAgent ?? this.activeAgent, messages: messages ?? this.messages, conversationId: conversationId ?? this.conversationId, isStreaming: isStreaming ?? this.isStreaming, thinkingText: thinkingText ?? this.thinkingText, ); } class SelectedAgentNotifier extends Notifier { @override ActiveAgent? build() => null; void select(ActiveAgent? a) => state = a; } final selectedAgentProvider = NotifierProvider( SelectedAgentNotifier.new, ); final chatProvider = NotifierProvider( ChatNotifier.new, ); class ChatNotifier extends Notifier { StreamSubscription>? _subscription; Completer? _streamDone; ActiveAgent? _lastTriggeredAgent; void markNeedsRebuild() => state = state.copyWith(); /// 不可变消息操作方法(供 chat_messages_view 新版代码调用) Future confirmMessage(String id) async { final msgs = state.messages.toList(); final i = msgs.indexWhere((m) => m.id == id); if (i < 0) return '确认卡片不存在'; final rawIds = msgs[i].metadata?['confirmationIds']; final confirmationIds = rawIds is List ? rawIds.map((e) => e.toString()).where((e) => e.isNotEmpty).toList() : []; if (confirmationIds.isEmpty) { return '确认信息已失效,请重新发送需要录入的内容'; } try { final api = ref.read(apiClientProvider); for (final confirmationId in confirmationIds.toList()) { final response = await api.post('/api/ai/confirm-write/$confirmationId'); final body = response.data; if (body is! Map || body['code'] != 0) { return body is Map ? body['message']?.toString() ?? '录入失败' : '录入失败'; } confirmationIds.remove(confirmationId); msgs[i].metadata?['confirmationIds'] = confirmationIds.toList(); state = state.copyWith(messages: msgs); } } catch (e) { return '录入失败,请检查网络后重试'; } msgs[i].confirmed = true; state = state.copyWith(messages: msgs); ref.invalidate(medicationListProvider); ref.invalidate(latestHealthProvider); return null; } @override ChatState build() { ref.onDispose(() { _subscription?.cancel(); _subscription = null; if (_streamDone != null && !_streamDone!.isCompleted) { _streamDone!.complete(); } _streamDone = null; }); Future.microtask(() { insertTaskCard(); }); return const ChatState(); } void insertTaskCard() { if (state.messages.any((m) => m.type == MessageType.taskCard)) return; state = state.copyWith( messages: [ ChatMessage( id: 'task_card', role: 'assistant', content: '', createdAt: DateTime.now(), type: MessageType.taskCard, ), ...state.messages, ], ); } void setAgent(ActiveAgent a) { // 流式回复中忽略胶囊切换,防止状态混乱 if (state.isStreaming) return; _cancelActiveStream(); state = state.copyWith(activeAgent: a); ref.read(selectedAgentProvider.notifier).select(a); } /// 根据 AI 调用的工具自动切换智能体胶囊 void _switchAgentByTool(String tool) { ActiveAgent? agent; switch (tool) { case 'record_health_data': case 'query_health_records': agent = ActiveAgent.health; break; case 'estimate_food_text': agent = ActiveAgent.diet; break; case 'manage_medication': agent = ActiveAgent.medication; break; case 'manage_exercise': agent = ActiveAgent.exercise; break; case 'request_doctor': agent = ActiveAgent.consultation; break; case 'analyze_report': agent = ActiveAgent.report; break; } if (agent != null) { ref.read(selectedAgentProvider.notifier).select(agent); state = state.copyWith(activeAgent: agent); } } Future loadConversation(String convId) async { await _cancelActiveStream(); try { final api = ref.read(apiClientProvider); final res = await api.get('/api/ai/conversations/$convId'); final rawMessages = (res.data['data'] as List?) ?? []; final messages = rawMessages.map((m) { final map = m as Map; return ChatMessage( id: map['id']?.toString() ?? '', role: map['role']?.toString() ?? 'user', content: map['content']?.toString() ?? '', createdAt: DateTime.tryParse(map['createdAt']?.toString() ?? '') ?? DateTime.now(), type: MessageType.text, ); }).toList(); state = state.copyWith( messages: messages, conversationId: convId, activeAgent: ActiveAgent.default_, ); ref.read(selectedAgentProvider.notifier).select(ActiveAgent.default_); } catch (_) {} } void insertAgentWelcome(ActiveAgent agent) { state = state.copyWith( messages: [ ...state.messages, ChatMessage( id: 'welcome_${agent.name}_${DateTime.now().millisecondsSinceEpoch}', role: 'assistant', content: '', createdAt: DateTime.now(), type: MessageType.agentWelcome, metadata: {'agent': agent.name}, ), ], ); } /// 点击胶囊:先出用户标签 → 0.4 秒后出欢迎卡片,不走 AI /// 重复点击同一胶囊不重复弹卡片 void triggerAgent(ActiveAgent agent, String label) { if (_lastTriggeredAgent == agent) return; _lastTriggeredAgent = agent; final userMsg = ChatMessage( id: 'agent_trigger_${DateTime.now().millisecondsSinceEpoch}', role: 'user', content: label, createdAt: DateTime.now(), ); state = state.copyWith(messages: [...state.messages, userMsg]); Future.delayed(const Duration(milliseconds: 400), () { insertAgentWelcome(agent); }); } Future sendImage(String imagePath, String text) async { final file = File(imagePath); if (!await file.exists()) return; _lastTriggeredAgent = null; // 先显示用户消息(本地显示图片路径) final userMsg = ChatMessage( id: '${DateTime.now().millisecondsSinceEpoch}', role: 'user', content: text.isNotEmpty ? text : '[图片]', createdAt: DateTime.now(), metadata: {'localImagePath': imagePath}, ); state = state.copyWith(messages: [...state.messages, userMsg]); // 异步上传图片 String? uploadedUrl; Object? uploadError; try { final api = ref.read(apiClientProvider); uploadedUrl = await api.uploadFile('/api/files/upload', file); } catch (e) { uploadError = e; } // 更新消息元数据(保留本地路径 + 添加远程URL) final updatedMsgs = state.messages.toList(); final idx = updatedMsgs.indexWhere((m) => m.id == userMsg.id); if (idx >= 0) { final meta = {'localImagePath': imagePath}; if (uploadedUrl != null) meta['imageUrl'] = uploadedUrl; updatedMsgs[idx] = ChatMessage( id: userMsg.id, role: 'user', content: userMsg.content, createdAt: userMsg.createdAt, metadata: meta, ); state = state.copyWith(messages: updatedMsgs); } if (uploadedUrl == null) { final errorMsg = ChatMessage( id: '${DateTime.now().millisecondsSinceEpoch}_upload_error', role: 'assistant', content: uploadError == null ? '图片上传失败,请稍后重试。' : '图片上传失败,请检查文件大小或网络后重试。', createdAt: DateTime.now(), ); state = state.copyWith(messages: [...state.messages, errorMsg]); return; } // 将图片 URL 作为消息内容发送给 AI final msgWithImage = text.isNotEmpty ? '$text\n[图片已上传]' : '[图片已上传]'; await _sendToAI(msgWithImage); } Future sendMessage(String text) async { if (text.trim().isEmpty || state.isStreaming) return; _lastTriggeredAgent = null; final userMsg = ChatMessage( id: '${DateTime.now().millisecondsSinceEpoch}', role: 'user', content: text, createdAt: DateTime.now(), ); state = state.copyWith( messages: [...state.messages, userMsg], isStreaming: true, ); await _sendToAI(text); } Future _sendToAI(String text) async { final aiMsg = ChatMessage( id: '${DateTime.now().millisecondsSinceEpoch}_ai', role: 'assistant', content: '', createdAt: DateTime.now(), ); // 立即加入空 AI 消息,让思考动画有载体 state = state.copyWith( messages: [...state.messages, aiMsg], isStreaming: true, ); try { final token = await ref.read(apiClientProvider).accessToken; if (token == null) { _addError(aiMsg, '未登录,请重新登录'); return; } // 始终用 unified 智能体,AI 自动判断意图分配工具 final stream = SseHandler.connect( agentType: 'unified', message: text, conversationId: state.conversationId, token: token, ); await _cancelActiveStream(); final done = Completer(); _streamDone = done; _subscription = stream.listen( (event) => _processEvent(event, aiMsg), onError: (_) { _addError(aiMsg, '网络异常,请稍后重试'); if (!done.isCompleted) done.complete(); }, onDone: () { if (!done.isCompleted) done.complete(); }, cancelOnError: true, ); await done.future; if (_streamDone == done) { _streamDone = null; _subscription = null; } if (state.isStreaming) { _done(aiMsg); } } catch (e) { _addError(aiMsg, '网络异常,请稍后重试'); } } Future _cancelActiveStream() async { await _subscription?.cancel(); _subscription = null; if (_streamDone != null && !_streamDone!.isCompleted) { _streamDone!.complete(); } _streamDone = null; } void _addError(ChatMessage aiMsg, String errorText) { aiMsg.content = errorText; aiMsg.type = MessageType.text; final u = state.messages.toList(); final i = u.indexWhere((x) => x.id == aiMsg.id); if (i >= 0) { u[i] = aiMsg; } else { u.add(aiMsg); } state = state.copyWith(messages: u, isStreaming: false, thinkingText: null); } void _processEvent(Map j, ChatMessage aiMsg) { final a = j['action'] as String?; switch (a) { case 'conversation_id': state = state.copyWith(conversationId: j['data']?.toString()); case 'answer': final messageType = j['type'] as String? ?? 'text'; aiMsg.type = _parseMessageType(messageType); if (j['metadata'] is Map) { aiMsg.metadata = Map.from(j['metadata']); } aiMsg.content += (j['data'] as String?) ?? ''; state = state.copyWith(thinkingText: null); _update(aiMsg); case 'notice': state = state.copyWith(thinkingText: j['message'] as String?); case 'tool_result': final tool = j['tool'] as String? ?? ''; // 根据 AI 调用的工具自动切换智能体胶囊 _switchAgentByTool(tool); if (tool == 'record_health_data') { ref.invalidate(latestHealthProvider); } case 'status': _done(aiMsg); case 'error': _done(aiMsg); } } MessageType _parseMessageType(String type) { switch (type) { case 'data_confirm': case 'medication_confirm': return MessageType.dataConfirm; case 'agent_welcome': return MessageType.agentWelcome; default: return MessageType.text; } } void _update(ChatMessage m) { final u = state.messages.toList(); final i = u.indexWhere((x) => x.id == m.id); if (i >= 0) { u[i] = m; } else { u.add(m); // 空内容也加入,让思考气泡有载体 } state = state.copyWith(messages: u); } void _done(ChatMessage m) { final u = state.messages.toList(); final content = m.content.trim(); if (content.isEmpty && m.type == MessageType.text) { m.content = '暂时没有收到回复,请稍后再试。'; } final i = u.indexWhere((x) => x.id == m.id); if (i >= 0) { u[i] = m; } else { u.add(m); } state = state.copyWith(messages: u, isStreaming: false, thinkingText: null); } }