AI 接入 V0.1

This commit is contained in:
2026-09-23 17:23:59 +08:00
parent a83cda63f5
commit 34bc2e04b0
9 changed files with 1018 additions and 116 deletions
+8
View File
@@ -29,6 +29,14 @@
- 统计**不再是顶层 Tab**,从记账首页右上角图标进(`BillStatisticsPage`)。
- `pages/bill/bill_home_page.dart` 和 `bill_statistics_page.dart` 都是被 push 进来的,所以顶部自绘了返回箭头,底部留白也按 `Navigator.canPop()` 判断(有导航栏时留 90,没有时留 24)。
### AI 助手(2026-09-23 阶段 B)
- 链路:`lib/api/ai_api.dart`(SSE 客户端)→ `lib/providers/ai_provider.dart`(`assistantProvider` + `chatSessionsProvider` + `aiStatusProvider`)→ `lib/pages/assistant/assistant_page.dart`(真对话页)。
- **SSE 是手动解析的**:dio 没有内置 SSE 支持,用 `ResponseType.stream` 拿字节流,再 `cast<List<int>>().transform(utf8.decoder).transform(LineSplitter())` 按 `event:` / `data:` / 空行切。`cast` 不能省 —— dio 给的是 `Stream<Uint8List>`,Dart 泛型不允许它直接喂给 `StreamTransformer<List<int>, String>`。
- **`sessionId` 传 null 开新会话**,后端在 `done` 事件里才把新 ID 返回,`AssistantNotifier.send` 必须把它记下来,否则下一条消息又会开一个新会话。
- 历史会话存后端(`chat_session` / `chat_message` 两张表),App 只发 `sessionId + content`。**工具调用的中间过程不入库**,所以 App 不用理解 tool 消息格式。
- 助手气泡用 `MarkdownBody` 渲染(回答里常有列表/代码块),用户气泡用 `SelectableText`。
- 后端未配 key 时不报错:`/api/ai/status` 返回 `enabled:false`,`/chat` 推一个 error 事件,页面顶部显示红色提示条。
## 设计规范(配色)
- 品牌主色:`#C8956E`(驼色/焦糖色),在 `app.dart` 中作为 `ColorScheme.fromSeed` 的 seedColor。
- 深棕文字色:`#5D4037`;辅助文字:`#8D6E63`;页面底色:`#F8F8F8`。
+147
View File
@@ -0,0 +1,147 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/network/api_exception.dart';
import '../core/network/dio_client.dart';
import '../models/ai.dart';
import '../models/json_util.dart';
final aiApiProvider = Provider<AiApi>((ref) => AiApi(ref.watch(dioProvider)));
/// AI 助手接口
class AiApi {
final Dio _dio;
AiApi(this._dio);
/// 后端是否配好了 key(没配的话 App 显示提示而不是让用户白问一句)
Future<bool> status() async {
try {
final res = await _dio.get('/ai/status');
final data = res.data;
return data is Map && data['enabled'] == true;
} on DioException {
return false;
}
}
Future<List<ChatSessionItem>> sessions() async {
try {
final res = await _dio.get('/ai/sessions');
final data = res.data;
if (data is! List) return const [];
return asMapList(data).map(ChatSessionItem.fromJson).toList();
} on DioException catch (e) {
final err = e.error;
throw err is ApiException ? err : ApiException(-1, '会话列表加载失败');
}
}
Future<List<ChatMessageItem>> messages(int sessionId) async {
try {
final res = await _dio.get('/ai/sessions/$sessionId/messages');
final data = res.data;
if (data is! List) return const [];
return asMapList(data).map(ChatMessageItem.fromJson).toList();
} on DioException catch (e) {
final err = e.error;
throw err is ApiException ? err : ApiException(-1, '消息加载失败');
}
}
Future<void> deleteSession(int sessionId) async {
try {
await _dio.delete('/ai/sessions/$sessionId');
} on DioException catch (e) {
final err = e.error;
throw err is ApiException ? err : ApiException(-1, '删除会话失败');
}
}
/// 发起对话,返回事件流。
///
/// 用 dio 的 [ResponseType.stream] 拿原始字节流、自己按 SSE 协议切分 ——
/// dio 没有内置 SSE 支持,但 OpenAI 兼容接口的流格式很简单
/// (`event: xxx` / `data: {...}` / 空行分隔),手动解析比多引一个依赖可控。
///
/// 注意:[sessionId] 传 null 表示开新会话,会话 ID 会由后端在 done 事件里返回。
Stream<AiEvent> chat({int? sessionId, required String content}) async* {
final Response<ResponseBody> res;
try {
res = await _dio.post<ResponseBody>(
'/ai/chat',
data: {'sessionId': sessionId, 'content': content},
options: Options(
responseType: ResponseType.stream,
// LLM 生成慢 + 工具循环可能跑好几轮,给足;后端 SseEmitter 是 180s
receiveTimeout: const Duration(minutes: 3),
headers: {'Accept': 'text/event-stream'},
),
);
} on DioException catch (e) {
final err = e.error;
yield AiEvent.error(err is ApiException ? err.message : 'AI 请求失败');
return;
}
final body = res.data;
if (body == null) {
yield const AiEvent.error('AI 没有返回内容');
return;
}
try {
// utf8.decoder 作为流转换器能正确处理「多字节字符被 chunk 切断」的情况,
// 直接对每块调 utf8.decode 会在这里出乱码。
// cast 是必须的:dio 给的是 Stream<Uint8List>,而 utf8.decoder 是
// StreamTransformer<List<int>, String>,Dart 的泛型不支持这个方向的隐式转换。
final lines = body.stream
.cast<List<int>>()
.transform(utf8.decoder)
.transform(const LineSplitter());
var event = '';
final data = StringBuffer();
await for (final line in lines) {
if (line.isEmpty) {
// 空行 = 一个事件结束
final payload = data.toString();
data.clear();
if (payload.isNotEmpty) {
final parsed = _parseEvent(event, payload);
if (parsed != null) yield parsed;
}
event = '';
continue;
}
if (line.startsWith('event:')) {
event = line.substring(6).trim();
} else if (line.startsWith('data:')) {
data.write(line.substring(5).trim());
}
}
} on DioException catch (e) {
final err = e.error;
yield AiEvent.error(err is ApiException ? err.message : 'AI 连接中断');
} catch (_) {
yield const AiEvent.error('AI 连接中断');
}
}
AiEvent? _parseEvent(String event, String payload) {
final decoded = jsonDecode(payload);
if (decoded is! Map<String, dynamic>) return null;
return switch (event) {
'delta' => AiEvent.delta(asString(decoded['content'])),
'tool' => AiEvent.tool(asString(decoded['name'], fallback: '查询')),
'done' => AiEvent.done(asNullableInt(decoded['sessionId'])),
'error' => AiEvent.error(asString(decoded['message'], fallback: 'AI 服务出错')),
// 未知事件直接忽略,后端以后加新事件类型时老版本 App 不会崩
_ => null,
};
}
}
+111
View File
@@ -0,0 +1,111 @@
import 'json_util.dart';
/// 会话列表项(不含消息)
class ChatSessionItem {
final int id;
final String title;
final String updateTime;
const ChatSessionItem({
required this.id,
required this.title,
required this.updateTime,
});
factory ChatSessionItem.fromJson(Map<String, dynamic> json) {
return ChatSessionItem(
id: asInt(json['id']),
title: asNonEmpty(json['title'], '新对话'),
updateTime: formatDateTime(json['updateTime']),
);
}
}
/// 会话里的一条消息。工具调用的中间过程后端不入库,所以只有 user / assistant
class ChatMessageItem {
final int id;
final String role;
final String content;
const ChatMessageItem({
required this.id,
required this.role,
required this.content,
});
bool get isUser => role == 'user';
factory ChatMessageItem.fromJson(Map<String, dynamic> json) {
return ChatMessageItem(
id: asInt(json['id']),
role: asString(json['role'], fallback: 'assistant'),
content: asString(json['content']),
);
}
}
/// 页面上的一个气泡。
///
/// 和 [ChatMessageItem] 的区别:这个没有 id、带 streaming 标记,
/// 因为正在生成的那条回答还没落库。
class ChatBubble {
final String role;
final String content;
/// 正在生成中(末尾会显示光标 / 工具提示)
final bool streaming;
const ChatBubble({
required this.role,
required this.content,
this.streaming = false,
});
const ChatBubble.user(String content) : this(role: 'user', content: content);
const ChatBubble.assistant(String content, {bool streaming = false})
: this(role: 'assistant', content: content, streaming: streaming);
bool get isUser => role == 'user';
ChatBubble copyWith({String? content, bool? streaming}) {
return ChatBubble(
role: role,
content: content ?? this.content,
streaming: streaming ?? this.streaming,
);
}
factory ChatBubble.fromItem(ChatMessageItem item) =>
ChatBubble(role: item.role, content: item.content);
}
/// SSE 事件类型
enum AiEventType { delta, tool, done, error }
/// 一次 SSE 事件。后端只会发这四种,见 AiChatService。
class AiEvent {
final AiEventType type;
/// delta:本次新增的文本
final String? content;
/// tool:正在调用的工具名
final String? toolName;
/// done:会话 ID(新会话时后端才刚创建,App 要靠它接着聊)
final int? sessionId;
/// error:错误提示
final String? message;
const AiEvent._(this.type, {this.content, this.toolName, this.sessionId, this.message});
const AiEvent.delta(String content) : this._(AiEventType.delta, content: content);
const AiEvent.tool(String name) : this._(AiEventType.tool, toolName: name);
const AiEvent.done(int? sessionId) : this._(AiEventType.done, sessionId: sessionId);
const AiEvent.error(String message) : this._(AiEventType.error, message: message);
}
+7
View File
@@ -55,3 +55,10 @@ List<String> asStringList(Object? value) {
.where((e) => e.isNotEmpty)
.toList();
}
/// 后端给的是 `yyyy-MM-dd HH:mm:ss`,这里裁成 `yyyy-MM-dd HH:mm`。
/// 列表里显示到秒没有意义,还会把卡片撑宽。
String formatDateTime(Object? value) {
final text = asString(value);
return text.length >= 16 ? text.substring(0, 16) : text;
}
-7
View File
@@ -191,10 +191,3 @@ class NoteLocation {
}
}
}
/// 后端给的是 `yyyy-MM-dd HH:mm:ss`,这里裁成 `yyyy-MM-dd HH:mm`
/// 列表里显示到秒没有意义,还会把卡片撑宽。
String formatDateTime(Object? value) {
final text = asString(value);
return text.length >= 16 ? text.substring(0, 16) : text;
}
+523 -98
View File
@@ -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);
}
}
}
+1 -11
View File
@@ -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)),
],
),
),
+189
View File
@@ -0,0 +1,189 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/ai_api.dart';
import '../core/network/api_exception.dart';
import '../models/ai.dart';
/// 后端 AI 是否配好了(没配 key 时 App 显示提示,而不是让用户白问一句)
final aiStatusProvider = FutureProvider.autoDispose<bool>((ref) {
return ref.watch(aiApiProvider).status();
});
/// 会话列表
final chatSessionsProvider = FutureProvider.autoDispose<List<ChatSessionItem>>((ref) {
return ref.watch(aiApiProvider).sessions();
});
/// 助手页状态
class AssistantState {
/// null = 新对话(还没落库,第一条消息发出去时后端才建)
final int? sessionId;
final List<ChatBubble> bubbles;
/// 正在生成回答
final bool streaming;
/// 正在调用哪个工具(null = 没在查东西)
final String? activeTool;
final String? error;
/// 正在拉历史消息
final bool loadingHistory;
const AssistantState({
this.sessionId,
this.bubbles = const [],
this.streaming = false,
this.activeTool,
this.error,
this.loadingHistory = false,
});
/// 真的一句都没有(不是加载中、也不是出错)
bool get isEmpty => bubbles.isEmpty && !loadingHistory && error == null;
AssistantState copyWith({
Object? sessionId = _sentinel,
List<ChatBubble>? bubbles,
bool? streaming,
Object? activeTool = _sentinel,
Object? error = _sentinel,
bool? loadingHistory,
}) {
return AssistantState(
sessionId: sessionId == _sentinel ? this.sessionId : sessionId as int?,
bubbles: bubbles ?? this.bubbles,
streaming: streaming ?? this.streaming,
activeTool: activeTool == _sentinel ? this.activeTool : activeTool as String?,
error: error == _sentinel ? this.error : error as String?,
loadingHistory: loadingHistory ?? this.loadingHistory,
);
}
static const _sentinel = Object();
}
class AssistantNotifier extends Notifier<AssistantState> {
@override
AssistantState build() => const AssistantState();
/// 新对话:清空界面,但**不清 sessionId 为 null 之前的会话**(后端那边还在,能从历史里翻出来)
void newChat() {
state = const AssistantState();
}
void clearError() {
state = state.copyWith(error: null);
}
/// 打开历史会话
Future<void> openSession(int sessionId) async {
state = AssistantState(sessionId: sessionId, loadingHistory: true);
try {
final items = await ref.read(aiApiProvider).messages(sessionId);
if (!ref.mounted) return;
state = AssistantState(
sessionId: sessionId,
bubbles: items.map(ChatBubble.fromItem).toList(),
);
} catch (e) {
if (!ref.mounted) return;
state = AssistantState(
sessionId: sessionId,
error: e is ApiException ? e.message : '消息加载失败',
);
}
}
/// 发一条消息并接收流式回答
Future<void> send(String text) async {
final trimmed = text.trim();
if (trimmed.isEmpty || state.streaming) return;
state = state.copyWith(
// 先乐观地把用户气泡和「空的、正在生成」的回答气泡放上去
bubbles: [
...state.bubbles,
ChatBubble.user(trimmed),
const ChatBubble.assistant('', streaming: true),
],
streaming: true,
error: null,
activeTool: null,
);
var failed = false;
// 收到 done 说明回答已经完整了,之后连接怎么断都不该算失败 ——
// SSE 的收尾阶段出错(服务端异步派发、代理截断)很常见,
// 弹个红条出来只会让人以为回答没拿到。
var sawDone = false;
try {
await for (final event in ref
.read(aiApiProvider)
.chat(sessionId: state.sessionId, content: trimmed)) {
if (!ref.mounted) return;
switch (event.type) {
case AiEventType.delta:
_appendDelta(event.content ?? '');
case AiEventType.tool:
state = state.copyWith(activeTool: event.toolName);
case AiEventType.done:
// 新会话时后端在这一刻才把 ID 告诉我们,必须记下来,
// 否则下一条消息又会开一个新会话
sawDone = true;
state = state.copyWith(sessionId: event.sessionId);
case AiEventType.error:
if (!sawDone) {
failed = true;
state = state.copyWith(error: event.message);
}
}
}
} catch (e) {
if (!sawDone) {
failed = true;
state = state.copyWith(error: e is ApiException ? e.message : 'AI 连接中断');
}
}
_finish(failed: failed);
}
void _appendDelta(String delta) {
if (delta.isEmpty) return;
final bubbles = [...state.bubbles];
if (bubbles.isEmpty) return;
final last = bubbles.last;
bubbles[bubbles.length - 1] = last.copyWith(content: last.content + delta);
// 有文字出来了,说明工具已经查完
state = state.copyWith(bubbles: bubbles, activeTool: null);
}
void _finish({required bool failed}) {
var bubbles = [...state.bubbles];
if (failed) {
// 一条字都没生成出来时,把那个空气泡去掉,只留错误提示
if (bubbles.isNotEmpty && !bubbles.last.isUser && bubbles.last.content.isEmpty) {
bubbles.removeLast();
}
} else if (bubbles.isNotEmpty && bubbles.last.streaming) {
bubbles[bubbles.length - 1] = bubbles.last.copyWith(streaming: false);
}
state = state.copyWith(bubbles: bubbles, streaming: false, activeTool: null);
if (!failed) {
// 新会话要出现在列表里、老会话的标题/时间要更新
ref.invalidate(chatSessionsProvider);
}
}
}
/// 不加 autoDispose:切到别的 Tab 再回来,对话还在
final assistantProvider =
NotifierProvider<AssistantNotifier, AssistantState>(AssistantNotifier.new);
+32
View File
@@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:qiangbao_accounting/models/bill.dart';
import 'package:qiangbao_accounting/models/note.dart';
import 'package:qiangbao_accounting/models/statistics_response.dart';
import 'package:qiangbao_accounting/pages/assistant/assistant_page.dart';
import 'package:qiangbao_accounting/pages/bill/bill_home_page.dart';
import 'package:qiangbao_accounting/pages/home/dashboard_page.dart';
import 'package:qiangbao_accounting/providers/bill_provider.dart';
@@ -102,5 +103,36 @@ void main() {
expect(find.text('我的账单'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('助手页(push 进来)$label 不溢出', (tester) async {
useSize(tester, size);
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
home: Builder(
builder: (ctx) => Scaffold(
body: Center(
child: TextButton(
onPressed: () => Navigator.of(ctx).push(
MaterialPageRoute(builder: (_) => const AssistantPage()),
),
child: const Text('进入'),
),
),
),
),
),
),
);
await tester.tap(find.text('进入'));
await tester.pumpAndSettle();
// 空状态:标题 + 引导问题 + 底部输入框
expect(find.text('问我点什么'), findsOneWidget);
expect(find.text('这个月花了多少钱?'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
}