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

1346 lines
52 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 'package:ainoval/blocs/editor/editor_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/blocs/ai_config/ai_config_bloc.dart';
import 'package:ainoval/blocs/public_models/public_models_bloc.dart';
import 'package:ainoval/models/novel_structure.dart';
import 'package:ainoval/models/unified_ai_model.dart';
import 'package:ainoval/models/ai_request_models.dart';
import 'package:ainoval/widgets/common/unified_ai_model_dropdown.dart';
import 'package:ainoval/widgets/common/scene_selector.dart';
import 'package:ainoval/config/app_config.dart';
import 'package:ainoval/utils/logger.dart';
import 'package:flutter/material.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
// import 'package:ainoval/screens/editor/managers/editor_layout_manager.dart';
import 'package:flutter/services.dart';
import 'package:ainoval/widgets/common/top_toast.dart';
// import 'package:ainoval/widgets/common/app_search_field.dart';
import 'package:ainoval/models/context_selection_models.dart';
import 'package:ainoval/widgets/common/form_dialog_template.dart';
import 'package:ainoval/utils/quill_helper.dart';
import 'package:ainoval/screens/editor/components/ai_dialog_common_logic.dart';
import 'package:ainoval/blocs/credit/credit_bloc.dart';
/// AI生成面板提供根据摘要生成场景的功能
class AIGenerationPanel extends StatefulWidget {
const AIGenerationPanel({
Key? key,
required this.novelId,
required this.onClose,
this.isCardMode = false,
}) : super(key: key);
final String novelId;
final VoidCallback onClose;
final bool isCardMode; // 是否以卡片模式显示
@override
State<AIGenerationPanel> createState() => _AIGenerationPanelState();
}
class _AIGenerationPanelState extends State<AIGenerationPanel> with AIDialogCommonLogic {
final TextEditingController _summaryController = TextEditingController();
final TextEditingController _styleController = TextEditingController();
final TextEditingController _generatedContentController = TextEditingController();
final ScrollController _scrollController = ScrollController();
final LayerLink _layerLink = LayerLink();
UnifiedAIModel? _selectedModel;
bool _enableSmartContext = true;
bool _userScrolled = false;
// bool _contentEdited = false; // 未使用,注释避免警告
bool _isGenerating = false;
// String _generatedText = '';
bool _thisInstanceIsGenerating = false; // 标记是否是当前实例发起的生成请求
late ContextSelectionData _contextSelectionData;
String? _selectedPromptTemplateId;
// 临时自定义提示词
String? _customSystemPrompt;
String? _customUserPrompt;
bool _contextInitialized = false;
@override
void initState() {
super.initState();
// 监听滚动事件,检测用户是否主动滚动
_scrollController.addListener(_handleUserScroll);
// 初始化默认模型配置
WidgetsBinding.instance.addPostFrameCallback((_) {
_initializeDefaultModel();
_initializeContextData();
});
// 读取待处理的摘要内容或当前场景的摘要
WidgetsBinding.instance.addPostFrameCallback((_) {
final editorState = context.read<EditorBloc>().state;
if (editorState is EditorLoaded) {
if (editorState.pendingSummary != null && editorState.pendingSummary!.isNotEmpty) {
// 优先使用待处理摘要
_summaryController.text = editorState.pendingSummary!;
// 清除待处理摘要,避免下次打开时仍然显示
context.read<EditorBloc>().add(const SetPendingSummary(summary: ''));
} else {
// 自动导入当前场景的摘要
_loadCurrentSceneSummary(editorState);
}
}
});
}
void _initializeContextData() {
if (_contextInitialized) return;
final editorState = context.read<EditorBloc>().state;
if (editorState is EditorLoaded) {
_contextSelectionData = ContextSelectionDataBuilder.fromNovel(editorState.novel);
_contextInitialized = true;
}
}
void _initializeDefaultModel() {
final aiConfigState = context.read<AiConfigBloc>().state;
final publicModelsState = context.read<PublicModelsBloc>().state;
// 合并私有模型和公共模型
final allModels = _combineModels(aiConfigState, publicModelsState);
if (allModels.isNotEmpty && _selectedModel == null) {
// 优先选择默认配置
UnifiedAIModel? defaultModel;
// 首先查找私有模型中的默认配置
for (final model in allModels) {
if (!model.isPublic && (model as PrivateAIModel).userConfig.isDefault) {
defaultModel = model;
break;
}
}
// 如果没有默认私有模型,选择第一个公共模型
defaultModel ??= allModels.firstWhere(
(model) => model.isPublic,
orElse: () => allModels.first,
);
setState(() {
_selectedModel = defaultModel;
});
}
}
/// 合并私有模型和公共模型
List<UnifiedAIModel> _combineModels(AiConfigState aiState, PublicModelsState publicState) {
final List<UnifiedAIModel> allModels = [];
// 添加已验证的私有模型
final validatedConfigs = aiState.validatedConfigs;
for (final config in validatedConfigs) {
allModels.add(PrivateAIModel(config));
}
// 添加公共模型
if (publicState is PublicModelsLoaded) {
for (final publicModel in publicState.models) {
allModels.add(PublicAIModel(publicModel));
}
}
return allModels;
}
/// 加载当前场景的摘要到输入框
void _loadCurrentSceneSummary(EditorLoaded state) {
if (state.activeActId != null &&
state.activeChapterId != null &&
state.activeSceneId != null) {
final scene = state.novel.getScene(
state.activeActId!,
state.activeChapterId!,
sceneId: state.activeSceneId,
);
if (scene != null && scene.summary.content.isNotEmpty) {
setState(() {
_summaryController.text = scene.summary.content;
});
}
}
}
@override
void dispose() {
_summaryController.dispose();
_styleController.dispose();
_generatedContentController.dispose();
_scrollController.removeListener(_handleUserScroll);
_scrollController.dispose();
super.dispose();
}
void _handleUserScroll() {
if (_scrollController.hasClients) {
// 如果用户向上滚动(滚动位置不在底部),标记为用户滚动
if (_scrollController.position.pixels <
_scrollController.position.maxScrollExtent - 50) {
_userScrolled = true;
}
// 如果用户滚动到底部,重置标记
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 10) {
_userScrolled = false;
}
}
}
/// 复制内容到剪贴板
void _copyToClipboard(String content) {
Clipboard.setData(ClipboardData(text: content)).then((_) {
TopToast.success(context, '内容已复制到剪贴板');
});
}
Widget _buildModelConfigSection(BuildContext context, EditorLoaded state) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: WebTheme.getSecondaryBorderColor(context), width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'模型设置',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 8),
// 统一模型选择器
_buildUnifiedModelSelector(context, state),
const SizedBox(height: 10),
// 智能上下文开关
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'智能上下文',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
const SizedBox(height: 2),
Text(
'自动检索相关设定和背景信息',
style: TextStyle(
fontSize: 10,
color: Colors.grey[600],
),
),
],
),
),
Switch(
value: _enableSmartContext,
onChanged: (value) {
setState(() {
_enableSmartContext = value;
});
},
activeColor: Colors.black,
activeTrackColor: Colors.grey[300],
inactiveThumbColor: Colors.grey[400],
inactiveTrackColor: Colors.grey[200],
),
],
),
const SizedBox(height: 10),
// 上下文选择
if (_contextInitialized)
FormFieldFactory.createContextSelectionField(
contextData: _contextSelectionData,
onSelectionChanged: (newData) {
setState(() {
_contextSelectionData = newData;
});
},
title: '附加上下文',
description: '为AI提供的任何额外信息',
onReset: () {
setState(() {
_contextSelectionData = ContextSelectionDataBuilder.fromNovel(state.novel);
});
},
dropdownWidth: 400,
initialChapterId: state.activeChapterId,
initialSceneId: state.activeSceneId,
),
if (_contextInitialized) const SizedBox(height: 10),
// 关联提示词模板
FormFieldFactory.createPromptTemplateSelectionField(
selectedTemplateId: _selectedPromptTemplateId,
onTemplateSelected: (templateId) {
setState(() {
_selectedPromptTemplateId = templateId;
});
},
aiFeatureType: 'SUMMARY_TO_SCENE',
title: '关联提示词模板',
description: '可选,选择一个提示词模板优化生成效果',
onReset: () {
setState(() {
_selectedPromptTemplateId = null;
});
},
onTemporaryPromptsSaved: (sys, user) {
setState(() {
_customSystemPrompt = sys.trim().isEmpty ? null : sys.trim();
_customUserPrompt = user.trim().isEmpty ? null : user.trim();
});
},
),
],
),
);
}
/// 构建统一模型选择器
Widget _buildUnifiedModelSelector(BuildContext context, EditorLoaded state) {
return BlocBuilder<AiConfigBloc, AiConfigState>(
builder: (context, aiState) {
return BlocBuilder<PublicModelsBloc, PublicModelsState>(
builder: (context, publicState) {
final allModels = _combineModels(aiState, publicState);
return CompositedTransformTarget(
link: _layerLink,
child: InkWell(
onTap: () {
_showModelDropdown(context, state, allModels);
},
borderRadius: BorderRadius.circular(6),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: WebTheme.getCardColor(context),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: WebTheme.getSecondaryBorderColor(context), width: 1),
),
child: Row(
children: [
Expanded(
child: _selectedModel != null
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_selectedModel!.displayName,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: WebTheme.getTextColor(context),
),
),
const SizedBox(height: 2),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: _selectedModel!.isPublic ? Colors.green[50] : Colors.blue[50],
borderRadius: BorderRadius.circular(3),
border: Border.all(
color: _selectedModel!.isPublic ? Colors.green[200]! : Colors.blue[200]!,
width: 0.5,
),
),
child: Text(
_selectedModel!.isPublic ? '系统' : '私有',
style: TextStyle(
fontSize: 10,
color: _selectedModel!.isPublic ? Colors.green[700] : Colors.blue[700],
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 6),
Text(
_selectedModel!.provider,
style: TextStyle(
fontSize: 10,
color: WebTheme.getSecondaryTextColor(context),
),
),
],
),
],
)
: Text(
'选择AI模型',
style: TextStyle(
fontSize: 13,
color: WebTheme.getSecondaryTextColor(context),
),
),
),
Icon(
Icons.arrow_drop_down,
color: WebTheme.getSecondaryTextColor(context),
size: 20,
),
],
),
),
),
);
},
);
},
);
}
/// 显示模型选择下拉菜单
void _showModelDropdown(BuildContext context, EditorLoaded state, List<UnifiedAIModel> allModels) {
UnifiedAIModelDropdown.show(
context: context,
layerLink: _layerLink,
selectedModel: _selectedModel,
onModelSelected: (model) {
setState(() {
_selectedModel = model;
});
},
showSettingsButton: false,
maxHeight: 300,
novel: state.novel,
);
}
/// 构建章节下拉菜单选项
List<DropdownMenuItem<String>> _buildChapterDropdownItems(Novel novel) {
final items = <DropdownMenuItem<String>>[];
for (final act in novel.acts) {
// 添加Act分组标题
items.add(
DropdownMenuItem<String>(
enabled: false,
child: Container(
margin: const EdgeInsets.only(top: 6, bottom: 3),
child: Text(
act.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
color: Colors.black54,
),
),
),
),
);
// 添加Act下的Chapter
for (final chapter in act.chapters) {
items.add(
DropdownMenuItem<String>(
value: chapter.id,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
const SizedBox(width: 8), // 缩进
const Icon(Icons.menu_book_outlined, size: 14, color: Colors.black54),
const SizedBox(width: 6),
Expanded(
child: Text(
chapter.title,
style: const TextStyle(
fontSize: 12,
color: Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
);
}
}
return items;
}
@override
Widget build(BuildContext context) {
return BlocBuilder<EditorBloc, EditorState>(
builder: (context, editorState) {
if (editorState is! EditorLoaded) {
return const Center(child: CircularProgressIndicator());
}
return BlocConsumer<UniversalAIBloc, UniversalAIState>(
listener: (context, state) {
// 只处理场景生成相关的状态变化
if (state is UniversalAIStreaming) {
// 检查是否是场景生成请求
if (_isGenerationRequest(state)) {
setState(() {
_isGenerating = true;
_generatedContentController.text = state.partialResponse;
// _contentEdited = false;
});
// 如果用户没有主动滚动,自动滚动到底部
if (!_userScrolled && _scrollController.hasClients) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
}
} else if (state is UniversalAISuccess) {
// 检查是否是场景生成请求
if (_isGenerationRequest(state)) {
setState(() {
_isGenerating = false;
_thisInstanceIsGenerating = false; // 重置实例生成标记
_generatedContentController.text = state.response.content;
// _contentEdited = false;
});
// 🚀 生成完成后刷新积分
try {
WidgetsBinding.instance.addPostFrameCallback((_) {
// ignore: use_build_context_synchronously
context.read<CreditBloc>().add(const RefreshUserCredits());
});
} catch (_) {}
}
} else if (state is UniversalAICancelled) {
// 处理取消状态
if (_thisInstanceIsGenerating) {
setState(() {
_isGenerating = false;
_thisInstanceIsGenerating = false;
});
}
} else if (state is UniversalAIError) {
// 检查是否是场景生成请求
if (_isGenerationRequest(state)) {
setState(() {
_isGenerating = false;
_thisInstanceIsGenerating = false; // 重置实例生成标记
});
TopToast.error(context, '生成场景失败: ${state.message}');
}
} else if (state is UniversalAILoading) {
// 检查是否是场景生成请求
if (_isGenerationRequest(state)) {
setState(() {
_isGenerating = true;
});
}
}
},
builder: (context, universalAIState) {
return Column(
children: [
// 面板标题栏
_buildHeader(context, editorState),
// 面板内容
Expanded(
child: _buildSceneGenerationPanel(context, editorState),
),
],
);
},
);
},
);
}
Widget _buildHeader(BuildContext context, EditorLoaded state) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 10.0),
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context),
border: Border(
bottom: BorderSide(
color: WebTheme.getSecondaryBorderColor(context),
width: 1,
),
),
),
child: Column(
children: [
// 标题行
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: WebTheme.getPrimaryColor(context),
borderRadius: BorderRadius.circular(4),
),
child: Icon(
Icons.auto_awesome,
size: 14,
color: WebTheme.white,
),
),
const SizedBox(width: 8),
Text(
'AI场景生成',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: WebTheme.getTextColor(context),
),
),
],
),
Row(
children: [
// 状态指示器
if (_isGenerating) ...[
SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(WebTheme.getTextColor(context)),
),
),
const SizedBox(width: 6),
Text(
'正在生成...',
style: TextStyle(
fontSize: 11,
color: WebTheme.getTextColor(context),
),
),
const SizedBox(width: 8),
],
// 帮助按钮
Tooltip(
message: '使用说明',
child: IconButton(
icon: Icon(
Icons.help_outline,
size: 16,
color: WebTheme.getSecondaryTextColor(context),
),
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
onPressed: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: WebTheme.getCardColor(context),
surfaceTintColor: Colors.transparent,
title: Text(
'AI场景生成说明',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: WebTheme.getTextColor(context),
),
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'1. 填写场景摘要/大纲描述想要生成的内容',
style: TextStyle(fontSize: 12, color: WebTheme.getTextColor(context)),
),
SizedBox(height: 6),
Text(
'2. 选择AI模型和配置参数',
style: TextStyle(fontSize: 12, color: WebTheme.getTextColor(context)),
),
SizedBox(height: 6),
Text(
'3. 可选择启用智能上下文获取相关设定',
style: TextStyle(fontSize: 12, color: WebTheme.getTextColor(context)),
),
SizedBox(height: 6),
Text(
'4. 点击"生成场景"按钮开始生成',
style: TextStyle(fontSize: 12, color: WebTheme.getTextColor(context)),
),
SizedBox(height: 6),
Text(
'5. 生成完成后,可以编辑内容并添加为新场景',
style: TextStyle(fontSize: 12, color: WebTheme.getTextColor(context)),
),
],
),
),
actions: [
ElevatedButton(
onPressed: () => Navigator.pop(context),
style: ElevatedButton.styleFrom(
backgroundColor: WebTheme.getPrimaryColor(context),
foregroundColor: WebTheme.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
child: const Text('了解了', style: TextStyle(fontSize: 12)),
),
],
),
);
},
),
),
const SizedBox(width: 2),
IconButton(
icon: Icon(Icons.close, size: 16, color: WebTheme.getSecondaryTextColor(context)),
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
padding: const EdgeInsets.all(4),
onPressed: widget.onClose,
tooltip: '关闭',
),
],
),
],
),
// 当前场景信息行
const SizedBox(height: 8),
_buildCurrentSceneInfo(context, state),
],
),
);
}
Widget _buildCurrentSceneInfo(BuildContext context, EditorLoaded state) {
return SceneSelector(
novel: state.novel,
activeSceneId: state.activeSceneId,
onSceneSelected: (sceneId, actId, chapterId) {
// 更新活跃场景
context.read<EditorBloc>().add(SetActiveScene(
actId: actId,
chapterId: chapterId,
sceneId: sceneId,
));
},
onSummaryLoaded: (summary) {
// 加载场景摘要到输入框
setState(() {
_summaryController.text = summary;
});
},
);
}
/// 构建场景生成面板
Widget _buildSceneGenerationPanel(BuildContext context, EditorLoaded state) {
final hasGenerated = _generatedContentController.text.isNotEmpty;
return Padding(
padding: const EdgeInsets.all(10.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 模型配置区域
_buildModelConfigSection(context, state),
const SizedBox(height: 10),
// 摘要文本输入
const Text(
'场景摘要/大纲',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 6),
Container(
decoration: BoxDecoration(
color: WebTheme.getCardColor(context),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: WebTheme.getSecondaryBorderColor(context),
width: 1,
),
),
child: TextField(
controller: _summaryController,
maxLines: 4,
decoration: InputDecoration(
hintText: '请输入场景大纲或摘要AI将根据此内容生成完整场景',
hintStyle: TextStyle(fontSize: 12, color: WebTheme.getSecondaryTextColor(context)),
contentPadding: const EdgeInsets.all(12),
border: InputBorder.none,
suffixIcon: _summaryController.text.isNotEmpty
? IconButton(
icon: Icon(
Icons.clear,
size: 16,
color: WebTheme.getSecondaryTextColor(context),
),
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
padding: const EdgeInsets.all(4),
onPressed: () {
setState(() {
_summaryController.clear();
});
},
)
: null,
),
style: TextStyle(
fontSize: 13,
height: 1.4,
color: WebTheme.getTextColor(context),
),
onChanged: (_) => setState(() {}),
),
),
const SizedBox(height: 10),
// 风格指令输入
const Text(
'风格指令(可选)',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 6),
Container(
decoration: BoxDecoration(
color: WebTheme.getCardColor(context),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: WebTheme.getSecondaryBorderColor(context),
width: 1,
),
),
child: TextField(
controller: _styleController,
decoration: InputDecoration(
hintText: '例如:多对话,少描写,悬疑风格',
hintStyle: TextStyle(fontSize: 12, color: WebTheme.getSecondaryTextColor(context)),
contentPadding: const EdgeInsets.all(12),
border: InputBorder.none,
suffixIcon: _styleController.text.isNotEmpty
? IconButton(
icon: Icon(
Icons.clear,
size: 16,
color: WebTheme.getSecondaryTextColor(context),
),
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
padding: const EdgeInsets.all(4),
onPressed: () {
setState(() {
_styleController.clear();
});
},
)
: null,
),
style: TextStyle(
fontSize: 13,
color: WebTheme.getTextColor(context),
),
onChanged: (_) => setState(() {}),
),
),
const SizedBox(height: 10),
// 章节选择(可选)
if (state.novel.acts.isNotEmpty) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'目标章节(可选)',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
if (state.activeChapterId != null)
OutlinedButton.icon(
onPressed: () {
// 查找当前章节信息
String chapterTitle = "";
for (final act in state.novel.acts) {
for (final chapter in act.chapters) {
if (chapter.id == state.activeChapterId) {
chapterTitle = chapter.title;
break;
}
}
if (chapterTitle.isNotEmpty) break;
}
if (chapterTitle.isNotEmpty) {
// 添加章节相关信息到摘要
final currentText = _summaryController.text;
final chapterContext = "本场景为《$chapterTitle》章节的一部分,";
if (currentText.isNotEmpty) {
_summaryController.text = '$chapterContext$currentText';
} else {
_summaryController.text = chapterContext;
}
}
},
icon: Icon(Icons.add_box_outlined, size: 14, color: WebTheme.getTextColor(context)),
label: Text(
'添加到摘要',
style: TextStyle(fontSize: 11, color: WebTheme.getTextColor(context)),
),
style: OutlinedButton.styleFrom(
side: BorderSide(color: WebTheme.getSecondaryBorderColor(context), width: 1),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
minimumSize: const Size(0, 28),
),
),
],
),
const SizedBox(height: 6),
Container(
decoration: BoxDecoration(
color: WebTheme.getCardColor(context),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: WebTheme.getSecondaryBorderColor(context),
width: 1,
),
),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
isExpanded: true,
value: state.activeChapterId,
items: _buildChapterDropdownItems(state.novel),
onChanged: (chapterId) {
if (chapterId != null) {
// 查找选中章节所属的Act
String? actId;
for (final act in state.novel.acts) {
for (final chapter in act.chapters) {
if (chapter.id == chapterId) {
actId = act.id;
break;
}
}
if (actId != null) break;
}
if (actId != null) {
// 更新活跃章节
context.read<EditorBloc>().add(SetActiveChapter(
actId: actId,
chapterId: chapterId,
));
}
}
},
style: TextStyle(
fontSize: 12,
color: WebTheme.getTextColor(context),
),
hint: Text(
'选择一个目标章节',
style: TextStyle(
fontSize: 12,
color: WebTheme.getSecondaryTextColor(context),
),
),
icon: Icon(
Icons.keyboard_arrow_down_rounded,
size: 16,
color: WebTheme.getSecondaryTextColor(context),
),
dropdownColor: WebTheme.getCardColor(context),
menuMaxHeight: 240,
),
),
),
],
// 生成结果或操作区域
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (hasGenerated || _isGenerating) ...[
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'生成结果',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: WebTheme.getTextColor(context),
),
),
if (hasGenerated)
Row(
children: [
Tooltip(
message: '重新生成',
child: IconButton(
onPressed: () {
// 重新生成内容
context.read<EditorBloc>().add(
GenerateSceneFromSummaryRequested(
novelId: state.novel.id,
summary: _summaryController.text,
chapterId: state.activeChapterId,
styleInstructions: _styleController.text.isNotEmpty
? _styleController.text
: null,
useStreamingMode: true,
),
);
// 重置用户滚动标记
_userScrolled = false;
},
icon: Icon(Icons.refresh, size: 16, color: WebTheme.getSecondaryTextColor(context)),
tooltip: '重新生成',
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
padding: const EdgeInsets.all(4),
),
),
Tooltip(
message: '复制全文',
child: IconButton(
onPressed: () => _copyToClipboard(_generatedContentController.text),
icon: Icon(Icons.copy, size: 16, color: WebTheme.getSecondaryTextColor(context)),
tooltip: '复制全文',
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
padding: const EdgeInsets.all(4),
),
),
Tooltip(
message: '添加为新场景',
child: IconButton(
onPressed: () {
// 将生成内容应用到编辑器
if (state.activeActId != null && state.activeChapterId != null) {
// 获取布局管理器
// 最初用于触发布局刷新,当前未使用
// final layoutManager = Provider.of<EditorLayoutManager>(context, listen: false);
// 创建新场景并使用生成内容
final sceneId = 'scene_${DateTime.now().millisecondsSinceEpoch}';
// 添加新场景
context.read<EditorBloc>().add(AddNewScene(
novelId: widget.novelId,
actId: state.activeActId!,
chapterId: state.activeChapterId!,
sceneId: sceneId,
));
// 等待短暂时间,确保场景已添加
Future.delayed(const Duration(milliseconds: 500), () {
// 设置场景内容
context.read<EditorBloc>().add(UpdateSceneContent(
novelId: widget.novelId,
actId: state.activeActId!,
chapterId: state.activeChapterId!,
sceneId: sceneId,
content: _generatedContentController.text,
));
// 设置为活动场景
context.read<EditorBloc>().add(SetActiveScene(
actId: state.activeActId!,
chapterId: state.activeChapterId!,
sceneId: sceneId,
));
// 关闭生成面板
widget.onClose();
// 显示通知
TopToast.success(context, '已创建新场景并应用生成内容');
});
}
},
icon: Icon(Icons.add_circle_outline, size: 16, color: WebTheme.getSecondaryTextColor(context)),
tooltip: '添加为新场景',
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
padding: const EdgeInsets.all(4),
),
),
],
),
],
),
const SizedBox(height: 6),
Expanded(
child: _buildGenerationResultSection(context, state),
),
],
const SizedBox(height: 12),
// 生成按钮区域
_buildGenerationButtons(context, state),
],
),
),
],
),
);
}
Widget _buildGenerationResultSection(BuildContext context, EditorLoaded state) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.grey[300]!,
width: 1,
),
),
clipBehavior: Clip.antiAlias,
child: _isGenerating && _generatedContentController.text.isEmpty
? const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.black),
),
),
SizedBox(height: 12),
Text(
'正在生成场景...',
style: TextStyle(
fontSize: 12,
color: Colors.black87,
),
),
],
),
)
: !_generatedContentController.text.isNotEmpty && !_isGenerating
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.auto_awesome,
color: Colors.grey[400],
size: 28,
),
const SizedBox(height: 12),
Text(
'点击"生成场景"按钮开始生成',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
)
: TextField(
controller: _generatedContentController,
scrollController: _scrollController,
maxLines: null,
expands: true,
decoration: const InputDecoration(
contentPadding: EdgeInsets.all(12),
border: InputBorder.none,
hintText: '生成的场景内容将显示在这里',
hintStyle: TextStyle(fontSize: 12, color: Colors.black45),
),
style: const TextStyle(
fontSize: 13,
height: 1.4,
color: Colors.black87,
),
onChanged: (_) {
setState(() {
// _contentEdited = true;
});
},
),
);
}
Widget _buildGenerationButtons(BuildContext context, EditorLoaded state) {
final hasContent = _summaryController.text.isNotEmpty;
if (!_isGenerating) {
return SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: (hasContent && _selectedModel != null) ? () => _generateScene(context, state) : null,
icon: const Icon(Icons.auto_awesome, size: 16, color: Colors.white),
label: const Text(
'生成场景',
style: TextStyle(fontSize: 13, color: Colors.white),
),
style: ElevatedButton.styleFrom(
backgroundColor: (hasContent && _selectedModel != null) ? Colors.black : Colors.grey[400],
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
);
} else {
return SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {
context.read<UniversalAIBloc>().add(const StopStreamRequestEvent());
setState(() {
_thisInstanceIsGenerating = false;
_isGenerating = false;
});
},
icon: const Icon(Icons.cancel, size: 16, color: Colors.black87),
label: const Text(
'取消生成',
style: TextStyle(fontSize: 13, color: Colors.black87),
),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.grey, width: 1),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
);
}
}
/// 检查是否是场景生成请求
bool _isGenerationRequest(UniversalAIState state) {
// 对于流式响应状态,只有当前实例发起的请求才处理
if (state is UniversalAIStreaming) {
return _thisInstanceIsGenerating;
}
// 对于成功状态,检查请求类型
else if (state is UniversalAISuccess) {
return state.response.requestType == AIRequestType.generation;
}
// 对于错误和加载状态,检查当前实例是否有生成任务
else if (state is UniversalAIError || state is UniversalAILoading) {
return _thisInstanceIsGenerating;
}
return false;
}
/// 生成场景
void _generateScene(BuildContext context, EditorLoaded state) {
if (_selectedModel == null) return;
// 清空现有内容
_generatedContentController.clear();
AppLogger.i('AIGenerationPanel', '开始生成场景');
// 使用公共逻辑创建模型配置(公共模型会被包装为临时配置)
final modelConfig = createModelConfig(_selectedModel!);
// 构建AI请求将摘要文本按需从Quill Delta转换为纯文本
final String plainSummaryText = QuillHelper.deltaToText(_summaryController.text);
final request = UniversalAIRequest(
requestType: AIRequestType.generation,
userId: AppConfig.userId ?? 'unknown',
novelId: widget.novelId,
chapterId: state.activeChapterId,
sceneId: state.activeSceneId,
modelConfig: modelConfig,
selectedText: plainSummaryText, // 使用纯文本作为输入
instructions: _styleController.text.isNotEmpty
? '请根据以下摘要生成完整的小说场景。风格要求:${_styleController.text}'
: '请根据以下摘要生成完整的小说场景。',
contextSelections: _contextSelectionData,
enableSmartContext: _enableSmartContext,
parameters: {
'temperature': 0.8,
'maxTokens': 2000,
'promptTemplateId': _selectedPromptTemplateId,
if (_customSystemPrompt != null) 'customSystemPrompt': _customSystemPrompt,
if (_customUserPrompt != null) 'customUserPrompt': _customUserPrompt,
},
metadata: createModelMetadata(_selectedModel!, {
'actId': state.activeActId,
'chapterId': state.activeChapterId,
'sceneId': state.activeSceneId,
'action': 'summary_to_scene',
'source': 'ai_generation_panel',
}),
);
// 公共模型预估积分并确认
if (_selectedModel!.isPublic) {
handlePublicModelCreditConfirmation(_selectedModel!, request).then((ok) {
if (!ok) return;
setState(() { _thisInstanceIsGenerating = true; });
context.read<UniversalAIBloc>().add(SendAIStreamRequestEvent(request));
});
return;
}
// 私有模型直接发送
setState(() { _thisInstanceIsGenerating = true; });
context.read<UniversalAIBloc>().add(SendAIStreamRequestEvent(request));
}
}