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 settings; final List settingGroups; final List snippets; final ValueChanged? onDataChanged; final Function(UniversalAIRequest, UnifiedAIModel)? onGenerate; final VoidCallback? onClose; @override State createState() => _OverlaySceneBeatPanelState(); } class _OverlaySceneBeatPanelState extends State with SingleTickerProviderStateMixin, AIDialogCommonLogic { bool _isExpanded = false; OverlayEntry? _tempOverlay; late TextEditingController _quickInstructionsController; late TextEditingController _customLengthController; late AnimationController _animationController; late Animation _widthAnimation; late Animation _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( begin: 120.0, end: 360.0, ).animate(CurvedAnimation( parent: _animationController, curve: Curves.easeInOut, )); _fadeAnimation = Tween( 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().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().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((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 _showCreditEstimationAndConfirm(UniversalAIRequest request) async { try { return await showDialog( context: context, barrierDismissible: false, builder: (BuildContext dialogContext) { return BlocProvider.value( value: context.read(), 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 _estimateCost() async { try { final universalAIBloc = context.read(); universalAIBloc.add(EstimateCostEvent(widget.request)); } catch (e) { setState(() { _errorMessage = '预估失败: $e'; }); } } @override Widget build(BuildContext context) { return BlocListener( 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( 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('确认生成'), ), ], ); }, ), ); } }