1762 lines
62 KiB
Dart
1762 lines
62 KiB
Dart
import 'dart:convert';
|
||
import 'dart:math' show max;
|
||
import 'package:ainoval/models/scene_beat_data.dart';
|
||
import 'package:ainoval/models/ai_request_models.dart';
|
||
import 'package:ainoval/models/context_selection_models.dart';
|
||
import 'package:ainoval/models/novel_structure.dart';
|
||
import 'package:ainoval/models/novel_setting_item.dart';
|
||
import 'package:ainoval/models/setting_group.dart';
|
||
import 'package:ainoval/models/novel_snippet.dart';
|
||
import 'package:ainoval/models/unified_ai_model.dart';
|
||
import 'package:ainoval/models/preset_models.dart';
|
||
import 'package:ainoval/models/user_ai_model_config_model.dart';
|
||
import 'package:ainoval/widgets/common/unified_ai_model_dropdown.dart';
|
||
import 'package:ainoval/widgets/common/top_toast.dart';
|
||
import 'package:ainoval/widgets/common/context_selection_dropdown_menu_anchor.dart';
|
||
import 'package:ainoval/blocs/ai_config/ai_config_bloc.dart';
|
||
import 'package:ainoval/blocs/public_models/public_models_bloc.dart';
|
||
import 'package:ainoval/blocs/universal_ai/universal_ai_bloc.dart';
|
||
import 'package:ainoval/blocs/universal_ai/universal_ai_event.dart';
|
||
import 'package:ainoval/blocs/universal_ai/universal_ai_state.dart';
|
||
import 'package:ainoval/config/app_config.dart';
|
||
import 'package:ainoval/utils/logger.dart';
|
||
import 'package:ainoval/utils/context_selection_helper.dart';
|
||
import 'package:ainoval/utils/quill_helper.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||
import 'package:ainoval/screens/editor/components/scene_beat_edit_dialog.dart';
|
||
import 'package:ainoval/screens/editor/components/ai_dialog_common_logic.dart';
|
||
import 'package:ainoval/widgets/common/preset_dropdown_button.dart';
|
||
import 'package:ainoval/utils/web_theme.dart';
|
||
import 'package:collection/collection.dart';
|
||
|
||
/// Overlay版本的场景节拍面板
|
||
/// 固定在屏幕左侧中间位置,与滚动内容解耦
|
||
class OverlaySceneBeatPanel extends StatefulWidget {
|
||
const OverlaySceneBeatPanel({
|
||
super.key,
|
||
required this.sceneId,
|
||
required this.data,
|
||
this.novel,
|
||
this.settings = const [],
|
||
this.settingGroups = const [],
|
||
this.snippets = const [],
|
||
this.onDataChanged,
|
||
this.onGenerate,
|
||
this.onClose,
|
||
});
|
||
|
||
final String sceneId;
|
||
final SceneBeatData data;
|
||
final Novel? novel;
|
||
final List<NovelSettingItem> settings;
|
||
final List<SettingGroup> settingGroups;
|
||
final List<NovelSnippet> snippets;
|
||
final ValueChanged<SceneBeatData>? onDataChanged;
|
||
final Function(UniversalAIRequest, UnifiedAIModel)? onGenerate;
|
||
final VoidCallback? onClose;
|
||
|
||
@override
|
||
State<OverlaySceneBeatPanel> createState() => _OverlaySceneBeatPanelState();
|
||
}
|
||
|
||
class _OverlaySceneBeatPanelState extends State<OverlaySceneBeatPanel>
|
||
with SingleTickerProviderStateMixin, AIDialogCommonLogic {
|
||
bool _isExpanded = false;
|
||
|
||
OverlayEntry? _tempOverlay;
|
||
late TextEditingController _quickInstructionsController;
|
||
late TextEditingController _customLengthController;
|
||
late AnimationController _animationController;
|
||
late Animation<double> _widthAnimation;
|
||
late Animation<double> _fadeAnimation;
|
||
late String _currentLength;
|
||
AIPromptPreset? _currentPreset;
|
||
late ContextSelectionData _contextData;
|
||
bool _skipNextContextRebuild = false; // 🚀 本地更新后跳过一次重建
|
||
bool _includeCurrentSceneAsInput = true; // 🚀 默认将当前场景摘要与内容作为输入
|
||
|
||
// 🚀 新增:缓存布局计算结果,避免频繁重建
|
||
double? _cachedLeft;
|
||
double? _cachedTop;
|
||
double? _cachedScreenWidth;
|
||
double? _cachedScreenHeight;
|
||
double? _cachedPanelWidth; // 🚀 新增:缓存面板宽度
|
||
|
||
UnifiedAIModel? _selectedUnifiedModel;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_initializeAnimations();
|
||
_loadSelectedModel();
|
||
_initializeQuickInstructions();
|
||
_currentLength = widget.data.selectedLength ?? '400';
|
||
_customLengthController = TextEditingController(text: _currentLength);
|
||
_contextData = _createContextData();
|
||
_persistDefaultContextIfNeeded();
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(OverlaySceneBeatPanel oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
|
||
// 场景切换时同步配置
|
||
if (oldWidget.sceneId != widget.sceneId) {
|
||
AppLogger.i('OverlaySceneBeatPanel', '场景切换: ${oldWidget.sceneId} -> ${widget.sceneId}');
|
||
_syncConfigFromData();
|
||
// 🚀 清除缓存,强制重新计算位置
|
||
_clearLayoutCache();
|
||
}
|
||
|
||
// 🚀 优化:只在关键数据变化时才同步配置
|
||
if (_shouldSyncConfig(oldWidget.data, widget.data)) {
|
||
_syncConfigFromData();
|
||
}
|
||
|
||
// 仅当依赖发生变化时才重建上下文数据
|
||
if (_shouldRebuildContextData(oldWidget)) {
|
||
setState(() {
|
||
_contextData = _createContextData();
|
||
});
|
||
}
|
||
}
|
||
|
||
/// 🚀 判断是否需要同步配置(避免无意义的同步)
|
||
bool _shouldSyncConfig(SceneBeatData oldData, SceneBeatData newData) {
|
||
return oldData.selectedUnifiedModelId != newData.selectedUnifiedModelId ||
|
||
oldData.selectedLength != newData.selectedLength ||
|
||
oldData.requestData != newData.requestData;
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_animationController.dispose();
|
||
_tempOverlay?.remove();
|
||
_quickInstructionsController.dispose();
|
||
_customLengthController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
/// 🚀 清除布局缓存
|
||
void _clearLayoutCache() {
|
||
_cachedLeft = null;
|
||
_cachedTop = null;
|
||
_cachedScreenWidth = null;
|
||
_cachedScreenHeight = null;
|
||
_cachedPanelWidth = null; // 🚀 清除面板宽度缓存
|
||
}
|
||
|
||
/// 🚀 计算布局位置(带缓存,保持原有定位逻辑不变)
|
||
({double left, double top}) _calculatePosition(BuildContext context, double panelWidth) {
|
||
final screenWidth = MediaQuery.of(context).size.width;
|
||
final screenHeight = MediaQuery.of(context).size.height;
|
||
|
||
// 🚀 缓存检查:屏幕尺寸和面板宽度都没变化时才使用缓存
|
||
if (_cachedScreenWidth == screenWidth &&
|
||
_cachedScreenHeight == screenHeight &&
|
||
_cachedPanelWidth == panelWidth &&
|
||
_cachedLeft != null &&
|
||
_cachedTop != null) {
|
||
return (left: _cachedLeft!, top: _cachedTop!);
|
||
}
|
||
|
||
// ===== 保持原有定位逻辑完全不变 =====
|
||
const double _kMaxContentWidth = 1100.0; // 与编辑器中心内容宽度保持一致
|
||
const double _kMargin = 20.0; // 与内容之间的间距
|
||
const double _kMinLeft = 280.0; // 左侧边栏宽度,避免遮挡
|
||
final double leftSpace = (screenWidth - _kMaxContentWidth) / 2;
|
||
double computedLeft = _kMargin;
|
||
if (leftSpace > panelWidth + _kMargin) {
|
||
computedLeft = leftSpace - panelWidth - _kMargin;
|
||
}
|
||
|
||
// 确保不会覆盖左侧边栏
|
||
computedLeft = max(computedLeft, _kMinLeft);
|
||
|
||
final double computedTop = screenHeight * 0.4;
|
||
|
||
// 🚀 缓存计算结果(包括面板宽度)
|
||
_cachedLeft = computedLeft;
|
||
_cachedTop = computedTop;
|
||
_cachedScreenWidth = screenWidth;
|
||
_cachedScreenHeight = screenHeight;
|
||
_cachedPanelWidth = panelWidth;
|
||
|
||
return (left: computedLeft, top: computedTop);
|
||
}
|
||
|
||
void _initializeAnimations() {
|
||
_animationController = AnimationController(
|
||
duration: const Duration(milliseconds: 300),
|
||
vsync: this,
|
||
);
|
||
|
||
_widthAnimation = Tween<double>(
|
||
begin: 120.0,
|
||
end: 360.0,
|
||
).animate(CurvedAnimation(
|
||
parent: _animationController,
|
||
curve: Curves.easeInOut,
|
||
));
|
||
|
||
_fadeAnimation = Tween<double>(
|
||
begin: 0.0,
|
||
end: 1.0,
|
||
).animate(CurvedAnimation(
|
||
parent: _animationController,
|
||
curve: const Interval(0.5, 1.0, curve: Curves.easeInOut),
|
||
));
|
||
}
|
||
|
||
void _initializeQuickInstructions() {
|
||
final parsedRequest = widget.data.parsedRequest;
|
||
_quickInstructionsController = TextEditingController(
|
||
text: parsedRequest?.instructions ?? '一个关键时刻,重要的事情发生改变,推动故事发展。',
|
||
);
|
||
}
|
||
|
||
void _syncConfigFromData() {
|
||
final parsedRequest = widget.data.parsedRequest;
|
||
if (parsedRequest?.instructions != null &&
|
||
parsedRequest!.instructions != _quickInstructionsController.text) {
|
||
_quickInstructionsController.text = parsedRequest.instructions!;
|
||
}
|
||
|
||
if (widget.data.selectedUnifiedModelId != null &&
|
||
widget.data.selectedUnifiedModelId!.isNotEmpty &&
|
||
_selectedUnifiedModel?.id != widget.data.selectedUnifiedModelId) {
|
||
_loadSelectedModel();
|
||
}
|
||
|
||
if (widget.data.selectedLength != null &&
|
||
widget.data.selectedLength != _currentLength) {
|
||
setState(() {
|
||
_currentLength = widget.data.selectedLength!;
|
||
if (_customLengthController.text != _currentLength) {
|
||
_customLengthController.text = _currentLength;
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
void _loadSelectedModel() {
|
||
final modelId = widget.data.selectedUnifiedModelId;
|
||
if (modelId == null || modelId.isEmpty) {
|
||
AppLogger.d('OverlaySceneBeatPanel', '没有保存的模型ID,跳过加载');
|
||
return;
|
||
}
|
||
|
||
AppLogger.d('OverlaySceneBeatPanel', '尝试加载模型ID: $modelId');
|
||
|
||
final unifiedModel = _findUnifiedModelById(modelId);
|
||
if (unifiedModel != null) {
|
||
AppLogger.d('OverlaySceneBeatPanel', '成功加载模型: ${unifiedModel.displayName}');
|
||
setState(() {
|
||
_selectedUnifiedModel = unifiedModel;
|
||
});
|
||
} else {
|
||
AppLogger.w('OverlaySceneBeatPanel', '未找到ID=$modelId 对应的模型');
|
||
}
|
||
}
|
||
|
||
UnifiedAIModel? _findUnifiedModelById(String id) {
|
||
AppLogger.d('OverlaySceneBeatPanel', '查找模型ID: $id');
|
||
|
||
// 1. 私有模型(用户配置)
|
||
try {
|
||
final aiConfigState = context.read<AiConfigBloc>().state;
|
||
AppLogger.d('OverlaySceneBeatPanel',
|
||
'搜索私有模型,可用配置数量: ${aiConfigState.configs.length}');
|
||
final privateConfig = aiConfigState.configs.firstWhereOrNull(
|
||
(c) => c.id == id,
|
||
);
|
||
if (privateConfig != null) {
|
||
AppLogger.d('OverlaySceneBeatPanel', '在私有模型中找到: ${privateConfig.name}');
|
||
return PrivateAIModel(privateConfig);
|
||
}
|
||
} catch (e) {
|
||
AppLogger.e('OverlaySceneBeatPanel', '读取 AiConfigBloc 失败或未找到私有模型: $e');
|
||
}
|
||
|
||
// 2. 公共模型
|
||
try {
|
||
final publicState = context.read<PublicModelsBloc>().state;
|
||
AppLogger.d('OverlaySceneBeatPanel', '搜索公共模型,状态类型: ${publicState.runtimeType}');
|
||
if (publicState is PublicModelsLoaded) {
|
||
AppLogger.d('OverlaySceneBeatPanel',
|
||
'搜索公共模型,可用模型数量: ${publicState.models.length}');
|
||
final publicModel = publicState.models.firstWhereOrNull(
|
||
(m) => m.id == id,
|
||
);
|
||
if (publicModel != null) {
|
||
AppLogger.d('OverlaySceneBeatPanel', '在公共模型中找到: ${publicModel.displayName}');
|
||
return PublicAIModel(publicModel);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
AppLogger.e('OverlaySceneBeatPanel', '读取 PublicModelsBloc 失败或未找到公共模型: $e');
|
||
}
|
||
|
||
AppLogger.w('OverlaySceneBeatPanel', '未找到ID为 $id 的模型');
|
||
return null;
|
||
}
|
||
|
||
void _toggleExpanded() {
|
||
setState(() {
|
||
_isExpanded = !_isExpanded;
|
||
});
|
||
|
||
if (_isExpanded) {
|
||
_animationController.forward();
|
||
} else {
|
||
_animationController.reverse();
|
||
}
|
||
|
||
// 🚀 展开/折叠时清除位置缓存
|
||
_clearLayoutCache();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
// 🚀 降低日志频率:仅在状态变化时记录,避免生成期间的频繁日志
|
||
if (widget.data.status != SceneBeatStatus.generating) {
|
||
AppLogger.d('OverlaySceneBeatPanel',
|
||
'构建场景节拍面板 - 场景: ${widget.sceneId}, 状态: ${widget.data.status.name}, 可生成: ${widget.data.status.canGenerate}, 已选择模型: ${_selectedUnifiedModel?.displayName ?? "无"}');
|
||
}
|
||
|
||
// 🚀 如果是生成状态且面板是折叠的,使用静态构建避免频繁重建
|
||
if (widget.data.status == SceneBeatStatus.generating && !_isExpanded) {
|
||
return _buildStaticCollapsedPanel(context);
|
||
}
|
||
|
||
return AnimatedBuilder(
|
||
animation: _widthAnimation,
|
||
builder: (context, _) {
|
||
final panelWidth = _widthAnimation.value.clamp(120.0, 360.0); // 🚀 限制面板最小/最大宽度
|
||
final position = _calculatePosition(context, panelWidth);
|
||
|
||
return Positioned(
|
||
left: position.left,
|
||
top: position.top,
|
||
child: Material(
|
||
elevation: 8,
|
||
borderRadius: BorderRadius.circular(12),
|
||
shadowColor: Colors.black.withOpacity(0.3),
|
||
child: Container(
|
||
width: panelWidth,
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context).colorScheme.surface,
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(
|
||
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
||
),
|
||
),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(12),
|
||
child: _isExpanded ? _buildExpandedContent() : _buildCollapsedContent(),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
/// 🚀 构建静态的折叠面板(避免动画重建)
|
||
Widget _buildStaticCollapsedPanel(BuildContext context) {
|
||
final position = _calculatePosition(context, 120.0); // 折叠状态固定宽度
|
||
|
||
return Positioned(
|
||
left: position.left,
|
||
top: position.top,
|
||
child: Material(
|
||
elevation: 8,
|
||
borderRadius: BorderRadius.circular(12),
|
||
shadowColor: Colors.black.withOpacity(0.3),
|
||
child: Container(
|
||
width: 120,
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context).colorScheme.surface,
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(
|
||
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
||
),
|
||
),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(12),
|
||
child: _buildCollapsedContent(),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildCollapsedContent() {
|
||
return InkWell(
|
||
onTap: _toggleExpanded,
|
||
borderRadius: BorderRadius.circular(12),
|
||
child: Container(
|
||
width: 120,
|
||
height: 40,
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(
|
||
Icons.auto_stories,
|
||
size: 16,
|
||
color: Theme.of(context).colorScheme.primary,
|
||
),
|
||
const SizedBox(width: 6),
|
||
Flexible(
|
||
child: Text(
|
||
'场景节拍',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildExpandedContent() {
|
||
return Container(
|
||
width: 360,
|
||
constraints: const BoxConstraints(maxHeight: 600),
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
// 标题栏
|
||
_buildHeader(),
|
||
const SizedBox(height: 12),
|
||
|
||
// 内容区域
|
||
Flexible(
|
||
child: FadeTransition(
|
||
opacity: _fadeAnimation,
|
||
child: SingleChildScrollView(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
|
||
// 预设选择和编辑按钮
|
||
_buildPresetAndEditRow(),
|
||
const SizedBox(height: 12),
|
||
|
||
// 快速指令输入框
|
||
_buildQuickInstructionsField(),
|
||
const SizedBox(height: 12),
|
||
|
||
// 🚀 勾选:将当前场景摘要与内容作为输入
|
||
_buildIncludeCurrentSceneToggle(),
|
||
const SizedBox(height: 12),
|
||
|
||
// 上下文选择组件
|
||
_buildContextSelectionField(),
|
||
const SizedBox(height: 12),
|
||
|
||
// 字数单独一排(含自定义输入)
|
||
_buildLengthRow(),
|
||
const SizedBox(height: 12),
|
||
|
||
// 模型与发送在一行
|
||
_buildModelGenerateRow(),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildIncludeCurrentSceneToggle() {
|
||
return Row(
|
||
children: [
|
||
SizedBox(
|
||
width: 18,
|
||
height: 18,
|
||
child: Checkbox(
|
||
value: _includeCurrentSceneAsInput,
|
||
onChanged: (val) {
|
||
setState(() {
|
||
_includeCurrentSceneAsInput = val ?? true;
|
||
});
|
||
},
|
||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
'将当前场景摘要与内容作为输入(selectedText)',
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
fontSize: 11,
|
||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
|
||
),
|
||
),
|
||
)
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildHeader() {
|
||
return Row(
|
||
children: [
|
||
Icon(
|
||
Icons.auto_stories,
|
||
color: Theme.of(context).colorScheme.primary,
|
||
size: 20,
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
'场景节拍',
|
||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
// 关闭按钮
|
||
IconButton(
|
||
onPressed: widget.onClose,
|
||
icon: const Icon(Icons.close, size: 18),
|
||
iconSize: 18,
|
||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||
padding: EdgeInsets.zero,
|
||
style: IconButton.styleFrom(
|
||
backgroundColor: Colors.transparent,
|
||
foregroundColor: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||
),
|
||
),
|
||
const SizedBox(width: 4),
|
||
// 折叠按钮
|
||
IconButton(
|
||
onPressed: _toggleExpanded,
|
||
icon: const Icon(Icons.keyboard_arrow_left, size: 18),
|
||
iconSize: 18,
|
||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||
padding: EdgeInsets.zero,
|
||
style: IconButton.styleFrom(
|
||
backgroundColor: Colors.transparent,
|
||
foregroundColor: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildPresetAndEditRow() {
|
||
return Row(
|
||
children: [
|
||
// 预设选择器部分
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'预设',
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
fontSize: 11,
|
||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
PresetDropdownButton(
|
||
featureType: 'SCENE_BEAT_GENERATION',
|
||
currentPreset: _currentPreset,
|
||
onPresetSelected: _handlePresetSelected,
|
||
onCreatePreset: _handleCreatePreset,
|
||
onManagePresets: _showManagePresetsPage,
|
||
novelId: widget.novel?.id,
|
||
label: '选择预设',
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
const SizedBox(width: 12),
|
||
|
||
// 编辑按钮部分
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'详细配置',
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
fontSize: 11,
|
||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
TextButton.icon(
|
||
onPressed: _showEditDialog,
|
||
icon: Icon(
|
||
Icons.edit,
|
||
size: 14,
|
||
color: WebTheme.getSecondaryTextColor(context),
|
||
),
|
||
label: Text(
|
||
'修改详细设置',
|
||
style: WebTheme.labelSmall.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
color: WebTheme.getSecondaryTextColor(context),
|
||
),
|
||
),
|
||
style: TextButton.styleFrom(
|
||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||
minimumSize: Size.zero,
|
||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||
foregroundColor: WebTheme.getSecondaryTextColor(context),
|
||
backgroundColor: Colors.transparent,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildContextSelectionField() {
|
||
// 🚀 使用缓存的上下文数据,避免重复计算
|
||
final contextData = _contextData;
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'上下文',
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
fontSize: 11,
|
||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0),
|
||
decoration: BoxDecoration(
|
||
border: Border.all(
|
||
color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
|
||
),
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: Wrap(
|
||
spacing: 8,
|
||
runSpacing: 8,
|
||
crossAxisAlignment: WrapCrossAlignment.center,
|
||
children: [
|
||
// 🚀 优化:减少条件检查和组件重建
|
||
if (ContextSelectionHelper.validateContextData(contextData))
|
||
ContextSelectionDropdownBuilder.buildMenuAnchor(
|
||
data: contextData,
|
||
onSelectionChanged: (newData) {
|
||
final updatedData = ContextSelectionHelper.handleSelectionChanged(
|
||
contextData,
|
||
newData,
|
||
);
|
||
_updateContextData(updatedData);
|
||
},
|
||
placeholder: '+ 添加上下文',
|
||
maxHeight: 300,
|
||
// 通过 sceneId 反推当前章节用于初始滚动定位
|
||
initialChapterId: _getActiveChapterId(),
|
||
initialSceneId: widget.sceneId,
|
||
)
|
||
else
|
||
Container(
|
||
height: 32,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context)
|
||
.colorScheme
|
||
.surfaceContainerHighest
|
||
.withOpacity(0.5),
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(
|
||
color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
|
||
),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
Icons.warning_amber,
|
||
size: 14,
|
||
color: Theme.of(context)
|
||
.colorScheme
|
||
.onSurface
|
||
.withOpacity(0.5),
|
||
),
|
||
const SizedBox(width: 6),
|
||
Text(
|
||
'上下文数据无效',
|
||
style: TextStyle(
|
||
fontSize: 10,
|
||
color: Theme.of(context)
|
||
.colorScheme
|
||
.onSurface
|
||
.withOpacity(0.7),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
// 🚀 已选择的上下文项目(优化渲染)
|
||
...contextData.selectedItems.values.map<Widget>((item) {
|
||
return Container(
|
||
height: 32,
|
||
constraints: const BoxConstraints(maxWidth: 150),
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context)
|
||
.colorScheme
|
||
.surfaceContainerHighest
|
||
.withOpacity(0.75),
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(
|
||
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
||
),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
item.type.icon,
|
||
size: 14,
|
||
color: Theme.of(context)
|
||
.colorScheme
|
||
.onSurface
|
||
.withOpacity(0.7),
|
||
),
|
||
const SizedBox(width: 6),
|
||
Flexible(
|
||
child: Text(
|
||
item.title,
|
||
style: TextStyle(
|
||
fontSize: 10,
|
||
fontWeight: FontWeight.w500,
|
||
color: Theme.of(context).colorScheme.onSurface,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
const SizedBox(width: 4),
|
||
InkWell(
|
||
onTap: () {
|
||
final newData = contextData.deselectItem(item.id);
|
||
_updateContextData(newData);
|
||
},
|
||
borderRadius: BorderRadius.circular(8),
|
||
child: Container(
|
||
padding: const EdgeInsets.all(2),
|
||
child: Icon(
|
||
Icons.close,
|
||
size: 12,
|
||
color: Theme.of(context)
|
||
.colorScheme
|
||
.onSurface
|
||
.withOpacity(0.5),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}).toList(),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildQuickInstructionsField() {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'指令',
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
fontSize: 11,
|
||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Container(
|
||
height: 60,
|
||
decoration: BoxDecoration(
|
||
border: Border.all(
|
||
color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
|
||
),
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: TextField(
|
||
controller: _quickInstructionsController,
|
||
maxLines: 3,
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(fontSize: 11),
|
||
decoration: InputDecoration(
|
||
hintText: '快速指令...',
|
||
hintStyle: TextStyle(
|
||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
|
||
fontSize: 11,
|
||
),
|
||
border: InputBorder.none,
|
||
contentPadding: const EdgeInsets.all(8),
|
||
),
|
||
onChanged: _updateQuickInstructions,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildLengthRow() {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'字数',
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
fontSize: 11,
|
||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
SingleChildScrollView(
|
||
scrollDirection: Axis.horizontal,
|
||
child: Row(
|
||
children: [
|
||
...['200', '400', '600'].asMap().entries.map((entry) {
|
||
final index = entry.key;
|
||
final length = entry.value;
|
||
final isSelected = _currentLength == length;
|
||
return GestureDetector(
|
||
onTap: () => _updateLength(length),
|
||
child: Container(
|
||
width: 50,
|
||
margin: EdgeInsets.only(right: index < 2 ? 6 : 8),
|
||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||
decoration: BoxDecoration(
|
||
color: isSelected
|
||
? Theme.of(context).colorScheme.primary.withOpacity(0.1)
|
||
: Colors.transparent,
|
||
border: Border.all(
|
||
color: isSelected
|
||
? Theme.of(context).colorScheme.primary
|
||
: Theme.of(context).colorScheme.outline.withOpacity(0.3),
|
||
),
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Text(
|
||
length,
|
||
textAlign: TextAlign.center,
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
fontSize: 11,
|
||
color: isSelected ? Theme.of(context).colorScheme.primary : null,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}).toList(),
|
||
|
||
// 自定义字数输入框
|
||
SizedBox(
|
||
width: 76,
|
||
child: TextField(
|
||
controller: _customLengthController,
|
||
keyboardType: TextInputType.number,
|
||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||
maxLines: 1,
|
||
textAlign: TextAlign.center,
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(fontSize: 11),
|
||
decoration: InputDecoration(
|
||
isDense: true,
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||
hintText: '自定义',
|
||
hintStyle: TextStyle(
|
||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.4),
|
||
fontSize: 11,
|
||
),
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(4),
|
||
borderSide: BorderSide(
|
||
color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
|
||
),
|
||
),
|
||
),
|
||
onSubmitted: _handleCustomLengthSubmitted,
|
||
onEditingComplete: () {
|
||
_handleCustomLengthSubmitted(_customLengthController.text);
|
||
},
|
||
),
|
||
),
|
||
const SizedBox(width: 6),
|
||
Text(
|
||
'字',
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(fontSize: 11),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildModelGenerateRow() {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'模型 & 生成',
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
fontSize: 11,
|
||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
_buildModelGenerateButton(),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildModelGenerateButton() {
|
||
return Container(
|
||
height: 36,
|
||
decoration: BoxDecoration(
|
||
border: Border.all(
|
||
color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
|
||
),
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
// 模型选择部分
|
||
Expanded(
|
||
child: GestureDetector(
|
||
onTap: () {
|
||
AppLogger.d('OverlaySceneBeatPanel', '模型选择区域被点击!');
|
||
_showModelSelectorDropdown();
|
||
},
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context)
|
||
.colorScheme
|
||
.surfaceContainerHighest
|
||
.withOpacity(0.3),
|
||
borderRadius: const BorderRadius.only(
|
||
topLeft: Radius.circular(5),
|
||
bottomLeft: Radius.circular(5),
|
||
),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(
|
||
Icons.smart_toy,
|
||
size: 16,
|
||
color: Theme.of(context)
|
||
.colorScheme
|
||
.onSurface
|
||
.withOpacity(0.6),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
_selectedUnifiedModel?.displayName ?? '选择模型',
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
fontSize: 12,
|
||
),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
// 生成按钮部分
|
||
Container(
|
||
width: 40,
|
||
height: 36,
|
||
decoration: BoxDecoration(
|
||
color: widget.data.status.canGenerate
|
||
? Theme.of(context).colorScheme.primary
|
||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||
borderRadius: const BorderRadius.only(
|
||
topRight: Radius.circular(5),
|
||
bottomRight: Radius.circular(5),
|
||
),
|
||
),
|
||
child: Material(
|
||
color: Colors.transparent,
|
||
child: InkWell(
|
||
onTap: widget.data.status.canGenerate
|
||
? () {
|
||
AppLogger.d('OverlaySceneBeatPanel',
|
||
'生成按钮被点击!状态: ${widget.data.status.name}');
|
||
_handleGenerate();
|
||
}
|
||
: () {
|
||
AppLogger.w('OverlaySceneBeatPanel',
|
||
'生成按钮被点击但状态不允许生成: ${widget.data.status.name}');
|
||
},
|
||
borderRadius: const BorderRadius.only(
|
||
topRight: Radius.circular(5),
|
||
bottomRight: Radius.circular(5),
|
||
),
|
||
child: Icon(
|
||
Icons.arrow_forward,
|
||
size: 16,
|
||
color: widget.data.status.canGenerate
|
||
? Colors.white
|
||
: Theme.of(context)
|
||
.colorScheme
|
||
.onSurface
|
||
.withOpacity(0.3),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
void _handlePresetSelected(AIPromptPreset preset) {
|
||
try {
|
||
setState(() {
|
||
_currentPreset = preset;
|
||
});
|
||
|
||
applyPresetToForm(
|
||
preset,
|
||
instructionsController: _quickInstructionsController,
|
||
onLengthChanged: (length) {
|
||
setState(() {
|
||
if (length != null && ['200', '400', '600'].contains(length)) {
|
||
_currentLength = length;
|
||
} else if (length != null) {
|
||
_currentLength = length; // 自定义长度作为当前值
|
||
}
|
||
});
|
||
// 同步到数据模型
|
||
final updated = widget.data.copyWith(
|
||
selectedLength: _currentLength,
|
||
updatedAt: DateTime.now(),
|
||
);
|
||
widget.onDataChanged?.call(updated);
|
||
},
|
||
onSmartContextChanged: (value) {
|
||
final updated = widget.data.copyWith(
|
||
enableSmartContext: value,
|
||
updatedAt: DateTime.now(),
|
||
);
|
||
widget.onDataChanged?.call(updated);
|
||
},
|
||
onPromptTemplateChanged: (templateId) {
|
||
final updated = widget.data.copyWith(
|
||
selectedPromptTemplateId: templateId,
|
||
updatedAt: DateTime.now(),
|
||
);
|
||
widget.onDataChanged?.call(updated);
|
||
},
|
||
onTemperatureChanged: (temperature) {
|
||
final updated = widget.data.copyWith(
|
||
temperature: temperature,
|
||
updatedAt: DateTime.now(),
|
||
);
|
||
widget.onDataChanged?.call(updated);
|
||
},
|
||
onTopPChanged: (topP) {
|
||
final updated = widget.data.copyWith(
|
||
topP: topP,
|
||
updatedAt: DateTime.now(),
|
||
);
|
||
widget.onDataChanged?.call(updated);
|
||
},
|
||
onContextSelectionChanged: (contextData) {
|
||
_updateContextData(contextData);
|
||
},
|
||
onModelChanged: (unifiedModel) {
|
||
setState(() {
|
||
_selectedUnifiedModel = unifiedModel;
|
||
});
|
||
if (unifiedModel != null) {
|
||
_updateModelSelection(unifiedModel);
|
||
}
|
||
},
|
||
currentContextData: _contextData,
|
||
);
|
||
|
||
// 同步指令到请求数据
|
||
_updateQuickInstructions(_quickInstructionsController.text);
|
||
|
||
// 记录最后使用的预设ID
|
||
final updatedWithPreset = widget.data.copyWith(
|
||
lastUsedPresetId: preset.presetId,
|
||
updatedAt: DateTime.now(),
|
||
);
|
||
widget.onDataChanged?.call(updatedWithPreset);
|
||
} catch (e) {
|
||
AppLogger.e('OverlaySceneBeatPanel', '应用预设失败', e);
|
||
TopToast.error(context, '应用预设失败: $e');
|
||
}
|
||
}
|
||
|
||
void _handleCreatePreset() {
|
||
// 基于当前 UI 构建请求
|
||
final request = _buildAIRequest();
|
||
if (request == null) {
|
||
TopToast.warning(context, '请先选择AI模型');
|
||
return;
|
||
}
|
||
showPresetNameDialog(request, onPresetCreated: (preset) {
|
||
setState(() {
|
||
_currentPreset = preset;
|
||
});
|
||
TopToast.success(context, '预设 "${preset.presetName}" 创建成功');
|
||
});
|
||
}
|
||
|
||
void _showManagePresetsPage() {
|
||
TopToast.info(context, '预设管理功能开发中...');
|
||
}
|
||
|
||
void _showModelSelectorDropdown() {
|
||
AppLogger.d('OverlaySceneBeatPanel', '显示模型选择器');
|
||
|
||
final renderBox = context.findRenderObject() as RenderBox?;
|
||
if (renderBox == null) {
|
||
AppLogger.w('OverlaySceneBeatPanel', '无法获取RenderBox');
|
||
return;
|
||
}
|
||
|
||
final position = renderBox.localToGlobal(Offset.zero);
|
||
final size = renderBox.size;
|
||
final anchorRect =
|
||
Rect.fromLTWH(position.dx, position.dy, size.width, size.height);
|
||
|
||
_tempOverlay?.remove();
|
||
|
||
AppLogger.d('OverlaySceneBeatPanel', '创建模型选择器下拉框');
|
||
|
||
_tempOverlay = UnifiedAIModelDropdown.show(
|
||
context: context,
|
||
anchorRect: anchorRect,
|
||
selectedModel: _selectedUnifiedModel,
|
||
onModelSelected: (unifiedModel) {
|
||
AppLogger.d('OverlaySceneBeatPanel',
|
||
'模型选择完成: ${unifiedModel?.displayName ?? "null"}');
|
||
setState(() {
|
||
_selectedUnifiedModel = unifiedModel;
|
||
});
|
||
_updateModelSelection(unifiedModel!);
|
||
},
|
||
showSettingsButton: true,
|
||
novel: widget.novel,
|
||
settings: widget.settings,
|
||
settingGroups: widget.settingGroups,
|
||
snippets: widget.snippets,
|
||
onClose: () {
|
||
AppLogger.d('OverlaySceneBeatPanel', '模型选择器已关闭');
|
||
_tempOverlay = null;
|
||
},
|
||
);
|
||
}
|
||
|
||
void _updateQuickInstructions(String value) {
|
||
final parsedRequest = widget.data.parsedRequest;
|
||
if (parsedRequest != null) {
|
||
final updatedRequest = UniversalAIRequest(
|
||
requestType: parsedRequest.requestType,
|
||
userId: parsedRequest.userId,
|
||
novelId: parsedRequest.novelId,
|
||
modelConfig: parsedRequest.modelConfig,
|
||
prompt: parsedRequest.prompt,
|
||
instructions: value,
|
||
contextSelections: parsedRequest.contextSelections,
|
||
enableSmartContext: parsedRequest.enableSmartContext,
|
||
parameters: parsedRequest.parameters,
|
||
metadata: parsedRequest.metadata,
|
||
);
|
||
|
||
final updatedData = widget.data.updateRequestData(updatedRequest);
|
||
widget.onDataChanged?.call(updatedData);
|
||
}
|
||
}
|
||
|
||
void _updateLength(String length) {
|
||
setState(() {
|
||
_currentLength = length;
|
||
if (_customLengthController.text != length) {
|
||
_customLengthController.text = length;
|
||
}
|
||
});
|
||
|
||
final updatedData = widget.data.copyWith(
|
||
selectedLength: length,
|
||
updatedAt: DateTime.now(),
|
||
);
|
||
widget.onDataChanged?.call(updatedData);
|
||
}
|
||
|
||
void _handleCustomLengthSubmitted(String value) {
|
||
final trimmed = value.trim();
|
||
if (trimmed.isEmpty) return;
|
||
final parsed = int.tryParse(trimmed);
|
||
if (parsed == null) return;
|
||
// 合理范围保护(50-5000),可根据需要调整
|
||
final clamped = parsed.clamp(50, 5000);
|
||
final finalValue = clamped.toString();
|
||
_updateLength(finalValue);
|
||
}
|
||
|
||
void _updateModelSelection(UnifiedAIModel model) {
|
||
AppLogger.d('OverlaySceneBeatPanel',
|
||
'更新模型选择: ${model.displayName} (ID: ${model.id})');
|
||
|
||
final updatedData = widget.data.copyWith(
|
||
selectedUnifiedModelId: model.id,
|
||
updatedAt: DateTime.now(),
|
||
);
|
||
|
||
AppLogger.d('OverlaySceneBeatPanel', '调用onDataChanged回调');
|
||
widget.onDataChanged?.call(updatedData);
|
||
|
||
AppLogger.d('OverlaySceneBeatPanel', '模型选择更新完成');
|
||
}
|
||
|
||
void _updateContextData(ContextSelectionData newData) {
|
||
setState(() {
|
||
_contextData = newData;
|
||
});
|
||
|
||
final updatedData = widget.data.copyWith(
|
||
contextSelectionsData: newData.selectedCount > 0
|
||
? jsonEncode({
|
||
'novelId': newData.novelId,
|
||
'selectedItems': newData.selectedItems.values
|
||
.map((item) => {
|
||
'id': item.id,
|
||
'title': item.title,
|
||
'type': item.type.value,
|
||
'metadata': item.metadata,
|
||
})
|
||
.toList(),
|
||
})
|
||
: null,
|
||
updatedAt: DateTime.now(),
|
||
);
|
||
// 🚀 标记:这是一次本地触发的上下文更新,下一次来自父组件的数据变更触发的上下文重建将被跳过
|
||
_skipNextContextRebuild = true;
|
||
widget.onDataChanged?.call(updatedData);
|
||
}
|
||
|
||
void _showEditDialog() {
|
||
showSceneBeatEditDialog(
|
||
context,
|
||
data: widget.data,
|
||
novel: widget.novel,
|
||
settings: widget.settings,
|
||
settingGroups: widget.settingGroups,
|
||
snippets: widget.snippets,
|
||
selectedUnifiedModel: _selectedUnifiedModel,
|
||
onDataChanged: (updatedData) {
|
||
// 本地同步
|
||
setState(() {
|
||
_currentLength = updatedData.selectedLength ?? _currentLength;
|
||
if (_customLengthController.text != _currentLength) {
|
||
_customLengthController.text = _currentLength;
|
||
}
|
||
|
||
// 同步指令
|
||
final parsed = updatedData.parsedRequest;
|
||
if (parsed?.instructions != null) {
|
||
_quickInstructionsController.text = parsed!.instructions!;
|
||
}
|
||
|
||
// 同步模型
|
||
if (updatedData.selectedUnifiedModelId != null &&
|
||
updatedData.selectedUnifiedModelId != _selectedUnifiedModel?.id) {
|
||
_loadSelectedModel();
|
||
}
|
||
});
|
||
|
||
// 继续向上传递
|
||
widget.onDataChanged?.call(updatedData);
|
||
},
|
||
onGenerate: widget.onGenerate,
|
||
);
|
||
}
|
||
|
||
void _handleGenerate() async {
|
||
AppLogger.d('OverlaySceneBeatPanel', '开始生成处理流程');
|
||
|
||
if (_selectedUnifiedModel == null) {
|
||
AppLogger.w('OverlaySceneBeatPanel', '未选择AI模型');
|
||
TopToast.warning(context, '请先选择AI模型');
|
||
return;
|
||
}
|
||
|
||
AppLogger.d('OverlaySceneBeatPanel', '已选择模型: ${_selectedUnifiedModel!.displayName}');
|
||
|
||
// 构建AI请求
|
||
final request = _buildAIRequest();
|
||
if (request == null) {
|
||
AppLogger.e('OverlaySceneBeatPanel', '构建AI请求失败');
|
||
TopToast.error(context, '构建AI请求失败');
|
||
return;
|
||
}
|
||
|
||
AppLogger.d('OverlaySceneBeatPanel', 'AI请求构建成功: ${request.requestType}');
|
||
|
||
// 对于公共模型,先进行积分预估和确认
|
||
if (_selectedUnifiedModel!.isPublic) {
|
||
AppLogger.d('OverlaySceneBeatPanel',
|
||
'检测到公共模型,启动积分预估确认流程: ${_selectedUnifiedModel!.displayName}');
|
||
bool shouldProceed = await _showCreditEstimationAndConfirm(request);
|
||
if (!shouldProceed) {
|
||
AppLogger.d('OverlaySceneBeatPanel', '用户取消了积分预估确认,停止生成');
|
||
return;
|
||
}
|
||
AppLogger.d('OverlaySceneBeatPanel', '用户确认了积分预估,继续生成');
|
||
} else {
|
||
AppLogger.d('OverlaySceneBeatPanel',
|
||
'检测到私有模型,直接生成: ${_selectedUnifiedModel!.displayName}');
|
||
}
|
||
|
||
AppLogger.d('OverlaySceneBeatPanel', '开始调用onGenerate回调');
|
||
|
||
// 启动流式生成
|
||
widget.onGenerate?.call(request, _selectedUnifiedModel!);
|
||
|
||
AppLogger.d('OverlaySceneBeatPanel', '更新状态为生成中');
|
||
|
||
// 更新状态为生成中
|
||
final updatedData = widget.data.updateStatus(SceneBeatStatus.generating);
|
||
widget.onDataChanged?.call(updatedData);
|
||
|
||
AppLogger.d('OverlaySceneBeatPanel', '生成流程已启动');
|
||
}
|
||
|
||
UniversalAIRequest? _buildAIRequest() {
|
||
if (_selectedUnifiedModel == null) return null;
|
||
|
||
final parsedRequest = widget.data.parsedRequest;
|
||
final String? selectedText = _includeCurrentSceneAsInput
|
||
? _buildSelectedTextFromCurrentScene()
|
||
: null;
|
||
|
||
// 创建模型配置
|
||
late UserAIModelConfigModel modelConfig;
|
||
if (_selectedUnifiedModel!.isPublic) {
|
||
final publicModel = (_selectedUnifiedModel as PublicAIModel).publicConfig;
|
||
modelConfig = 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 {
|
||
modelConfig = (_selectedUnifiedModel as PrivateAIModel).userConfig;
|
||
}
|
||
|
||
return UniversalAIRequest(
|
||
requestType: AIRequestType.sceneBeat,
|
||
userId: AppConfig.userId ?? 'unknown',
|
||
novelId: widget.novel?.id,
|
||
chapterId: _getActiveChapterId(),
|
||
sceneId: widget.sceneId,
|
||
modelConfig: modelConfig,
|
||
prompt: parsedRequest?.prompt ?? '续写故事。',
|
||
selectedText: selectedText,
|
||
instructions: _quickInstructionsController.text.trim(),
|
||
contextSelections: widget.data.parsedContextSelections,
|
||
enableSmartContext: widget.data.enableSmartContext,
|
||
parameters: {
|
||
'length': _currentLength,
|
||
'temperature': widget.data.temperature,
|
||
'topP': widget.data.topP,
|
||
'maxTokens': 4000,
|
||
'modelName': _selectedUnifiedModel!.modelId,
|
||
'enableSmartContext': widget.data.enableSmartContext,
|
||
'promptTemplateId': widget.data.selectedPromptTemplateId,
|
||
},
|
||
metadata: {
|
||
'action': 'scene_beat',
|
||
'source': 'overlay_scene_beat_panel',
|
||
'featureType': 'SCENE_BEAT_GENERATION',
|
||
'modelName': _selectedUnifiedModel!.modelId,
|
||
'modelProvider': _selectedUnifiedModel!.provider,
|
||
'modelConfigId': _selectedUnifiedModel!.id,
|
||
'isPublicModel': _selectedUnifiedModel!.isPublic,
|
||
if (_selectedUnifiedModel!.isPublic)
|
||
'publicModelConfigId': (_selectedUnifiedModel as PublicAIModel).publicConfig.id,
|
||
if (_selectedUnifiedModel!.isPublic)
|
||
'publicModelId': (_selectedUnifiedModel as PublicAIModel).publicConfig.id,
|
||
},
|
||
);
|
||
}
|
||
|
||
String? _buildSelectedTextFromCurrentScene() {
|
||
try {
|
||
if (widget.novel == null || widget.sceneId.isEmpty) return null;
|
||
for (final act in widget.novel!.acts) {
|
||
for (final chapter in act.chapters) {
|
||
for (final scene in chapter.scenes) {
|
||
if (scene.id == widget.sceneId) {
|
||
final String summary = (scene.summary.content).toString();
|
||
final String plainContent = QuillHelper.deltaToText(scene.content);
|
||
final buffer = StringBuffer();
|
||
buffer.writeln('【当前场景摘要】');
|
||
buffer.writeln(summary.trim().isEmpty ? '(无摘要)' : summary.trim());
|
||
buffer.writeln();
|
||
buffer.writeln('【当前场景内容】');
|
||
buffer.writeln(plainContent.trim().isEmpty ? '(无内容)' : plainContent.trim());
|
||
return buffer.toString().trim();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
return null;
|
||
}
|
||
|
||
String? _getActiveChapterId() {
|
||
try {
|
||
// 通过 sceneId 反查章节:先在 novel 中找到含该 scene 的章节
|
||
if (widget.novel == null || widget.sceneId.isEmpty) return null;
|
||
for (final act in widget.novel!.acts) {
|
||
for (final chapter in act.chapters) {
|
||
if (chapter.scenes.any((s) => s.id == widget.sceneId)) {
|
||
return chapter.id;
|
||
}
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
return null;
|
||
}
|
||
|
||
Future<bool> _showCreditEstimationAndConfirm(UniversalAIRequest request) async {
|
||
try {
|
||
return await showDialog<bool>(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (BuildContext dialogContext) {
|
||
return BlocProvider.value(
|
||
value: context.read<UniversalAIBloc>(),
|
||
child: _CreditEstimationDialog(
|
||
modelName: _selectedUnifiedModel!.displayName,
|
||
request: request,
|
||
onConfirm: () => Navigator.of(dialogContext).pop(true),
|
||
onCancel: () => Navigator.of(dialogContext).pop(false),
|
||
),
|
||
);
|
||
},
|
||
) ??
|
||
false;
|
||
} catch (e) {
|
||
AppLogger.e('OverlaySceneBeatPanel', '积分预估失败', e);
|
||
TopToast.error(context, '积分预估失败: $e');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
bool _shouldRebuildContextData(OverlaySceneBeatPanel oldWidget) {
|
||
// 🚀 修复:更精确地判断上下文数据是否需要重建
|
||
// 只有当基础数据(小说、设定等)或上下文选择的序列化数据真正变化时才重建
|
||
if (widget.novel != oldWidget.novel ||
|
||
widget.settings != oldWidget.settings ||
|
||
widget.settingGroups != oldWidget.settingGroups ||
|
||
widget.snippets != oldWidget.snippets) {
|
||
AppLogger.d('OverlaySceneBeatPanel', '🔄 基础数据变化,需要重建上下文');
|
||
return true;
|
||
}
|
||
|
||
// 🚀 比较序列化的上下文选择数据,而不是解析后的对象
|
||
final oldContextData = oldWidget.data.contextSelectionsData;
|
||
final newContextData = widget.data.contextSelectionsData;
|
||
|
||
if (oldContextData != newContextData) {
|
||
if (_skipNextContextRebuild) {
|
||
// 🚀 跳过一次:这是由本地 setState + onDataChanged 触发的回流
|
||
_skipNextContextRebuild = false;
|
||
AppLogger.d('OverlaySceneBeatPanel', '⏭️ 跳过一次上下文重建(本地更新回流)');
|
||
return false;
|
||
}
|
||
AppLogger.d('OverlaySceneBeatPanel', '🔄 上下文选择数据变化,需要重建上下文');
|
||
return true;
|
||
}
|
||
|
||
// 🚀 所有关键数据都没有变化,无需重建
|
||
return false;
|
||
}
|
||
|
||
ContextSelectionData _createContextData() {
|
||
// 构建基础数据,优先应用已保存的选择
|
||
ContextSelectionData data = ContextSelectionHelper.initializeContextData(
|
||
novel: widget.novel,
|
||
settings: widget.settings,
|
||
settingGroups: widget.settingGroups,
|
||
snippets: widget.snippets,
|
||
initialSelections: widget.data.parsedContextSelections,
|
||
);
|
||
return data;
|
||
}
|
||
|
||
/// 当应用了默认上下文时,持久化到数据模型,确保请求包含默认上下文
|
||
void _persistDefaultContextIfNeeded() {
|
||
final bool hasSaved = (widget.data.parsedContextSelections?.selectedCount ?? 0) > 0;
|
||
if (!hasSaved && _contextData.selectedCount > 0) {
|
||
// 使用下一帧提交,避免initState阶段的同步更新引发抖动
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (!mounted) return;
|
||
_updateContextData(_contextData);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 积分预估确认对话框
|
||
class _CreditEstimationDialog extends StatefulWidget {
|
||
final String modelName;
|
||
final UniversalAIRequest request;
|
||
final VoidCallback onConfirm;
|
||
final VoidCallback onCancel;
|
||
|
||
const _CreditEstimationDialog({
|
||
required this.modelName,
|
||
required this.request,
|
||
required this.onConfirm,
|
||
required this.onCancel,
|
||
});
|
||
|
||
@override
|
||
State<_CreditEstimationDialog> createState() => _CreditEstimationDialogState();
|
||
}
|
||
|
||
class _CreditEstimationDialogState extends State<_CreditEstimationDialog> {
|
||
CostEstimationResponse? _costEstimation;
|
||
String? _errorMessage;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_estimateCost();
|
||
}
|
||
|
||
Future<void> _estimateCost() async {
|
||
try {
|
||
final universalAIBloc = context.read<UniversalAIBloc>();
|
||
universalAIBloc.add(EstimateCostEvent(widget.request));
|
||
} catch (e) {
|
||
setState(() {
|
||
_errorMessage = '预估失败: $e';
|
||
});
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return BlocListener<UniversalAIBloc, UniversalAIState>(
|
||
listener: (context, state) {
|
||
if (state is UniversalAICostEstimationSuccess) {
|
||
setState(() {
|
||
_costEstimation = state.costEstimation;
|
||
_errorMessage = null;
|
||
});
|
||
} else if (state is UniversalAIError) {
|
||
setState(() {
|
||
_errorMessage = state.message;
|
||
_costEstimation = null;
|
||
});
|
||
}
|
||
},
|
||
child: BlocBuilder<UniversalAIBloc, UniversalAIState>(
|
||
builder: (context, state) {
|
||
final isLoading = state is UniversalAILoading;
|
||
|
||
return AlertDialog(
|
||
title: Row(
|
||
children: [
|
||
Icon(
|
||
Icons.account_balance_wallet,
|
||
color: Theme.of(context).colorScheme.primary,
|
||
),
|
||
const SizedBox(width: 8),
|
||
const Text('积分消耗预估'),
|
||
],
|
||
),
|
||
content: SizedBox(
|
||
width: 300,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'模型: ${widget.modelName}',
|
||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
if (isLoading) ...[
|
||
const Row(
|
||
children: [
|
||
SizedBox(
|
||
width: 20,
|
||
height: 20,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
),
|
||
SizedBox(width: 12),
|
||
Text('正在估算积分消耗...'),
|
||
],
|
||
),
|
||
] else if (_errorMessage != null) ...[
|
||
Container(
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context).colorScheme.errorContainer,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(
|
||
Icons.error_outline,
|
||
color: Theme.of(context).colorScheme.error,
|
||
size: 20,
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
_errorMessage!,
|
||
style: TextStyle(
|
||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
] else if (_costEstimation != null) ...[
|
||
Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context)
|
||
.colorScheme
|
||
.primaryContainer
|
||
.withOpacity(0.3),
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(
|
||
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
|
||
),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
const Text(
|
||
'预估消耗积分:',
|
||
style: TextStyle(fontWeight: FontWeight.w500),
|
||
),
|
||
Text(
|
||
'${_costEstimation!.estimatedCost}',
|
||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||
color: Theme.of(context).colorScheme.primary,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
if (_costEstimation!.estimatedInputTokens != null ||
|
||
_costEstimation!.estimatedOutputTokens != null) ...[
|
||
const SizedBox(height: 8),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(
|
||
'Token预估:',
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
color: Theme.of(context)
|
||
.colorScheme
|
||
.onSurface
|
||
.withOpacity(0.7),
|
||
),
|
||
),
|
||
Text(
|
||
'输入: ${_costEstimation!.estimatedInputTokens ?? 0}, 输出: ${_costEstimation!.estimatedOutputTokens ?? 0}',
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
color: Theme.of(context)
|
||
.colorScheme
|
||
.onSurface
|
||
.withOpacity(0.7),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'实际消耗可能因内容长度和模型响应而有所不同',
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
color: Theme.of(context)
|
||
.colorScheme
|
||
.onSurface
|
||
.withOpacity(0.7),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
'确认要继续生成场景节拍吗?',
|
||
style: Theme.of(context).textTheme.bodyMedium,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: isLoading ? null : widget.onCancel,
|
||
child: const Text('取消'),
|
||
),
|
||
ElevatedButton(
|
||
onPressed: isLoading || _errorMessage != null || _costEstimation == null
|
||
? null
|
||
: widget.onConfirm,
|
||
child: const Text('确认生成'),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
} |