Files
MaliangAINovalWriter/AINoval/lib/widgets/common/model_selector.dart
2025-09-10 00:07:52 +08:00

768 lines
25 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../blocs/ai_config/ai_config_bloc.dart';
import '../../models/user_ai_model_config_model.dart';
import '../../models/novel_structure.dart';
import '../../models/novel_setting_item.dart';
import '../../models/setting_group.dart';
import '../../models/novel_snippet.dart';
import '../../models/ai_request_models.dart';
import '../../screens/chat/widgets/chat_settings_dialog.dart';
import '../../config/provider_icons.dart';
import 'model_dropdown_menu.dart';
/// 模型选择器公共组件
///
/// 功能特性:
/// - 按供应商分组显示模型
/// - 模型图标显示
/// - 默认模型标识
/// - 模型标签支持(如免费标签)
/// - 分为模型列表区和底部操作区
class ModelSelector extends StatefulWidget {
const ModelSelector({
Key? key,
this.selectedModel,
required this.onModelSelected,
this.onSettingsPressed,
this.compact = false,
this.showSettingsButton = true,
this.maxHeight = 2400,
this.novel,
this.settings = const [],
this.settingGroups = const [],
this.snippets = const [],
this.chatConfig,
this.onConfigChanged,
}) : super(key: key);
/// 当前选中的模型
final UserAIModelConfigModel? selectedModel;
/// 模型选择回调
final Function(UserAIModelConfigModel?) onModelSelected;
/// 设置按钮点击回调
final VoidCallback? onSettingsPressed;
/// 是否紧凑模式
final bool compact;
/// 是否显示设置按钮
final bool showSettingsButton;
/// 最大高度
final double maxHeight;
/// 小说数据,用于上下文选择
final Novel? novel;
/// 设定数据
final List<NovelSettingItem> settings;
/// 设定组数据
final List<SettingGroup> settingGroups;
/// 片段数据
final List<NovelSnippet> snippets;
/// 🚀 聊天配置
final UniversalAIRequest? chatConfig;
/// 🚀 配置变更回调
final ValueChanged<UniversalAIRequest>? onConfigChanged;
@override
State<ModelSelector> createState() => _ModelSelectorState();
}
class _ModelSelectorState extends State<ModelSelector> {
OverlayEntry? _overlayEntry;
final LayerLink _layerLink = LayerLink();
bool _isMenuOpen = false;
/// 公开方法:触发菜单显示/隐藏
void showDropdown() {
final aiConfigBloc = context.read<AiConfigBloc>();
final validatedConfigs = aiConfigBloc.state.validatedConfigs;
if (validatedConfigs.isNotEmpty) {
_toggleMenu(context, validatedConfigs);
}
}
@override
void dispose() {
_removeOverlay();
super.dispose();
}
void _removeOverlay() {
_overlayEntry?.remove();
_overlayEntry = null;
_isMenuOpen = false;
}
void _toggleMenu(BuildContext context, List<UserAIModelConfigModel> configs) {
if (_isMenuOpen) {
_removeOverlay();
} else {
_createOverlay(context, configs);
_isMenuOpen = true;
}
}
void _createOverlay(BuildContext context, List<UserAIModelConfigModel> configs) {
_overlayEntry = ModelDropdownMenu.show(
context: context,
layerLink: _layerLink,
configs: configs,
selectedModel: widget.selectedModel,
onModelSelected: (model) {
widget.onModelSelected(model);
setState(() {});
},
showSettingsButton: widget.showSettingsButton,
maxHeight: widget.maxHeight,
novel: widget.novel,
settings: widget.settings,
settingGroups: widget.settingGroups,
snippets: widget.snippets,
chatConfig: widget.chatConfig,
onConfigChanged: widget.onConfigChanged,
onClose: () {
_overlayEntry = null;
setState(() {
_isMenuOpen = false;
});
},
);
}
Widget _buildMenuContent(List<UserAIModelConfigModel> configs) {
if (configs.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.model_training_outlined,
size: 48,
color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.5),
),
const SizedBox(height: 12),
Text(
'无可用模型',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'请先配置AI模型',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.7),
),
),
],
),
),
);
}
return Column(
mainAxisSize: MainAxisSize.max,
children: [
Expanded(
child: _buildModelList(configs),
),
if (widget.showSettingsButton)
_buildBottomActions(),
],
);
}
Widget _buildModelList(List<UserAIModelConfigModel> configs) {
final groupedModels = _groupModelsByProvider(configs);
final colorScheme = Theme.of(context).colorScheme;
// Sort providers: default provider first, then alphabetically
final sortedProviders = groupedModels.keys.toList()..sort((a, b) {
final aIsDefault = groupedModels[a]!.any((c) => c.isDefault);
final bIsDefault = groupedModels[b]!.any((c) => c.isDefault);
if (aIsDefault && !bIsDefault) return -1;
if (!aIsDefault && bIsDefault) return 1;
return a.compareTo(b);
});
return ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 8.0),
itemCount: sortedProviders.length,
separatorBuilder: (context, index) => Divider(
height: 16,
thickness: 0.8,
color: colorScheme.outlineVariant.withOpacity(0.12),
indent: 20,
endIndent: 20,
),
itemBuilder: (context, index) {
final provider = sortedProviders[index];
final models = groupedModels[provider]!;
return _buildProviderGroup(provider, models);
},
);
}
Widget _buildProviderGroup(String provider, List<UserAIModelConfigModel> models) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 供应商分组标题 - 完全移除图标,增大字体
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 6),
child: Text(
provider.toUpperCase(),
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: isDark
? colorScheme.primary.withOpacity(0.9)
: colorScheme.primary,
fontWeight: FontWeight.w700,
letterSpacing: 1.0,
fontSize: 14,
),
),
),
// 该供应商下的模型列表
...models.map((model) => _buildModelItem(model)).toList(),
const SizedBox(height: 6),
],
);
}
Widget _buildModelItem(UserAIModelConfigModel model) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final isSelected = widget.selectedModel?.id == model.id;
final displayName = model.alias.isNotEmpty ? model.alias : model.modelName;
return InkWell(
onTap: () {
widget.onModelSelected(model);
_removeOverlay();
},
borderRadius: BorderRadius.circular(10),
splashColor: colorScheme.primary.withOpacity(0.08),
highlightColor: colorScheme.primary.withOpacity(0.04),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isSelected
? (isDark
? colorScheme.primaryContainer.withOpacity(0.2)
: colorScheme.primaryContainer.withOpacity(0.15))
: null,
borderRadius: BorderRadius.circular(8),
border: isSelected
? Border.all(
color: colorScheme.primary.withOpacity(0.2),
width: 1,
)
: null,
),
child: Row(
children: [
// 模型图标 - 外层包装防止突兀
Container(
padding: const EdgeInsets.all(2),
child: _getModelIcon(model.provider),
),
const SizedBox(width: 10),
// 模型信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 模型名称行
Row(
children: [
Flexible(
child: Text(
displayName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
color: isSelected
? colorScheme.primary
: (isDark
? colorScheme.onSurface.withOpacity(0.9)
: colorScheme.onSurface),
fontSize: 13,
height: 1.2,
),
overflow: TextOverflow.ellipsis,
),
),
// 默认模型标识
if (model.isDefault) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: isDark
? Colors.amber.withOpacity(0.15)
: Colors.amber.withOpacity(0.2),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: Colors.amber.withOpacity(isDark ? 0.4 : 0.5),
width: 0.5,
),
),
child: Text(
'默认',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: isDark
? Colors.amber.shade300
: Colors.amber.shade700,
fontSize: 9,
fontWeight: FontWeight.w600,
),
),
),
],
],
),
// 模型标签行(预留区域)
if (_getModelTags(model).isNotEmpty) ...[
const SizedBox(height: 3),
Wrap(
spacing: 3,
runSpacing: 2,
children: _getModelTags(model).map((tag) => _buildModelTag(tag)).toList(),
),
],
],
),
),
// 选中标识
if (isSelected)
Icon(
Icons.check_circle_rounded,
size: 16,
color: colorScheme.primary,
),
],
),
),
);
}
Widget _buildModelTag(ModelTag tag) {
final isDark = Theme.of(context).brightness == Brightness.dark;
MaterialColor tagColor;
switch (tag.type) {
case ModelTagType.free:
tagColor = Colors.green;
break;
case ModelTagType.premium:
tagColor = Colors.purple;
break;
case ModelTagType.beta:
tagColor = Colors.orange;
break;
default:
tagColor = Colors.grey;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: isDark
? tagColor.withOpacity(0.08)
: tagColor.withOpacity(0.12),
borderRadius: BorderRadius.circular(3),
border: Border.all(
color: tagColor.withOpacity(isDark ? 0.2 : 0.3),
width: 0.5,
),
),
child: Text(
tag.label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: isDark
? tagColor.shade300
: tagColor.shade700,
fontSize: 8,
fontWeight: FontWeight.w500,
),
),
);
}
Widget _buildBottomActions() {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isDark
? colorScheme.surface.withOpacity(0.8)
: colorScheme.surface,
border: Border(
top: BorderSide(
color: colorScheme.outlineVariant.withOpacity(isDark ? 0.15 : 0.2),
width: 1.0,
),
),
),
child: SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
_removeOverlay();
// 显示聊天设置对话框
showChatSettingsDialog(
context,
selectedModel: widget.selectedModel,
onModelChanged: (model) {
widget.onModelSelected(model);
},
onSettingsSaved: () {
widget.onSettingsPressed?.call();
},
novel: widget.novel,
settings: widget.settings,
settingGroups: widget.settingGroups,
snippets: widget.snippets,
// 🚀 传递聊天配置,确保设置对话框能够同步
initialChatConfig: widget.chatConfig,
onConfigChanged: widget.onConfigChanged,
initialContextSelections: null, // 🚀 让ChatSettingsDialog自己构建上下文数据
);
},
icon: const Icon(Icons.tune_rounded, size: 18),
label: const Text('调整并生成'),
style: ElevatedButton.styleFrom(
foregroundColor: isDark
? colorScheme.primary.withOpacity(0.9)
: colorScheme.primary,
backgroundColor: isDark
? colorScheme.primaryContainer.withOpacity(0.08)
: colorScheme.primaryContainer.withOpacity(0.1),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
elevation: 0,
side: BorderSide(
color: colorScheme.primary.withOpacity(isDark ? 0.2 : 0.3),
width: 0.8,
),
),
),
),
);
}
Map<String, List<UserAIModelConfigModel>> _groupModelsByProvider(
List<UserAIModelConfigModel> configs) {
final Map<String, List<UserAIModelConfigModel>> grouped = {};
for (final config in configs) {
final provider = config.provider;
grouped.putIfAbsent(provider, () => []);
grouped[provider]!.add(config);
}
// 对每个供应商的模型按名称排序,默认模型排在前面
for (final models in grouped.values) {
models.sort((a, b) {
if (a.isDefault && !b.isDefault) return -1;
if (!a.isDefault && b.isDefault) return 1;
return a.name.compareTo(b.name);
});
}
return grouped;
}
Widget _getProviderIcon(String provider) {
return ProviderIcons.getProviderIconForContext(
provider,
iconSize: IconSize.small,
);
}
Widget _getModelIcon(String provider) {
final color = ProviderIcons.getProviderColor(provider);
final isDark = Theme.of(context).brightness == Brightness.dark;
return Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: isDark
? Colors.white.withOpacity(0.9) // 暗黑模式下背景为白色
: color.withOpacity(0.12),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: isDark
? color.withOpacity(0.3)
: color.withOpacity(0.25),
width: 0.5,
),
boxShadow: isDark ? [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 2,
offset: const Offset(0, 1),
),
] : null,
),
child: Padding(
padding: const EdgeInsets.all(2),
child: ProviderIcons.getProviderIcon(
provider,
size: 10,
useHighQuality: true,
),
),
);
}
List<ModelTag> _getModelTags(UserAIModelConfigModel model) {
// 根据模型信息返回标签列表
List<ModelTag> tags = [];
// 示例:根据模型名称或其他属性添加标签
if (model.modelName.toLowerCase().contains('free') ||
model.modelName.toLowerCase().contains('gpt-3.5')) {
tags.add(const ModelTag(label: '免费', type: ModelTagType.free));
}
if (model.modelName.toLowerCase().contains('beta')) {
tags.add(const ModelTag(label: 'Beta', type: ModelTagType.beta));
}
if (model.modelName.toLowerCase().contains('pro') ||
model.modelName.toLowerCase().contains('gpt-4')) {
tags.add(const ModelTag(label: '专业版', type: ModelTagType.premium));
}
return tags;
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return BlocBuilder<AiConfigBloc, AiConfigState>(
builder: (context, state) {
final validatedConfigs = state.validatedConfigs;
// 确定当前选中的模型
UserAIModelConfigModel? currentSelection;
if (widget.selectedModel != null &&
validatedConfigs.any((c) => c.id == widget.selectedModel!.id)) {
currentSelection = widget.selectedModel;
} else if (state.defaultConfig != null &&
validatedConfigs.any((c) => c.id == state.defaultConfig!.id)) {
currentSelection = state.defaultConfig;
} else if (validatedConfigs.isNotEmpty) {
currentSelection = validatedConfigs.first;
}
// 加载状态
if (state.status == AiConfigStatus.loading && validatedConfigs.isEmpty) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withOpacity(0.3),
borderRadius: BorderRadius.circular(widget.compact ? 12 : 16),
border: Border.all(
color: colorScheme.outline.withOpacity(0.2),
width: 0.8,
),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2.5),
),
SizedBox(width: 8),
Text('加载中...', style: TextStyle(fontSize: 12)),
],
),
);
}
// 无模型状态
if (state.status != AiConfigStatus.loading && validatedConfigs.isEmpty) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.errorContainer.withOpacity(0.1),
borderRadius: BorderRadius.circular(widget.compact ? 12 : 16),
border: Border.all(
color: colorScheme.error.withOpacity(0.3),
width: 0.8,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.warning_outlined,
size: 16,
color: colorScheme.error,
),
const SizedBox(width: 6),
Text(
'无可用模型',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: colorScheme.error,
),
),
],
),
);
}
// 正常状态 - 模型选择器
return CompositedTransformTarget(
link: _layerLink,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: validatedConfigs.isNotEmpty
? () => _toggleMenu(context, validatedConfigs)
: null,
borderRadius: BorderRadius.circular(8),
hoverColor: colorScheme.onSurface.withOpacity(0.08),
splashColor: colorScheme.onSurface.withOpacity(0.12),
child: Container(
height: 44,
constraints: const BoxConstraints(maxWidth: 128),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: Colors.transparent,
border: Border.all(
color: Colors.transparent,
width: 1,
),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
// 主要内容区域
Expanded(
child: Row(
children: [
// 文字内容
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 第一行General Chat
Text(
'General Chat',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: colorScheme.onSurface,
height: 1.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// 第二行:模型名称
Text(
_getModelDisplayName(currentSelection),
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: colorScheme.onSurface.withOpacity(0.5),
height: 1.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
// 下拉箭头
if (validatedConfigs.length > 1)
Container(
margin: const EdgeInsets.only(left: 8),
child: Icon(
_isMenuOpen
? Icons.keyboard_arrow_up_rounded
: Icons.keyboard_arrow_down_rounded,
size: 12,
color: colorScheme.onSurface.withOpacity(0.4),
),
),
],
),
),
],
),
),
),
),
);
},
);
}
String _getDisplayText(UserAIModelConfigModel? model) {
if (model == null) {
return '选择模型';
}
final namePart = model.alias.isNotEmpty ? model.alias : model.modelName;
return widget.compact ? namePart : '${model.provider}/$namePart';
}
String _getModelDisplayName(UserAIModelConfigModel? model) {
if (model == null) {
return '请选择模型';
}
final namePart = model.alias.isNotEmpty ? model.alias : model.modelName;
return namePart;
}
}
/// 模型标签数据类
class ModelTag {
const ModelTag({
required this.label,
required this.type,
});
final String label;
final ModelTagType type;
}
/// 模型标签类型枚举
enum ModelTagType {
free, // 免费
premium, // 专业版
beta, // 测试版
custom, // 自定义
}