feat: AI 意图路由/草稿存储 + Prompts 模块化 + UI 视觉统一 + AI 同意门 + 资源更新
后端: - 新增 ai_intent_router 意图路由 - 新增 AiEntryDraft 草稿存储 + EF 迁移 - 新增 Prompts 模块化(global/modules/rag/router) - AI Agent handlers 扩展 前端: - 新增 AI 同意门 (ai_consent_gate/provider/details_page) - HealthMetricVisuals 视觉统一 - 设备扫描/管理页面优化 - agent 插图替换 + 趋势指标图标 配置: - DeepSeek 模型改 deepseek-v4-flash - .gitignore 加 artifacts/audit - build.gradle.kts 签名配置调整
This commit is contained in:
93
health_app/lib/providers/ai_consent_provider.dart
Normal file
93
health_app/lib/providers/ai_consent_provider.dart
Normal file
@@ -0,0 +1,93 @@
|
||||
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 isSaving;
|
||||
final bool granted;
|
||||
final String? userId;
|
||||
final String? error;
|
||||
|
||||
const AiConsentState({
|
||||
this.isLoading = true,
|
||||
this.isSaving = false,
|
||||
this.granted = false,
|
||||
this.userId,
|
||||
this.error,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
try {
|
||||
final value = await ref.read(localDbProvider).read(_key(userId));
|
||||
state = AiConsentState(
|
||||
isLoading: false,
|
||||
granted: value == 'granted',
|
||||
userId: userId,
|
||||
);
|
||||
} catch (_) {
|
||||
state = AiConsentState(
|
||||
isLoading: false,
|
||||
userId: userId,
|
||||
error: '授权状态加载失败,请重试',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> grant(String userId) async {
|
||||
state = AiConsentState(
|
||||
isLoading: false,
|
||||
isSaving: true,
|
||||
granted: state.granted,
|
||||
userId: userId,
|
||||
);
|
||||
try {
|
||||
await ref.read(localDbProvider).write(_key(userId), 'granted');
|
||||
state = AiConsentState(isLoading: false, granted: true, userId: userId);
|
||||
return true;
|
||||
} catch (_) {
|
||||
state = AiConsentState(
|
||||
isLoading: false,
|
||||
userId: userId,
|
||||
error: '授权保存失败,请稍后重试',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> revoke(String userId) async {
|
||||
state = AiConsentState(
|
||||
isLoading: false,
|
||||
isSaving: true,
|
||||
granted: state.granted,
|
||||
userId: userId,
|
||||
);
|
||||
try {
|
||||
await ref.read(localDbProvider).delete(_key(userId));
|
||||
state = AiConsentState(isLoading: false, granted: false, userId: userId);
|
||||
return true;
|
||||
} catch (_) {
|
||||
state = AiConsentState(
|
||||
isLoading: false,
|
||||
granted: true,
|
||||
userId: userId,
|
||||
error: '撤回授权失败,请稍后重试',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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