马良AI写作初始化仓库

This commit is contained in:
邓滨杰
2025-09-10 00:07:52 +08:00
parent 3c06bb1a03
commit 39c0f8840f
1309 changed files with 318528 additions and 0 deletions

View 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,
),
);
}
}