Files
Aq-Accounting-Flutter/lib/pages/assistant/assistant_page.dart
T
2026-09-24 14:24:34 +08:00

603 lines
20 KiB
Dart
Raw 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 '../../utils/toast.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 Spacer(),
_iconButton(
icon: Icons.history,
tooltip: '历史对话',
onTap: () => _showSessions(context),
),
_iconButton(
icon: Icons.add_comment_outlined,
tooltip: '新对话',
onTap: () => ref.read(assistantProvider.notifier).newChat(),
),
],
),
);
}
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)),
],
),
),
);
}
// ---------------------------------------------------------------- 气泡
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())),
),
),
),
if (bubble.streaming && bubble.content.isNotEmpty) ...[
const SizedBox(height: 6),
const _TypingDots(),
],
],
),
),
);
}
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<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);
}
}
}