马良AI写作初始化仓库
This commit is contained in:
976
AINoval/lib/widgets/common/unified_ai_model_dropdown.dart
Normal file
976
AINoval/lib/widgets/common/unified_ai_model_dropdown.dart
Normal file
@@ -0,0 +1,976 @@
|
||||
// import 'package:ainoval/utils/logger.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../models/unified_ai_model.dart';
|
||||
import '../../models/user_ai_model_config_model.dart';
|
||||
// import '../../models/public_model_config.dart';
|
||||
import '../../models/novel_structure.dart';
|
||||
import '../../models/novel_setting_item.dart';
|
||||
import '../../models/setting_group.dart';
|
||||
import '../../models/novel_snippet.dart';
|
||||
import '../../blocs/ai_config/ai_config_bloc.dart';
|
||||
import '../../blocs/public_models/public_models_bloc.dart';
|
||||
import '../../screens/chat/widgets/chat_settings_dialog.dart';
|
||||
import '../../config/provider_icons.dart';
|
||||
import '../../models/ai_request_models.dart';
|
||||
import '../../screens/editor/managers/editor_layout_manager.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:ainoval/config/app_config.dart';
|
||||
import 'package:ainoval/screens/settings/settings_panel.dart';
|
||||
import 'package:ainoval/screens/editor/managers/editor_state_manager.dart';
|
||||
import 'package:ainoval/widgets/common/top_toast.dart';
|
||||
|
||||
// ==================== 统一 AI 模型下拉菜单 - 尺寸常量定义 ====================
|
||||
|
||||
/// 菜单整体尺寸配置
|
||||
class _MenuDimensions {
|
||||
/// 菜单固定宽度
|
||||
static const double menuWidth = 320.0;
|
||||
|
||||
/// 菜单默认最大高度
|
||||
static const double defaultMaxHeight = 900.0;
|
||||
|
||||
/// 屏幕边缘的安全边距,防止菜单被状态栏或导航栏遮挡
|
||||
static const double screenSafeMargin = 80.0;
|
||||
|
||||
/// 菜单最小高度(有设置按钮时)
|
||||
static const double minHeightWithSettings = 180.0;
|
||||
|
||||
/// 菜单最小高度(无设置按钮时)
|
||||
static const double minHeightWithoutSettings = 120.0;
|
||||
|
||||
/// 菜单与锚点的垂直间距
|
||||
static const double anchorVerticalOffset = 6.0;
|
||||
|
||||
/// 菜单水平边距
|
||||
static const double horizontalMargin = 16.0;
|
||||
}
|
||||
|
||||
/// 菜单内容区域尺寸配置
|
||||
class _ContentDimensions {
|
||||
/// 供应商分组标题高度
|
||||
static const double groupHeaderHeight = 36.0;
|
||||
|
||||
/// 单个模型项的高度(包含标签显示空间)
|
||||
static const double modelItemHeight = 40.0;
|
||||
|
||||
/// 底部操作按钮区域高度
|
||||
static const double bottomButtonHeight = 56.0;
|
||||
|
||||
/// 菜单内容的上下内边距
|
||||
static const double verticalPadding = 6.0;
|
||||
|
||||
/// 菜单内容的左右内边距
|
||||
static const double horizontalPadding = 4.0;
|
||||
}
|
||||
|
||||
/// 模型项内部尺寸配置
|
||||
class _ModelItemDimensions {
|
||||
/// 模型图标容器大小
|
||||
static const double iconContainerSize = 20.0;
|
||||
|
||||
/// 模型图标实际大小
|
||||
static const double iconSize = 12.0;
|
||||
|
||||
/// 模型图标与文字的间距
|
||||
static const double iconTextSpacing = 10.0;
|
||||
|
||||
/// 选中指示器图标大小
|
||||
static const double selectedIconSize = 16.0;
|
||||
|
||||
/// 模型项的水平内边距
|
||||
static const double itemHorizontalPadding = 12.0;
|
||||
|
||||
/// 模型项的垂直内边距
|
||||
static const double itemVerticalPadding = 10.0;
|
||||
|
||||
/// 模型项的外边距
|
||||
static const double itemMargin = 6.0;
|
||||
|
||||
/// 模型项的圆角半径
|
||||
static const double itemBorderRadius = 8.0;
|
||||
}
|
||||
|
||||
/// 标签样式尺寸配置
|
||||
class _TagDimensions {
|
||||
/// 标签水平内边距
|
||||
static const double tagHorizontalPadding = 6.0;
|
||||
|
||||
/// 标签垂直内边距
|
||||
static const double tagVerticalPadding = 2.0;
|
||||
|
||||
/// 标签圆角半径
|
||||
static const double tagBorderRadius = 8.0;
|
||||
|
||||
/// 标签边框宽度
|
||||
static const double tagBorderWidth = 0.5;
|
||||
|
||||
/// 标签之间的间距
|
||||
static const double tagSpacing = 4.0;
|
||||
|
||||
/// 标签行之间的间距
|
||||
static const double tagRunSpacing = 2.0;
|
||||
|
||||
/// 标签与模型名称的间距
|
||||
static const double tagTopSpacing = 2.0;
|
||||
}
|
||||
|
||||
/// 菜单外观样式配置
|
||||
class _MenuStyling {
|
||||
/// 菜单圆角半径
|
||||
static const double menuBorderRadius = 16.0;
|
||||
|
||||
/// 菜单边框宽度
|
||||
static const double menuBorderWidth = 0.8;
|
||||
|
||||
/// 分割线高度
|
||||
static const double dividerHeight = 8.0;
|
||||
|
||||
/// 分割线厚度
|
||||
static const double dividerThickness = 0.6;
|
||||
|
||||
/// 分割线缩进
|
||||
static const double dividerIndent = 16.0;
|
||||
|
||||
/// 分割线结束缩进
|
||||
static const double dividerEndIndent = 16.0;
|
||||
|
||||
/// 菜单阴影高度(暗色主题)
|
||||
static const double elevationDark = 12.0;
|
||||
|
||||
/// 菜单阴影高度(亮色主题)
|
||||
static const double elevationLight = 8.0;
|
||||
}
|
||||
|
||||
/// 底部操作区域尺寸配置
|
||||
class _BottomActionDimensions {
|
||||
/// 底部操作区域内边距
|
||||
static const double bottomPadding = 12.0;
|
||||
|
||||
/// 按钮垂直内边距
|
||||
static const double buttonVerticalPadding = 12.0;
|
||||
|
||||
/// 按钮圆角半径
|
||||
static const double buttonBorderRadius = 10.0;
|
||||
|
||||
/// 按钮边框宽度
|
||||
static const double buttonBorderWidth = 0.8;
|
||||
|
||||
/// 按钮图标大小
|
||||
static const double buttonIconSize = 18.0;
|
||||
|
||||
/// “添加我的私人模型”按钮的高度估算(用于高度计算)
|
||||
static const double secondaryButtonHeight = 44.0;
|
||||
}
|
||||
|
||||
/// 空状态显示尺寸配置
|
||||
class _EmptyStateDimensions {
|
||||
/// 空状态容器内边距
|
||||
static const double emptyPadding = 24.0;
|
||||
|
||||
/// 空状态图标大小
|
||||
static const double emptyIconSize = 48.0;
|
||||
|
||||
/// 空状态图标与文字的间距
|
||||
static const double emptyIconTextSpacing = 12.0;
|
||||
|
||||
/// 空状态标题与副标题的间距
|
||||
static const double emptyTitleSubtitleSpacing = 8.0;
|
||||
}
|
||||
|
||||
// ==================== 统一 AI 模型下拉菜单组件实现 ====================
|
||||
|
||||
/// 统一的AI模型下拉菜单组件,支持显示私有模型和公共模型
|
||||
/// 通过 [show] 静态方法弹出 Overlay 菜单
|
||||
class UnifiedAIModelDropdown {
|
||||
static OverlayEntry show({
|
||||
required BuildContext context,
|
||||
LayerLink? layerLink,
|
||||
Rect? anchorRect,
|
||||
UnifiedAIModel? selectedModel,
|
||||
required Function(UnifiedAIModel?) onModelSelected,
|
||||
bool showSettingsButton = true,
|
||||
bool showAdjustAndGenerate = true,
|
||||
double maxHeight = _MenuDimensions.defaultMaxHeight,
|
||||
Novel? novel,
|
||||
List<NovelSettingItem> settings = const [],
|
||||
List<SettingGroup> settingGroups = const [],
|
||||
List<NovelSnippet> snippets = const [],
|
||||
UniversalAIRequest? chatConfig,
|
||||
ValueChanged<UniversalAIRequest>? onConfigChanged,
|
||||
VoidCallback? onClose,
|
||||
}) {
|
||||
assert(layerLink != null || anchorRect != null, '必须提供 layerLink 或 anchorRect');
|
||||
|
||||
late OverlayEntry entry;
|
||||
bool _closed = false;
|
||||
|
||||
void safeClose() {
|
||||
if (_closed) return;
|
||||
_closed = true;
|
||||
if (entry.mounted) {
|
||||
entry.remove();
|
||||
}
|
||||
onClose?.call();
|
||||
}
|
||||
|
||||
entry = OverlayEntry(
|
||||
builder: (ctx) {
|
||||
return Stack(
|
||||
children: [
|
||||
// 点击空白处关闭
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: safeClose,
|
||||
child: Container(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
if (layerLink != null) ...[
|
||||
Positioned(
|
||||
width: _MenuDimensions.menuWidth,
|
||||
child: CompositedTransformFollower(
|
||||
link: layerLink,
|
||||
showWhenUnlinked: false,
|
||||
targetAnchor: Alignment.bottomCenter,
|
||||
followerAnchor: Alignment.topCenter,
|
||||
offset: const Offset(0, _MenuDimensions.anchorVerticalOffset), // 向下偏移
|
||||
child: BlocBuilder<AiConfigBloc, AiConfigState>(
|
||||
builder: (context, aiState) {
|
||||
return BlocBuilder<PublicModelsBloc, PublicModelsState>(
|
||||
builder: (context, publicState) {
|
||||
final allModels = _combineModels(aiState, publicState);
|
||||
// 结合当前屏幕高度动态限制菜单高度,避免超出屏幕导致无法滚动
|
||||
final screenH = MediaQuery.of(context).size.height;
|
||||
final double maxAllowableHeight = screenH - _MenuDimensions.screenSafeMargin;
|
||||
final menuHeight = _calculateMenuHeight(allModels, showSettingsButton, showAdjustAndGenerate, maxHeight)
|
||||
.clamp(0.0, maxAllowableHeight)
|
||||
.toDouble();
|
||||
return _buildMenuContainer(
|
||||
context,
|
||||
menuHeight,
|
||||
allModels,
|
||||
selectedModel,
|
||||
onModelSelected,
|
||||
showSettingsButton,
|
||||
showAdjustAndGenerate,
|
||||
novel,
|
||||
settings,
|
||||
settingGroups,
|
||||
snippets,
|
||||
chatConfig,
|
||||
onConfigChanged,
|
||||
safeClose
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
] else if (anchorRect != null) ...[
|
||||
BlocBuilder<AiConfigBloc, AiConfigState>(
|
||||
builder: (context, aiState) {
|
||||
return BlocBuilder<PublicModelsBloc, PublicModelsState>(
|
||||
builder: (context, publicState) {
|
||||
final allModels = _combineModels(aiState, publicState);
|
||||
// 结合当前屏幕高度动态限制菜单高度,避免超出屏幕导致无法滚动
|
||||
final screenH = MediaQuery.of(context).size.height;
|
||||
final double maxAllowableHeight = screenH - _MenuDimensions.screenSafeMargin;
|
||||
final menuHeight = _calculateMenuHeight(allModels, showSettingsButton, showAdjustAndGenerate, maxHeight)
|
||||
.clamp(0.0, maxAllowableHeight)
|
||||
.toDouble();
|
||||
return _buildPositionedMenu(
|
||||
context,
|
||||
anchorRect,
|
||||
menuHeight,
|
||||
allModels,
|
||||
selectedModel,
|
||||
onModelSelected,
|
||||
showSettingsButton,
|
||||
showAdjustAndGenerate,
|
||||
novel,
|
||||
settings,
|
||||
settingGroups,
|
||||
snippets,
|
||||
chatConfig,
|
||||
onConfigChanged,
|
||||
safeClose
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Overlay.of(context).insert(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/// 合并私有模型和公共模型
|
||||
static List<UnifiedAIModel> _combineModels(AiConfigState aiState, PublicModelsState publicState) {
|
||||
final List<UnifiedAIModel> allModels = [];
|
||||
|
||||
// 添加已验证的私有模型
|
||||
final validatedConfigs = aiState.validatedConfigs;
|
||||
for (final config in validatedConfigs) {
|
||||
allModels.add(PrivateAIModel(config));
|
||||
}
|
||||
|
||||
// 添加公共模型
|
||||
if (publicState is PublicModelsLoaded) {
|
||||
for (final publicModel in publicState.models) {
|
||||
allModels.add(PublicAIModel(publicModel));
|
||||
}
|
||||
}
|
||||
|
||||
return allModels;
|
||||
}
|
||||
|
||||
/// 按供应商分组模型,系统模型优先
|
||||
static Map<String, List<UnifiedAIModel>> _groupModelsByProvider(List<UnifiedAIModel> models) {
|
||||
final Map<String, List<UnifiedAIModel>> grouped = {};
|
||||
|
||||
for (var model in models) {
|
||||
final provider = model.provider;
|
||||
grouped.putIfAbsent(provider, () => []);
|
||||
grouped[provider]!.add(model);
|
||||
}
|
||||
|
||||
// 对每个供应商内的模型进行排序
|
||||
for (var list in grouped.values) {
|
||||
list.sort((a, b) {
|
||||
// 系统模型(公共模型)优先
|
||||
if (a.isPublic && !b.isPublic) return -1;
|
||||
if (!a.isPublic && b.isPublic) return 1;
|
||||
|
||||
// 如果都是公共模型,按优先级排序
|
||||
if (a.isPublic && b.isPublic) {
|
||||
final aPriority = (a as PublicAIModel).publicConfig.priority ?? 0;
|
||||
final bPriority = (b as PublicAIModel).publicConfig.priority ?? 0;
|
||||
if (aPriority != bPriority) {
|
||||
return bPriority.compareTo(aPriority); // 优先级高的在前
|
||||
}
|
||||
}
|
||||
|
||||
// 如果都是私有模型,默认配置在前
|
||||
if (!a.isPublic && !b.isPublic) {
|
||||
final aIsDefault = (a as PrivateAIModel).userConfig.isDefault;
|
||||
final bIsDefault = (b as PrivateAIModel).userConfig.isDefault;
|
||||
if (aIsDefault && !bIsDefault) return -1;
|
||||
if (!aIsDefault && bIsDefault) return 1;
|
||||
}
|
||||
|
||||
return a.displayName.compareTo(b.displayName);
|
||||
});
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/// 计算菜单高度
|
||||
static double _calculateMenuHeight(
|
||||
List<UnifiedAIModel> models,
|
||||
bool showSettingsButton,
|
||||
bool showAdjustAndGenerate,
|
||||
double maxHeight,
|
||||
) {
|
||||
final grouped = _groupModelsByProvider(models);
|
||||
int totalItems = models.length;
|
||||
final bool hasPrivateModels = models.any((m) => !m.isPublic);
|
||||
final double addButtonHeight = showSettingsButton && !hasPrivateModels
|
||||
? (_BottomActionDimensions.secondaryButtonHeight + 8.0)
|
||||
: 0.0;
|
||||
final double adjustButtonHeight = showSettingsButton && showAdjustAndGenerate
|
||||
? _ContentDimensions.bottomButtonHeight
|
||||
: 0.0;
|
||||
final double contentHeight =
|
||||
(grouped.length * _ContentDimensions.groupHeaderHeight) +
|
||||
(totalItems * _ContentDimensions.modelItemHeight) +
|
||||
addButtonHeight +
|
||||
adjustButtonHeight +
|
||||
(_ContentDimensions.verticalPadding * 2);
|
||||
final double minHeight = showSettingsButton
|
||||
? _MenuDimensions.minHeightWithSettings
|
||||
: _MenuDimensions.minHeightWithoutSettings;
|
||||
return contentHeight.clamp(minHeight, maxHeight);
|
||||
}
|
||||
|
||||
static Widget _buildMenuContainer(
|
||||
BuildContext context,
|
||||
double menuHeight,
|
||||
List<UnifiedAIModel> models,
|
||||
UnifiedAIModel? selectedModel,
|
||||
Function(UnifiedAIModel?) onModelSelected,
|
||||
bool showSettingsButton,
|
||||
bool showAdjustAndGenerate,
|
||||
Novel? novel,
|
||||
List<NovelSettingItem> settings,
|
||||
List<SettingGroup> settingGroups,
|
||||
List<NovelSnippet> snippets,
|
||||
UniversalAIRequest? chatConfig,
|
||||
ValueChanged<UniversalAIRequest>? onConfigChanged,
|
||||
VoidCallback onClose,
|
||||
) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
return Material(
|
||||
elevation: isDark ? _MenuStyling.elevationDark : _MenuStyling.elevationLight,
|
||||
borderRadius: BorderRadius.circular(_MenuStyling.menuBorderRadius),
|
||||
color: isDark
|
||||
? Theme.of(context).colorScheme.surfaceContainer.withOpacity(0.95)
|
||||
: Theme.of(context).colorScheme.surfaceContainer,
|
||||
shadowColor: Colors.black.withOpacity(isDark ? 0.3 : 0.15),
|
||||
child: Container(
|
||||
height: menuHeight,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(_MenuStyling.menuBorderRadius),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant.withOpacity(isDark ? 0.2 : 0.3),
|
||||
width: _MenuStyling.menuBorderWidth,
|
||||
),
|
||||
),
|
||||
child: _UnifiedMenuContent(
|
||||
models: models,
|
||||
selectedModel: selectedModel,
|
||||
onModelSelected: onModelSelected,
|
||||
onClose: onClose,
|
||||
showSettingsButton: showSettingsButton,
|
||||
showAdjustAndGenerate: showAdjustAndGenerate,
|
||||
novel: novel,
|
||||
settings: settings,
|
||||
settingGroups: settingGroups,
|
||||
snippets: snippets,
|
||||
chatConfig: chatConfig,
|
||||
onConfigChanged: onConfigChanged,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _buildPositionedMenu(
|
||||
BuildContext context,
|
||||
Rect anchorRect,
|
||||
double menuHeight,
|
||||
List<UnifiedAIModel> models,
|
||||
UnifiedAIModel? selectedModel,
|
||||
Function(UnifiedAIModel?) onModelSelected,
|
||||
bool showSettingsButton,
|
||||
bool showAdjustAndGenerate,
|
||||
Novel? novel,
|
||||
List<NovelSettingItem> settings,
|
||||
List<SettingGroup> settingGroups,
|
||||
List<NovelSnippet> snippets,
|
||||
UniversalAIRequest? chatConfig,
|
||||
ValueChanged<UniversalAIRequest>? onConfigChanged,
|
||||
VoidCallback onClose,
|
||||
) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
double left = anchorRect.left;
|
||||
if (left + _MenuDimensions.menuWidth > screenSize.width - _MenuDimensions.horizontalMargin) {
|
||||
left = screenSize.width - _MenuDimensions.menuWidth - _MenuDimensions.horizontalMargin;
|
||||
}
|
||||
|
||||
// 计算垂直放置位置,确保菜单完整显示在屏幕内
|
||||
double top = anchorRect.top - menuHeight - _MenuDimensions.anchorVerticalOffset; // 先尝试放在目标组件上方
|
||||
final double safeTop = MediaQuery.of(context).padding.top + 10;
|
||||
final double safeBottom = screenSize.height - 10;
|
||||
|
||||
// 如果上方空间不足则放到下方
|
||||
if (top < safeTop) {
|
||||
top = anchorRect.bottom + _MenuDimensions.anchorVerticalOffset;
|
||||
}
|
||||
|
||||
// 如果下方还是溢出,则将菜单整体上移
|
||||
if (top + menuHeight > safeBottom) {
|
||||
top = safeBottom - menuHeight;
|
||||
// 仍保证不碰到状态栏
|
||||
if (top < safeTop) {
|
||||
top = safeTop;
|
||||
}
|
||||
}
|
||||
|
||||
return Positioned(
|
||||
left: left,
|
||||
top: top,
|
||||
width: _MenuDimensions.menuWidth,
|
||||
child: _buildMenuContainer(
|
||||
context,
|
||||
menuHeight,
|
||||
models,
|
||||
selectedModel,
|
||||
onModelSelected,
|
||||
showSettingsButton,
|
||||
showAdjustAndGenerate,
|
||||
novel,
|
||||
settings,
|
||||
settingGroups,
|
||||
snippets,
|
||||
chatConfig,
|
||||
onConfigChanged,
|
||||
onClose,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------ 内部菜单内容 ------------------
|
||||
class _UnifiedMenuContent extends StatelessWidget {
|
||||
const _UnifiedMenuContent({
|
||||
Key? key,
|
||||
required this.models,
|
||||
required this.selectedModel,
|
||||
required this.onModelSelected,
|
||||
required this.onClose,
|
||||
required this.showSettingsButton,
|
||||
required this.showAdjustAndGenerate,
|
||||
this.novel,
|
||||
this.settings = const [],
|
||||
this.settingGroups = const [],
|
||||
this.snippets = const [],
|
||||
this.chatConfig,
|
||||
this.onConfigChanged,
|
||||
}) : super(key: key);
|
||||
|
||||
final List<UnifiedAIModel> models;
|
||||
final UnifiedAIModel? selectedModel;
|
||||
final Function(UnifiedAIModel?) onModelSelected;
|
||||
final VoidCallback onClose;
|
||||
final bool showSettingsButton;
|
||||
final bool showAdjustAndGenerate;
|
||||
final Novel? novel;
|
||||
final List<NovelSettingItem> settings;
|
||||
final List<SettingGroup> settingGroups;
|
||||
final List<NovelSnippet> snippets;
|
||||
final UniversalAIRequest? chatConfig;
|
||||
final ValueChanged<UniversalAIRequest>? onConfigChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (models.isEmpty) {
|
||||
return _buildEmpty(context);
|
||||
}
|
||||
|
||||
final grouped = UnifiedAIModelDropdown._groupModelsByProvider(models);
|
||||
final providers = grouped.keys.toList();
|
||||
|
||||
// 供应商排序:有系统模型的供应商优先
|
||||
providers.sort((a, b) {
|
||||
final aHasPublic = grouped[a]!.any((m) => m.isPublic);
|
||||
final bHasPublic = grouped[b]!.any((m) => m.isPublic);
|
||||
if (aHasPublic && !bHasPublic) return -1;
|
||||
if (!aHasPublic && bHasPublic) return 1;
|
||||
return a.compareTo(b);
|
||||
});
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: _ContentDimensions.horizontalPadding,
|
||||
vertical: _ContentDimensions.verticalPadding
|
||||
),
|
||||
itemCount: providers.length,
|
||||
separatorBuilder: (c, i) => Divider(
|
||||
height: _MenuStyling.dividerHeight,
|
||||
thickness: _MenuStyling.dividerThickness,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withOpacity(0.12),
|
||||
indent: _MenuStyling.dividerIndent,
|
||||
endIndent: _MenuStyling.dividerEndIndent,
|
||||
),
|
||||
itemBuilder: (c, index) {
|
||||
final provider = providers[index];
|
||||
final providerModels = grouped[provider]!;
|
||||
return _ProviderGroup(
|
||||
provider: provider,
|
||||
models: providerModels,
|
||||
selectedModel: selectedModel,
|
||||
onModelSelected: (m) {
|
||||
onModelSelected(m);
|
||||
onClose();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (showSettingsButton) _buildBottomActions(context),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmpty(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(_EmptyStateDimensions.emptyPadding),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.model_training_outlined,
|
||||
size: _EmptyStateDimensions.emptyIconSize, color: cs.onSurfaceVariant.withOpacity(0.5)),
|
||||
const SizedBox(height: _EmptyStateDimensions.emptyIconTextSpacing),
|
||||
Text('无可用模型',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(color: cs.onSurfaceVariant)),
|
||||
const SizedBox(height: _EmptyStateDimensions.emptyTitleSubtitleSpacing),
|
||||
Text('请先配置AI模型或等待公共模型加载',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: cs.onSurfaceVariant.withOpacity(0.7))),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomActions(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(_BottomActionDimensions.bottomPadding),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? cs.surface.withOpacity(0.8) : cs.surface,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: cs.outlineVariant.withOpacity(isDark ? 0.15 : 0.2),
|
||||
width: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!models.any((m) => !m.isPublic)) ...[
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
onClose();
|
||||
// 优先尝试编辑器内打开
|
||||
try {
|
||||
final layoutManager = Provider.of<EditorLayoutManager>(context, listen: false);
|
||||
layoutManager.toggleSettingsPanel();
|
||||
return;
|
||||
} catch (_) {}
|
||||
// 回退:列表页等环境直接弹出设置对话框
|
||||
final userId = AppConfig.userId;
|
||||
if (userId == null || userId.isEmpty) {
|
||||
TopToast.info(context, '请先登录后再添加私人模型');
|
||||
return;
|
||||
}
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (dialogContext) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: dialogContext.read<AiConfigBloc>()),
|
||||
],
|
||||
child: Dialog(
|
||||
insetPadding: const EdgeInsets.all(16),
|
||||
backgroundColor: Colors.transparent,
|
||||
child: SettingsPanel(
|
||||
stateManager: EditorStateManager(),
|
||||
userId: userId,
|
||||
onClose: () => Navigator.of(dialogContext).pop(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('添加我的私人模型'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
foregroundColor: isDark ? cs.primary.withOpacity(0.9) : cs.primary,
|
||||
side: BorderSide(color: cs.primary.withOpacity(isDark ? 0.2 : 0.3), width: _BottomActionDimensions.buttonBorderWidth),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(_BottomActionDimensions.buttonBorderRadius)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (showAdjustAndGenerate)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
onClose(); // 先关闭 Overlay
|
||||
// 只有选中私有模型时才能进入设置对话框
|
||||
UserAIModelConfigModel? userModel;
|
||||
if (selectedModel != null && !selectedModel!.isPublic) {
|
||||
userModel = (selectedModel as PrivateAIModel).userConfig;
|
||||
}
|
||||
showChatSettingsDialog(
|
||||
context,
|
||||
selectedModel: userModel,
|
||||
onModelChanged: (m) {
|
||||
if (m != null) {
|
||||
onModelSelected(PrivateAIModel(m));
|
||||
}
|
||||
},
|
||||
novel: novel,
|
||||
settings: settings,
|
||||
settingGroups: settingGroups,
|
||||
snippets: snippets,
|
||||
initialChatConfig: chatConfig,
|
||||
onConfigChanged: onConfigChanged,
|
||||
initialContextSelections: null, // 🚀 让ChatSettingsDialog自己构建上下文数据
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.tune_rounded, size: _BottomActionDimensions.buttonIconSize),
|
||||
label: const Text('调整并生成'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
foregroundColor: isDark ? cs.primary.withOpacity(0.9) : cs.primary,
|
||||
backgroundColor: isDark ? cs.primaryContainer.withOpacity(0.08) : cs.primaryContainer.withOpacity(0.1),
|
||||
padding: const EdgeInsets.symmetric(vertical: _BottomActionDimensions.buttonVerticalPadding),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(_BottomActionDimensions.buttonBorderRadius)),
|
||||
elevation: 0,
|
||||
side: BorderSide(color: cs.primary.withOpacity(isDark ? 0.2 : 0.3), width: _BottomActionDimensions.buttonBorderWidth),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 供应商分组组件
|
||||
class _ProviderGroup extends StatelessWidget {
|
||||
const _ProviderGroup({
|
||||
Key? key,
|
||||
required this.provider,
|
||||
required this.models,
|
||||
required this.selectedModel,
|
||||
required this.onModelSelected,
|
||||
}) : super(key: key);
|
||||
|
||||
final String provider;
|
||||
final List<UnifiedAIModel> models;
|
||||
final UnifiedAIModel? selectedModel;
|
||||
final Function(UnifiedAIModel?) onModelSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
// 检查是否有系统模型
|
||||
final hasPublicModels = models.any((m) => m.isPublic);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
hasPublicModels ? Icons.public : Icons.person_outline,
|
||||
size: 16,
|
||||
color: isDark ? cs.primary.withOpacity(0.8) : cs.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
provider.toUpperCase(),
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: isDark ? cs.primary.withOpacity(0.9) : cs.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${models.length}个',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: cs.onSurfaceVariant.withOpacity(0.7),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
...models.map((m) => _UnifiedModelItem(
|
||||
model: m,
|
||||
isSelected: selectedModel?.id == m.id,
|
||||
onTap: () => onModelSelected(m),
|
||||
)),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UnifiedModelItem extends StatelessWidget {
|
||||
const _UnifiedModelItem({
|
||||
Key? key,
|
||||
required this.model,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
}) : super(key: key);
|
||||
|
||||
final UnifiedAIModel model;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(_ModelItemDimensions.itemBorderRadius),
|
||||
splashColor: cs.primary.withOpacity(0.08),
|
||||
highlightColor: cs.primary.withOpacity(0.04),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: _ModelItemDimensions.itemMargin, vertical: 1.0),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: _ModelItemDimensions.itemHorizontalPadding,
|
||||
vertical: _ModelItemDimensions.itemVerticalPadding
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? (isDark
|
||||
? cs.primaryContainer.withOpacity(0.2)
|
||||
: cs.primaryContainer.withOpacity(0.15))
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(_ModelItemDimensions.itemBorderRadius),
|
||||
border: isSelected
|
||||
? Border.all(color: cs.primary.withOpacity(0.2), width: 1.0)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Icon
|
||||
Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: _getModelIcon(model.provider, context),
|
||||
),
|
||||
const SizedBox(width: _ModelItemDimensions.iconTextSpacing),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
model.displayName,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: isSelected
|
||||
? cs.primary
|
||||
: (isDark
|
||||
? cs.onSurface.withOpacity(0.9)
|
||||
: cs.onSurface),
|
||||
fontSize: 13,
|
||||
height: 1.2,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
// 显示所有标签
|
||||
if (model.modelTags.isNotEmpty) ...[
|
||||
const SizedBox(height: _TagDimensions.tagTopSpacing),
|
||||
Wrap(
|
||||
spacing: _TagDimensions.tagSpacing,
|
||||
runSpacing: _TagDimensions.tagRunSpacing,
|
||||
children: model.modelTags.map((tag) => _buildTag(tag, context)).toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isSelected)
|
||||
Icon(Icons.check_circle_rounded, size: _ModelItemDimensions.selectedIconSize, color: cs.primary),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTag(String tag, BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
Color tagColor;
|
||||
Color backgroundColor;
|
||||
Color borderColor;
|
||||
|
||||
if (tag == '私有') {
|
||||
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 (tag == '系统') {
|
||||
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 (tag == '推荐') {
|
||||
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 (tag == '免费') {
|
||||
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 (tag.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: _TagDimensions.tagHorizontalPadding,
|
||||
vertical: _TagDimensions.tagVerticalPadding
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(_TagDimensions.tagBorderRadius),
|
||||
border: Border.all(
|
||||
color: borderColor,
|
||||
width: _TagDimensions.tagBorderWidth,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
tag,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: tagColor.withOpacity(isDark ? 0.9 : 0.8),
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getModelIcon(String provider, BuildContext context) {
|
||||
final color = ProviderIcons.getProviderColor(provider);
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
return Container(
|
||||
width: _ModelItemDimensions.iconContainerSize,
|
||||
height: _ModelItemDimensions.iconContainerSize,
|
||||
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(provider, size: _ModelItemDimensions.iconSize, useHighQuality: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user