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:
MingNian
2026-07-06 12:44:59 +08:00
parent 4507083f3f
commit 7a93237069
38 changed files with 4165 additions and 1157 deletions

View File

@@ -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);
}
}

View File

@@ -0,0 +1,85 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/auth_provider.dart';
import '../providers/chat_provider.dart';
/// 对话历史列表项
class ConversationListItem {
final String id;
final String? title;
final String? summary;
final int messageCount;
final DateTime updatedAt;
const ConversationListItem({
required this.id,
this.title,
this.summary,
required this.messageCount,
required this.updatedAt,
});
factory ConversationListItem.fromJson(Map<String, dynamic> json) =>
ConversationListItem(
id: json['id']?.toString() ?? '',
title: json['title']?.toString(),
summary: json['summary']?.toString(),
messageCount: (json['messageCount'] as num?)?.toInt() ?? 0,
updatedAt: DateTime.tryParse(json['updatedAt']?.toString() ?? '') ??
DateTime.now(),
);
}
/// 对话历史 Notifier缓存列表提供刷新/删除/清空。
class ConversationHistoryNotifier
extends AsyncNotifier<List<ConversationListItem>> {
@override
Future<List<ConversationListItem>> build() => _fetch();
Future<List<ConversationListItem>> _fetch() async {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/ai/conversations');
final raw = (res.data['data'] as List?) ?? const [];
return raw
.whereType<Map>()
.map((m) => ConversationListItem.fromJson(Map<String, dynamic>.from(m)))
.toList();
}
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(_fetch);
}
/// 删除单条;先乐观更新 UI失败回滚。
Future<void> deleteOne(String id) async {
final previous = state.asData?.value ?? const <ConversationListItem>[];
state = AsyncValue.data(previous.where((e) => e.id != id).toList());
try {
final api = ref.read(apiClientProvider);
await api.delete('/api/ai/conversations/$id');
} catch (_) {
state = AsyncValue.data(previous);
rethrow;
}
// 同步清掉当前 chat state如果删的就是当前会话
final chat = ref.read(chatProvider);
if (chat.conversationId == id) {
ref.read(chatProvider.notifier).resetSession();
}
}
/// 一键清空当前用户的全部对话。
Future<int> clearAll() async {
final api = ref.read(apiClientProvider);
final res = await api.delete('/api/ai/conversations');
state = const AsyncValue.data([]);
ref.read(chatProvider.notifier).resetSession();
final deleted = res.data['data']?['deleted'];
return deleted is num ? deleted.toInt() : 0;
}
}
final conversationHistoryProvider = AsyncNotifierProvider<
ConversationHistoryNotifier, List<ConversationListItem>>(
ConversationHistoryNotifier.new,
);