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

@@ -21,7 +21,8 @@ class HomePage extends ConsumerStatefulWidget {
ConsumerState<HomePage> createState() => _HomePageState();
}
class _HomePageState extends ConsumerState<HomePage> {
class _HomePageState extends ConsumerState<HomePage>
with WidgetsBindingObserver {
final _textCtrl = TextEditingController();
final _scrollCtrl = ScrollController();
final _focusNode = FocusNode();
@@ -32,6 +33,7 @@ class _HomePageState extends ConsumerState<HomePage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback(
(_) => ref.invalidate(notificationUnreadCountProvider),
);
@@ -41,8 +43,19 @@ class _HomePageState extends ConsumerState<HomePage> {
);
}
@override
void didChangeMetrics() {
// 键盘动画期间每帧都会回调,让列表底部始终贴住输入区上沿
if (!_focusNode.hasFocus) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scrollCtrl.hasClients) return;
_scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent);
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_notificationTimer?.cancel();
_textCtrl.dispose();
_scrollCtrl.dispose();
@@ -102,16 +115,18 @@ class _HomePageState extends ConsumerState<HomePage> {
return Scaffold(
backgroundColor: const Color(0xFFFFFCFF),
drawer: const HealthDrawer(),
drawerEdgeDragWidth: MediaQuery.of(context).size.width * 0.35,
drawerEdgeDragWidth: MediaQuery.sizeOf(context).width * 0.65,
body: AppBackground(
safeArea: true,
child: Column(
children: [
_buildHeader(user),
Expanded(
child: ChatMessagesView(
scrollCtrl: _scrollCtrl,
messages: chatState.messages,
child: RepaintBoundary(
child: ChatMessagesView(
scrollCtrl: _scrollCtrl,
messages: chatState.messages,
),
),
),
_buildBottomBar(context),
@@ -472,17 +487,29 @@ class _HomePageState extends ConsumerState<HomePage> {
),
ListTile(
leading: const Icon(
Icons.file_open_outlined,
Icons.picture_as_pdf_outlined,
color: AppColors.primary,
),
title: const Text('传文件'),
title: const Text('上传 PDF'),
onTap: () async {
Navigator.pop(ctx);
final result = await FilePicker.platform.pickFiles();
if (result != null && result.files.isNotEmpty) {
_textCtrl.text = '[文件已选择] ${result.files.first.name}';
if (mounted) setState(() {});
}
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf'],
withData: false,
);
if (result == null || result.files.isEmpty) return;
final pdfFile = result.files.first;
final path = pdfFile.path;
if (path == null || path.isEmpty) return;
// 上传 + 让 AI 看 PDF 内容
await ref.read(chatProvider.notifier).sendPdf(
path,
pdfFile.name,
_textCtrl.text.trim(),
);
_textCtrl.clear();
if (mounted) setState(() {});
},
),
],

View File

@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../core/app_colors.dart';
import '../../../core/app_theme.dart';
import '../../../core/api_client.dart' show baseUrl;
import '../../../core/navigation_provider.dart';
import '../../../providers/chat_provider.dart';
import '../../../providers/data_providers.dart';
@@ -88,10 +89,7 @@ class ChatMessagesView extends ConsumerWidget {
),
),
const SizedBox(height: 16),
Text(
'开始和小脉健康对话吧',
style: Theme.of(context).textTheme.bodyMedium,
),
Text('开始和小脉健康对话吧', style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 8),
const Text(
'记录健康数据,获取专业建议',
@@ -138,7 +136,7 @@ class ChatMessagesView extends ConsumerWidget {
msg.content.trim().isEmpty) {
return _buildThinkingBubble(context, chatState.thinkingText);
}
return _buildTextBubble(context, msg, chatState);
return _buildTextBubble(context, ref, msg, chatState);
}
}
@@ -154,7 +152,7 @@ class ChatMessagesView extends ConsumerWidget {
) {
final info = _agentInfo(agent);
final actions = agent.actions;
final screenWidth = MediaQuery.of(context).size.width;
final screenWidth = MediaQuery.sizeOf(context).width;
final agentColors = _agentColors(agent);
final artworkPath = _agentArtworkPath(agent);
@@ -467,7 +465,7 @@ class ChatMessagesView extends ConsumerWidget {
) {
final meta = msg.metadata ?? <String, dynamic>{};
final backendType = meta['type'] as String? ?? '';
final screenWidth = MediaQuery.of(context).size.width;
final screenWidth = MediaQuery.sizeOf(context).width;
// 识别是哪种数据类型
final isMedication =
@@ -501,23 +499,17 @@ class ChatMessagesView extends ConsumerWidget {
final time = meta['time'] as String? ?? '';
if (time.isNotEmpty) {
final timeValue = meta['服药时间'] as String? ?? time;
fields.add(
_ConfirmField(label: '服药时间', value: timeValue),
);
fields.add(_ConfirmField(label: '服药时间', value: timeValue));
}
final frequency = meta['frequency'] as String? ?? '';
if (frequency.isNotEmpty) {
final freqValue = meta['频率'] as String? ?? _freqLabel(frequency);
fields.add(
_ConfirmField(label: '频率', value: freqValue),
);
fields.add(_ConfirmField(label: '频率', value: freqValue));
}
final duration = meta['duration_days'] as int?;
if (duration != null && duration > 0) {
final durationValue = meta['服用天数'] as String? ?? '$duration';
fields.add(
_ConfirmField(label: '服用天数', value: durationValue),
);
fields.add(_ConfirmField(label: '服用天数', value: durationValue));
}
} else if (isExercise) {
// 运动计划 — 主展示区大号显示运动名,字段列时长/频率/天数
@@ -558,17 +550,13 @@ class ChatMessagesView extends ConsumerWidget {
}
final intensity = (meta['intensity'] ?? meta['运动强度'] ?? '').toString();
if (intensity.isNotEmpty) {
fields.add(
_ConfirmField(label: '运动强度', value: intensity),
);
fields.add(_ConfirmField(label: '运动强度', value: intensity));
}
final calories =
(meta['calories'] ?? meta['calorie'] ?? meta['消耗热量'] ?? '')
.toString();
if (calories.isNotEmpty) {
fields.add(
_ConfirmField(label: '消耗热量', value: '$calories kcal'),
);
fields.add(_ConfirmField(label: '消耗热量', value: '$calories kcal'));
}
} else {
// 健康指标 — 主展示区已显示指标+数值+单位,详情只列额外信息
@@ -580,15 +568,11 @@ class ChatMessagesView extends ConsumerWidget {
if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt);
final timeValue = meta['记录时间'] as String? ?? recordTime;
fields.add(
_ConfirmField(label: '记录时间', value: timeValue),
);
fields.add(_ConfirmField(label: '记录时间', value: timeValue));
final note = meta['note'] as String? ?? '';
if (note.isNotEmpty) {
final noteValue = meta['备注'] as String? ?? note;
fields.add(
_ConfirmField(label: '备注', value: noteValue),
);
fields.add(_ConfirmField(label: '备注', value: noteValue));
}
}
@@ -911,9 +895,9 @@ class ChatMessagesView extends ConsumerWidget {
.read(chatProvider.notifier)
.confirmMessage(msg.id);
if (error != null && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error)));
}
},
borderRadius: BorderRadius.circular(18),
@@ -999,16 +983,15 @@ class ChatMessagesView extends ConsumerWidget {
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
padding: const EdgeInsets.all(1.4),
decoration: BoxDecoration(
color: AppColors.cardBackground,
gradient: AppColors.actionOutlineGradient,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(4),
topRight: Radius.circular(20),
bottomLeft: Radius.circular(20),
bottomRight: Radius.circular(20),
),
border: Border.all(color: AppColors.border, width: 1.5),
boxShadow: [
BoxShadow(
color: AppTheme.primary.withAlpha(12),
@@ -1017,28 +1000,40 @@ class ChatMessagesView extends ConsumerWidget {
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 26,
height: 26,
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(
color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(13),
),
child: const CircularProgressIndicator(
strokeWidth: 2.2,
color: AppTheme.primary,
),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
decoration: const BoxDecoration(
color: AppColors.cardBackground,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(3),
topRight: Radius.circular(18.6),
bottomLeft: Radius.circular(18.6),
bottomRight: Radius.circular(18.6),
),
const SizedBox(width: 10),
Text(
thinkingText?.isNotEmpty == true ? thinkingText! : '正在分析...',
style: const TextStyle(fontSize: 17, color: AppColors.textHint),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 26,
height: 26,
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(
color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(13),
),
child: const CircularProgressIndicator(
strokeWidth: 2.2,
color: AppTheme.primary,
),
),
const SizedBox(width: 10),
Text(
thinkingText?.isNotEmpty == true ? thinkingText! : '正在分析...',
style: const TextStyle(fontSize: 17, color: AppColors.textHint),
),
],
),
),
),
);
@@ -1046,153 +1041,228 @@ class ChatMessagesView extends ConsumerWidget {
Widget _buildTextBubble(
BuildContext context,
WidgetRef ref,
ChatMessage msg,
ChatState? chatState,
) {
final isUser = msg.isUser;
final imageUrl = msg.metadata?['imageUrl'] as String?;
final localPath = msg.metadata?['localImagePath'] as String?;
final pdfFileName =
msg.metadata?['pdfFileName']?.toString() ??
msg.metadata?['fileName']?.toString();
final hasImage = imageUrl != null || localPath != null;
final hasPdf = pdfFileName != null && pdfFileName.isNotEmpty;
return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 12),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.82,
maxWidth: MediaQuery.sizeOf(context).width * 0.82,
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: isUser ? Colors.white : AppColors.cardBackground,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(isUser ? 20 : 4),
topRight: Radius.circular(isUser ? 4 : 20),
bottomLeft: const Radius.circular(20),
bottomRight: const Radius.circular(20),
),
border: Border.all(
color: isUser ? const Color(0xFFE8E4FF) : AppColors.border,
width: isUser ? 1 : 1.5,
),
boxShadow: isUser
? []
: [
padding: EdgeInsets.all(isUser ? 0 : 1.4),
decoration: isUser
? null
: BoxDecoration(
gradient: AppColors.actionOutlineGradient,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(4),
topRight: Radius.circular(20),
bottomLeft: Radius.circular(20),
bottomRight: Radius.circular(20),
),
boxShadow: [
BoxShadow(
color: AppTheme.primary.withAlpha(12),
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 文字内容
if (isUser)
SelectableText(
msg.content,
style: const TextStyle(
fontSize: 19,
color: AppColors.textPrimary,
height: 1.5,
),
)
else
MarkdownBody(
data: _cleanAiText(msg.content),
selectable: true,
styleSheet: MarkdownStyleSheet(
p: const TextStyle(
),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: isUser ? AppTheme.primary : AppColors.cardBackground,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(isUser ? 18.6 : 3),
topRight: Radius.circular(isUser ? 3 : 18.6),
bottomLeft: const Radius.circular(18.6),
bottomRight: const Radius.circular(18.6),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 文字内容
if (isUser)
SelectableText(
msg.content,
style: const TextStyle(
fontSize: 19,
color: AppColors.textPrimary,
color: Colors.white,
height: 1.5,
),
strong: const TextStyle(
fontSize: 19,
color: AppColors.textPrimary,
height: 1.5,
fontWeight: FontWeight.w700,
),
h1: const TextStyle(
fontSize: 21,
color: AppColors.textPrimary,
fontWeight: FontWeight.w700,
),
h2: const TextStyle(
fontSize: 20,
color: AppColors.textPrimary,
fontWeight: FontWeight.w700,
),
),
),
// 图片缩略图(在文字下方)
if (hasImage)
Padding(
padding: const EdgeInsets.only(top: 8),
child: GestureDetector(
onTap: () => _showFullImage(context, localPath ?? imageUrl),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 160,
maxHeight: 120,
),
child: localPath != null
? Image.file(File(localPath), fit: BoxFit.cover)
: imageUrl != null
? Image.network(
imageUrl,
fit: BoxFit.cover,
errorBuilder: (_, e, s) => Container(
width: 80,
height: 60,
decoration: BoxDecoration(
color: AppColors.backgroundSecondary,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.image,
size: 28,
color: AppColors.textHint,
),
),
)
: const SizedBox.shrink(),
)
else
MarkdownBody(
data: _cleanAiText(msg.content),
selectable: true,
onTapLink: (text, href, title) =>
_handleMarkdownLink(context, ref, href),
styleSheet: MarkdownStyleSheet(
p: const TextStyle(
fontSize: 19,
color: AppColors.textPrimary,
height: 1.5,
),
a: const TextStyle(
fontSize: 19,
color: AppColors.primary,
fontWeight: FontWeight.w700,
decoration: TextDecoration.underline,
decorationColor: AppColors.primary,
),
strong: const TextStyle(
fontSize: 19,
color: AppColors.textPrimary,
height: 1.5,
fontWeight: FontWeight.w700,
),
h1: const TextStyle(
fontSize: 22,
color: AppColors.textPrimary,
height: 1.35,
fontWeight: FontWeight.w800,
),
h2: const TextStyle(
fontSize: 21,
color: AppColors.textPrimary,
height: 1.4,
fontWeight: FontWeight.w800,
),
h3: const TextStyle(
fontSize: 20,
color: AppColors.textPrimary,
height: 1.4,
fontWeight: FontWeight.w800,
),
listBullet: const TextStyle(
fontSize: 24,
color: AppColors.textPrimary,
height: 1.2,
fontWeight: FontWeight.w900,
),
listBulletPadding: const EdgeInsets.only(right: 8),
),
),
),
if (!isUser && msg.content.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Row(
children: [
const CircleAvatar(
radius: 10,
backgroundColor: AppTheme.primaryLight,
child: Icon(
Icons.chat_bubble_outline,
size: 17,
color: AppTheme.primary,
// 图片缩略图(在文字下方)
if (hasImage)
Padding(
padding: const EdgeInsets.only(top: 8),
child: GestureDetector(
onTap: () => _showFullImage(context, localPath ?? imageUrl),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 160,
maxHeight: 120,
),
child: localPath != null
? Image.file(File(localPath), fit: BoxFit.cover)
: imageUrl != null
? Image.network(
_mediaUrl(imageUrl),
fit: BoxFit.cover,
errorBuilder: (_, e, s) => Container(
width: 80,
height: 60,
decoration: BoxDecoration(
color: AppColors.backgroundSecondary,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.image,
size: 28,
color: AppColors.textHint,
),
),
)
: const SizedBox.shrink(),
),
),
const SizedBox(width: 6),
const Text(
'小脉健康',
style: TextStyle(fontSize: 15, color: AppColors.textHint),
),
const SizedBox(width: 4),
const Text(
'仅供参考',
style: TextStyle(fontSize: 14, color: AppColors.textHint),
),
],
),
),
),
],
if (hasPdf)
Container(
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: isUser
? Colors.white.withValues(alpha: 0.16)
: AppColors.backgroundSecondary,
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.picture_as_pdf_outlined,
size: 18,
color: isUser ? Colors.white : AppColors.error,
),
const SizedBox(width: 6),
Flexible(
child: Text(
pdfFileName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: isUser
? Colors.white
: AppColors.textPrimary,
),
),
),
],
),
),
if (!isUser && msg.content.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Row(
children: [
const CircleAvatar(
radius: 10,
backgroundColor: AppTheme.primaryLight,
child: Icon(
Icons.chat_bubble_outline,
size: 17,
color: AppTheme.primary,
),
),
const SizedBox(width: 6),
const Text(
'内容由 AI 生成',
style: TextStyle(
fontSize: 14,
color: AppColors.textHint,
),
),
],
),
),
],
),
),
),
);
@@ -1204,6 +1274,7 @@ class ChatMessagesView extends ConsumerWidget {
static void _showFullImage(BuildContext context, String? path) {
if (path == null) return;
final resolvedPath = _mediaUrl(path);
showDialog(
context: context,
builder: (ctx) => Dialog(
@@ -1216,8 +1287,10 @@ class ChatMessagesView extends ConsumerWidget {
borderRadius: BorderRadius.circular(12),
child: InteractiveViewer(
child: path.startsWith('http')
? Image.network(path, fit: BoxFit.contain)
: Image.file(File(path), fit: BoxFit.contain),
? Image.network(resolvedPath, fit: BoxFit.contain)
: resolvedPath.startsWith('http')
? Image.network(resolvedPath, fit: BoxFit.contain)
: Image.file(File(resolvedPath), fit: BoxFit.contain),
),
),
Positioned(
@@ -1241,6 +1314,12 @@ class ChatMessagesView extends ConsumerWidget {
);
}
static String _mediaUrl(String path) {
if (path.startsWith('http://') || path.startsWith('https://')) return path;
if (path.startsWith('/uploads/')) return '$baseUrl$path';
return path;
}
/// 清理 AI 返回文本中的异常占位符Markdown 格式交给 MarkdownBody 渲染)
static String _cleanAiText(String text) {
var t = text;
@@ -1255,6 +1334,37 @@ class ChatMessagesView extends ConsumerWidget {
return t.trim();
}
/// 处理 AI 回复里的 markdown 链接点击:
/// - app://diet → 触发拍照/相册选择,跳到饮食拍照流程
/// - app://report → 跳到报告列表(用户可在那里上传新报告)
/// - app://device → 跳到蓝牙设备页
/// - 其他 app://xxx → 当作 route name 直接跳
static void _handleMarkdownLink(
BuildContext context,
WidgetRef ref,
String? href,
) {
if (href == null || href.isEmpty) return;
final uri = Uri.tryParse(href);
if (uri == null) return;
if (uri.scheme == 'app') {
switch (uri.host) {
case 'diet':
ref.read(chatProvider.notifier).triggerAgent(ActiveAgent.diet, '拍饮食');
break;
case 'report':
pushRoute(ref, 'reports');
break;
case 'device':
pushRoute(ref, 'devices');
break;
default:
if (uri.host.isNotEmpty) pushRoute(ref, uri.host);
}
}
}
String _freqLabel(String freq) {
switch (freq.toLowerCase()) {
case 'daily':
@@ -1493,8 +1603,11 @@ class ChatMessagesView extends ConsumerWidget {
}
if (hr is Map && hr['value'] is num) {
final v = (hr['value'] as num).toDouble();
if (v > 100) abnormals.add('心率 ${v.toStringAsFixed(0)} 偏快');
else if (v < 60) abnormals.add('心率 ${v.toStringAsFixed(0)}');
if (v > 100) {
abnormals.add('心率 ${v.toStringAsFixed(0)}');
} else if (v < 60) {
abnormals.add('心率 ${v.toStringAsFixed(0)} 偏慢');
}
}
if (bs is Map && bs['value'] is num) {
final v = (bs['value'] as num).toDouble();
@@ -1554,7 +1667,8 @@ class ChatMessagesView extends ConsumerWidget {
if (plan != null) {
final items =
(plan['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final today = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
final today =
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
final todayItem = items.cast<Map<String, dynamic>?>().firstWhere(
(i) => i?['scheduledDate']?.toString() == today,
orElse: () => null,
@@ -1631,7 +1745,7 @@ class ChatMessagesView extends ConsumerWidget {
}
// ── 3. 用药打卡 ──
// 只显示已过期(漏服)的药;用颜色区分严重程度;没漏服则整行不显示
// 漏服时突出提醒;未到服药时间时也保留一行轻提示,避免今日健康缺少用药状态。
const medIconColor = Color(0xFFEC4899);
const medIconBg = Color(0xFFFCE7F3);
reminders.whenOrNull(
@@ -1639,7 +1753,21 @@ class ChatMessagesView extends ConsumerWidget {
final overdueMeds = meds
.where((m) => m['status'] == 'overdue')
.toList();
if (overdueMeds.isEmpty) return;
if (overdueMeds.isEmpty) {
tasks.add(
_taskRow(
context,
Icons.medication_rounded,
'用药',
trailing: meds.isEmpty ? '暂无用药安排' : '暂时不用吃药',
status: 'done',
iconColor: medIconColor,
iconBg: medIconBg,
onTap: () => pushRoute(ref, 'medCheckIn'),
),
);
return;
}
// 按过期时长排序——先把最严重的放前面
final now = DateTime.now();
@@ -1658,8 +1786,7 @@ class ChatMessagesView extends ConsumerWidget {
? 0
: now.difference(scheduled).inMinutes / 60.0;
return (m, overdueHours);
}).toList()
..sort((a, b) => b.$2.compareTo(a.$2));
}).toList()..sort((a, b) => b.$2.compareTo(a.$2));
// 取最严重那一项作为代表,其他用"等"省略
final first = withDelta.first;
@@ -1969,10 +2096,7 @@ class _AgentAction {
class _ConfirmField {
final String label;
final String value;
const _ConfirmField({
required this.label,
required this.value,
});
const _ConfirmField({required this.label, required this.value});
}
final _agentActions = <ActiveAgent, List<_AgentAction>>{