482 lines
16 KiB
Dart
482 lines
16 KiB
Dart
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,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
|