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, medicationConfirm, dietAnalysis, reportAnalysis, quickOptions, agentWelcome } class ChatMessage { final String id; final String role; String content; final DateTime createdAt; MessageType type; final Map? metadata; ChatMessage({ required this.id, required this.role, required this.content, required this.createdAt, this.type = MessageType.text, this.metadata, }); 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 ConversationItem { final String id; final String title; final String lastMessage; final DateTime updatedAt; final ActiveAgent agent; ConversationItem({ required this.id, required this.title, required this.lastMessage, required this.updatedAt, required this.agent, }); } class SelectedAgentNotifier extends Notifier { @override ActiveAgent? build() => null; void select(ActiveAgent? a) => state = a; } final selectedAgentProvider = NotifierProvider(SelectedAgentNotifier.new); final chatProvider = NotifierProvider(ChatNotifier.new); final conversationListProvider = FutureProvider>((ref) async { final api = ref.watch(apiClientProvider); final token = await api.accessToken; if (token == null) return []; try { final res = await api.get('/api/conversations'); final list = res.data['data'] as List? ?? []; return list.map((item) { final data = item as Map; return ConversationItem( id: data['id']?.toString() ?? '', title: data['title']?.toString() ?? '对话', lastMessage: data['lastMessage']?.toString() ?? '', updatedAt: DateTime.parse(data['updatedAt']?.toString() ?? DateTime.now().toIso8601String()), agent: _parseAgent(data['agentType']?.toString()), ); }).toList(); } catch (_) { return []; } }); 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; @override ChatState build() => const ChatState(); void setAgent(ActiveAgent a) { _subscription?.cancel(); state = state.copyWith(activeAgent: a); } 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}, )]); } 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/upload', file); } catch (_) { // 上传失败:保留本地路径,仍然可以本地显示 } // 更新消息元数据(上传成功则替换为远程 URL) final finalUrl = uploadedUrl ?? imagePath; final updatedMsgs = state.messages.toList(); final idx = updatedMsgs.indexWhere((m) => m.id == userMsg.id); if (idx >= 0) { updatedMsgs[idx] = ChatMessage( id: userMsg.id, role: 'user', content: userMsg.content, createdAt: userMsg.createdAt, metadata: {'imageUrl': finalUrl}, ); 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(), ); state = state.copyWith(isStreaming: true); try { final token = await ref.read(apiClientProvider).accessToken; if (token == null) { _addError(aiMsg, '未登录,请重新登录'); return; } final agentPath = state.activeAgent.name.replaceFirst('default_', 'default'); final stream = SseHandler.connect( agentType: agentPath, 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': aiMsg.content += (j['data'] as String?) ?? ''; final messageType = j['type'] as String? ?? 'text'; aiMsg.type = _parseMessageType(messageType); 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? ?? ''; 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': return MessageType.dataConfirm; case 'medication_confirm': return MessageType.medicationConfirm; case 'diet_analysis': return MessageType.dietAnalysis; case 'report_analysis': return MessageType.reportAnalysis; case 'quick_options': return MessageType.quickOptions; 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 if (m.content.isNotEmpty) { u.add(m); } state = state.copyWith(messages: u); } void _done(ChatMessage m) { 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); } }