feat: AI 提示词模块化 + AI 同意门控 + AI 草稿存储 + 意图路由 + 医疗引用知识库 + 多页面 UI 优化
- AI 提示词拆分为 markdown 模块(Prompts/global + modules + rag + router) - 新增 AI 同意门控(用户需同意后才能使用 AI 功能) - 新增 AI 录入草稿存储(AiEntryDraftContracts/EfAiEntryDraftStore/AiEntryDraftRecord) - 新增 AI 意图路由(ai_intent_router) - 新增医疗引用知识库(MedicalCitationKnowledge) - 重构 prompt_manager 和 ai_chat_endpoints - 优化聊天/趋势/档案/抽屉/医生端等多个页面 UI - 新增 agent 插画和趋势指标图标资源 - 删除 HANDOFF-2026-07-17.md
This commit is contained in:
56
health_app/lib/providers/ai_consent_provider.dart
Normal file
56
health_app/lib/providers/ai_consent_provider.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'auth_provider.dart' show localDbProvider;
|
||||
|
||||
const aiConsentVersion = 'ai-data-consent-v1';
|
||||
|
||||
class AiConsentState {
|
||||
final bool isLoading;
|
||||
final bool granted;
|
||||
final String? userId;
|
||||
|
||||
const AiConsentState({
|
||||
this.isLoading = true,
|
||||
this.granted = false,
|
||||
this.userId,
|
||||
});
|
||||
|
||||
AiConsentState copyWith({bool? isLoading, bool? granted, String? userId}) {
|
||||
return AiConsentState(
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
granted: granted ?? this.granted,
|
||||
userId: userId ?? this.userId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final aiConsentProvider = NotifierProvider<AiConsentNotifier, AiConsentState>(
|
||||
AiConsentNotifier.new,
|
||||
);
|
||||
|
||||
class AiConsentNotifier extends Notifier<AiConsentState> {
|
||||
@override
|
||||
AiConsentState build() => const AiConsentState();
|
||||
|
||||
String _key(String userId) => '$aiConsentVersion:$userId';
|
||||
|
||||
Future<void> load(String userId) async {
|
||||
state = AiConsentState(isLoading: true, userId: userId);
|
||||
final value = await ref.read(localDbProvider).read(_key(userId));
|
||||
state = AiConsentState(
|
||||
isLoading: false,
|
||||
granted: value == 'granted',
|
||||
userId: userId,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> grant(String userId) async {
|
||||
await ref.read(localDbProvider).write(_key(userId), 'granted');
|
||||
state = AiConsentState(isLoading: false, granted: true, userId: userId);
|
||||
}
|
||||
|
||||
Future<void> revoke(String userId) async {
|
||||
await ref.read(localDbProvider).delete(_key(userId));
|
||||
state = AiConsentState(isLoading: false, granted: false, userId: userId);
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,13 @@ class AuthNotifier extends Notifier<AuthState> {
|
||||
|
||||
Future<void> refreshProfile() => _loadProfile();
|
||||
|
||||
Future<void> applyProfile(Map<String, dynamic> profile) async {
|
||||
final user = _userFromMap(profile, fallback: state.user);
|
||||
state = AuthState(isLoggedIn: true, isLoading: false, user: user);
|
||||
await _cacheUser(user);
|
||||
_publishSessionIdentity(user);
|
||||
}
|
||||
|
||||
/// 发送验证码
|
||||
Future<({String? error, String? devCode})> sendSms(String phone) async {
|
||||
try {
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'auth_provider.dart';
|
||||
import 'conversation_history_provider.dart';
|
||||
import 'data_providers.dart';
|
||||
import 'data_refresh_providers.dart';
|
||||
import '../core/api_client.dart';
|
||||
import '../utils/sse_handler.dart';
|
||||
|
||||
enum MessageType { text, dataConfirm, agentWelcome, taskCard }
|
||||
@@ -104,6 +105,7 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
Timer? _agentTapLockTimer;
|
||||
bool _loadingConversation = false;
|
||||
int _generation = 0;
|
||||
final Set<String> _confirmingMessageIds = <String>{};
|
||||
|
||||
/// 重置整个会话:取消正在进行的 SSE,清空消息和会话 ID。
|
||||
/// 历史记录页一键清空 / 删除当前会话时调用。
|
||||
@@ -117,20 +119,23 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
|
||||
/// 不可变消息操作方法(供 chat_messages_view 新版代码调用)
|
||||
Future<String?> confirmMessage(String id) async {
|
||||
if (!_confirmingMessageIds.add(id)) return null;
|
||||
final msgs = state.messages.toList();
|
||||
final i = msgs.indexWhere((m) => m.id == id);
|
||||
if (i < 0) return '确认卡片不存在';
|
||||
if (msgs[i].isReadOnly) return '历史记录中的录入卡片仅供查看';
|
||||
|
||||
final rawIds = msgs[i].metadata?['confirmationIds'];
|
||||
final confirmationIds = rawIds is List
|
||||
? rawIds.map((e) => e.toString()).where((e) => e.isNotEmpty).toList()
|
||||
: <String>[];
|
||||
if (confirmationIds.isEmpty) {
|
||||
return '确认信息已失效,请重新发送需要录入的内容';
|
||||
}
|
||||
|
||||
try {
|
||||
if (i < 0) return '确认卡片不存在';
|
||||
if (msgs[i].isReadOnly) return '历史记录中的录入卡片仅供查看';
|
||||
|
||||
final original = msgs[i];
|
||||
final metadata = _cloneMetadata(original.metadata) ?? <String, dynamic>{};
|
||||
final rawIds = metadata['confirmationIds'];
|
||||
final confirmationIds = rawIds is List
|
||||
? rawIds.map((e) => e.toString()).where((e) => e.isNotEmpty).toList()
|
||||
: <String>[];
|
||||
if (confirmationIds.isEmpty) {
|
||||
return '确认信息已失效,请重新发送需要录入的内容';
|
||||
}
|
||||
|
||||
final api = ref.read(apiClientProvider);
|
||||
for (final confirmationId in confirmationIds.toList()) {
|
||||
final response = await api.post(
|
||||
@@ -141,19 +146,24 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
return body is Map ? body['message']?.toString() ?? '录入失败' : '录入失败';
|
||||
}
|
||||
confirmationIds.remove(confirmationId);
|
||||
msgs[i].metadata?['confirmationIds'] = confirmationIds.toList();
|
||||
metadata['confirmationIds'] = List<String>.from(confirmationIds);
|
||||
msgs[i] = _copyMessage(original, metadata: metadata);
|
||||
state = state.copyWith(messages: msgs);
|
||||
}
|
||||
} catch (e) {
|
||||
return '录入失败,请检查网络后重试';
|
||||
}
|
||||
|
||||
msgs[i].confirmed = true;
|
||||
state = state.copyWith(messages: msgs);
|
||||
ref.read(medicationDataRefreshSignalProvider.notifier).trigger();
|
||||
ref.invalidate(latestHealthProvider);
|
||||
ref.read(exerciseDataRefreshSignalProvider.notifier).trigger();
|
||||
return null;
|
||||
msgs[i] = _copyMessage(original, metadata: metadata, confirmed: true);
|
||||
state = state.copyWith(messages: msgs);
|
||||
ref.read(medicationDataRefreshSignalProvider.notifier).trigger();
|
||||
ref.invalidate(latestHealthProvider);
|
||||
ref.read(exerciseDataRefreshSignalProvider.notifier).trigger();
|
||||
return null;
|
||||
} on ApiException catch (e) {
|
||||
return e.message;
|
||||
} catch (_) {
|
||||
return '录入失败,请检查网络后重试';
|
||||
} finally {
|
||||
_confirmingMessageIds.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -280,7 +290,7 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
|
||||
// 先显示用户消息(本地显示图片路径)
|
||||
final userMsg = ChatMessage(
|
||||
id: '${DateTime.now().millisecondsSinceEpoch}',
|
||||
id: '${DateTime.now().microsecondsSinceEpoch}',
|
||||
role: 'user',
|
||||
content: text.isNotEmpty ? text : '[图片]',
|
||||
createdAt: DateTime.now(),
|
||||
@@ -349,7 +359,7 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
_resumeConversationFromHistory();
|
||||
|
||||
final userMsg = ChatMessage(
|
||||
id: '${DateTime.now().millisecondsSinceEpoch}',
|
||||
id: '${DateTime.now().microsecondsSinceEpoch}',
|
||||
role: 'user',
|
||||
content: text.isNotEmpty ? text : '请帮我看看这份 PDF',
|
||||
createdAt: DateTime.now(),
|
||||
@@ -407,7 +417,7 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
_resumeConversationFromHistory();
|
||||
|
||||
final userMsg = ChatMessage(
|
||||
id: '${DateTime.now().millisecondsSinceEpoch}',
|
||||
id: '${DateTime.now().microsecondsSinceEpoch}',
|
||||
role: 'user',
|
||||
content: text,
|
||||
createdAt: DateTime.now(),
|
||||
@@ -428,7 +438,7 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
}) async {
|
||||
if (generation != _generation || !state.isStreaming) return;
|
||||
final aiMsg = ChatMessage(
|
||||
id: '${DateTime.now().millisecondsSinceEpoch}_ai',
|
||||
id: '${DateTime.now().microsecondsSinceEpoch}_ai',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
createdAt: DateTime.now(),
|
||||
@@ -563,6 +573,15 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
aiMsg.content += (j['data'] as String?) ?? '';
|
||||
state = state.copyWith(thinkingText: null);
|
||||
_update(aiMsg);
|
||||
case 'confirmation':
|
||||
aiMsg.type = _parseMessageType(j['type'] as String? ?? 'data_confirm');
|
||||
aiMsg.confirmed = false;
|
||||
if (j['metadata'] is Map) {
|
||||
aiMsg.metadata = _cloneMetadata(
|
||||
Map<String, dynamic>.from(j['metadata']),
|
||||
);
|
||||
}
|
||||
_update(aiMsg);
|
||||
case 'notice':
|
||||
state = state.copyWith(thinkingText: j['message'] as String?);
|
||||
case 'tool_result':
|
||||
@@ -620,6 +639,27 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
state = state.copyWith(messages: u);
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _cloneMetadata(Map<String, dynamic>? metadata) {
|
||||
if (metadata == null) return null;
|
||||
return Map<String, dynamic>.from(jsonDecode(jsonEncode(metadata)) as Map);
|
||||
}
|
||||
|
||||
ChatMessage _copyMessage(
|
||||
ChatMessage source, {
|
||||
Map<String, dynamic>? metadata,
|
||||
bool? confirmed,
|
||||
}) {
|
||||
return ChatMessage(
|
||||
id: source.id,
|
||||
role: source.role,
|
||||
content: source.content,
|
||||
createdAt: source.createdAt,
|
||||
type: source.type,
|
||||
metadata: metadata ?? _cloneMetadata(source.metadata),
|
||||
confirmed: confirmed ?? source.confirmed,
|
||||
);
|
||||
}
|
||||
|
||||
void _done(ChatMessage m) {
|
||||
final u = state.messages.toList();
|
||||
final content = m.content.trim();
|
||||
|
||||
Reference in New Issue
Block a user