AI人设可以配置

This commit is contained in:
2026-09-28 17:16:34 +08:00
parent 2dd89bfde0
commit 80222099ff
10 changed files with 1269 additions and 23 deletions
+74 -2
View File
@@ -7,6 +7,7 @@ import '../core/network/api_exception.dart';
import '../core/network/dio_client.dart';
import '../models/ai.dart';
import '../models/ai_config.dart';
import '../models/ai_persona.dart';
import '../models/json_util.dart';
final aiApiProvider = Provider<AiApi>((ref) => AiApi(ref.watch(dioProvider)));
@@ -68,13 +69,24 @@ class AiApi {
/// (`event: xxx` / `data: {...}` / 空行分隔),手动解析比多引一个依赖可控。
///
/// 注意:[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;
try {
res = await _dio.post<ResponseBody>(
'/ai/chat',
// configId 传 null = 用服务端 .env 里那套(列表里虚拟的「默认」项)
data: {'sessionId': sessionId, 'configId': configId, 'content': content},
// personaId 传 null = 用服务端内置的默认人设。两者互相独立
data: {
'sessionId': sessionId,
'configId': configId,
'personaId': personaId,
'content': content,
},
options: Options(
responseType: ResponseType.stream,
// 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) {
final decoded = jsonDecode(payload);
if (decoded is! Map<String, dynamic>) return null;
+18
View File
@@ -127,4 +127,22 @@ class LocalStorage {
/// 读选中的配置。null = 用默认。
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);
}
+80
View File
@@ -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;
}
+48 -17
View File
@@ -7,15 +7,15 @@ import '../../../models/ai_config.dart';
import '../../../providers/ai_config_provider.dart';
import '../../../utils/toast.dart';
import '../../../widgets/state_views.dart';
import 'ai_persona_page.dart';
/// AI 模型配置管理:列出现有配置、切换、增删改。
/// 「AI 设置」页:两个 Tab —— 模型 / 人设。
///
/// 设计上的三个要点:
/// 1. **「默认」那一项是虚拟的** —— 值来自服务器 `.env`,不能编辑/删除。
/// 它就是「什么都不选」的状态,选中后请求里 configId 传 null。
/// 2. **列表不打勾谁在用**,打勾的是 [selectedAiConfigProvider] 里存的那个。
/// 选中状态存在手机本地,不改服务端 —— 否则网页端和手机会互相打架。
/// 3. **api key 只显示掩码**。编辑时留空 = 不改动。
/// 为什么是 Tab 而不是顶栏再加一个胶囊:顶栏已经有返回、标题、当前模型胶囊、
/// 历史、新对话五块,320 宽的小屏塞不下第二个(`page_layout_test.dart` 会抓出来)。
/// 而且模型和人设本来就是**一对平行的配置**,放一个页面里切换很自然。
///
/// 入口函数名保留 `openAiConfigPage` —— 助手页的胶囊用它,不用改。
Future<void> openAiConfigPage(BuildContext context) {
return Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const AiConfigPage()),
@@ -25,21 +25,52 @@ Future<void> openAiConfigPage(BuildContext context) {
class AiConfigPage extends ConsumerWidget {
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
Widget build(BuildContext context, WidgetRef ref) {
final configs = ref.watch(aiConfigsProvider);
final selectedId = ref.watch(selectedAiConfigProvider);
return Scaffold(
appBar: AppBar(
title: const Text('AI 模型'),
actions: [
IconButton(
tooltip: '新增配置',
icon: const Icon(Icons.add),
onPressed: () => _openEditor(context, ref, null),
),
],
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: configs.when(
loading: () => const LoadingView(tip: '正在加载配置...'),
@@ -50,7 +81,7 @@ class AiConfigPage extends ConsumerWidget {
data: (list) {
final custom = list.where((c) => c.editable).length;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 88),
children: [
_introCard(),
const SizedBox(height: 14),
+590
View File
@@ -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)),
),
),
],
),
);
}
}
+77
View File
@@ -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;
});
+9 -3
View File
@@ -4,6 +4,7 @@ import '../api/ai_api.dart';
import '../core/network/api_exception.dart';
import '../models/ai.dart';
import 'ai_config_provider.dart';
import 'ai_persona_provider.dart';
import 'bill_refresh.dart';
import 'note_provider.dart';
@@ -160,9 +161,14 @@ class AssistantNotifier extends Notifier<AssistantState> {
try {
// 带上用户选中的配置。null = 用服务端 .env 那套(虚拟的「默认」项)
final configId = ref.read(selectedAiConfigProvider);
await for (final event in ref
.read(aiApiProvider)
.chat(sessionId: state.sessionId, configId: configId, content: trimmed)) {
// 人设是独立的另一套。null = 用服务端内置的默认人设
final personaId = ref.read(selectedAiPersonaProvider);
await for (final event in ref.read(aiApiProvider).chat(
sessionId: state.sessionId,
configId: configId,
personaId: personaId,
content: trimmed,
)) {
if (!ref.mounted) return;
switch (event.type) {