Files
AI-Health/health_app/lib/providers/chat_provider.dart
MingNian 4f1ad82345 fix: 欢迎卡片UI优化 + 蓝牙录入按钮 + 多处修复
- 欢迎卡片:去掉白框圆角错位、图片满宽、按钮去箭头改圆角居中
- 渐变actionOutlineGradient改紫蓝双色循环
- 新增健康Agent"蓝牙录入"按钮→蓝牙设备页
- "查看健康情况"改名"健康概览"
- 饮食编辑框删除bug修复(控制器缓存)
- 启动脚本加adb reverse自动转发
- 登录闪屏修复
2026-06-17 10:58:59 +08:00

447 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;
ActiveAgent? _lastTriggeredAgent;
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.4 秒后出欢迎卡片,不走 AI
/// 重复点击同一胶囊不重复弹卡片
void triggerAgent(ActiveAgent agent, String label) {
if (_lastTriggeredAgent == agent) return;
_lastTriggeredAgent = agent;
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;
_lastTriggeredAgent = null;
// 先显示用户消息(本地显示图片路径)
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;
_lastTriggeredAgent = null;
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);
}
if (state.isStreaming) {
_done(aiMsg);
}
} catch (e) {
_addError(aiMsg, '网络异常,请稍后重试');
}
}
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;
}
}
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);
}
}