Files
AI-Health/health_app/lib/providers/chat_provider.dart
MingNian 63d2092c24 feat: UI全面改造 + 后端多项修复
【后端修复】
- 删除diet/consultation agent假工具声明
- 修复DayOfWeek实体注释
- 修复Vision API content序列化
- cleanup_service级联删除修复
- 用药提醒时区偏差修复
- 统一DateTime处理(UtcNow+8)
- 新增UTC DateTime JSON转换器

【前端UI重构】
- 配色体系全面更新(#8B5CF6淡紫+#F0ECFF背景)
- 登录页重设计
- 首页重设计(透明顶栏、渐变背景、胶囊输入区)
- 聊天卡片加白蓝边框、渐变标题
- 侧边栏重构(渐变背景、合并顶部、删除底部设置)
- 确认卡片可编辑字段恢复
- 所有子页面加返回按钮
- catch异常加日志
- 删除后refresh provider缓存
2026-06-10 18:27:38 +08:00

402 lines
12 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: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<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';
}
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,
);
}
class SelectedAgentNotifier extends Notifier<ActiveAgent?> {
@override
ActiveAgent? build() => null;
void select(ActiveAgent? a) => state = a;
}
final selectedAgentProvider =
NotifierProvider<SelectedAgentNotifier, ActiveAgent?>(SelectedAgentNotifier.new);
final chatProvider = NotifierProvider<ChatNotifier, ChatState>(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<ChatState> {
StreamSubscription<Map<String, dynamic>>? _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<void> 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<String, dynamic>;
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<void> 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 = <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);
}
// 将图片 URL 作为消息内容发送给 AI
final msgWithImage = text.isNotEmpty ? '$text\n[图片已上传]' : '[图片已上传]';
await _sendToAI(msgWithImage);
}
Future<void> 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<void> _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<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':
_streamBuffer += (j['data'] as String?) ?? '';
final messageType = j['type'] as String? ?? 'text';
aiMsg.type = _parseMessageType(messageType);
if (j['metadata'] is Map) {
aiMsg.metadata = Map<String, dynamic>.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);
}
}