Files
AI-Health/health_app/lib/providers/chat_provider.dart
MingNian e654c1e0cc feat: 通知推送/免打扰偏好 + 饮食记录侧滑删除修复 + 聊天交互优化
- 通知: 新增推送开关/免打扰时段偏好持久化 + EF 迁移; 通知管线支持免打扰过滤
- 饮食: 侧滑删除 confirmDismiss 按方向放行(修复删不掉); 新增 diet_nutrition_widgets; 饮食录入页重构
- 聊天: 智能体胶囊点击锁防误触; 流式状态 select 优化; 卡片入场动画
- 主页: 消息列表提取 _HomeMessages + 侧滑手势打开抽屉
- 其他: api_client IP 适配; 通知/用药/饮食端点微调; 测试更新
2026-07-13 10:39:34 +08:00

584 lines
17 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_provider.dart';
import 'conversation_history_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<String, dynamic>? 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';
bool get isReadOnly => metadata?['readOnly'] == true;
}
enum ActiveAgent {
default_,
consultation,
health,
diet,
medication,
report,
exercise,
}
class ChatState {
final ActiveAgent activeAgent;
final List<ChatMessage> 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<ChatMessage>? 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,
);
}
final chatProvider = NotifierProvider<ChatNotifier, ChatState>(
ChatNotifier.new,
);
class ChatNotifier extends Notifier<ChatState> {
StreamSubscription<Map<String, dynamic>>? _subscription;
Completer<void>? _streamDone;
ActiveAgent? _lastTriggeredAgent;
Timer? _agentTapLockTimer;
/// 重置整个会话:取消正在进行的 SSE清空消息和会话 ID。
/// 历史记录页一键清空 / 删除当前会话时调用。
Future<void> resetSession() async {
await _cancelActiveStream();
_cancelPendingAgentWelcome();
_lastTriggeredAgent = null;
state = const ChatState();
}
/// 不可变消息操作方法(供 chat_messages_view 新版代码调用)
Future<String?> confirmMessage(String id) async {
final msgs = state.messages.toList();
final i = msgs.indexWhere((m) => m.id == id);
if (i < 0) return '确认卡片不存在';
if (msgs[i].isReadOnly) return '历史记录中的录入卡片仅供查看';
final rawIds = msgs[i].metadata?['confirmationIds'];
final confirmationIds = rawIds is List
? rawIds.map((e) => e.toString()).where((e) => e.isNotEmpty).toList()
: <String>[];
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(medicationReminderProvider);
ref.invalidate(latestHealthProvider);
ref.invalidate(currentExercisePlanProvider);
return null;
}
@override
ChatState build() {
ref.onDispose(() {
_subscription?.cancel();
_agentTapLockTimer?.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,
],
);
}
/// 根据 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) {
state = state.copyWith(activeAgent: agent);
}
}
Future<String?> loadConversation(String convId) async {
await _cancelActiveStream();
_cancelPendingAgentWelcome();
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<String, dynamic>;
final role = map['role']?.toString().toLowerCase() == 'user'
? 'user'
: 'assistant';
final metadata = _parseMetadata(map['metadataJson']) ?? {};
metadata['readOnly'] = true;
return ChatMessage(
id: map['id']?.toString() ?? '',
role: role,
content: map['content']?.toString() ?? '',
createdAt:
DateTime.tryParse(map['createdAt']?.toString() ?? '') ??
DateTime.now(),
type: _messageTypeFromMetadata(metadata),
metadata: metadata,
confirmed: metadata['confirmationIds'] is! List,
);
}).toList();
state = state.copyWith(
messages: messages,
conversationId: convId,
activeAgent: ActiveAgent.default_,
);
return null;
} catch (_) {
return '会话加载失败,请稍后重试';
}
}
/// 点击胶囊:用户标签和欢迎卡片立即出现,不走 AI。
/// 300ms 点击锁只防止误触,不延迟界面反馈。
/// 重复点击同一胶囊不重复弹卡片
void triggerAgent(ActiveAgent agent, String label) {
if (_agentTapLockTimer != null || _lastTriggeredAgent == agent) return;
_lastTriggeredAgent = agent;
final now = DateTime.now();
final userMsg = ChatMessage(
id: 'agent_trigger_${now.microsecondsSinceEpoch}',
role: 'user',
content: label,
createdAt: now,
);
final welcomeMsg = ChatMessage(
id: 'welcome_${agent.name}_${now.microsecondsSinceEpoch}',
role: 'assistant',
content: '',
createdAt: now,
type: MessageType.agentWelcome,
metadata: {'agent': agent.name},
);
state = state.copyWith(
messages: [...state.messages, userMsg, welcomeMsg],
activeAgent: agent,
);
_agentTapLockTimer = Timer(const Duration(milliseconds: 300), () {
_agentTapLockTimer = null;
});
}
void _cancelPendingAgentWelcome() {
_agentTapLockTimer?.cancel();
_agentTapLockTimer = null;
}
Future<void> sendImage(String imagePath, String text) async {
if (state.isStreaming) return;
final file = File(imagePath);
if (!await file.exists()) return;
_lastTriggeredAgent = null;
_cancelPendingAgentWelcome();
// 先显示用户消息(本地显示图片路径)
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],
isStreaming: true,
);
// 异步上传图片
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 = <String, dynamic>{'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]);
state = state.copyWith(isStreaming: false);
return;
}
// 把图片 URL 透传给后端,后端会调 VLM 识图并把描述拼到 LLM 上下文
final userText = text.isNotEmpty ? text : '请帮我看看这张图片';
await _sendToAI(userText, imageUrl: uploadedUrl);
}
/// 发送 PDF 附件 + 文字PDF 解析在后端做)。
Future<void> sendPdf(String pdfPath, String fileName, String text) async {
if (state.isStreaming) return;
final file = File(pdfPath);
if (!await file.exists()) return;
_lastTriggeredAgent = null;
_cancelPendingAgentWelcome();
final userMsg = ChatMessage(
id: '${DateTime.now().millisecondsSinceEpoch}',
role: 'user',
content: text.isNotEmpty ? text : '请帮我看看这份 PDF',
createdAt: DateTime.now(),
metadata: {'pdfFileName': fileName},
);
state = state.copyWith(
messages: [...state.messages, userMsg],
isStreaming: true,
);
String? uploadedUrl;
try {
final api = ref.read(apiClientProvider);
uploadedUrl = await api.uploadFile('/api/files/upload', file);
} catch (_) {
// ignore下方统一处理
}
// 更新消息附带的远程 URL
if (uploadedUrl != null) {
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: {'pdfFileName': fileName, 'pdfUrl': uploadedUrl},
);
state = state.copyWith(messages: updatedMsgs);
}
} else {
final errorMsg = ChatMessage(
id: '${DateTime.now().millisecondsSinceEpoch}_upload_error',
role: 'assistant',
content: 'PDF 上传失败,请检查文件大小或网络后重试。',
createdAt: DateTime.now(),
);
state = state.copyWith(messages: [...state.messages, errorMsg]);
state = state.copyWith(isStreaming: false);
return;
}
await _sendToAI(userMsg.content, pdfUrl: uploadedUrl);
}
Future<void> sendMessage(String text) async {
if (text.trim().isEmpty || state.isStreaming) return;
_lastTriggeredAgent = null;
_cancelPendingAgentWelcome();
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<void> _sendToAI(
String text, {
String? imageUrl,
String? pdfUrl,
}) 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,
imageUrl: imageUrl,
pdfUrl: pdfUrl,
token: token,
);
await _cancelActiveStream();
final done = Completer<void>();
_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<void> _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<String, dynamic> 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<String, dynamic>.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;
}
}
Map<String, dynamic>? _parseMetadata(dynamic raw) {
if (raw == null) return null;
if (raw is Map) return Map<String, dynamic>.from(raw);
if (raw is! String || raw.trim().isEmpty) return null;
try {
final decoded = jsonDecode(raw);
return decoded is Map ? Map<String, dynamic>.from(decoded) : null;
} catch (_) {
return null;
}
}
MessageType _messageTypeFromMetadata(Map<String, dynamic>? metadata) {
if (metadata == null) return MessageType.text;
final type = metadata['messageType']?.toString();
if (type != null && type.isNotEmpty) return _parseMessageType(type);
if (metadata['confirmationIds'] is List) return MessageType.dataConfirm;
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);
ref.invalidate(conversationHistoryProvider);
}
}