AI 接入 V0.1
This commit is contained in:
@@ -1,14 +1,49 @@
|
||||
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 '../../utils/toast.dart';
|
||||
import '../../widgets/state_views.dart';
|
||||
|
||||
/// 助手:和 AI 对话,问账单、问笔记。
|
||||
///
|
||||
/// 现在是骨架里预留的位置 —— 先让布局一次到位,阶段 B 接 AI 时只填内容、
|
||||
/// 不再动导航结构。同时它也是首页那个输入框的落地页。
|
||||
class AssistantPage extends StatelessWidget {
|
||||
/// 既是底部 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 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,
|
||||
@@ -24,36 +59,9 @@ class AssistantPage extends StatelessWidget {
|
||||
child: Column(
|
||||
children: [
|
||||
_header(context),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(24, 40, 24, 0),
|
||||
children: [
|
||||
const Center(child: Text('✨', style: TextStyle(fontSize: 46))),
|
||||
const SizedBox(height: 16),
|
||||
const Center(
|
||||
child: Text(
|
||||
'AI 助手正在建设中',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Center(
|
||||
child: Text(
|
||||
'位置已经预留好,接入后不用再改布局',
|
||||
style: TextStyle(fontSize: 12.5, color: Color(0xFF8D6E63)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
_planCard(),
|
||||
const SizedBox(height: 90),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(child: _body(context, state)),
|
||||
if (state.error != null) _errorBar(state.error!),
|
||||
_inputBar(state),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -61,20 +69,18 @@ class AssistantPage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// 用 Tab 打开时没有上一页,就不显示返回箭头
|
||||
// ---------------------------------------------------------------- 顶部
|
||||
|
||||
Widget _header(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 6, 16, 6),
|
||||
padding: const EdgeInsets.fromLTRB(10, 6, 8, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
if (Navigator.of(context).canPop())
|
||||
InkWell(
|
||||
_iconButton(
|
||||
icon: Icons.arrow_back,
|
||||
tooltip: '返回',
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(6),
|
||||
child: Icon(Icons.arrow_back, size: 22, color: Color(0xFF5D4037)),
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 6),
|
||||
@@ -87,79 +93,498 @@ class AssistantPage extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
_iconButton(
|
||||
icon: Icons.history,
|
||||
tooltip: '历史对话',
|
||||
onTap: () => _showSessions(context),
|
||||
),
|
||||
_iconButton(
|
||||
icon: Icons.add_comment_outlined,
|
||||
tooltip: '新对话',
|
||||
onTap: () => ref.read(assistantProvider.notifier).newChat(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _planCard() {
|
||||
const items = [
|
||||
('一句话记账', '「昨天买咖啡 32」→ 自动填好分类和金额'),
|
||||
('笔记问答', '从你的知识库里找答案,而不是重读一遍'),
|
||||
('服务器状态', '查状态、看日志 —— 只走预定义动作,不给任意 shell'),
|
||||
('跨模块查询', '「上周服务器相关的花费」这类要跨模块的问题'),
|
||||
];
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(18, 16, 18, 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
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)),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'规划中的能力',
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 主体
|
||||
|
||||
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: Text('✨', style: TextStyle(fontSize: 44))),
|
||||
const SizedBox(height: 14),
|
||||
const Center(
|
||||
child: Text(
|
||||
'问我点什么',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
for (final (title, desc) in items) ...[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 5),
|
||||
child: Icon(Icons.circle, size: 5, color: Color(0xFFC8956E)),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF5D4037),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
desc,
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63)),
|
||||
),
|
||||
],
|
||||
),
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 气泡
|
||||
|
||||
Widget _bubble(BuildContext context, ChatBubble bubble) {
|
||||
if (bubble.isUser) {
|
||||
return Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(top: 10, left: 48),
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(top: 10, right: 24),
|
||||
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())),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
),
|
||||
if (bubble.streaming && bubble.content.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
const _TypingDots(),
|
||||
],
|
||||
],
|
||||
const Divider(height: 1, color: Color(0xFFF0E6D8)),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
'隐私:涉及你笔记里的服务器信息时,会在请求发出前先脱敏。',
|
||||
style: TextStyle(fontSize: 11.5, color: Color(0xFFBCAAA4), height: 1.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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<int>(
|
||||
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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 滚动
|
||||
|
||||
/// 只在用户本来就贴着底部时才自动滚动 —— 生成过程中用户往上翻看历史时别抢
|
||||
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 _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),
|
||||
title: Text(
|
||||
s.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 14.5, color: Color(0xFF5D4037)),
|
||||
),
|
||||
subtitle: Text(
|
||||
s.updateTime,
|
||||
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.id),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,17 +114,7 @@ class DashboardPage extends StatelessWidget {
|
||||
style: TextStyle(fontSize: 14, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5EFE6),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Text(
|
||||
'建设中',
|
||||
style: TextStyle(fontSize: 11, color: Color(0xFF8D6E63)),
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right, size: 20, color: Color(0xFFBCAAA4)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user