Files

865 lines
30 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../api/ai_api.dart';
import '../../core/network/api_env.dart';
import '../../models/ai.dart';
import '../../providers/ai_provider.dart';
import '../../providers/ai_config_provider.dart';
import '../../providers/ai_persona_provider.dart';
import '../../providers/auth_provider.dart';
import 'ai_config_page.dart';
import '../../utils/toast.dart';
import '../../widgets/avatar.dart';
import '../../widgets/state_views.dart';
import '../../widgets/cute_icon.dart';
/// 助手:和 AI 对话,问账单、问笔记。
///
/// 既是底部 Tab,也能从首页那个输入框 push 进来 —— 所以顶部自己画了
/// 返回箭头(canPop 时才画)。
class AssistantPage extends ConsumerStatefulWidget {
const AssistantPage({super.key});
@override
ConsumerState<AssistantPage> createState() => _AssistantPageState();
}
class _AssistantPageState extends ConsumerState<AssistantPage> {
final _input = TextEditingController();
final _scroll = ScrollController();
/// 空状态下的引导问题 —— 让用户知道能问什么,比一句「你好」有用
static const _suggestions = [
'这个月花了多少钱?',
'上个月的支出分类排行',
'我的笔记里有哪些关于 MySQL 的?',
];
@override
void initState() {
super.initState();
// 打开就把上一次的对话展开,而不是每次都从空白开始。
// 放进 postFrameCallback:initState 里改 provider 状态会撞上「构建期间改状态」。
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref.read(assistantProvider.notifier).restoreLastSessionIfNeeded();
});
}
@override
void dispose() {
_input.dispose();
_scroll.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(assistantProvider);
_scheduleScroll();
return Scaffold(
body: Container(
width: double.infinity,
height: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFFE5CC), Color(0xFFFFD4A8), Color(0xFFFFC08A)],
),
),
child: SafeArea(
child: Column(
children: [
_header(context),
Expanded(child: _body(context, state)),
if (state.error != null) _errorBar(state.error!),
_inputBar(state),
],
),
),
),
);
}
// ---------------------------------------------------------------- 顶部
Widget _header(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(10, 6, 8, 6),
child: Row(
children: [
if (Navigator.of(context).canPop())
_iconButton(
icon: Icons.arrow_back,
tooltip: '返回',
onTap: () => Navigator.of(context).pop(),
),
const Padding(
padding: EdgeInsets.only(left: 6),
child: Text(
'助手',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
),
const SizedBox(width: 8),
// 当前用的哪个模型 —— 点一下就能换。
// 放在标题旁边而不是藏进菜单里,是因为「刚才那句话怎么答得怪怪的」
// 十有八九就是切错了模型,得让它随时可见。
Flexible(child: _configChip(context)),
const Spacer(),
_iconButton(
icon: Icons.history,
tooltip: '历史对话',
onTap: () => _showSessions(context),
),
_iconButton(
icon: Icons.add_comment_outlined,
tooltip: '新对话',
// 新对话要先选人设 —— 选了之后这一轮就锁定它,中途不会换
onTap: () => _startNewChat(context),
),
],
),
);
}
/// 顶栏上的「当前模型」小胶囊
Widget _configChip(BuildContext context) {
final active = ref.watch(activeAiConfigProvider);
final label = active?.name ?? '--';
return GestureDetector(
onTap: () => openAiConfigPage(context),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFFFFF3E6),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.memory, size: 12, color: Color(0xFFC8956E)),
const SizedBox(width: 4),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11.5, color: Color(0xFF8D6E63)),
),
),
const Icon(Icons.expand_more, size: 13, color: Color(0xFFBCAAA4)),
],
),
),
);
}
Widget _iconButton({
required IconData icon,
required String tooltip,
required VoidCallback onTap,
}) {
return Tooltip(
message: tooltip,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.all(7),
child: Icon(icon, size: 21, color: const Color(0xFF5D4037)),
),
),
);
}
// ---------------------------------------------------------------- 主体
Widget _body(BuildContext context, AssistantState state) {
if (state.loadingHistory) {
return const LoadingView(tip: '正在加载对话…');
}
if (state.isEmpty) {
return _welcome(context);
}
final showTool = state.streaming && state.bubbles.isNotEmpty;
return ListView.builder(
controller: _scroll,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(14, 8, 14, 12),
itemCount: state.bubbles.length + (showTool ? 1 : 0),
itemBuilder: (context, index) {
if (index >= state.bubbles.length) {
return _toolIndicator(state.activeTool);
}
return _bubble(context, state.bubbles[index]);
},
);
}
Widget _welcome(BuildContext context) {
return ListView(
padding: const EdgeInsets.fromLTRB(24, 40, 24, 0),
children: [
const Center(child: CuteIcon('assistant', size: 48)),
const SizedBox(height: 14),
const Center(
child: Text(
'问我点什么',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
),
const SizedBox(height: 6),
const Center(
child: Text(
'我能查你的账单统计和笔记',
style: TextStyle(fontSize: 12.5, color: Color(0xFF8D6E63)),
),
),
const SizedBox(height: 26),
for (final s in _suggestions) ...[
_suggestionChip(s),
const SizedBox(height: 10),
],
],
);
}
Widget _suggestionChip(String text) {
return GestureDetector(
onTap: () => _send(text),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFE8D9C5)),
),
child: Row(
children: [
Expanded(
child: Text(
text,
style: const TextStyle(fontSize: 13.5, color: Color(0xFF5D4037)),
),
),
const Icon(Icons.north_east, size: 15, color: Color(0xFFBCAAA4)),
],
),
),
);
}
// ---------------------------------------------------------------- 新对话
/// 「新对话」:先让用户挑个人设,再开始。
///
/// 挑完就锁定在这一轮对话上(存进 AssistantState.personaId),
/// 之后不管用户在设置页怎么换人设,这轮都不会变 —— 这是刻意的,
/// 见 `AssistantState.personaId` 的注释。
///
/// 列表里第一项是「默认人设」,也就是什么都不选的状态。用户直接关掉弹层
/// (返回 null)时也按默认处理,不强迫他做选择。
Future<void> _startNewChat(BuildContext context) async {
final picked = await showModalBottomSheet<_PersonaChoice>(
context: context,
backgroundColor: Colors.white,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => const _PersonaPickerSheet(),
);
if (!mounted) return;
// 关掉弹层 = 取消,不开新对话。
// 不自动用默认人设开始:误触一下就把用户正在看的对话顶掉太亏了。
// 想用默认人设就点列表里第一项「默认人设」,一样是一下
if (picked == null) return;
// 记住这次选的,下次新对话默认预选它。
// 这是**唯一**写这个偏好的地方 —— 设置页不提供「选说话的人」这个动作
ref.read(preferredPersonaProvider.notifier).remember(picked.personaId);
ref.read(assistantProvider.notifier).newChat(personaId: picked.personaId);
}
// ---------------------------------------------------------------- 气泡
//
// 微信风格:左右两侧都带头像,气泡紧贴头像那一侧。
// · 对方(AI)头像在左,用**当前会话绑的人设头像**;没设过就是默认小人
// · 自己(用户)头像在右,用账号头像;没设过是默认小人
//
// 头像只给 36px —— 太大反而抢戏,微信也就这个量级。
Widget _bubble(BuildContext context, ChatBubble bubble) {
final user = ref.watch(authProvider).user;
if (bubble.isUser) {
return _row(
avatar: Avatar(
assetPath: user?.avatar,
size: 36,
fallbackIcon: 'user',
),
bubble: Container(
margin: const EdgeInsets.only(left: 40),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFFC8956E), Color(0xFFD9A87C)],
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
bottomLeft: Radius.circular(16),
bottomRight: Radius.circular(4),
),
),
child: SelectableText(
bubble.content,
style: const TextStyle(
fontSize: 14.5,
height: 1.5,
color: Colors.white,
),
),
),
isUser: true,
);
}
return _row(
avatar: Avatar(
// 会话锁定了哪个人设,气泡上就显示谁 —— 和这段回答实际用的 prompt 同一个人。
// 不能用「新对话预选的那条」:用户之后新对话选了别人,
// 这条旧会话的历史消息不该跟着换头像
assetPath: _sessionPersonaAvatar(),
size: 36,
fallbackIcon: 'assistant',
),
bubble: Container(
margin: const EdgeInsets.only(right: 40),
padding: const EdgeInsets.fromLTRB(14, 10, 14, 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
bottomLeft: Radius.circular(4),
bottomRight: Radius.circular(16),
),
boxShadow: [
BoxShadow(
color: const Color(0xFF5D4037).withValues(alpha: 0.06),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (bubble.content.isEmpty)
const _TypingDots()
else
MarkdownBody(
data: bubble.content,
selectable: true,
styleSheet: _markdownStyle(context),
onTapLink: (text, href, title) {
if (href == null || href.isEmpty) return;
Clipboard.setData(ClipboardData(text: href));
showToast(context, '链接已复制');
},
imageBuilder: (uri, title, alt) => Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(resolveAssetUrl(uri.toString())),
),
),
),
if (bubble.streaming && bubble.content.isNotEmpty) ...[
const SizedBox(height: 6),
const _TypingDots(),
],
],
),
),
isUser: false,
);
}
/// 当前会话绑定的人设头像。null = 没设过(或人设被删了),显示默认图标。
///
/// 关键在「按会话存的那个 id 去找」而不是读全局偏好 —— 见
/// [personaByIdProvider] 的说明。
String? _sessionPersonaAvatar() {
final personaId = ref.watch(assistantProvider.select((s) => s.personaId));
return ref.watch(personaByIdProvider(personaId))?.avatar;
}
/// 一行 = 头像 + 气泡。用户侧左右镜像。
///
/// 头像用 [CrossAxisAlignment.start](贴顶)而不是居中 —— 回答很长时
/// 头像跟着垂直居中会飘到中间,微信也是贴顶的。
Widget _row({
required Widget avatar,
required Widget bubble,
required bool isUser,
}) {
return Padding(
padding: const EdgeInsets.only(top: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: isUser
? [
Expanded(child: Align(alignment: Alignment.centerRight, child: bubble)),
const SizedBox(width: 8),
avatar,
]
: [
avatar,
const SizedBox(width: 8),
Expanded(child: Align(alignment: Alignment.centerLeft, child: bubble)),
],
),
);
}
MarkdownStyleSheet _markdownStyle(BuildContext context) {
final base = MarkdownStyleSheet.fromTheme(Theme.of(context));
return base.copyWith(
p: const TextStyle(fontSize: 14.5, height: 1.6, color: Color(0xFF3E2723)),
h1: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, height: 1.4, color: Color(0xFF3E2723)),
h2: const TextStyle(fontSize: 16.5, fontWeight: FontWeight.bold, height: 1.4, color: Color(0xFF3E2723)),
h3: const TextStyle(fontSize: 15.5, fontWeight: FontWeight.w600, height: 1.4, color: Color(0xFF3E2723)),
listBullet: const TextStyle(fontSize: 14.5, height: 1.6, color: Color(0xFF3E2723)),
code: const TextStyle(
fontSize: 13,
backgroundColor: Color(0xFFF5EFE6),
color: Color(0xFF8D4B2A),
),
codeblockDecoration: BoxDecoration(
color: const Color(0xFFF5EFE6),
borderRadius: BorderRadius.circular(8),
),
blockquoteDecoration: BoxDecoration(
color: const Color(0xFFFBF6EF),
borderRadius: BorderRadius.circular(6),
border: const Border(left: BorderSide(color: Color(0xFFC8956E), width: 3)),
),
tableBorder: TableBorder.all(color: const Color(0xFFE8D9C5)),
tableCellsPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
);
}
/// 工具调用提示:「正在查账单…」
Widget _toolIndicator(String? tool) {
return Padding(
padding: const EdgeInsets.only(top: 10, left: 4),
child: Row(
children: [
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(strokeWidth: 1.6, color: Color(0xFFC8956E)),
),
const SizedBox(width: 8),
Text(
_toolLabel(tool),
style: const TextStyle(fontSize: 12.5, color: Color(0xFF8D6E63)),
),
],
),
);
}
String _toolLabel(String? name) => switch (name) {
'query_bills' => '正在查账单…',
'query_notes' => '正在搜笔记…',
'get_note' => '正在读笔记…',
_ => '正在查询…',
};
// ---------------------------------------------------------------- 错误与输入
Widget _errorBar(String message) {
return Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(14, 0, 14, 6),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
decoration: BoxDecoration(
color: const Color(0xFFFFEBEE),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
const Icon(Icons.error_outline, size: 16, color: Color(0xFFD32F2F)),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: const TextStyle(fontSize: 12.5, color: Color(0xFFB71C1C)),
),
),
GestureDetector(
onTap: () => ref.read(assistantProvider.notifier).clearError(),
child: const Icon(Icons.close, size: 15, color: Color(0xFFB71C1C)),
),
],
),
);
}
Widget _inputBar(AssistantState state) {
return Container(
padding: const EdgeInsets.fromLTRB(14, 8, 14, 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(22),
border: Border.all(color: const Color(0xFFE8D9C5)),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
child: TextField(
controller: _input,
enabled: !state.streaming,
minLines: 1,
maxLines: 4,
textInputAction: TextInputAction.send,
onSubmitted: _send,
decoration: const InputDecoration(
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 12),
hintText: '问点什么…',
hintStyle: TextStyle(fontSize: 14, color: Color(0xFFBCAAA4)),
),
style: const TextStyle(fontSize: 14.5, color: Color(0xFF5D4037)),
),
),
),
const SizedBox(width: 8),
_sendButton(state),
],
),
);
}
Widget _sendButton(AssistantState state) {
final disabled = state.streaming;
return GestureDetector(
onTap: disabled ? null : () => _send(_input.text),
child: Container(
width: 42,
height: 42,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: disabled
? null
: const LinearGradient(colors: [Color(0xFFC8956E), Color(0xFFF5D59E)]),
color: disabled ? const Color(0xFFE8D9C5) : null,
),
child: Icon(
disabled ? Icons.hourglass_empty : Icons.arrow_upward,
size: 20,
color: disabled ? const Color(0xFFBCAAA4) : const Color(0xFF5D4037),
),
),
);
}
void _send(String text) {
final trimmed = text.trim();
if (trimmed.isEmpty) return;
_input.clear();
ref.read(assistantProvider.notifier).send(trimmed);
}
// ---------------------------------------------------------------- 历史会话
Future<void> _showSessions(BuildContext context) async {
final selected = await showModalBottomSheet<ChatSessionItem>(
context: context,
backgroundColor: Colors.white,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => const _SessionSheet(),
);
if (selected == null || !mounted) return;
// 把人设一并带进去 —— 历史对话要用它当时绑的那个,
// 不能因为你现在换了默认人设就跟着变
ref
.read(assistantProvider.notifier)
.openSession(selected.id, personaId: selected.personaId);
}
// ---------------------------------------------------------------- 滚动
/// 只在用户本来就贴着底部时才自动滚动 —— 生成过程中用户往上翻看历史时别抢
void _scheduleScroll() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scroll.hasClients) return;
final pos = _scroll.position;
if (pos.maxScrollExtent - pos.pixels > 140) return;
_scroll.jumpTo(pos.maxScrollExtent);
});
}
}
/// 生成中的三个点
class _TypingDots extends StatelessWidget {
const _TypingDots();
@override
Widget build(BuildContext context) {
return const Text(
'●●●',
style: TextStyle(fontSize: 7, letterSpacing: 2, color: Color(0xFFC8956E)),
);
}
}
/// 新对话时选人设的结果
class _PersonaChoice {
const _PersonaChoice(this.personaId);
/// null = 内置默认人设
final int? personaId;
}
/// 新对话的人设选择面板。
///
/// 为什么新对话要先选人设:人设一旦定了,这轮对话就不再变(换了会前后不一致)。
/// 所以在「开始一段对话」这个时刻选是最自然的 —— 而不是聊着聊着去设置页改。
class _PersonaPickerSheet extends ConsumerWidget {
const _PersonaPickerSheet();
@override
Widget build(BuildContext context, WidgetRef ref) {
final personasAsync = ref.watch(aiPersonasProvider);
// 预选上次用的那套,省得每次重选。注意这只是「预选」——
// 真正生效的人设是新建会话时定下的那个
final preferred = ref.watch(preferredPersonaProvider);
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'这次想找谁聊?',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
const SizedBox(height: 4),
const Text(
'选定后整段对话都是这个人,中途不会换',
style: TextStyle(fontSize: 11.5, color: Color(0xFFBCAAA4)),
),
const SizedBox(height: 12),
Flexible(
child: personasAsync.when(
loading: () => const LoadingView(tip: '加载中…'),
error: (e, _) => ErrorView(
message: '人设加载失败',
onRetry: () => ref.invalidate(aiPersonasProvider),
),
data: (list) {
if (list.isEmpty) {
// 极端情况:后端一个人设都没有。不该卡住用户,
// 直接给一个「用默认」的出口
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
leading: const Avatar(size: 38, fallbackIcon: 'assistant'),
title: const Text('默认人设', style: TextStyle(fontSize: 14.5)),
onTap: () => Navigator.pop(context, const _PersonaChoice(null)),
);
}
return ListView.builder(
shrinkWrap: true,
itemCount: list.length,
itemBuilder: (context, index) {
final p = list[index];
final active = p.selectionKey == (preferred ?? -1);
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
leading: Avatar(
assetPath: p.avatar,
size: 42,
fallbackIcon: 'assistant',
),
title: Text(
p.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.5,
fontWeight: active ? FontWeight.w600 : FontWeight.normal,
color: const Color(0xFF5D4037),
),
),
subtitle: Text(
p.isDefault ? '代码内置的那套' : p.preview,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11.5, color: Color(0xFFBCAAA4)),
),
trailing: active
? const Icon(Icons.check_circle,
size: 19, color: Color(0xFFC8956E))
: null,
onTap: () => Navigator.pop(context, _PersonaChoice(p.id)),
);
},
);
},
),
),
],
),
),
);
}
}
/// 历史会话面板
class _SessionSheet extends ConsumerWidget {
const _SessionSheet();
@override
Widget build(BuildContext context, WidgetRef ref) {
final sessionsAsync = ref.watch(chatSessionsProvider);
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'历史对话',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
const SizedBox(height: 12),
Flexible(
child: sessionsAsync.when(
loading: () => const LoadingView(tip: '加载中…'),
error: (e, _) => ErrorView(
message: '会话列表加载失败',
onRetry: () => ref.invalidate(chatSessionsProvider),
),
data: (sessions) {
if (sessions.isEmpty) {
return const EmptyView(title: '还没有对话', tip: '问一句就有了');
}
return ListView.builder(
shrinkWrap: true,
itemCount: sessions.length,
itemBuilder: (context, index) {
final s = sessions[index];
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
// 会话头像 = 那条会话绑定的人设头像。
// 一眼看出「这段对话是谁在陪你」,不用点进去
leading: Avatar(
assetPath: s.personaAvatar,
size: 38,
fallbackIcon: 'assistant',
),
title: Text(
s.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14.5, color: Color(0xFF5D4037)),
),
subtitle: Text(
'${s.personaName} · ${s.updateTime}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11.5, color: Color(0xFFBCAAA4)),
),
trailing: IconButton(
tooltip: '删除',
icon: const Icon(Icons.delete_outline, size: 19),
color: const Color(0xFFBCAAA4),
onPressed: () => _confirmDelete(context, ref, s),
),
onTap: () => Navigator.pop(context, s),
);
},
);
},
),
),
],
),
),
);
}
Future<void> _confirmDelete(BuildContext context, WidgetRef ref, ChatSessionItem session) async {
final ok = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('删除对话'),
content: Text('「${session.title}」将被删除,不能再恢复。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('删除', style: TextStyle(color: Color(0xFFD32F2F))),
),
],
),
);
if (ok != true) return;
try {
await ref.read(aiApiProvider).deleteSession(session.id);
ref.invalidate(chatSessionsProvider);
// 删掉的正好是当前打开的会话,就退回新对话
final current = ref.read(assistantProvider).sessionId;
if (current == session.id) {
ref.read(assistantProvider.notifier).newChat();
}
} catch (e) {
if (context.mounted) showToast(context, '删除失败', error: true);
}
}
}