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:
MingNian
2026-07-27 16:53:20 +08:00
parent ad93e38b7e
commit 3b5cec10a6
80 changed files with 4358 additions and 1339 deletions

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

View File

@@ -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 {

View File

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