diff --git a/lib/api/ai_api.dart b/lib/api/ai_api.dart index a574ada..dc323be 100644 --- a/lib/api/ai_api.dart +++ b/lib/api/ai_api.dart @@ -6,6 +6,7 @@ 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/ai_config.dart'; import '../models/json_util.dart'; final aiApiProvider = Provider((ref) => AiApi(ref.watch(dioProvider))); @@ -67,12 +68,13 @@ class AiApi { /// (`event: xxx` / `data: {...}` / 空行分隔),手动解析比多引一个依赖可控。 /// /// 注意:[sessionId] 传 null 表示开新会话,会话 ID 会由后端在 done 事件里返回。 - Stream chat({int? sessionId, required String content}) async* { + Stream chat({int? sessionId, int? configId, required String content}) async* { final Response res; try { res = await _dio.post( '/ai/chat', - data: {'sessionId': sessionId, 'content': content}, + // configId 传 null = 用服务端 .env 里那套(列表里虚拟的「默认」项) + data: {'sessionId': sessionId, 'configId': configId, 'content': content}, options: Options( responseType: ResponseType.stream, // LLM 生成慢 + 工具循环可能跑好几轮,给足;后端 SseEmitter 是 180s @@ -131,6 +133,73 @@ class AiApi { } } + // ---------------------------------------------------------------- AI 配置 + + /// 配置列表。后端把虚拟的「默认」项放在第一位(id 为 null)。 + Future> configs() async { + try { + final res = await _dio.get('/ai/configs'); + final data = res.data; + if (data is! List) return const []; + return asMapList(data).map(AiConfigItem.fromJson).toList(); + } on DioException catch (e) { + final err = e.error; + throw err is ApiException ? err : ApiException(-1, '配置列表加载失败'); + } + } + + /// 新增。创建时 apiKey 必填。 + Future createConfig({ + required String name, + required String baseUrl, + required String apiKey, + required String model, + }) async { + try { + final res = await _dio.post('/ai/configs', data: { + 'name': name, + 'baseUrl': baseUrl, + 'apiKey': apiKey, + 'model': model, + }); + return AiConfigItem.fromJson(Map.from(res.data as Map)); + } on DioException catch (e) { + final err = e.error; + throw err is ApiException ? err : ApiException(-1, '保存失败'); + } + } + + /// 更新。[apiKey] 传空表示不改动 —— App 只有掩码,不该被迫重新输入 key。 + Future updateConfig({ + required int id, + required String name, + required String baseUrl, + required String model, + String? apiKey, + }) async { + try { + final res = await _dio.put('/ai/configs/$id', data: { + 'name': name, + 'baseUrl': baseUrl, + 'model': model, + if (apiKey != null && apiKey.isNotEmpty) 'apiKey': apiKey, + }); + return AiConfigItem.fromJson(Map.from(res.data as Map)); + } on DioException catch (e) { + final err = e.error; + throw err is ApiException ? err : ApiException(-1, '保存失败'); + } + } + + Future deleteConfig(int id) async { + try { + await _dio.delete('/ai/configs/$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) return null; diff --git a/lib/core/storage/local_storage.dart b/lib/core/storage/local_storage.dart index 4c6342e..47c4be9 100644 --- a/lib/core/storage/local_storage.dart +++ b/lib/core/storage/local_storage.dart @@ -107,4 +107,24 @@ class LocalStorage { List getWatermarkAddresses() { return _prefs?.getStringList(_keyWatermarkAddresses) ?? const []; } + + // ---- AI 模型配置 ---- + // 只存「用户选中了哪一条」。配置本身(含 key)在服务端,App 这边拿不到真 key。 + // + // 存的是 selectionKey:具体配置用它的 id,虚拟的「默认」项用 -1。 + // 用 -1 而不是 0 是为了和「没存过」区分开 —— 0 不是合法的 id。 + static const _keyAiConfigId = 'ai_config_id'; + + /// 存选中的配置。null 表示「默认」(服务端 .env 那套)。 + Future saveAiConfigId(int? id) async { + final prefs = await _getPrefs(); + if (id == null) { + await prefs.remove(_keyAiConfigId); + } else { + await prefs.setInt(_keyAiConfigId, id); + } + } + + /// 读选中的配置。null = 用默认。 + int? getAiConfigId() => _prefs?.getInt(_keyAiConfigId); } diff --git a/lib/models/ai_config.dart b/lib/models/ai_config.dart new file mode 100644 index 0000000..df5fb9a --- /dev/null +++ b/lib/models/ai_config.dart @@ -0,0 +1,58 @@ +import 'json_util.dart'; + +/// 一套 AI 模型配置(base url + api key + 模型名) +/// +/// ⚠️ **不含完整 api key**,只有 [apiKeyMasked]。真正的 key 在服务端, +/// 调用由后端代理 —— App 不需要也不该拿到真 key。 +class AiConfigItem { + const AiConfigItem({ + required this.id, + required this.name, + required this.baseUrl, + required this.apiKeyMasked, + required this.model, + required this.isDefault, + required this.editable, + required this.fromEnv, + required this.ready, + }); + + /// null = 虚拟的「默认」项(值来自服务端 .env) + final int? id; + + final String name; + final String baseUrl; + final String apiKeyMasked; + final String model; + + /// 是否是「默认」那一项 + final bool isDefault; + + /// 是否可编辑/删除。默认项为 false + final bool editable; + + /// 来源是不是 .env + final bool fromEnv; + + /// 三项是否齐全可用 + final bool ready; + + /// 用于「显示已选中的是哪一项」的稳定标识。 + /// + /// 默认项没有 id,用 -1 代替 —— 不能直接用 null 当 map key 去比较。 + int get selectionKey => id ?? -1; + + factory AiConfigItem.fromJson(Map json) { + return AiConfigItem( + id: asNullableInt(json['id']), + name: asString(json['name'], fallback: '未命名'), + baseUrl: asString(json['baseUrl']), + apiKeyMasked: asString(json['apiKeyMasked']), + model: asString(json['model']), + isDefault: json['isDefault'] == true, + editable: json['editable'] != false, + fromEnv: json['fromEnv'] == true, + ready: json['ready'] == true, + ); + } +} diff --git a/lib/pages/assistant/ai_config_page.dart b/lib/pages/assistant/ai_config_page.dart new file mode 100644 index 0000000..665e380 --- /dev/null +++ b/lib/pages/assistant/ai_config_page.dart @@ -0,0 +1,588 @@ +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_config.dart'; +import '../../../providers/ai_config_provider.dart'; +import '../../../utils/toast.dart'; +import '../../../widgets/state_views.dart'; + +/// AI 模型配置管理:列出现有配置、切换、增删改。 +/// +/// 设计上的三个要点: +/// 1. **「默认」那一项是虚拟的** —— 值来自服务器 `.env`,不能编辑/删除。 +/// 它就是「什么都不选」的状态,选中后请求里 configId 传 null。 +/// 2. **列表不打勾谁在用**,打勾的是 [selectedAiConfigProvider] 里存的那个。 +/// 选中状态存在手机本地,不改服务端 —— 否则网页端和手机会互相打架。 +/// 3. **api key 只显示掩码**。编辑时留空 = 不改动。 +Future openAiConfigPage(BuildContext context) { + return Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const AiConfigPage()), + ); +} + +class AiConfigPage extends ConsumerWidget { + const AiConfigPage({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), + ), + ], + ), + body: configs.when( + loading: () => const LoadingView(tip: '正在加载配置...'), + error: (e, _) => ErrorView( + message: e is ApiException ? e.message : '配置加载失败', + onRetry: () => ref.invalidate(aiConfigsProvider), + ), + data: (list) { + final custom = list.where((c) => c.editable).length; + return ListView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + children: [ + _introCard(), + const SizedBox(height: 14), + for (final item in list) ...[ + _configTile(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' + '「默认」用的是服务器 .env 里那套,不需要在这里填。', + 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' + '比如 DeepSeek、通义、智谱,填上各自的 base url、api key 和模型名。', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12.5, height: 1.7, color: Color(0xFFBCAAA4)), + ), + ), + ); + } + + Widget _configTile( + BuildContext context, + WidgetRef ref, + AiConfigItem 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(selectedAiConfigProvider.notifier).select(item.id); + showToast(context, '已切换到「${item.name}」', icon: 'check'); + }, + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 8, 12), + child: Row( + children: [ + 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.ready) ...[ + const SizedBox(width: 6), + _tag('不可用', const Color(0xFFE53935)), + ], + ], + ), + const SizedBox(height: 4), + Text( + item.model.isEmpty ? '未填模型名' : item.model, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63)), + ), + const SizedBox(height: 2), + Text( + item.baseUrl.isEmpty ? '未填 base url' : item.baseUrl, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 11, color: Color(0xFFBCAAA4)), + ), + if (item.apiKeyMasked.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + 'key: ${item.apiKeyMasked}', + style: const TextStyle(fontSize: 11, color: Color(0xFFBCAAA4)), + ), + ], + ], + ), + ), + // 默认项不给编辑/删除的入口 —— 它的值来自服务器 .env + if (item.editable) + PopupMenuButton( + 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 _openEditor( + BuildContext context, + WidgetRef ref, + AiConfigItem? existing, + ) async { + final saved = await showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => _AiConfigEditor(existing: existing), + ); + if (saved == true && context.mounted) { + ref.invalidate(aiConfigsProvider); + showToast(context, existing == null ? '已添加' : '已保存', icon: 'check'); + } + } + + Future _confirmDelete( + BuildContext context, + WidgetRef ref, + AiConfigItem item, + int customCount, + ) async { + final ok = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('删除配置?', style: TextStyle(fontSize: 16)), + content: Text( + '「${item.name}」会被删掉。\n' + '${customCount == 1 ? '删掉之后会自动回到「默认」(服务器 .env 那套)。' : ''}', + 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).deleteConfig(item.id!); + if (!context.mounted) return; + + // 删掉的正好是当前选中的 → 退回默认,否则界面会显示一个不存在的名字 + if (ref.read(selectedAiConfigProvider) == item.id) { + ref.read(selectedAiConfigProvider.notifier).select(null); + } + ref.invalidate(aiConfigsProvider); + 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 _AiConfigEditor extends ConsumerStatefulWidget { + const _AiConfigEditor({this.existing}); + + /// null = 新增 + final AiConfigItem? existing; + + @override + ConsumerState<_AiConfigEditor> createState() => _AiConfigEditorState(); +} + +class _AiConfigEditorState extends ConsumerState<_AiConfigEditor> { + late final TextEditingController _name; + late final TextEditingController _baseUrl; + late final TextEditingController _apiKey; + late final TextEditingController _model; + + bool _saving = false; + + bool get _isEdit => widget.existing != null; + + @override + void initState() { + super.initState(); + final e = widget.existing; + _name = TextEditingController(text: e?.name ?? ''); + _baseUrl = TextEditingController(text: e?.baseUrl ?? ''); + // key 不回显(拿到的只是掩码),编辑时留空 = 不改动 + _apiKey = TextEditingController(); + _model = TextEditingController(text: e?.model ?? ''); + } + + @override + void dispose() { + _name.dispose(); + _baseUrl.dispose(); + _apiKey.dispose(); + _model.dispose(); + super.dispose(); + } + + Future _save() async { + final baseUrl = _baseUrl.text.trim(); + final model = _model.text.trim(); + final apiKey = _apiKey.text.trim(); + + if (baseUrl.isEmpty) { + showToast(context, 'base url 不能为空', error: true); + return; + } + if (model.isEmpty) { + showToast(context, '模型名称不能为空', error: true); + return; + } + // 新增时 key 必填;编辑时留空表示不改 + if (!_isEdit && apiKey.isEmpty) { + showToast(context, 'api key 不能为空', error: true); + return; + } + + setState(() => _saving = true); + try { + final api = ref.read(aiApiProvider); + if (_isEdit) { + await api.updateConfig( + id: widget.existing!.id!, + name: _name.text.trim(), + baseUrl: baseUrl, + model: model, + apiKey: apiKey, + ); + } else { + await api.createConfig( + name: _name.text.trim(), + baseUrl: baseUrl, + apiKey: apiKey, + model: model, + ); + } + 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); + } + } + + @override + Widget build(BuildContext context) { + 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: '留空则用模型名,比如:DeepSeek 便宜', + ), + _field( + label: 'Base URL', + controller: _baseUrl, + hint: 'https://api.deepseek.com/v1', + helper: '结尾不要带 /chat/completions', + ), + _field( + label: 'API Key', + controller: _apiKey, + hint: _isEdit && widget.existing!.apiKeyMasked.isNotEmpty + ? '留空表示不改动(当前 ${widget.existing!.apiKeyMasked})' + : 'sk-...', + helper: _isEdit ? '留空 = 保持原来的 key' : '只存在你的服务器上,App 拿不到完整值', + obscure: true, + ), + _field( + label: '模型名称', + controller: _model, + hint: 'deepseek-chat / qwen-plus / glm-4 …', + ), + + const SizedBox(height: 6), + _presetHint(), + + const SizedBox(height: 18), + 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 _presetHint() { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF7F4F1), + borderRadius: BorderRadius.circular(10), + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '常用地址(供参考)', + style: TextStyle(fontSize: 11.5, color: Color(0xFF8D6E63)), + ), + SizedBox(height: 6), + Text( + 'DeepSeek https://api.deepseek.com/v1\n' + '通义 https://dashscope.aliyuncs.com/compatible-mode/v1\n' + '智谱 https://open.bigmodel.cn/api/paas/v4\n' + 'OpenAI https://api.openai.com/v1', + style: TextStyle( + fontSize: 10.5, + height: 1.8, + color: Color(0xFFBCAAA4), + fontFamily: 'monospace', + ), + ), + ], + ), + ); + } + + Widget _field({ + required String label, + required TextEditingController controller, + required String hint, + String? helper, + bool obscure = false, + }) { + 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, + obscureText: obscure, + autocorrect: false, + enableSuggestions: false, + style: const TextStyle(fontSize: 14, color: Color(0xFF5D4037)), + decoration: InputDecoration( + border: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 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)), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/assistant/assistant_page.dart b/lib/pages/assistant/assistant_page.dart index 7d5eeea..73668e5 100644 --- a/lib/pages/assistant/assistant_page.dart +++ b/lib/pages/assistant/assistant_page.dart @@ -7,6 +7,8 @@ import '../../api/ai_api.dart'; import '../../core/network/api_env.dart'; import '../../models/ai.dart'; import '../../providers/ai_provider.dart'; +import '../../providers/ai_config_provider.dart'; +import 'ai_config_page.dart'; import '../../utils/toast.dart'; import '../../widgets/state_views.dart'; import '../../widgets/cute_icon.dart'; @@ -105,6 +107,11 @@ class _AssistantPageState extends ConsumerState { ), ), ), + const SizedBox(width: 8), + // 当前用的哪个模型 —— 点一下就能换。 + // 放在标题旁边而不是藏进菜单里,是因为「刚才那句话怎么答得怪怪的」 + // 十有八九就是切错了模型,得让它随时可见。 + Flexible(child: _configChip(context)), const Spacer(), _iconButton( icon: Icons.history, @@ -121,6 +128,39 @@ class _AssistantPageState extends ConsumerState { ); } + /// 顶栏上的「当前模型」小胶囊 + Widget _configChip(BuildContext context) { + final active = ref.watch(activeAiConfigProvider); + final label = active?.name ?? '--'; + + return GestureDetector( + onTap: () => openAiConfigPage(context), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFFFF3E6), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.memory, size: 12, color: Color(0xFFC8956E)), + const SizedBox(width: 4), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 11.5, color: Color(0xFF8D6E63)), + ), + ), + const Icon(Icons.expand_more, size: 13, color: Color(0xFFBCAAA4)), + ], + ), + ), + ); + } + Widget _iconButton({ required IconData icon, required String tooltip, diff --git a/lib/providers/ai_config_provider.dart b/lib/providers/ai_config_provider.dart new file mode 100644 index 0000000..09afc8e --- /dev/null +++ b/lib/providers/ai_config_provider.dart @@ -0,0 +1,57 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/ai_api.dart'; +import '../core/storage/local_storage.dart'; +import '../models/ai_config.dart'; + +/// 可选的 AI 配置列表(含虚拟的「默认」项) +final aiConfigsProvider = FutureProvider.autoDispose>((ref) { + return ref.watch(aiApiProvider).configs(); +}); + +/// 当前选中的配置。 +/// +/// **不设 autoDispose**:用户在设置页选完,回到对话页要立刻生效; +/// 而且这是本地状态,重新读一遍没什么成本,但保持常驻能避免 +/// 「切个页面选择就丢了」这种困惑。 +class SelectedAiConfigNotifier extends Notifier { + @override + int? build() { + // 存的就是「真实 id」,null = 用默认。 + // 默认项在列表里的 id 也是 null,两边语义天然一致。 + return LocalStorage.instance.getAiConfigId(); + } + + void select(int? id) { + state = id; + LocalStorage.instance.saveAiConfigId(id); + } +} + +final selectedAiConfigProvider = + NotifierProvider(SelectedAiConfigNotifier.new); + +/// 从配置列表里挑出当前选中的那一条。 +/// +/// 三种情况都要兜住: +/// · 还没选过 → 用列表第一项(也就是「默认」) +/// · 选过但那条被删了 → 退回第一项,并顺手把本地选择改掉 +/// · 列表还没加载出来 → 返回 null,界面显示「--」 +final activeAiConfigProvider = Provider.autoDispose((ref) { + final configs = ref.watch(aiConfigsProvider).value; + if (configs == null || configs.isEmpty) return null; + + final selectedId = ref.watch(selectedAiConfigProvider); + if (selectedId == null) return configs.first; + + for (final item in configs) { + if (item.selectionKey == selectedId) return item; + } + + // 选中的那条已经不存在了(被删了)。退回默认。 + // 放在微任务里改 state —— build 过程中不能同步改另一个 provider。 + Future.microtask(() { + ref.read(selectedAiConfigProvider.notifier).select(null); + }); + return configs.first; +}); diff --git a/lib/providers/ai_provider.dart b/lib/providers/ai_provider.dart index d91410b..014e9c1 100644 --- a/lib/providers/ai_provider.dart +++ b/lib/providers/ai_provider.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../api/ai_api.dart'; import '../core/network/api_exception.dart'; import '../models/ai.dart'; +import 'ai_config_provider.dart'; import 'bill_refresh.dart'; import 'note_provider.dart'; @@ -157,9 +158,11 @@ class AssistantNotifier extends Notifier { // 这次对话动过哪些数据 final mutated = <_Mutated>{}; try { + // 带上用户选中的配置。null = 用服务端 .env 那套(虚拟的「默认」项) + final configId = ref.read(selectedAiConfigProvider); await for (final event in ref .read(aiApiProvider) - .chat(sessionId: state.sessionId, content: trimmed)) { + .chat(sessionId: state.sessionId, configId: configId, content: trimmed)) { if (!ref.mounted) return; switch (event.type) { diff --git a/test/ai_refresh_test.dart b/test/ai_refresh_test.dart index b80ee9a..0b7476e 100644 --- a/test/ai_refresh_test.dart +++ b/test/ai_refresh_test.dart @@ -15,7 +15,7 @@ class FakeAiApi extends AiApi { final List _events; @override - Stream chat({int? sessionId, required String content}) async* { + Stream chat({int? sessionId, int? configId, required String content}) async* { for (final e in _events) { yield e; }