620 lines
21 KiB
Dart
620 lines
21 KiB
Dart
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';
|
||
import 'ai_persona_page.dart';
|
||
|
||
/// 「AI 设置」页:两个 Tab —— 模型 / 人设。
|
||
///
|
||
/// 为什么是 Tab 而不是顶栏再加一个胶囊:顶栏已经有返回、标题、当前模型胶囊、
|
||
/// 历史、新对话五块,320 宽的小屏塞不下第二个(`page_layout_test.dart` 会抓出来)。
|
||
/// 而且模型和人设本来就是**一对平行的配置**,放一个页面里切换很自然。
|
||
///
|
||
/// 入口函数名保留 `openAiConfigPage` —— 助手页的胶囊用它,不用改。
|
||
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) {
|
||
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(
|
||
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: '正在加载配置...'),
|
||
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, 88),
|
||
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)),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|