Files
AI-Health/health_app/lib/providers/chat_provider.dart
MingNian fade61ac21 feat: 二级页面色彩刷新 + 用药/通知/设备重构 + 后端健康档案/通知管线增强 + 大量测试
## 后端
- 健康档案: 新增手术状态字段 + EF 迁移; HealthArchiveService 新增查询方法
- 健康记录: HealthRecordService 新增批量/统计方法; 契约扩展
- 用药: 新增 MedicationScheduleStatus 枚举; MedicationService 排班逻辑调整
- 通知: EfUserNotificationPipeline 重构; 新增 EfReminderCatchUpService; 通知管线支持更多场景
- 用户: UserService 账号删除逻辑; 新增 local_account_file_cleanup; EfUserRepository 扩展
- AI: medication_agent_handler 微调; prompt_manager 优化; AiConversationService 上下文处理
- Endpoint: doctor/medication/exercise/health/notification/user 等多接口调整
- BackgroundService: health_record_reminder_service 重构, 提醒补漏逻辑
- 测试: 新增 account_deletion/doctor_endpoint/medication_schedule/medication_update/prompt_manager 测试

## 前端
- UI 系统: app_theme 大幅重构; app_colors/app_design_tokens/app_module_visuals 调整; 二级页面色彩刷新
- 主页: home_page 背景渐变 + 消息列表提取 _HomeMessages + 通知检查逻辑; chat_messages_view 全面重构
- 用药: medication_list/edit/checkin 三页重构, 新增 medication_ui_logic 抽取
- 通知: notification_prefs_page 重构, 新增 notification_prefs_logic; notification_center 优化
- 设备: device_management 重构, 新增 device_sync_ui_logic; device_scan 优化
- 趋势图: trend_page 大幅重构
- 登录: login_page 重构
- 个人资料: 新增 profile_edit_page; profile_page 优化
- 运动: 新增 exercise/ 目录 + care_plan_ui_logic
- 其他: remaining_pages/report_pages/health_drawer/admin/doctor 等多页面调整
- 组件: common_widgets/app_empty_state/app_error_state/app_future_view/app_toast/ai_content 优化
- Provider: chat_provider/consultation_provider/data_providers/auth_provider 调整
- AndroidManifest: 移除多余权限
- 测试: 新增 ai_content/care_plan/home_message/login_flow/medication_checkin/medication_ui/notification_prefs/profile_device/secondary_page/swipe_delete 等大量测试

## 文档
- 新增 ui-design-system.md 设计系统文档
- 新增 secondary-page-color-refresh 计划 + specs 目录
2026-07-15 23:22:52 +08:00

582 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,
}
const _keepConversationId = Object();
class ChatState {
final ActiveAgent activeAgent;
final List<ChatMessage> messages;
final String? conversationId;
final bool isStreaming;
final String? thinkingText;
final bool isViewingHistory;
const ChatState({
this.activeAgent = ActiveAgent.default_,
this.messages = const [],
this.conversationId,
this.isStreaming = false,
this.thinkingText,
this.isViewingHistory = false,
});
ChatState copyWith({
ActiveAgent? activeAgent,
List<ChatMessage>? messages,
Object? conversationId = _keepConversationId,
bool? isStreaming,
String? thinkingText,
bool? isViewingHistory,
}) => ChatState(
activeAgent: activeAgent ?? this.activeAgent,
messages: messages ?? this.messages,
conversationId: identical(conversationId, _keepConversationId)
? this.conversationId
: conversationId as String?,
isStreaming: isStreaming ?? this.isStreaming,
thinkingText: thinkingText ?? this.thinkingText,
isViewingHistory: isViewingHistory ?? this.isViewingHistory,
);
}
final chatProvider = NotifierProvider<ChatNotifier, ChatState>(
ChatNotifier.new,
);
class ChatNotifier extends Notifier<ChatState> {
StreamSubscription<Map<String, dynamic>>? _subscription;
Completer<void>? _streamDone;
ActiveAgent? _lastTriggeredAgent;
Timer? _agentTapLockTimer;
bool _loadingConversation = false;
/// 重置整个会话:取消正在进行的 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,
],
);
}
Future<String?> loadConversation(String convId) async {
if (state.isStreaming) return '小脉正在回复,请稍后再切换对话';
if (_loadingConversation) return '正在加载其他对话,请稍候';
_loadingConversation = true;
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?) ?? [];
if (rawMessages.isEmpty) return '该对话已不存在,请刷新记录';
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 = ChatState(
messages: messages,
conversationId: convId,
activeAgent: ActiveAgent.default_,
isViewingHistory: true,
);
_lastTriggeredAgent = null;
return null;
} catch (_) {
return '会话加载失败,请稍后重试';
} finally {
_loadingConversation = false;
}
}
/// 点击胶囊:用户标签和欢迎卡片立即出现,不走 AI。
/// 300ms 点击锁只防止误触,不延迟界面反馈。
/// 重复点击同一胶囊不重复弹卡片
void triggerAgent(ActiveAgent agent, String label) {
if (_agentTapLockTimer != null || _lastTriggeredAgent == agent) return;
_resumeConversationFromHistory();
_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();
_resumeConversationFromHistory();
// 先显示用户消息(本地显示图片路径)
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();
_resumeConversationFromHistory();
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();
_resumeConversationFromHistory();
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, '网络异常,请稍后重试');
}
}
void _resumeConversationFromHistory() {
if (!state.isViewingHistory) return;
state = state.copyWith(isViewingHistory: false);
}
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(),
isViewingHistory: false,
);
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? ?? '';
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);
}
}