Files
MaliangAINovalWriter/AINoval/lib/widgets/editor/overlay_scene_beat_panel.dart
2025-09-10 00:07:52 +08:00

1762 lines
62 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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('确认生成'),
),
],
);
},
),
);
}
}