Files
AI-Health/health_app/lib/providers/chat_provider.dart
MingNian b944a31983 fix: VLM识别修复 + 饮食CRUD + 侧边栏美化 + UI优化
- VLM: ChatMessage.Content string→object, 修复视觉content双重序列化
- 饮食: diet/medication端点 record→手动JSON解析, 修复循环引用
- 饮食记录: 左滑删除 + 去评分 + AI饮食评语(DeepSeek)
- 侧边栏: 功能区统一Row+Expanded, 服务包compact模式
- 历史对话: 点击加载历史消息
- 登录页: 居中布局, 去底部空白
- UI: 主色加深#6C5CE7, maxWidth:1024防图片超大
2026-06-04 16:27:03 +08:00

384 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, medicationConfirm, dietAnalysis, reportAnalysis, quickOptions, agentWelcome, taskCard, onboarding }
class ChatMessage {
final String id;
final String role;
String content;
final DateTime createdAt;
MessageType type;
final Map<String, dynamic>? 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, onboarding }
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 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<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);
final conversationListProvider = FutureProvider<List<ConversationItem>>((ref) async {
final api = ref.watch(apiClientProvider);
final token = await api.accessToken;
if (token == null) return [];
try {
final res = await api.get('/api/ai/conversations');
final list = res.data['data'] as List? ?? [];
return list.map((item) {
final data = item as Map<String, dynamic>;
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<ChatState> {
StreamSubscription<Map<String, dynamic>>? _subscription;
@override
ChatState build() {
Future.microtask(() {
insertTaskCard();
_checkOnboarding();
});
return const ChatState();
}
void _checkOnboarding() async {
try {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/user/health-archive');
final archive = res.data['data'];
// 档案全空 → 触发建档引导
final isEmpty = archive == null ||
(archive['diagnosis'] == null && archive['surgeryType'] == null &&
(archive['allergies'] == null || (archive['allergies'] as List).isEmpty) &&
(archive['chronicDiseases'] == null || (archive['chronicDiseases'] as List).isEmpty) &&
(archive['dietRestrictions'] == null || (archive['dietRestrictions'] as List).isEmpty));
if (isEmpty && !state.messages.any((m) => m.type == MessageType.onboarding)) {
final db = ref.read(localDbProvider);
final skipped = await db.read('onboarding_skipped');
if (skipped == null) {
state = state.copyWith(messages: [
...state.messages,
ChatMessage(id: 'onboarding_card', role: 'assistant', content: '', createdAt: DateTime.now(), type: MessageType.onboarding),
]);
}
}
} catch (_) {}
}
void startOnboarding() {
setAgent(ActiveAgent.onboarding);
// Remove onboarding card
state = state.copyWith(messages: state.messages.where((m) => m.type != MessageType.onboarding).toList());
// Send trigger message
Future.microtask(() => _sendToAI('开始建档'));
}
void dismissOnboarding() async {
final db = ref.read(localDbProvider);
await db.write('onboarding_skipped', DateTime.now().toIso8601String());
state = state.copyWith(messages: state.messages.where((m) => m.type != MessageType.onboarding).toList());
}
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) {
_subscription?.cancel();
state = state.copyWith(activeAgent: a);
}
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();
// 从对话列表中找到对应的 agent 类型
final convList = ref.read(conversationListProvider).value ?? [];
final conv = convList.firstWhere((c) => c.id == convId, orElse: () => ConversationItem(id: convId, title: '', lastMessage: '', updatedAt: DateTime.now(), agent: ActiveAgent.default_));
state = state.copyWith(
messages: messages,
conversationId: convId,
activeAgent: conv.agent,
);
ref.read(selectedAgentProvider.notifier).select(conv.agent);
} 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},
)]);
}
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(),
);
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<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':
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);
}
}