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

482 lines
16 KiB
Dart
Raw Permalink 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 '../../blocs/public_models/public_models_bloc.dart';
import '../../config/app_config.dart';
import '../../config/provider_icons.dart';
import '../../models/ai_request_models.dart';
import '../../models/novel_setting_item.dart';
import '../../models/novel_snippet.dart';
import '../../models/novel_structure.dart';
import '../../models/setting_group.dart';
import '../../models/unified_ai_model.dart';
import '../../models/user_ai_model_config_model.dart';
import '../../models/public_model_config.dart';
import 'top_toast.dart';
import 'unified_ai_model_dropdown.dart';
/// 尺寸变体:根据不同大小展示不同的信息密度
enum ModelDisplaySize { small, medium, large }
/// 通用的“模型显示与选择”组件
/// - 支持显示模型名称、标签,可选显示提供商图标
/// - 点击后弹出统一的模型下拉菜单(自动根据空间选择上下方向)
class ModelDisplaySelector extends StatefulWidget {
const ModelDisplaySelector({
Key? key,
this.selectedModel,
this.onModelSelected,
this.chatConfig,
this.onConfigChanged,
this.novel,
this.settings = const [],
this.settingGroups = const [],
this.snippets = const [],
this.placeholder = '选择模型',
this.size = ModelDisplaySize.medium,
this.showIcon = true,
this.showTags = true,
this.showSettingsButton = true,
this.width,
this.height,
}) : super(key: key);
final UnifiedAIModel? selectedModel;
final ValueChanged<UnifiedAIModel?>? onModelSelected;
final UniversalAIRequest? chatConfig;
final ValueChanged<UniversalAIRequest>? onConfigChanged;
final Novel? novel;
final List<NovelSettingItem> settings;
final List<SettingGroup> settingGroups;
final List<NovelSnippet> snippets;
final String placeholder;
final ModelDisplaySize size;
final bool showIcon;
final bool showTags;
final bool showSettingsButton;
final double? width;
final double? height; // 可覆盖默认高度
@override
State<ModelDisplaySelector> createState() => _ModelDisplaySelectorState();
}
class _ModelDisplaySelectorState extends State<ModelDisplaySelector> {
OverlayEntry? _overlay;
bool _autoPickDone = false;
@override
void initState() {
super.initState();
// 首帧尝试自动选择默认模型
WidgetsBinding.instance.addPostFrameCallback((_) => _maybeAutoPickDefault());
}
@override
void dispose() {
_removeOverlay();
super.dispose();
}
void _removeOverlay() {
if (_overlay != null && _overlay!.mounted) {
_overlay!.remove();
}
_overlay = null;
}
void _showDropdown() {
if (_overlay != null) {
_removeOverlay();
return;
}
// 兜底:如果没有任何可用模型,提示并返回
final aiState = context.read<AiConfigBloc>().state;
final publicState = context.read<PublicModelsBloc>().state;
final hasPrivate = aiState.validatedConfigs.isNotEmpty;
final hasPublic = publicState is PublicModelsLoaded && publicState.models.isNotEmpty;
if (!hasPrivate && !hasPublic) {
TopToast.error(context, '暂无可用的AI模型配置');
return;
}
// 计算触发器组件的全局矩形作为锚点
final RenderBox box = context.findRenderObject() as RenderBox;
final Offset globalPosition = box.localToGlobal(Offset.zero);
final Rect anchorRect = Rect.fromLTWH(
globalPosition.dx,
globalPosition.dy,
box.size.width,
box.size.height,
);
_overlay = UnifiedAIModelDropdown.show(
context: context,
anchorRect: anchorRect,
selectedModel: widget.selectedModel,
onModelSelected: (unifiedModel) {
// 直接回传统一模型
widget.onModelSelected?.call(unifiedModel);
// 如果需要同步到聊天配置(保留与旧接口兼容)
if (widget.onConfigChanged != null && widget.chatConfig != null && unifiedModel != null) {
UserAIModelConfigModel? compatModel;
if (unifiedModel.isPublic) {
final publicModel = (unifiedModel as PublicAIModel).publicConfig;
compatModel = UserAIModelConfigModel.fromJson({
'id': 'public_${publicModel.id}',
'userId': AppConfig.userId ?? 'unknown',
'alias': publicModel.displayName,
'modelName': publicModel.modelId,
'provider': publicModel.provider,
'apiEndpoint': '',
'isDefault': false,
'isValidated': true,
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
});
} else {
compatModel = (unifiedModel as PrivateAIModel).userConfig;
}
final Map<String, dynamic> mergedMetadata = {
...?widget.chatConfig?.metadata,
'modelName': unifiedModel.modelId,
'modelProvider': unifiedModel.provider,
'modelConfigId': unifiedModel.id,
'isPublicModel': unifiedModel.isPublic,
};
if (unifiedModel.isPublic) {
final publicId = (unifiedModel as PublicAIModel).publicConfig.id;
mergedMetadata['publicModelConfigId'] = publicId;
mergedMetadata['publicModelId'] = publicId;
} else {
mergedMetadata.remove('publicModelConfigId');
mergedMetadata.remove('publicModelId');
}
final updated = widget.chatConfig!.copyWith(
modelConfig: compatModel,
metadata: mergedMetadata,
);
widget.onConfigChanged!(updated);
}
},
showSettingsButton: widget.showSettingsButton,
// 隐藏“调整并生成”入口:小说列表输入框不需要该动作
// 该组件当前仅用于首页/列表输入区因此固定为false
// 如将来复用到其他地方,可将该参数暴露为构造函数可配置
showAdjustAndGenerate: false,
novel: widget.novel,
settings: widget.settings,
settingGroups: widget.settingGroups,
snippets: widget.snippets,
chatConfig: widget.chatConfig,
onConfigChanged: widget.onConfigChanged,
onClose: () {
_overlay = null;
},
);
}
void _maybeAutoPickDefault() {
if (_autoPickDone) return;
if (widget.selectedModel != null) return;
final UnifiedAIModel? defaultModel = _computeDefaultModel();
if (defaultModel != null) {
_autoPickDone = true;
widget.onModelSelected?.call(defaultModel);
}
}
UnifiedAIModel? _computeDefaultModel() {
// 优先:已登录用户的默认私有模型
final String? userId = AppConfig.userId;
final aiState = context.read<AiConfigBloc>().state;
if (userId != null) {
final defaults = aiState.validatedConfigs.where((c) => c.isDefault).toList();
if (defaults.isNotEmpty) {
return PrivateAIModel(defaults.first);
}
// 可选:如无默认,继续尝试公共模型
}
// 未登录或无默认 → 使用公共服务 gemini-2.0或最优的gemini可用项
final publicState = context.read<PublicModelsBloc>().state;
if (publicState is PublicModelsLoaded) {
final List<PublicModel> models = publicState.models;
PublicModel? target;
for (final m in models) {
if (m.modelId.toLowerCase() == 'gemini-2.0') {
target = m;
break;
}
}
if (target == null) {
// 选择 provider/modelId 含 gemini 的优先项(按 priority 降序)
final geminiCandidates = models.where((m) {
final p = m.provider.toLowerCase();
final id = m.modelId.toLowerCase();
return p.contains('gemini') || p.contains('google') || id.contains('gemini');
}).toList();
if (geminiCandidates.isNotEmpty) {
geminiCandidates.sort((a, b) => (b.priority ?? 0).compareTo(a.priority ?? 0));
target = geminiCandidates.first;
}
}
if (target != null) {
return PublicAIModel(target);
}
}
return null;
}
String _displayName() {
if (widget.selectedModel != null) return widget.selectedModel!.displayName;
final configModel = widget.chatConfig?.modelConfig;
if (configModel != null) {
return configModel.alias.isNotEmpty ? configModel.alias : configModel.modelName;
}
return widget.placeholder;
}
double _heightForSize() {
if (widget.height != null) return widget.height!;
switch (widget.size) {
case ModelDisplaySize.small:
return 32;
case ModelDisplaySize.medium:
return 36;
case ModelDisplaySize.large:
return 44;
}
}
double _fontSizeForSize() {
switch (widget.size) {
case ModelDisplaySize.small:
return 12;
case ModelDisplaySize.medium:
return 13;
case ModelDisplaySize.large:
return 14;
}
}
int _maxTagsToShow() {
if (!widget.showTags) return 0;
switch (widget.size) {
case ModelDisplaySize.small:
return 1;
case ModelDisplaySize.medium:
return 2;
case ModelDisplaySize.large:
return 4;
}
}
@override
Widget build(BuildContext context) {
// 主题与展示数据
final isDark = Theme.of(context).brightness == Brightness.dark;
final textColor = isDark ? const Color(0xFFD1D5DB) : const Color(0xFF374151);
final borderColor = isDark ? const Color(0xFF4B5563) : const Color(0xFFD1D5DB);
List<String> tags;
final sel = widget.selectedModel;
if (sel != null) {
final bool isPublicById = sel.id.startsWith('public_') || sel.isPublic;
if (isPublicById) {
tags = ['系统'];
} else {
tags = sel.modelTags;
}
} else {
final cfgId = widget.chatConfig?.modelConfig?.id;
if (cfgId != null && cfgId.startsWith('public_')) {
tags = ['系统'];
} else {
tags = const [];
}
}
final int showTagCount = _maxTagsToShow().clamp(0, tags.length);
// 监听相关Bloc以在数据加载后执行一次自动选择
// 注意仅在尚未自动选择且外部未传入selectedModel时才会触发
// 使用Listener而非Builder避免无谓重建
final child = GestureDetector(
onTap: _showDropdown,
child: Container(
width: widget.width,
height: _heightForSize(),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF374151) : Colors.white,
border: Border.all(color: borderColor, width: 1.0),
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 1,
offset: const Offset(0, 1),
),
],
),
child: Row(
children: [
if (widget.showIcon)
Padding(
padding: const EdgeInsets.only(right: 8),
child: _buildProviderIcon(),
),
Expanded(
child: Row(
children: [
Expanded(
child: Text(
_displayName(),
style: TextStyle(
fontSize: _fontSizeForSize(),
fontWeight: FontWeight.w500,
color: textColor,
),
overflow: TextOverflow.ellipsis,
),
),
if (showTagCount > 0) const SizedBox(width: 8),
if (showTagCount > 0)
Flexible(
child: Wrap(
spacing: 4,
runSpacing: 2,
children: tags
.take(showTagCount)
.map((t) => _TagChip(text: t))
.toList(),
),
),
],
),
),
const SizedBox(width: 8),
Icon(
Icons.expand_more,
size: 18,
color: isDark ? const Color(0xFF9CA3AF) : const Color(0xFF6B7280),
),
],
),
),
);
return MultiBlocListener(
listeners: [
BlocListener<AiConfigBloc, AiConfigState>(
listenWhen: (p, c) => !_autoPickDone && widget.selectedModel == null,
listener: (context, state) => _maybeAutoPickDefault(),
),
BlocListener<PublicModelsBloc, PublicModelsState>(
listenWhen: (p, c) => !_autoPickDone && widget.selectedModel == null,
listener: (context, state) => _maybeAutoPickDefault(),
),
],
child: child,
);
}
Widget _buildProviderIcon() {
final model = widget.selectedModel;
final isDark = Theme.of(context).brightness == Brightness.dark;
if (model == null) {
return Icon(
Icons.model_training_outlined,
size: 16,
color: isDark ? const Color(0xFF9CA3AF) : const Color(0xFF6B7280),
);
}
final color = ProviderIcons.getProviderColor(model.provider);
return Container(
width: 20,
height: 20,
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,
),
),
child: Padding(
padding: const EdgeInsets.all(2),
child: ProviderIcons.getProviderIcon(model.provider, size: 12, useHighQuality: true),
),
);
}
}
class _TagChip extends StatelessWidget {
const _TagChip({required this.text});
final String text;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
Color tagColor;
Color backgroundColor;
Color borderColor;
if (text == '私有') {
tagColor = Colors.blue;
backgroundColor = isDark ? Colors.blue.withOpacity(0.15) : Colors.blue.withOpacity(0.1);
borderColor = Colors.blue.withOpacity(isDark ? 0.3 : 0.2);
} else if (text == '系统') {
tagColor = Colors.green;
backgroundColor = isDark ? Colors.green.withOpacity(0.15) : Colors.green.withOpacity(0.1);
borderColor = Colors.green.withOpacity(isDark ? 0.3 : 0.2);
} else if (text == '推荐') {
tagColor = Colors.orange;
backgroundColor = isDark ? Colors.orange.withOpacity(0.15) : Colors.orange.withOpacity(0.1);
borderColor = Colors.orange.withOpacity(isDark ? 0.3 : 0.2);
} else if (text == '免费') {
tagColor = Colors.purple;
backgroundColor = isDark ? Colors.purple.withOpacity(0.15) : Colors.purple.withOpacity(0.1);
borderColor = Colors.purple.withOpacity(isDark ? 0.3 : 0.2);
} else if (text.contains('积分')) {
tagColor = Colors.red;
backgroundColor = isDark ? Colors.red.withOpacity(0.15) : Colors.red.withOpacity(0.1);
borderColor = Colors.red.withOpacity(isDark ? 0.3 : 0.2);
} else {
tagColor = cs.outline;
backgroundColor = isDark ? cs.surfaceVariant.withOpacity(0.3) : cs.surfaceVariant.withOpacity(0.5);
borderColor = cs.outline.withOpacity(isDark ? 0.3 : 0.2);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: borderColor, width: 0.5),
),
child: Text(
text,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: tagColor.withOpacity(isDark ? 0.9 : 0.8),
fontSize: 10,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
);
}
}