马良AI写作初始化仓库
This commit is contained in:
481
AINoval/lib/widgets/common/model_display_selector.dart
Normal file
481
AINoval/lib/widgets/common/model_display_selector.dart
Normal file
@@ -0,0 +1,481 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user