AI配置化
This commit is contained in:
+71
-2
@@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../core/network/api_exception.dart';
|
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/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)));
|
||||||
@@ -67,12 +68,13 @@ class AiApi {
|
|||||||
/// (`event: xxx` / `data: {...}` / 空行分隔),手动解析比多引一个依赖可控。
|
/// (`event: xxx` / `data: {...}` / 空行分隔),手动解析比多引一个依赖可控。
|
||||||
///
|
///
|
||||||
/// 注意:[sessionId] 传 null 表示开新会话,会话 ID 会由后端在 done 事件里返回。
|
/// 注意:[sessionId] 传 null 表示开新会话,会话 ID 会由后端在 done 事件里返回。
|
||||||
Stream<AiEvent> chat({int? sessionId, required String content}) async* {
|
Stream<AiEvent> chat({int? sessionId, int? configId, 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',
|
||||||
data: {'sessionId': sessionId, 'content': content},
|
// configId 传 null = 用服务端 .env 里那套(列表里虚拟的「默认」项)
|
||||||
|
data: {'sessionId': sessionId, 'configId': configId, 'content': content},
|
||||||
options: Options(
|
options: Options(
|
||||||
responseType: ResponseType.stream,
|
responseType: ResponseType.stream,
|
||||||
// LLM 生成慢 + 工具循环可能跑好几轮,给足;后端 SseEmitter 是 180s
|
// LLM 生成慢 + 工具循环可能跑好几轮,给足;后端 SseEmitter 是 180s
|
||||||
@@ -131,6 +133,73 @@ class AiApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- AI 配置
|
||||||
|
|
||||||
|
/// 配置列表。后端把虚拟的「默认」项放在第一位(id 为 null)。
|
||||||
|
Future<List<AiConfigItem>> 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<AiConfigItem> 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<String, dynamic>.from(res.data as Map));
|
||||||
|
} on DioException catch (e) {
|
||||||
|
final err = e.error;
|
||||||
|
throw err is ApiException ? err : ApiException(-1, '保存失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新。[apiKey] 传空表示不改动 —— App 只有掩码,不该被迫重新输入 key。
|
||||||
|
Future<AiConfigItem> 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<String, dynamic>.from(res.data as Map));
|
||||||
|
} on DioException catch (e) {
|
||||||
|
final err = e.error;
|
||||||
|
throw err is ApiException ? err : ApiException(-1, '保存失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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) {
|
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;
|
||||||
|
|||||||
@@ -107,4 +107,24 @@ class LocalStorage {
|
|||||||
List<String> getWatermarkAddresses() {
|
List<String> getWatermarkAddresses() {
|
||||||
return _prefs?.getStringList(_keyWatermarkAddresses) ?? const [];
|
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<void> 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String, dynamic> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> 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<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,
|
||||||
|
AiConfigItem? 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: (_) => _AiConfigEditor(existing: existing),
|
||||||
|
);
|
||||||
|
if (saved == true && context.mounted) {
|
||||||
|
ref.invalidate(aiConfigsProvider);
|
||||||
|
showToast(context, existing == null ? '已添加' : '已保存', icon: 'check');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDelete(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
AiConfigItem 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 ? '删掉之后会自动回到「默认」(服务器 .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<void> _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)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import '../../api/ai_api.dart';
|
|||||||
import '../../core/network/api_env.dart';
|
import '../../core/network/api_env.dart';
|
||||||
import '../../models/ai.dart';
|
import '../../models/ai.dart';
|
||||||
import '../../providers/ai_provider.dart';
|
import '../../providers/ai_provider.dart';
|
||||||
|
import '../../providers/ai_config_provider.dart';
|
||||||
|
import 'ai_config_page.dart';
|
||||||
import '../../utils/toast.dart';
|
import '../../utils/toast.dart';
|
||||||
import '../../widgets/state_views.dart';
|
import '../../widgets/state_views.dart';
|
||||||
import '../../widgets/cute_icon.dart';
|
import '../../widgets/cute_icon.dart';
|
||||||
@@ -105,6 +107,11 @@ class _AssistantPageState extends ConsumerState<AssistantPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
// 当前用的哪个模型 —— 点一下就能换。
|
||||||
|
// 放在标题旁边而不是藏进菜单里,是因为「刚才那句话怎么答得怪怪的」
|
||||||
|
// 十有八九就是切错了模型,得让它随时可见。
|
||||||
|
Flexible(child: _configChip(context)),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
_iconButton(
|
_iconButton(
|
||||||
icon: Icons.history,
|
icon: Icons.history,
|
||||||
@@ -121,6 +128,39 @@ class _AssistantPageState extends ConsumerState<AssistantPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 顶栏上的「当前模型」小胶囊
|
||||||
|
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({
|
Widget _iconButton({
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
required String tooltip,
|
required String tooltip,
|
||||||
|
|||||||
@@ -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<List<AiConfigItem>>((ref) {
|
||||||
|
return ref.watch(aiApiProvider).configs();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 当前选中的配置。
|
||||||
|
///
|
||||||
|
/// **不设 autoDispose**:用户在设置页选完,回到对话页要立刻生效;
|
||||||
|
/// 而且这是本地状态,重新读一遍没什么成本,但保持常驻能避免
|
||||||
|
/// 「切个页面选择就丢了」这种困惑。
|
||||||
|
class SelectedAiConfigNotifier extends Notifier<int?> {
|
||||||
|
@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, int?>(SelectedAiConfigNotifier.new);
|
||||||
|
|
||||||
|
/// 从配置列表里挑出当前选中的那一条。
|
||||||
|
///
|
||||||
|
/// 三种情况都要兜住:
|
||||||
|
/// · 还没选过 → 用列表第一项(也就是「默认」)
|
||||||
|
/// · 选过但那条被删了 → 退回第一项,并顺手把本地选择改掉
|
||||||
|
/// · 列表还没加载出来 → 返回 null,界面显示「--」
|
||||||
|
final activeAiConfigProvider = Provider.autoDispose<AiConfigItem?>((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;
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../api/ai_api.dart';
|
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 'bill_refresh.dart';
|
import 'bill_refresh.dart';
|
||||||
import 'note_provider.dart';
|
import 'note_provider.dart';
|
||||||
|
|
||||||
@@ -157,9 +158,11 @@ class AssistantNotifier extends Notifier<AssistantState> {
|
|||||||
// 这次对话动过哪些数据
|
// 这次对话动过哪些数据
|
||||||
final mutated = <_Mutated>{};
|
final mutated = <_Mutated>{};
|
||||||
try {
|
try {
|
||||||
|
// 带上用户选中的配置。null = 用服务端 .env 那套(虚拟的「默认」项)
|
||||||
|
final configId = ref.read(selectedAiConfigProvider);
|
||||||
await for (final event in ref
|
await for (final event in ref
|
||||||
.read(aiApiProvider)
|
.read(aiApiProvider)
|
||||||
.chat(sessionId: state.sessionId, content: trimmed)) {
|
.chat(sessionId: state.sessionId, configId: configId, content: trimmed)) {
|
||||||
if (!ref.mounted) return;
|
if (!ref.mounted) return;
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class FakeAiApi extends AiApi {
|
|||||||
final List<AiEvent> _events;
|
final List<AiEvent> _events;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<AiEvent> chat({int? sessionId, required String content}) async* {
|
Stream<AiEvent> chat({int? sessionId, int? configId, 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