feat: AI 对话附件上下文解析 + 历史会话归档 + 多页面 UI 重构
- 后端: 新增 AttachmentContextBuilder 解析图片/PDF 摘要并拼入 LLM 上下文; ai_chat_endpoints 扩展附件接口; 新增 ReportAnalysisService - 前端: 新增历史会话页与 conversation_history_provider; chat 链路支持附件展示与回放 - UI: 重构 medication_checkin / notification_center / profile / health_drawer 等多页面 - 配置: api_client baseUrl 适配当前 WiFi IP
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
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';
|
||||
|
||||
@@ -86,6 +88,15 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
|
||||
void markNeedsRebuild() => state = state.copyWith();
|
||||
|
||||
/// 重置整个会话:取消正在进行的 SSE,清空消息和会话 ID。
|
||||
/// 历史记录页一键清空 / 删除当前会话时调用。
|
||||
Future<void> resetSession() async {
|
||||
await _cancelActiveStream();
|
||||
_lastTriggeredAgent = null;
|
||||
state = const ChatState();
|
||||
ref.read(selectedAgentProvider.notifier).select(null);
|
||||
}
|
||||
|
||||
/// 不可变消息操作方法(供 chat_messages_view 新版代码调用)
|
||||
Future<String?> confirmMessage(String id) async {
|
||||
final msgs = state.messages.toList();
|
||||
@@ -103,7 +114,9 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
for (final confirmationId in confirmationIds.toList()) {
|
||||
final response = await api.post('/api/ai/confirm-write/$confirmationId');
|
||||
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() ?? '录入失败' : '录入失败';
|
||||
@@ -203,14 +216,19 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
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']);
|
||||
return ChatMessage(
|
||||
id: map['id']?.toString() ?? '',
|
||||
role: map['role']?.toString() ?? 'user',
|
||||
role: role,
|
||||
content: map['content']?.toString() ?? '',
|
||||
createdAt:
|
||||
DateTime.tryParse(map['createdAt']?.toString() ?? '') ??
|
||||
DateTime.now(),
|
||||
type: MessageType.text,
|
||||
type: _messageTypeFromMetadata(metadata),
|
||||
metadata: metadata,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
@@ -259,6 +277,7 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
}
|
||||
|
||||
Future<void> sendImage(String imagePath, String text) async {
|
||||
if (state.isStreaming) return;
|
||||
final file = File(imagePath);
|
||||
if (!await file.exists()) return;
|
||||
_lastTriggeredAgent = null;
|
||||
@@ -303,16 +322,70 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
final errorMsg = ChatMessage(
|
||||
id: '${DateTime.now().millisecondsSinceEpoch}_upload_error',
|
||||
role: 'assistant',
|
||||
content: uploadError == null ? '图片上传失败,请稍后重试。' : '图片上传失败,请检查文件大小或网络后重试。',
|
||||
content: uploadError == null
|
||||
? '图片上传失败,请稍后重试。'
|
||||
: '图片上传失败,请检查文件大小或网络后重试。',
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
state = state.copyWith(messages: [...state.messages, errorMsg]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 将图片 URL 作为消息内容发送给 AI
|
||||
final msgWithImage = text.isNotEmpty ? '$text\n[图片已上传]' : '[图片已上传]';
|
||||
await _sendToAI(msgWithImage);
|
||||
// 把图片 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;
|
||||
|
||||
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]);
|
||||
|
||||
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]);
|
||||
return;
|
||||
}
|
||||
|
||||
await _sendToAI(userMsg.content, pdfUrl: uploadedUrl);
|
||||
}
|
||||
|
||||
Future<void> sendMessage(String text) async {
|
||||
@@ -333,7 +406,11 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
await _sendToAI(text);
|
||||
}
|
||||
|
||||
Future<void> _sendToAI(String text) async {
|
||||
Future<void> _sendToAI(
|
||||
String text, {
|
||||
String? imageUrl,
|
||||
String? pdfUrl,
|
||||
}) async {
|
||||
final aiMsg = ChatMessage(
|
||||
id: '${DateTime.now().millisecondsSinceEpoch}_ai',
|
||||
role: 'assistant',
|
||||
@@ -359,6 +436,8 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
agentType: 'unified',
|
||||
message: text,
|
||||
conversationId: state.conversationId,
|
||||
imageUrl: imageUrl,
|
||||
pdfUrl: pdfUrl,
|
||||
token: token,
|
||||
);
|
||||
|
||||
@@ -454,6 +533,26 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -478,5 +577,6 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
u.add(m);
|
||||
}
|
||||
state = state.copyWith(messages: u, isStreaming: false, thinkingText: null);
|
||||
ref.invalidate(conversationHistoryProvider);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user