865 lines
30 KiB
Dart
865 lines
30 KiB
Dart
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);
|
||
}
|
||
}
|
||
}
|