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); ActiveAgent _parseAgent(String? type) { switch (type?.toLowerCase()) { case 'consultation': return ActiveAgent.consultation; case 'health': return ActiveAgent.health; case 'diet': return ActiveAgent.diet; case 'medication': return ActiveAgent.medication; case 'report': return ActiveAgent.report; case 'exercise': return ActiveAgent.exercise; default: return ActiveAgent.default_; } } class ChatNotifier extends Notifier { StreamSubscription>? _subscription; String _streamBuffer = ''; Timer? _streamTimer; void markNeedsRebuild() => state = state.copyWith(); /// 不可变消息操作方法(供 chat_messages_view 新版代码调用) void confirmMessage(String id) { final msgs = state.messages.toList(); final i = msgs.indexWhere((m) => m.id == id); if (i >= 0) { msgs[i].confirmed = true; state = state.copyWith(messages: msgs); } ref.invalidate(medicationListProvider); ref.invalidate(latestHealthProvider); } void startEditingField(String msgId, String fieldLabel) { final msgs = state.messages.toList(); final i = msgs.indexWhere((m) => m.id == msgId); if (i >= 0) { msgs[i].metadata?['_editingField'] = fieldLabel; state = state.copyWith(messages: msgs); } } void finishEditingField(String msgId, String fieldLabel, String value) { final msgs = state.messages.toList(); final i = msgs.indexWhere((m) => m.id == msgId); if (i >= 0) { if (value.isNotEmpty) msgs[i].metadata?[fieldLabel] = value; msgs[i].metadata?.remove('_editingField'); state = state.copyWith(messages: msgs); } } @override ChatState build() { 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; _subscription?.cancel(); 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 { _subscription?.cancel(); 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.5 秒后出欢迎卡片,不走 AI void triggerAgent(ActiveAgent agent, String label) { 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; // 先显示用户消息(本地显示图片路径) 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; try { final api = ref.read(apiClientProvider); uploadedUrl = await api.uploadFile('/api/files/upload', file); } catch (_) { // 上传失败:保留本地路径,仍然可以本地显示 } // 更新消息元数据(保留本地路径 + 添加远程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); } // 将图片 URL 作为消息内容发送给 AI final msgWithImage = text.isNotEmpty ? '$text\n[图片已上传]' : '[图片已上传]'; await _sendToAI(msgWithImage); } Future sendMessage(String text) async { if (text.trim().isEmpty || state.isStreaming) return; 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 for (final event in stream) { _processEvent(event, aiMsg); } } catch (e) { _addError(aiMsg, '网络异常,请稍后重试'); } } void _addError(ChatMessage aiMsg, String errorText) { state = state.copyWith( messages: [ ...state.messages, ChatMessage( id: 'err_${DateTime.now().millisecondsSinceEpoch}', role: 'assistant', content: errorText, createdAt: DateTime.now(), ), ], 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': _streamBuffer += (j['data'] as String?) ?? ''; final messageType = j['type'] as String? ?? 'text'; aiMsg.type = _parseMessageType(messageType); if (j['metadata'] is Map) { aiMsg.metadata = Map.from(j['metadata']); } // 逐字释放 _streamTimer?.cancel(); _streamTimer = Timer.periodic(const Duration(milliseconds: 40), (t) { if (_streamBuffer.isEmpty) { t.cancel(); state = state.copyWith(thinkingText: null); return; } aiMsg.content += _streamBuffer[0]; _streamBuffer = _streamBuffer.substring(1); 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) { _streamTimer?.cancel(); // 释放剩余 buffer while (_streamBuffer.isNotEmpty) { m.content += _streamBuffer[0]; _streamBuffer = _streamBuffer.substring(1); } final u = state.messages.toList(); if (!u.any((x) => x.id == m.id) && m.content.isNotEmpty) u.add(m); state = state.copyWith(messages: u, isStreaming: false, thinkingText: null); } }