AI人设可以配置
This commit is contained in:
+74
-2
@@ -7,6 +7,7 @@ import '../core/network/api_exception.dart';
|
|||||||
import '../core/network/dio_client.dart';
|
import '../core/network/dio_client.dart';
|
||||||
import '../models/ai.dart';
|
import '../models/ai.dart';
|
||||||
import '../models/ai_config.dart';
|
import '../models/ai_config.dart';
|
||||||
|
import '../models/ai_persona.dart';
|
||||||
import '../models/json_util.dart';
|
import '../models/json_util.dart';
|
||||||
|
|
||||||
final aiApiProvider = Provider<AiApi>((ref) => AiApi(ref.watch(dioProvider)));
|
final aiApiProvider = Provider<AiApi>((ref) => AiApi(ref.watch(dioProvider)));
|
||||||
@@ -68,13 +69,24 @@ class AiApi {
|
|||||||
/// (`event: xxx` / `data: {...}` / 空行分隔),手动解析比多引一个依赖可控。
|
/// (`event: xxx` / `data: {...}` / 空行分隔),手动解析比多引一个依赖可控。
|
||||||
///
|
///
|
||||||
/// 注意:[sessionId] 传 null 表示开新会话,会话 ID 会由后端在 done 事件里返回。
|
/// 注意:[sessionId] 传 null 表示开新会话,会话 ID 会由后端在 done 事件里返回。
|
||||||
Stream<AiEvent> chat({int? sessionId, int? configId, required String content}) async* {
|
Stream<AiEvent> chat({
|
||||||
|
int? sessionId,
|
||||||
|
int? configId,
|
||||||
|
int? personaId,
|
||||||
|
required String content,
|
||||||
|
}) async* {
|
||||||
final Response<ResponseBody> res;
|
final Response<ResponseBody> res;
|
||||||
try {
|
try {
|
||||||
res = await _dio.post<ResponseBody>(
|
res = await _dio.post<ResponseBody>(
|
||||||
'/ai/chat',
|
'/ai/chat',
|
||||||
// configId 传 null = 用服务端 .env 里那套(列表里虚拟的「默认」项)
|
// configId 传 null = 用服务端 .env 里那套(列表里虚拟的「默认」项)
|
||||||
data: {'sessionId': sessionId, 'configId': configId, 'content': content},
|
// personaId 传 null = 用服务端内置的默认人设。两者互相独立
|
||||||
|
data: {
|
||||||
|
'sessionId': sessionId,
|
||||||
|
'configId': configId,
|
||||||
|
'personaId': personaId,
|
||||||
|
'content': content,
|
||||||
|
},
|
||||||
options: Options(
|
options: Options(
|
||||||
responseType: ResponseType.stream,
|
responseType: ResponseType.stream,
|
||||||
// LLM 生成慢 + 工具循环可能跑好几轮,给足;后端 SseEmitter 是 180s
|
// LLM 生成慢 + 工具循环可能跑好几轮,给足;后端 SseEmitter 是 180s
|
||||||
@@ -200,6 +212,66 @@ class AiApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- AI 人设
|
||||||
|
|
||||||
|
/// 人设列表。后端把虚拟的「默认」项放在第一位(id 为 null,带 defaultPrompt)。
|
||||||
|
Future<List<AiPersonaItem>> personas() async {
|
||||||
|
try {
|
||||||
|
final res = await _dio.get('/ai/personas');
|
||||||
|
final data = res.data;
|
||||||
|
if (data is! List) return const [];
|
||||||
|
return asMapList(data).map(AiPersonaItem.fromJson).toList();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
final err = e.error;
|
||||||
|
throw err is ApiException ? err : ApiException(-1, '人设列表加载失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 新增一套人设。
|
||||||
|
Future<AiPersonaItem> createPersona({
|
||||||
|
required String name,
|
||||||
|
required String systemPrompt,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final res = await _dio.post('/ai/personas', data: {
|
||||||
|
'name': name,
|
||||||
|
'systemPrompt': systemPrompt,
|
||||||
|
});
|
||||||
|
return AiPersonaItem.fromJson(Map<String, dynamic>.from(res.data as Map));
|
||||||
|
} on DioException catch (e) {
|
||||||
|
final err = e.error;
|
||||||
|
throw err is ApiException ? err : ApiException(-1, '保存失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 修改人设。提示词是**整体替换**的 —— 每次都把完整文本传上来,
|
||||||
|
/// 没有模型配置那种「key 留空 = 不改动」的语义。
|
||||||
|
Future<AiPersonaItem> updatePersona({
|
||||||
|
required int id,
|
||||||
|
required String name,
|
||||||
|
required String systemPrompt,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final res = await _dio.put('/ai/personas/$id', data: {
|
||||||
|
'name': name,
|
||||||
|
'systemPrompt': systemPrompt,
|
||||||
|
});
|
||||||
|
return AiPersonaItem.fromJson(Map<String, dynamic>.from(res.data as Map));
|
||||||
|
} on DioException catch (e) {
|
||||||
|
final err = e.error;
|
||||||
|
throw err is ApiException ? err : ApiException(-1, '保存失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deletePersona(int id) async {
|
||||||
|
try {
|
||||||
|
await _dio.delete('/ai/personas/$id');
|
||||||
|
} on DioException catch (e) {
|
||||||
|
final err = e.error;
|
||||||
|
throw err is ApiException ? err : ApiException(-1, '删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
AiEvent? _parseEvent(String event, String payload) {
|
AiEvent? _parseEvent(String event, String payload) {
|
||||||
final decoded = jsonDecode(payload);
|
final decoded = jsonDecode(payload);
|
||||||
if (decoded is! Map<String, dynamic>) return null;
|
if (decoded is! Map<String, dynamic>) return null;
|
||||||
|
|||||||
@@ -127,4 +127,22 @@ class LocalStorage {
|
|||||||
|
|
||||||
/// 读选中的配置。null = 用默认。
|
/// 读选中的配置。null = 用默认。
|
||||||
int? getAiConfigId() => _prefs?.getInt(_keyAiConfigId);
|
int? getAiConfigId() => _prefs?.getInt(_keyAiConfigId);
|
||||||
|
|
||||||
|
// ---- AI 人设配置 ----
|
||||||
|
// 与模型配置各存各的 —— 两者正交,切一个不该动另一个。
|
||||||
|
// 同样只存「用户选中了哪一条」,人设文本本身在服务端。
|
||||||
|
static const _keyAiPersonaId = 'ai_persona_id';
|
||||||
|
|
||||||
|
/// 存选中的人设。null 表示「默认」(服务端内置的那套)。
|
||||||
|
Future<void> saveAiPersonaId(int? id) async {
|
||||||
|
final prefs = await _getPrefs();
|
||||||
|
if (id == null) {
|
||||||
|
await prefs.remove(_keyAiPersonaId);
|
||||||
|
} else {
|
||||||
|
await prefs.setInt(_keyAiPersonaId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读选中的人设。null = 用内置默认。
|
||||||
|
int? getAiPersonaId() => _prefs?.getInt(_keyAiPersonaId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import 'json_util.dart';
|
||||||
|
|
||||||
|
/// 一套 AI 人设(system prompt)
|
||||||
|
///
|
||||||
|
/// 与 [AiConfigItem](模型配置)是**平行的两套配置**:一个管「用哪个模型」,
|
||||||
|
/// 一个管「用什么人设」。两者正交 —— App 里切模型不会换人设,切人设也不会换模型。
|
||||||
|
///
|
||||||
|
/// ⚠️ [systemPrompt] 里**不含日期等环境信息**。那些是运行时事实,
|
||||||
|
/// 由服务端在每次对话时追加(今天是几号、「这个月」指哪个月)。
|
||||||
|
/// 所以「新建时预填」拿到的是可编辑的那部分,用户不用管日期。
|
||||||
|
class AiPersonaItem {
|
||||||
|
const AiPersonaItem({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.systemPrompt,
|
||||||
|
required this.promptLength,
|
||||||
|
required this.isDefault,
|
||||||
|
required this.editable,
|
||||||
|
required this.defaultPrompt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// null = 虚拟的「默认」项(内容是服务端代码内置的)
|
||||||
|
final int? id;
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
/// 人设 + 运行规则。默认项上是内置默认文本,用户项上是自己配的
|
||||||
|
final String systemPrompt;
|
||||||
|
|
||||||
|
/// 服务端数好的字数,前端直接显示
|
||||||
|
final int promptLength;
|
||||||
|
|
||||||
|
/// 是否是「默认」那一项
|
||||||
|
final bool isDefault;
|
||||||
|
|
||||||
|
/// 是否可编辑/删除。默认项为 false
|
||||||
|
final bool editable;
|
||||||
|
|
||||||
|
/// 内置默认文本。**只有默认项上才有值**,供「恢复默认」按钮使用。
|
||||||
|
///
|
||||||
|
/// 放服务端下发而不是 App 里硬编码,是为了两边永远一致 ——
|
||||||
|
/// 以后改了默认人设,App 不用跟着改版本。
|
||||||
|
final String? defaultPrompt;
|
||||||
|
|
||||||
|
/// 用于「显示已选中的是哪一项」的稳定标识。
|
||||||
|
///
|
||||||
|
/// 默认项没有 id,用 -1 代替 —— 不能直接用 null 当 map key 去比较。
|
||||||
|
int get selectionKey => id ?? -1;
|
||||||
|
|
||||||
|
factory AiPersonaItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AiPersonaItem(
|
||||||
|
id: asNullableInt(json['id']),
|
||||||
|
name: asString(json['name'], fallback: '未命名'),
|
||||||
|
systemPrompt: asString(json['systemPrompt']),
|
||||||
|
promptLength: asInt(json['promptLength']),
|
||||||
|
isDefault: json['isDefault'] == true,
|
||||||
|
editable: json['editable'] != false,
|
||||||
|
// 用户项上这个字段是 null —— 不能直接用 asNonEmpty(它要 fallback 参数,
|
||||||
|
// 而我们恰恰需要保留「null = 没有」这个语义,供「恢复默认」按钮判断)
|
||||||
|
defaultPrompt: _nullableNonEmpty(json['defaultPrompt']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列表里显示的两行预览:把换行压平,太长的截断
|
||||||
|
String get preview {
|
||||||
|
final flat = systemPrompt.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||||
|
if (flat.length <= 50) return flat;
|
||||||
|
return '${flat.substring(0, 50)}…';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 空串/null 都返回 null,否则原样返回。
|
||||||
|
///
|
||||||
|
/// 不用 `asNonEmpty` 是因为它需要一个 fallback 字符串,而这里
|
||||||
|
/// 「有没有值」本身是有意义的(defaultPrompt 只有默认项才有)。
|
||||||
|
String? _nullableNonEmpty(Object? value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
final s = value.toString();
|
||||||
|
return s.isEmpty ? null : s;
|
||||||
|
}
|
||||||
@@ -7,15 +7,15 @@ import '../../../models/ai_config.dart';
|
|||||||
import '../../../providers/ai_config_provider.dart';
|
import '../../../providers/ai_config_provider.dart';
|
||||||
import '../../../utils/toast.dart';
|
import '../../../utils/toast.dart';
|
||||||
import '../../../widgets/state_views.dart';
|
import '../../../widgets/state_views.dart';
|
||||||
|
import 'ai_persona_page.dart';
|
||||||
|
|
||||||
/// AI 模型配置管理:列出现有配置、切换、增删改。
|
/// 「AI 设置」页:两个 Tab —— 模型 / 人设。
|
||||||
///
|
///
|
||||||
/// 设计上的三个要点:
|
/// 为什么是 Tab 而不是顶栏再加一个胶囊:顶栏已经有返回、标题、当前模型胶囊、
|
||||||
/// 1. **「默认」那一项是虚拟的** —— 值来自服务器 `.env`,不能编辑/删除。
|
/// 历史、新对话五块,320 宽的小屏塞不下第二个(`page_layout_test.dart` 会抓出来)。
|
||||||
/// 它就是「什么都不选」的状态,选中后请求里 configId 传 null。
|
/// 而且模型和人设本来就是**一对平行的配置**,放一个页面里切换很自然。
|
||||||
/// 2. **列表不打勾谁在用**,打勾的是 [selectedAiConfigProvider] 里存的那个。
|
///
|
||||||
/// 选中状态存在手机本地,不改服务端 —— 否则网页端和手机会互相打架。
|
/// 入口函数名保留 `openAiConfigPage` —— 助手页的胶囊用它,不用改。
|
||||||
/// 3. **api key 只显示掩码**。编辑时留空 = 不改动。
|
|
||||||
Future<void> openAiConfigPage(BuildContext context) {
|
Future<void> openAiConfigPage(BuildContext context) {
|
||||||
return Navigator.of(context).push(
|
return Navigator.of(context).push(
|
||||||
MaterialPageRoute(builder: (_) => const AiConfigPage()),
|
MaterialPageRoute(builder: (_) => const AiConfigPage()),
|
||||||
@@ -25,21 +25,52 @@ Future<void> openAiConfigPage(BuildContext context) {
|
|||||||
class AiConfigPage extends ConsumerWidget {
|
class AiConfigPage extends ConsumerWidget {
|
||||||
const AiConfigPage({super.key});
|
const AiConfigPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return DefaultTabController(
|
||||||
|
length: 2,
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('AI 设置'),
|
||||||
|
bottom: const TabBar(
|
||||||
|
labelColor: Color(0xFFC8956E),
|
||||||
|
unselectedLabelColor: Color(0xFFBCAAA4),
|
||||||
|
indicatorColor: Color(0xFFC8956E),
|
||||||
|
indicatorSize: TabBarIndicatorSize.label,
|
||||||
|
tabs: [
|
||||||
|
Tab(text: '模型'),
|
||||||
|
Tab(text: '人设'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: const TabBarView(
|
||||||
|
children: [
|
||||||
|
AiModelTab(),
|
||||||
|
AiPersonaTab(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模型配置 Tab(原来的整页内容,去掉了自己的 Scaffold/AppBar)
|
||||||
|
class AiModelTab extends ConsumerWidget {
|
||||||
|
const AiModelTab({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final configs = ref.watch(aiConfigsProvider);
|
final configs = ref.watch(aiConfigsProvider);
|
||||||
final selectedId = ref.watch(selectedAiConfigProvider);
|
final selectedId = ref.watch(selectedAiConfigProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
backgroundColor: const Color(0xFFFAF8F5),
|
||||||
title: const Text('AI 模型'),
|
floatingActionButton: FloatingActionButton(
|
||||||
actions: [
|
onPressed: () => _openEditor(context, ref, null),
|
||||||
IconButton(
|
backgroundColor: const Color(0xFFC8956E),
|
||||||
tooltip: '新增配置',
|
foregroundColor: Colors.white,
|
||||||
icon: const Icon(Icons.add),
|
tooltip: '新增配置',
|
||||||
onPressed: () => _openEditor(context, ref, null),
|
child: const Icon(Icons.add),
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
body: configs.when(
|
body: configs.when(
|
||||||
loading: () => const LoadingView(tip: '正在加载配置...'),
|
loading: () => const LoadingView(tip: '正在加载配置...'),
|
||||||
@@ -50,7 +81,7 @@ class AiConfigPage extends ConsumerWidget {
|
|||||||
data: (list) {
|
data: (list) {
|
||||||
final custom = list.where((c) => c.editable).length;
|
final custom = list.where((c) => c.editable).length;
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 88),
|
||||||
children: [
|
children: [
|
||||||
_introCard(),
|
_introCard(),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
|
|||||||
@@ -0,0 +1,590 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../api/ai_api.dart';
|
||||||
|
import '../../../core/network/api_exception.dart';
|
||||||
|
import '../../../models/ai_persona.dart';
|
||||||
|
import '../../../providers/ai_persona_provider.dart';
|
||||||
|
import '../../../utils/toast.dart';
|
||||||
|
import '../../../widgets/state_views.dart';
|
||||||
|
|
||||||
|
/// 人设列表页(不含 Scaffold/AppBar —— 它作为「AI 设置」页的一个 Tab 嵌进去)
|
||||||
|
///
|
||||||
|
/// 与模型配置页 [AiConfigPage] 是**平行的两套**:一个管模型,一个管人设。
|
||||||
|
/// 分成两个 Tab 而不是在顶栏加两个胶囊,是因为顶栏已经挤了五块东西,
|
||||||
|
/// 320 宽的小屏塞不下第二个胶囊。
|
||||||
|
///
|
||||||
|
/// 三个要点:
|
||||||
|
/// 1. **「默认」是虚拟项** —— 内容是服务端代码里的常量,不能编辑/删除。
|
||||||
|
/// 2. **预填而不是空框** —— 新增时把内置默认文本填进去,用户改一改就用,
|
||||||
|
/// 而不是面对一片空白不知道写什么。
|
||||||
|
/// 3. **提示词里不含日期** —— 那是运行时事实,服务端每次对话自动追加。
|
||||||
|
class AiPersonaTab extends ConsumerWidget {
|
||||||
|
const AiPersonaTab({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final personas = ref.watch(aiPersonasProvider);
|
||||||
|
final selectedId = ref.watch(selectedAiPersonaProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFFAF8F5),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: () => _openEditor(context, ref, null),
|
||||||
|
backgroundColor: const Color(0xFFC8956E),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
tooltip: '新增人设',
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
body: personas.when(
|
||||||
|
loading: () => const LoadingView(tip: '正在加载人设...'),
|
||||||
|
error: (e, _) => ErrorView(
|
||||||
|
message: e is ApiException ? e.message : '人设加载失败',
|
||||||
|
onRetry: () => ref.invalidate(aiPersonasProvider),
|
||||||
|
),
|
||||||
|
data: (list) {
|
||||||
|
final custom = list.where((p) => p.editable).length;
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 88),
|
||||||
|
children: [
|
||||||
|
_introCard(),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
for (final item in list) ...[
|
||||||
|
_personaTile(context, ref, item, selectedId, custom),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
],
|
||||||
|
if (custom == 0) _emptyHint(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _introCard() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFFFF3E6),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: const Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'换个说话的人',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF5D4037),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 5),
|
||||||
|
Text(
|
||||||
|
'人设决定助手的说话风格和做事规则,和用哪个模型是两件独立的事 ——\n'
|
||||||
|
'换模型不会换人设。\n'
|
||||||
|
'提示词里不用写日期,服务端每次会自动补上今天几号、「这个月」指哪个月。',
|
||||||
|
style: TextStyle(fontSize: 12, height: 1.6, color: Color(0xFF8D6E63)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _emptyHint() {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'还没有自己的人设,用的就是「默认」那套。\n'
|
||||||
|
'想换个风格就点右下角「+」—— 会预填一份能用的,改一改就行。',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 12.5, height: 1.7, color: Color(0xFFBCAAA4)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _personaTile(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
AiPersonaItem item,
|
||||||
|
int? selectedId,
|
||||||
|
int customCount,
|
||||||
|
) {
|
||||||
|
// 默认项的 selectionKey 是 -1,不能用 id 比较
|
||||||
|
final active = selectedId == null
|
||||||
|
? item.isDefault
|
||||||
|
: item.selectionKey == selectedId;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(
|
||||||
|
color: active ? const Color(0xFFC8956E) : const Color(0xFFF0E6D8),
|
||||||
|
width: active ? 1.5 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
onTap: () {
|
||||||
|
ref.read(selectedAiPersonaProvider.notifier).select(item.id);
|
||||||
|
showToast(context, '已切换到「${item.name}」', icon: 'check');
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 12, 8, 12),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 1),
|
||||||
|
child: Icon(
|
||||||
|
active
|
||||||
|
? Icons.radio_button_checked
|
||||||
|
: Icons.radio_button_unchecked,
|
||||||
|
size: 19,
|
||||||
|
color:
|
||||||
|
active ? const Color(0xFFC8956E) : const Color(0xFFD7CCC8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
item.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14.5,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF5D4037),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (item.isDefault) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
_tag('默认', const Color(0xFF8D6E63)),
|
||||||
|
],
|
||||||
|
if (item.promptLength > 0) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
_tag('${item.promptLength} 字', const Color(0xFFBCAAA4)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
// 提示词预览:两行封顶,别把列表撑散
|
||||||
|
Text(
|
||||||
|
item.preview.isEmpty ? '(空提示词)' : item.preview,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11.5,
|
||||||
|
height: 1.6,
|
||||||
|
color: Color(0xFFBCAAA4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 默认项不给编辑/删除的入口 —— 它的内容是服务端代码里内置的
|
||||||
|
if (item.editable)
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert,
|
||||||
|
size: 19, color: Color(0xFFBCAAA4)),
|
||||||
|
onSelected: (v) {
|
||||||
|
if (v == 'edit') {
|
||||||
|
_openEditor(context, ref, item);
|
||||||
|
} else {
|
||||||
|
_confirmDelete(context, ref, item, customCount);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (_) => const [
|
||||||
|
PopupMenuItem(value: 'edit', child: Text('编辑')),
|
||||||
|
PopupMenuItem(value: 'delete', child: Text('删除')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _tag(String text, Color color) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.11),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(text, style: TextStyle(fontSize: 10.5, color: color)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 增删改 ----------------
|
||||||
|
|
||||||
|
Future<void> _openEditor(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
AiPersonaItem? existing,
|
||||||
|
) async {
|
||||||
|
final saved = await showModalBottomSheet<bool>(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
isScrollControlled: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (_) => _PersonaEditor(existing: existing),
|
||||||
|
);
|
||||||
|
if (saved == true && context.mounted) {
|
||||||
|
ref.invalidate(aiPersonasProvider);
|
||||||
|
showToast(context, existing == null ? '已添加' : '已保存', icon: 'check');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDelete(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
AiPersonaItem item,
|
||||||
|
int customCount,
|
||||||
|
) async {
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('删除人设?', style: TextStyle(fontSize: 16)),
|
||||||
|
content: Text(
|
||||||
|
'「${item.name}」会被删掉。\n'
|
||||||
|
'${customCount == 1 ? '删掉之后会自动回到「默认」那套。' : ''}',
|
||||||
|
style: const TextStyle(fontSize: 13, height: 1.6),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||||
|
child: const Text('取消', style: TextStyle(color: Color(0xFF8D6E63))),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||||
|
child: const Text('删除', style: TextStyle(color: Color(0xFFE53935))),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok != true || !context.mounted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ref.read(aiApiProvider).deletePersona(item.id!);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
// 删掉的正好是当前选中的 → 退回默认
|
||||||
|
if (ref.read(selectedAiPersonaProvider) == item.id) {
|
||||||
|
ref.read(selectedAiPersonaProvider.notifier).select(null);
|
||||||
|
}
|
||||||
|
ref.invalidate(aiPersonasProvider);
|
||||||
|
showToast(context, '已删除', icon: 'check');
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
if (context.mounted) showToast(context, e.message, error: true);
|
||||||
|
} catch (_) {
|
||||||
|
if (context.mounted) showToast(context, '删除失败', error: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 新增 / 编辑人设的表单
|
||||||
|
class _PersonaEditor extends ConsumerStatefulWidget {
|
||||||
|
const _PersonaEditor({this.existing});
|
||||||
|
|
||||||
|
/// null = 新增
|
||||||
|
final AiPersonaItem? existing;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_PersonaEditor> createState() => _PersonaEditorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PersonaEditorState extends ConsumerState<_PersonaEditor> {
|
||||||
|
late final TextEditingController _name;
|
||||||
|
late final TextEditingController _prompt;
|
||||||
|
|
||||||
|
bool _saving = false;
|
||||||
|
|
||||||
|
bool get _isEdit => widget.existing != null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final e = widget.existing;
|
||||||
|
_name = TextEditingController(text: e?.name ?? '');
|
||||||
|
// 新增时预填内置默认文本 —— 面对的不是空白框,是一份能跑的。
|
||||||
|
// 拿不到(列表还没加载出来)就先空着,用户手动点「恢复默认」也能填
|
||||||
|
_prompt = TextEditingController(
|
||||||
|
text: e?.systemPrompt ?? ref.read(defaultPersonaPromptProvider) ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_name.dispose();
|
||||||
|
_prompt.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
final prompt = _prompt.text.trim();
|
||||||
|
if (prompt.isEmpty) {
|
||||||
|
showToast(context, '提示词不能为空', error: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _saving = true);
|
||||||
|
try {
|
||||||
|
final api = ref.read(aiApiProvider);
|
||||||
|
if (_isEdit) {
|
||||||
|
await api.updatePersona(
|
||||||
|
id: widget.existing!.id!,
|
||||||
|
name: _name.text.trim(),
|
||||||
|
systemPrompt: prompt,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await api.createPersona(
|
||||||
|
name: _name.text.trim(),
|
||||||
|
systemPrompt: prompt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
showToast(context, e.message, error: true);
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
showToast(context, '保存失败', error: true);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _saving = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _restoreDefault() {
|
||||||
|
final def = ref.read(defaultPersonaPromptProvider);
|
||||||
|
if (def == null || def.isEmpty) {
|
||||||
|
showToast(context, '默认人设还没加载出来,稍后再试', error: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _prompt.text = def);
|
||||||
|
showToast(context, '已填入默认人设,记得保存');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// 是否提示「没提到工具」——只提示,不阻止保存
|
||||||
|
final lacksToolHint = _prompt.text.trim().isNotEmpty &&
|
||||||
|
!_prompt.text.contains('工具');
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
|
||||||
|
child: SafeArea(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
_isEdit ? '编辑人设' : '新增人设',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF5D4037),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
|
||||||
|
_field(
|
||||||
|
label: '名称',
|
||||||
|
controller: _name,
|
||||||
|
hint: '留空则取提示词开头几个字,比如:花花',
|
||||||
|
maxLines: 1,
|
||||||
|
),
|
||||||
|
_field(
|
||||||
|
label: '提示词',
|
||||||
|
controller: _prompt,
|
||||||
|
hint: '比如:你是一个说话简洁的记账助手…',
|
||||||
|
helper: '人设 + 运行规则都写这里。日期由服务端自动补,不用写。',
|
||||||
|
maxLines: null,
|
||||||
|
minLines: 12,
|
||||||
|
),
|
||||||
|
|
||||||
|
if (lacksToolHint) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
_warnBox(),
|
||||||
|
],
|
||||||
|
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
// 「恢复默认」放左边,和右边的取消/保存隔开 —— 避免误点
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _saving ? null : _restoreDefault,
|
||||||
|
icon: const Icon(Icons.settings_backup_restore, size: 16),
|
||||||
|
label: const Text('恢复默认', style: TextStyle(fontSize: 12.5)),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: const Color(0xFF8D6E63),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 46,
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed:
|
||||||
|
_saving ? null : () => Navigator.of(context).pop(),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: const Color(0xFFC8956E),
|
||||||
|
side: const BorderSide(color: Color(0xFFC8956E)),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(23),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 46,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _saving ? null : _save,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFFC8956E),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(23),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _saving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text('保存'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 软提示:提示词里没提到「工具」就提醒一句。
|
||||||
|
///
|
||||||
|
/// 只提示、不阻止保存 —— 用户明确要求规则可编辑,不该替他做决定。
|
||||||
|
/// 但这条提醒很重要:删掉「必须用工具查询」之后,助手会开始凭印象编造金额,
|
||||||
|
/// 而且不会有任何报错。
|
||||||
|
Widget _warnBox() {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(11),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFFFF8E1),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: const Color(0xFFFFE082)),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'提示:没提到「工具」。助手可能会凭印象编造金额和数字 ——\n'
|
||||||
|
'建议保留「必须用工具查询,不要编造数字」这类要求。',
|
||||||
|
style: TextStyle(fontSize: 11.5, height: 1.65, color: Color(0xFF8A6D1F)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _field({
|
||||||
|
required String label,
|
||||||
|
required TextEditingController controller,
|
||||||
|
required String hint,
|
||||||
|
String? helper,
|
||||||
|
int? maxLines = 1,
|
||||||
|
int? minLines,
|
||||||
|
}) {
|
||||||
|
// 单行 vs 多行:maxLines 为 null 或 >1 都算多行
|
||||||
|
final multiline = maxLines == null || maxLines != 1;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label, style: const TextStyle(fontSize: 13, color: Color(0xFF8D6E63))),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF7F4F1),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: TextField(
|
||||||
|
controller: controller,
|
||||||
|
maxLines: maxLines,
|
||||||
|
minLines: minLines,
|
||||||
|
// 多行时让文字从顶部开始,否则会垂直居中显得很怪
|
||||||
|
textAlignVertical:
|
||||||
|
multiline ? TextAlignVertical.top : TextAlignVertical.center,
|
||||||
|
keyboardType: multiline ? TextInputType.multiline : TextInputType.text,
|
||||||
|
autocorrect: false,
|
||||||
|
enableSuggestions: false,
|
||||||
|
// 边打字边刷新 —— 上面那个软提示要根据内容变
|
||||||
|
onChanged: multiline ? (_) => setState(() {}) : null,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: multiline ? 13 : 14,
|
||||||
|
height: multiline ? 1.75 : null,
|
||||||
|
color: const Color(0xFF5D4037),
|
||||||
|
),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
border: InputBorder.none,
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: EdgeInsets.symmetric(
|
||||||
|
vertical: multiline ? 12 : 13,
|
||||||
|
),
|
||||||
|
hintText: hint,
|
||||||
|
hintStyle: const TextStyle(fontSize: 13.5, color: Color(0xFFD7CCC8)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (helper != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4, left: 2),
|
||||||
|
child: Text(
|
||||||
|
helper,
|
||||||
|
style: const TextStyle(fontSize: 11, color: Color(0xFFBCAAA4)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../api/ai_api.dart';
|
||||||
|
import '../core/storage/local_storage.dart';
|
||||||
|
import '../models/ai_persona.dart';
|
||||||
|
|
||||||
|
/// 可选的人设列表(含虚拟的「默认」项)
|
||||||
|
///
|
||||||
|
/// 与 [aiConfigsProvider] 完全平行:一个拉模型配置,一个拉人设。
|
||||||
|
final aiPersonasProvider = FutureProvider.autoDispose<List<AiPersonaItem>>((ref) {
|
||||||
|
return ref.watch(aiApiProvider).personas();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 当前选中的人设。
|
||||||
|
///
|
||||||
|
/// **不设 autoDispose**:理由和 [SelectedAiConfigNotifier] 一样 ——
|
||||||
|
/// 选完回对话页要立刻生效,切页面不该丢。
|
||||||
|
class SelectedAiPersonaNotifier extends Notifier<int?> {
|
||||||
|
@override
|
||||||
|
int? build() {
|
||||||
|
// 存的是「真实 id」,null = 用内置默认。
|
||||||
|
// 默认项在列表里的 id 也是 null,两边语义天然一致。
|
||||||
|
return LocalStorage.instance.getAiPersonaId();
|
||||||
|
}
|
||||||
|
|
||||||
|
void select(int? id) {
|
||||||
|
state = id;
|
||||||
|
LocalStorage.instance.saveAiPersonaId(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final selectedAiPersonaProvider =
|
||||||
|
NotifierProvider<SelectedAiPersonaNotifier, int?>(SelectedAiPersonaNotifier.new);
|
||||||
|
|
||||||
|
/// 从人设列表里挑出当前选中的那一条。
|
||||||
|
///
|
||||||
|
/// 三种情况都要兜住:
|
||||||
|
/// · 还没选过 → 用列表第一项(也就是「默认」)
|
||||||
|
/// · 选过但那条被删了 → 退回第一项,并顺手把本地选择改掉
|
||||||
|
/// · 列表还没加载出来 → 返回 null,界面显示「--」
|
||||||
|
///
|
||||||
|
/// 注意这里和服务端的降级是**两套独立的安全网**:服务端拿到过期 id 也会
|
||||||
|
/// 退回内置默认,所以就算这里没来得及纠正,对话也不会挂。
|
||||||
|
final activeAiPersonaProvider = Provider.autoDispose<AiPersonaItem?>((ref) {
|
||||||
|
final personas = ref.watch(aiPersonasProvider).value;
|
||||||
|
if (personas == null || personas.isEmpty) return null;
|
||||||
|
|
||||||
|
final selectedId = ref.watch(selectedAiPersonaProvider);
|
||||||
|
if (selectedId == null) return personas.first;
|
||||||
|
|
||||||
|
for (final item in personas) {
|
||||||
|
if (item.selectionKey == selectedId) return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选中的那条已经不存在了(被删了)。退回默认。
|
||||||
|
// 放在微任务里改 state —— build 过程中不能同步改另一个 provider。
|
||||||
|
Future.microtask(() {
|
||||||
|
ref.read(selectedAiPersonaProvider.notifier).select(null);
|
||||||
|
});
|
||||||
|
return personas.first;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 内置默认人设文本,供编辑页「恢复默认」按钮使用。
|
||||||
|
///
|
||||||
|
/// 从列表的默认项里取(服务端下发),拿不到时返回 null ——
|
||||||
|
/// 这时按钮会提示「还没加载出来」,而不是塞一份 App 里硬编码的文本进去
|
||||||
|
/// (那样两边迟早不一致)。
|
||||||
|
final defaultPersonaPromptProvider = Provider.autoDispose<String?>((ref) {
|
||||||
|
final personas = ref.watch(aiPersonasProvider).value;
|
||||||
|
if (personas == null) return null;
|
||||||
|
for (final p in personas) {
|
||||||
|
if (p.isDefault && (p.defaultPrompt?.isNotEmpty ?? false)) {
|
||||||
|
return p.defaultPrompt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import '../api/ai_api.dart';
|
|||||||
import '../core/network/api_exception.dart';
|
import '../core/network/api_exception.dart';
|
||||||
import '../models/ai.dart';
|
import '../models/ai.dart';
|
||||||
import 'ai_config_provider.dart';
|
import 'ai_config_provider.dart';
|
||||||
|
import 'ai_persona_provider.dart';
|
||||||
import 'bill_refresh.dart';
|
import 'bill_refresh.dart';
|
||||||
import 'note_provider.dart';
|
import 'note_provider.dart';
|
||||||
|
|
||||||
@@ -160,9 +161,14 @@ class AssistantNotifier extends Notifier<AssistantState> {
|
|||||||
try {
|
try {
|
||||||
// 带上用户选中的配置。null = 用服务端 .env 那套(虚拟的「默认」项)
|
// 带上用户选中的配置。null = 用服务端 .env 那套(虚拟的「默认」项)
|
||||||
final configId = ref.read(selectedAiConfigProvider);
|
final configId = ref.read(selectedAiConfigProvider);
|
||||||
await for (final event in ref
|
// 人设是独立的另一套。null = 用服务端内置的默认人设
|
||||||
.read(aiApiProvider)
|
final personaId = ref.read(selectedAiPersonaProvider);
|
||||||
.chat(sessionId: state.sessionId, configId: configId, content: trimmed)) {
|
await for (final event in ref.read(aiApiProvider).chat(
|
||||||
|
sessionId: state.sessionId,
|
||||||
|
configId: configId,
|
||||||
|
personaId: personaId,
|
||||||
|
content: trimmed,
|
||||||
|
)) {
|
||||||
if (!ref.mounted) return;
|
if (!ref.mounted) return;
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:qiangbao_accounting/api/ai_api.dart';
|
||||||
|
import 'package:qiangbao_accounting/models/ai_config.dart';
|
||||||
|
import 'package:qiangbao_accounting/models/ai_persona.dart';
|
||||||
|
import 'package:qiangbao_accounting/pages/assistant/ai_config_page.dart';
|
||||||
|
|
||||||
|
/// 「AI 设置」页(模型 / 人设 双 Tab)的布局测试。
|
||||||
|
///
|
||||||
|
/// 重点是小屏不溢出 —— 这类测试在健康模块抓过真 bug(320 宽溢出 9.8px)。
|
||||||
|
/// 这里风险更高的地方是**人设编辑弹层**:多行 TextField + 软提示 +
|
||||||
|
/// 「恢复默认」按钮,元素比模型那个弹层多。
|
||||||
|
///
|
||||||
|
/// 另外守两件事:
|
||||||
|
/// · 默认人设项的提示词预览能正常显示(换行被压平,不会撑爆卡片)
|
||||||
|
/// · 长提示词在列表里被两行截断,不会把列表撑散
|
||||||
|
|
||||||
|
/// 假 API:不发网络请求,返回固定的模型与人设列表
|
||||||
|
class _FakeAiApi extends AiApi {
|
||||||
|
_FakeAiApi() : super(Dio());
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<AiConfigItem>> configs() async => const [
|
||||||
|
AiConfigItem(
|
||||||
|
id: null,
|
||||||
|
name: '默认',
|
||||||
|
baseUrl: 'https://api.deepseek.com/v1',
|
||||||
|
apiKeyMasked: 'sk-****cdef',
|
||||||
|
model: 'deepseek-chat',
|
||||||
|
isDefault: true,
|
||||||
|
editable: false,
|
||||||
|
fromEnv: true,
|
||||||
|
ready: true,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<AiPersonaItem>> personas() async => [
|
||||||
|
const AiPersonaItem(
|
||||||
|
id: null,
|
||||||
|
name: '默认人设',
|
||||||
|
systemPrompt: '大名:王洋 小名:花花 关系:你的铁杆好朋友,俩人互相喊对方宝',
|
||||||
|
promptLength: 30,
|
||||||
|
isDefault: true,
|
||||||
|
editable: false,
|
||||||
|
defaultPrompt: '大名:王洋 小名:花花',
|
||||||
|
),
|
||||||
|
const AiPersonaItem(
|
||||||
|
id: 7,
|
||||||
|
name: '简洁模式',
|
||||||
|
// 故意很长 + 带换行,验证预览会被压平和截断
|
||||||
|
systemPrompt: '你是极简助手,只回一句话。\n必须用工具查询数据,不要编造数字。'
|
||||||
|
'这是一段很长的文本用来测试列表项会不会被撑爆,'
|
||||||
|
'再补一些字凑长度让预览一定要截断。',
|
||||||
|
promptLength: 60,
|
||||||
|
isDefault: false,
|
||||||
|
editable: true,
|
||||||
|
defaultPrompt: null,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<AiPersonaItem> createPersona({
|
||||||
|
required String name,
|
||||||
|
required String systemPrompt,
|
||||||
|
}) async =>
|
||||||
|
AiPersonaItem(
|
||||||
|
id: 99,
|
||||||
|
name: name.isEmpty ? '新的' : name,
|
||||||
|
systemPrompt: systemPrompt,
|
||||||
|
promptLength: systemPrompt.length,
|
||||||
|
isDefault: false,
|
||||||
|
editable: true,
|
||||||
|
defaultPrompt: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const sizes = [
|
||||||
|
('iPhone SE 窄屏', Size(320, 568)),
|
||||||
|
('常见安卓机', Size(393, 873)),
|
||||||
|
('大屏', Size(480, 1000)),
|
||||||
|
];
|
||||||
|
|
||||||
|
void useSize(WidgetTester tester, Size size) {
|
||||||
|
tester.view.physicalSize = size * 3;
|
||||||
|
tester.view.devicePixelRatio = 3.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget wrap() => ProviderScope(
|
||||||
|
overrides: [aiApiProvider.overrideWithValue(_FakeAiApi())],
|
||||||
|
child: const MaterialApp(home: AiConfigPage()),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 进人设 Tab
|
||||||
|
///
|
||||||
|
/// 必须 pumpAndSettle:TabBarView 的切换是**动画**,只 pump 几帧的话
|
||||||
|
/// 人设内容还没被构建出来(实测要 settle 完才出现)。
|
||||||
|
/// 带个超时上限,避免哪天真卡死时整个测试挂住。
|
||||||
|
Future<void> goToPersonaTab(WidgetTester tester) async {
|
||||||
|
await tester.tap(find.text('人设'));
|
||||||
|
await tester.pumpAndSettle(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 等列表数据加载完(FutureProvider 走完一轮)
|
||||||
|
Future<void> settleData(WidgetTester tester) async {
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final (label, size) in sizes) {
|
||||||
|
testWidgets('AI 设置页(模型 Tab)$label 不溢出', (tester) async {
|
||||||
|
useSize(tester, size);
|
||||||
|
await tester.pumpWidget(wrap());
|
||||||
|
await settleData(tester);
|
||||||
|
|
||||||
|
expect(find.text('模型'), findsOneWidget);
|
||||||
|
expect(find.text('人设'), findsOneWidget);
|
||||||
|
expect(find.text('默认'), findsWidgets);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('AI 设置页(人设 Tab)$label 不溢出', (tester) async {
|
||||||
|
useSize(tester, size);
|
||||||
|
await tester.pumpWidget(wrap());
|
||||||
|
await settleData(tester);
|
||||||
|
|
||||||
|
await goToPersonaTab(tester);
|
||||||
|
|
||||||
|
expect(find.text('默认人设'), findsOneWidget);
|
||||||
|
expect(find.text('简洁模式'), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull,
|
||||||
|
reason: '$label 下人设 Tab 溢出了');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('★ 长提示词在列表里被两行截断(否则卡片会被撑得很高)', (tester) async {
|
||||||
|
useSize(tester, const Size(393, 873));
|
||||||
|
await tester.pumpWidget(wrap());
|
||||||
|
await settleData(tester);
|
||||||
|
await goToPersonaTab(tester);
|
||||||
|
|
||||||
|
// 直接断言那个预览 Text 的属性。
|
||||||
|
//
|
||||||
|
// 为什么不靠「溢出」断言:这是纵向列表,卡片变高只会让列表更长、
|
||||||
|
// 不会报 RenderFlex overflow —— 我实测过,把 maxLines 去掉后
|
||||||
|
// 溢出断言照样通过。所以必须查组件本身。
|
||||||
|
final previewTexts = tester
|
||||||
|
.widgetList<Text>(find.byType(Text))
|
||||||
|
.where((t) => t.data != null && t.data!.contains('你是极简助手'))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
expect(previewTexts, isNotEmpty, reason: '没找到人设预览文本');
|
||||||
|
for (final t in previewTexts) {
|
||||||
|
expect(t.maxLines, 2, reason: '预览必须限 2 行,否则长提示词会把卡片撑爆');
|
||||||
|
expect(t.overflow, TextOverflow.ellipsis, reason: '超出部分要省略号截断');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预览里不该有原始换行(换行来自未压平的 systemPrompt)
|
||||||
|
final preview = previewTexts.first.data!;
|
||||||
|
expect(preview.contains('\n'), isFalse, reason: '预览应该已把换行压成空格');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('人设编辑弹层在窄屏打开不溢出(多行输入 + 软提示 + 恢复默认)', (tester) async {
|
||||||
|
useSize(tester, const Size(320, 568));
|
||||||
|
await tester.pumpWidget(wrap());
|
||||||
|
await settleData(tester);
|
||||||
|
await goToPersonaTab(tester);
|
||||||
|
|
||||||
|
// 点右下角 FAB 新增 —— 新增时会预填内置默认文本
|
||||||
|
await tester.tap(find.byIcon(Icons.add));
|
||||||
|
// ⚠️ 不能用 pumpAndSettle:弹层里的 TextField 光标闪烁是**无限动画**,
|
||||||
|
// settle 会一直等下去(这个坑在 watermark_layout_test 里踩过)
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
|
||||||
|
expect(find.text('新增人设'), findsOneWidget);
|
||||||
|
expect(find.text('提示词'), findsOneWidget);
|
||||||
|
expect(find.text('恢复默认'), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull, reason: '窄屏下人设编辑弹层溢出了');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('★ 编辑人设时提示词预填内置默认(不是空白框)', (tester) async {
|
||||||
|
useSize(tester, const Size(393, 873));
|
||||||
|
await tester.pumpWidget(wrap());
|
||||||
|
await settleData(tester);
|
||||||
|
await goToPersonaTab(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.add));
|
||||||
|
// ⚠️ 不能用 pumpAndSettle:弹层里的 TextField 光标闪烁是**无限动画**,
|
||||||
|
// settle 会一直等下去(这个坑在 watermark_layout_test 里踩过)
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
|
||||||
|
// 找到提示词那个多行 TextField,它应该有内容
|
||||||
|
final fields = tester.widgetList<TextField>(find.byType(TextField)).toList();
|
||||||
|
// 第一个是「名称」,第二个是「提示词」
|
||||||
|
expect(fields.length, greaterThanOrEqualTo(2));
|
||||||
|
expect(fields[1].controller?.text, isNotEmpty,
|
||||||
|
reason: '新增人设时提示词应该预填内置默认,而不是空框');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('人设列表项点一下能切换选中状态', (tester) async {
|
||||||
|
useSize(tester, const Size(393, 873));
|
||||||
|
await tester.pumpWidget(wrap());
|
||||||
|
await settleData(tester);
|
||||||
|
await goToPersonaTab(tester);
|
||||||
|
|
||||||
|
// 点「简洁模式」,应该弹 toast(不崩)
|
||||||
|
await tester.tap(find.text('简洁模式'));
|
||||||
|
await settleData(tester);
|
||||||
|
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('默认人设项没有编辑/删除入口(只有一排菜单图标 = 用户项数)', (tester) async {
|
||||||
|
useSize(tester, const Size(393, 873));
|
||||||
|
await tester.pumpWidget(wrap());
|
||||||
|
await settleData(tester);
|
||||||
|
await goToPersonaTab(tester);
|
||||||
|
|
||||||
|
// 两条人设里只有一条(简洁模式)可编辑 → 只该有一个 more_vert 菜单
|
||||||
|
expect(find.byIcon(Icons.more_vert), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:qiangbao_accounting/models/ai_persona.dart';
|
||||||
|
|
||||||
|
/// AiPersonaItem 的解析与预览测试。
|
||||||
|
///
|
||||||
|
/// 重点是**不崩**:服务端字段缺失(老版本后端、网络截断)时 fromJson
|
||||||
|
/// 不能抛异常,否则整个配置页会白屏。
|
||||||
|
void main() {
|
||||||
|
group('AiPersonaItem 解析', () {
|
||||||
|
test('完整字段正常解析', () {
|
||||||
|
final item = AiPersonaItem.fromJson({
|
||||||
|
'id': 5,
|
||||||
|
'name': '花花',
|
||||||
|
'systemPrompt': '你是助手。',
|
||||||
|
'promptLength': 6,
|
||||||
|
'isDefault': false,
|
||||||
|
'editable': true,
|
||||||
|
'defaultPrompt': null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(item.id, 5);
|
||||||
|
expect(item.name, '花花');
|
||||||
|
expect(item.systemPrompt, '你是助手。');
|
||||||
|
expect(item.promptLength, 6);
|
||||||
|
expect(item.isDefault, isFalse);
|
||||||
|
expect(item.editable, isTrue);
|
||||||
|
expect(item.defaultPrompt, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('默认项:id 为 null、不可编辑、带 defaultPrompt', () {
|
||||||
|
final item = AiPersonaItem.fromJson({
|
||||||
|
'id': null,
|
||||||
|
'name': '默认人设',
|
||||||
|
'systemPrompt': '内置文本',
|
||||||
|
'isDefault': true,
|
||||||
|
'editable': false,
|
||||||
|
'defaultPrompt': '内置文本',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(item.id, isNull);
|
||||||
|
expect(item.isDefault, isTrue);
|
||||||
|
expect(item.editable, isFalse);
|
||||||
|
expect(item.defaultPrompt, '内置文本');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('★ 字段全缺也不抛异常(老后端 / 网络截断)', () {
|
||||||
|
final item = AiPersonaItem.fromJson(const {});
|
||||||
|
|
||||||
|
expect(item.id, isNull);
|
||||||
|
expect(item.name, '未命名');
|
||||||
|
expect(item.systemPrompt, '');
|
||||||
|
expect(item.promptLength, 0);
|
||||||
|
expect(item.isDefault, isFalse);
|
||||||
|
// editable 默认 true —— 缺字段时倾向「让用户能改」,而不是锁死
|
||||||
|
expect(item.editable, isTrue);
|
||||||
|
expect(item.defaultPrompt, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectionKey:默认项是 -1,具体项是它的 id', () {
|
||||||
|
final def = AiPersonaItem.fromJson({'id': null, 'isDefault': true});
|
||||||
|
final mine = AiPersonaItem.fromJson({'id': 42});
|
||||||
|
|
||||||
|
expect(def.selectionKey, -1);
|
||||||
|
expect(mine.selectionKey, 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('defaultPrompt 为空串时归一成 null(不能拿空串去判断「有没有」)', () {
|
||||||
|
final item = AiPersonaItem.fromJson({'id': 1, 'defaultPrompt': ''});
|
||||||
|
expect(item.defaultPrompt, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('promptLength 缺失时按 0,不会让界面显示 null', () {
|
||||||
|
final item = AiPersonaItem.fromJson({'id': 1});
|
||||||
|
expect(item.promptLength, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('preview(列表里的两行预览)', () {
|
||||||
|
test('短文本原样返回', () {
|
||||||
|
final item = AiPersonaItem.fromJson({
|
||||||
|
'id': 1,
|
||||||
|
'systemPrompt': '你是助手。',
|
||||||
|
});
|
||||||
|
expect(item.preview, '你是助手。');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('换行被压成空格 —— 否则列表项会被撑成好几行', () {
|
||||||
|
final item = AiPersonaItem.fromJson({
|
||||||
|
'id': 1,
|
||||||
|
'systemPrompt': '第一行\n第二行\n\n第三行',
|
||||||
|
});
|
||||||
|
expect(item.preview, '第一行 第二行 第三行');
|
||||||
|
expect(item.preview.contains('\n'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('连续空白(含缩进)被压成一个空格', () {
|
||||||
|
final item = AiPersonaItem.fromJson({
|
||||||
|
'id': 1,
|
||||||
|
'systemPrompt': '甲 乙\t丙',
|
||||||
|
});
|
||||||
|
expect(item.preview, '甲 乙 丙');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('超长文本截断并加省略号', () {
|
||||||
|
final long = '啊' * 100;
|
||||||
|
final item = AiPersonaItem.fromJson({'id': 1, 'systemPrompt': long});
|
||||||
|
|
||||||
|
expect(item.preview.length, 51); // 50 + 省略号
|
||||||
|
expect(item.preview.endsWith('…'), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('刚好 50 字不截断', () {
|
||||||
|
final exact = '啊' * 50;
|
||||||
|
final item = AiPersonaItem.fromJson({'id': 1, 'systemPrompt': exact});
|
||||||
|
expect(item.preview, exact);
|
||||||
|
expect(item.preview.endsWith('…'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('空提示词不会崩', () {
|
||||||
|
final item = AiPersonaItem.fromJson({'id': 1, 'systemPrompt': ''});
|
||||||
|
expect(item.preview, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('只有空白字符时预览是空的', () {
|
||||||
|
final item = AiPersonaItem.fromJson({'id': 1, 'systemPrompt': ' \n\t '});
|
||||||
|
expect(item.preview, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('首尾空白被去掉', () {
|
||||||
|
final item = AiPersonaItem.fromJson({
|
||||||
|
'id': 1,
|
||||||
|
'systemPrompt': ' 你是助手。 ',
|
||||||
|
});
|
||||||
|
expect(item.preview, '你是助手。');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -15,7 +15,12 @@ class FakeAiApi extends AiApi {
|
|||||||
final List<AiEvent> _events;
|
final List<AiEvent> _events;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<AiEvent> chat({int? sessionId, int? configId, required String content}) async* {
|
Stream<AiEvent> chat({
|
||||||
|
int? sessionId,
|
||||||
|
int? configId,
|
||||||
|
int? personaId,
|
||||||
|
required String content,
|
||||||
|
}) async* {
|
||||||
for (final e in _events) {
|
for (final e in _events) {
|
||||||
yield e;
|
yield e;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user