马良AI写作初始化仓库

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,360 @@
import 'dart:async';
import 'package:flutter/material.dart';
/// 现代化AI设计的模糊占位组件
class AIShimmerPlaceholder extends StatefulWidget {
const AIShimmerPlaceholder({Key? key}) : super(key: key);
@override
State<AIShimmerPlaceholder> createState() => _AIShimmerPlaceholderState();
}
class _AIShimmerPlaceholderState extends State<AIShimmerPlaceholder>
with TickerProviderStateMixin {
late AnimationController _shimmerController;
late AnimationController _pulseController;
late Animation<double> _shimmerAnimation;
late Animation<double> _pulseAnimation;
String _currentMessage = 'AI 正在构思设定架构...';
late Timer _messageTimer;
@override
void initState() {
super.initState();
_shimmerController = AnimationController(
duration: const Duration(milliseconds: 2000),
vsync: this,
);
_pulseController = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
);
_shimmerAnimation = Tween<double>(
begin: -1.0,
end: 2.0,
).animate(CurvedAnimation(
parent: _shimmerController,
curve: Curves.easeInOut,
));
_pulseAnimation = Tween<double>(
begin: 0.3,
end: 0.7,
).animate(CurvedAnimation(
parent: _pulseController,
curve: Curves.easeInOut,
));
// 首帧后再启动动画,避免在构建/热重启过程中驱动渲染
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_shimmerController.repeat();
_pulseController.repeat(reverse: true);
// 定期更换提示消息
_messageTimer = Timer.periodic(const Duration(milliseconds: 2500), (timer) {
if (mounted) {
setState(() {
_currentMessage = _getRandomMessage();
});
}
});
});
}
@override
void dispose() {
if (_shimmerController.isAnimating) {
_shimmerController.stop();
}
if (_pulseController.isAnimating) {
_pulseController.stop();
}
_shimmerController.dispose();
_pulseController.dispose();
_messageTimer.cancel();
super.dispose();
}
String _getRandomMessage() {
final messages = [
'AI 正在构思设定架构...',
'正在分析故事背景...',
'构建世界观体系中...',
'生成角色关系网络...',
'设计情节主线框架...',
'创造独特的设定元素...',
];
return messages[DateTime.now().millisecond % messages.length];
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark
? const Color(0xFF1F2937).withOpacity(0.3)
: const Color(0xFFF9FAFB).withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDark
? const Color(0xFF374151)
: const Color(0xFFE5E7EB),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// AI思考状态指示器
AnimatedBuilder(
animation: _pulseAnimation,
builder: (context, child) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: const Color(0xFF6366F1).withOpacity(_pulseAnimation.value),
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: const Color(0xFF6366F1).withOpacity(0.2),
blurRadius: 8,
spreadRadius: 1,
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: const AlwaysStoppedAnimation<Color>(Colors.white),
),
),
const SizedBox(width: 12),
AnimatedSwitcher(
duration: const Duration(milliseconds: 600),
child: Text(
_currentMessage,
key: ValueKey(_currentMessage),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
fontSize: 14,
),
),
),
],
),
);
},
),
const SizedBox(height: 24),
// 模糊的节点占位符
Expanded(
child: SingleChildScrollView(
child: Column(
children: List.generate(8, (index) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _buildShimmerNode(
context,
level: index < 3 ? 0 : (index < 6 ? 1 : 2),
delay: index * 200.0,
),
);
}),
),
),
),
],
),
);
}
Widget _buildShimmerNode(BuildContext context, {required int level, required double delay}) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final leftPadding = level * 24.0 + 8.0;
return AnimatedBuilder(
animation: _shimmerAnimation,
builder: (context, child) {
return Container(
margin: EdgeInsets.only(left: leftPadding),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
(isDark ? const Color(0xFF374151) : const Color(0xFFE5E7EB)).withOpacity(0.3),
(isDark ? const Color(0xFF4B5563) : const Color(0xFFF3F4F6)).withOpacity(0.6),
(isDark ? const Color(0xFF374151) : const Color(0xFFE5E7EB)).withOpacity(0.3),
],
stops: [
(_shimmerAnimation.value - 1).clamp(0.0, 1.0),
_shimmerAnimation.value.clamp(0.0, 1.0),
(_shimmerAnimation.value + 1).clamp(0.0, 1.0),
],
),
),
child: Row(
children: [
// 图标占位符
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: (isDark ? const Color(0xFF6B7280) : const Color(0xFF9CA3AF)).withOpacity(0.5),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 12),
// 文字占位符
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 14,
width: double.infinity,
decoration: BoxDecoration(
color: (isDark ? const Color(0xFF6B7280) : const Color(0xFF9CA3AF)).withOpacity(0.5),
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 6),
Container(
height: 12,
width: MediaQuery.of(context).size.width * 0.6,
decoration: BoxDecoration(
color: (isDark ? const Color(0xFF6B7280) : const Color(0xFF9CA3AF)).withOpacity(0.3),
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
],
),
);
},
);
}
}
/// 现代化的AI加载指示器
class AILoadingIndicator extends StatefulWidget {
final String message;
const AILoadingIndicator({
Key? key,
this.message = 'AI正在处理...',
}) : super(key: key);
@override
State<AILoadingIndicator> createState() => _AILoadingIndicatorState();
}
class _AILoadingIndicatorState extends State<AILoadingIndicator>
with TickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
late Animation<double> _opacityAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1200),
vsync: this,
)..repeat();
_scaleAnimation = Tween<double>(
begin: 0.8,
end: 1.2,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
));
_opacityAnimation = Tween<double>(
begin: 0.4,
end: 1.0,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Opacity(
opacity: _opacityAnimation.value,
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(
colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: const Color(0xFF6366F1).withOpacity(0.3),
blurRadius: 20,
spreadRadius: 5,
),
],
),
child: const Icon(
Icons.psychology,
color: Colors.white,
size: 32,
),
),
),
);
},
),
const SizedBox(height: 24),
Text(
widget.message,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
],
),
);
}
}

View File

@@ -0,0 +1,732 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../blocs/setting_generation/setting_generation_bloc.dart';
import '../../../blocs/setting_generation/setting_generation_event.dart';
import '../../../blocs/setting_generation/setting_generation_state.dart';
import '../../../models/setting_node.dart';
import '../../../widgets/common/model_display_selector.dart';
import '../../../models/unified_ai_model.dart';
import '../../../utils/logger.dart';
// import '../../../config/app_config.dart';
/// 编辑面板组件
class EditorPanelWidget extends StatefulWidget {
final String? novelId;
const EditorPanelWidget({
Key? key,
this.novelId,
}) : super(key: key);
@override
State<EditorPanelWidget> createState() => _EditorPanelWidgetState();
}
class _EditorPanelWidgetState extends State<EditorPanelWidget> {
final TextEditingController _modificationController = TextEditingController();
final TextEditingController _descriptionController = TextEditingController();
UnifiedAIModel? _selectedModel;
String _selectedScope = 'self';
String? _currentNodeId;
final FocusNode _focusNode = FocusNode();
@override
void dispose() {
_modificationController.dispose();
_descriptionController.dispose();
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
// Ctrl+Enter -> 生成修改
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.enter): const _GenerateModificationIntent(),
// Ctrl+S -> 保存当前节点内容
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyS): const _SaveNodeIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{
_GenerateModificationIntent: CallbackAction<_GenerateModificationIntent>(
onInvoke: (intent) {
_triggerGenerateModificationViaShortcut();
return null;
},
),
_SaveNodeIntent: CallbackAction<_SaveNodeIntent>(
onInvoke: (intent) {
_triggerSaveNodeContentViaShortcut();
return null;
},
),
},
child: Focus(
focusNode: _focusNode,
autofocus: true,
child: Card(
elevation: 0,
color: Theme.of(context).cardColor.withOpacity(0.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
),
child: Column(
children: [
_buildHeader(),
Expanded(
child: BlocBuilder<SettingGenerationBloc, SettingGenerationState>(
builder: (context, state) {
return _buildContent(context, state);
},
),
),
],
),
),
),
),
);
}
Widget _buildHeader() {
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(
Icons.edit,
size: 20,
color: WebTheme.getPrimaryColor(context),
),
const SizedBox(width: 8),
Text(
'节点编辑',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
);
}
Widget _buildContent(BuildContext context, SettingGenerationState state) {
SettingNode? selectedNode;
bool hasSession = false;
if (state is SettingGenerationInProgress) {
selectedNode = state.selectedNode;
hasSession = true;
} else if (state is SettingGenerationCompleted) {
selectedNode = _findNodeById(state.activeSession.rootNodes, state.selectedNodeId ?? '');
hasSession = true;
} else if (state is SettingGenerationNodeUpdating) {
// 🔧 新增:支持节点修改状态
selectedNode = _findNodeById(state.activeSession.rootNodes, state.selectedNodeId ?? '');
hasSession = true;
}
if (selectedNode != null && selectedNode.id != _currentNodeId) {
_currentNodeId = selectedNode.id;
_descriptionController.text = selectedNode.description;
} else if (selectedNode != null && _currentNodeId == selectedNode.id) {
// 🔧 关键修复:即便选中的节点未变,只要描述发生变化也要同步到输入框
if (_descriptionController.text != selectedNode.description) {
_descriptionController.text = selectedNode.description;
}
} else if (selectedNode == null) {
_currentNodeId = null;
_descriptionController.text = '';
}
if (!hasSession) {
return _buildNoSessionView();
}
if (selectedNode == null) {
return _buildNoSelectionView();
}
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildNodeInfo(selectedNode, hasSession),
const SizedBox(height: 16),
_buildModificationSection(),
const SizedBox(height: 16),
_buildScopeSelector(),
const SizedBox(height: 16),
_buildModelSelector(),
const SizedBox(height: 16),
_buildActionButtons(selectedNode),
],
),
);
}
Widget _buildNoSessionView() {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.psychology_outlined,
size: 48,
color: Theme.of(context).textTheme.bodySmall?.color,
),
const SizedBox(height: 16),
Text(
'无活跃会话',
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 8),
Text(
'请先生成设定或选择已有会话',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color,
),
textAlign: TextAlign.center,
),
],
),
);
}
Widget _buildNoSelectionView() {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.touch_app,
size: 48,
color: Theme.of(context).textTheme.bodySmall?.color,
),
const SizedBox(height: 16),
Text(
'未选中节点',
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 8),
Text(
'请在中间面板中点击一个设定节点进行编辑',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color,
),
textAlign: TextAlign.center,
),
],
),
);
}
Widget _buildNodeInfo(SettingNode node, bool hasSession) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: WebTheme.getPrimaryColor(context).withOpacity(0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: WebTheme.getPrimaryColor(context).withOpacity(0.2),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.label,
size: 16,
color: WebTheme.getPrimaryColor(context),
),
const SizedBox(width: 8),
Expanded(
child: Text(
node.name,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: WebTheme.getPrimaryColor(context),
),
),
),
_buildStatusChip(node.generationStatus),
],
),
const SizedBox(height: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'节点描述',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
color: Theme.of(context).textTheme.bodyMedium?.color,
),
),
const SizedBox(height: 4),
TextField(
controller: _descriptionController,
decoration: InputDecoration(
hintText: '请输入节点描述...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
filled: true,
fillColor: Theme.of(context).colorScheme.surface,
),
maxLines: 4,
enabled: hasSession,
),
const SizedBox(height: 8),
// 保存节点设定按钮
SizedBox(
width: double.infinity,
child: BlocBuilder<SettingGenerationBloc, SettingGenerationState>(
builder: (context, state) {
return ElevatedButton(
onPressed: hasSession && _currentNodeId != null
? () {
// 🔧 简化:直接更新节点内容
context.read<SettingGenerationBloc>().add(
UpdateNodeContentEvent(
nodeId: _currentNodeId!,
content: _descriptionController.text,
),
);
}
: null,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.save, size: 16),
const SizedBox(width: 6),
Text('保存节点设定', style: TextStyle(fontSize: 12)),
],
),
);
},
),
),
],
),
],
),
);
}
Widget _buildStatusChip(GenerationStatus status) {
Color color;
String text;
switch (status) {
case GenerationStatus.pending:
color = Colors.orange;
text = '待生成';
break;
case GenerationStatus.generating:
color = Colors.blue;
text = '生成中';
break;
case GenerationStatus.completed:
color = Colors.green;
text = '已完成';
break;
case GenerationStatus.failed:
color = Colors.red;
text = '失败';
break;
case GenerationStatus.modified:
color = Colors.purple;
text = '已修改';
break;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: color.withOpacity(0.3),
width: 1,
),
),
child: Text(
text,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: color,
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
);
}
Widget _buildModificationSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'修改提示',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
TextField(
controller: _modificationController,
decoration: InputDecoration(
hintText: '描述您希望对此节点做出的修改...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
filled: true,
fillColor: Theme.of(context).colorScheme.surface,
),
maxLines: 4,
),
],
);
}
Widget _buildScopeSelector() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'修改范围',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
value: _selectedScope,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
filled: true,
fillColor: Theme.of(context).colorScheme.surface,
),
items: const [
DropdownMenuItem(
value: 'self',
child: Text('仅当前节点'),
),
DropdownMenuItem(
value: 'self_and_children',
child: Text('当前节点及子节点'),
),
DropdownMenuItem(
value: 'children_only',
child: Text('仅子节点'),
),
],
onChanged: (value) {
if (value != null) {
setState(() {
_selectedScope = value;
});
}
},
),
],
);
}
Widget _buildModelSelector() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'AI模型',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
ModelDisplaySelector(
selectedModel: _selectedModel,
onModelSelected: (model) {
setState(() {
_selectedModel = model;
});
},
size: ModelDisplaySize.medium,
height: 60, // 扩大一倍高度 (36px * 2)
showIcon: true,
showTags: true,
showSettingsButton: false,
placeholder: '选择AI模型',
),
],
);
}
Widget _buildActionButtons(SettingNode node) {
return Column(
children: [
BlocBuilder<SettingGenerationBloc, SettingGenerationState>(
builder: (context, state) {
// 🔧 新增:判断是否正在修改当前节点
bool isCurrentNodeUpdating = false;
if (state is SettingGenerationNodeUpdating) {
isCurrentNodeUpdating = state.updatingNodeId == node.id && state.isUpdating;
}
return SizedBox(
width: double.infinity,
child: ElevatedButton(
// 按钮可用条件:
// 1. 不在当前节点的修改流程中
// 2. 已输入修改提示
// 3. 存在可用的模型配置(下拉框选择或会话默认模型)
onPressed: (isCurrentNodeUpdating ||
_modificationController.text.trim().isEmpty ||
_getModelConfigId(state) == null)
? null
: () {
_handleNodeModification(node);
},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (isCurrentNodeUpdating) ...[
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.onPrimary,
),
),
),
const SizedBox(width: 8),
Text('修改中...'),
] else ...[
const Icon(Icons.auto_fix_high, size: 16),
const SizedBox(width: 8),
Text('生成修改'),
],
],
),
),
);
},
),
const SizedBox(height: 8),
BlocBuilder<SettingGenerationBloc, SettingGenerationState>(
builder: (context, state) {
bool hasPendingChanges = false;
if (state is SettingGenerationInProgress) {
hasPendingChanges = state.pendingChanges.isNotEmpty;
} else if (state is SettingGenerationCompleted) {
hasPendingChanges = state.pendingChanges.isNotEmpty;
} else if (state is SettingGenerationNodeUpdating) {
hasPendingChanges = state.pendingChanges.isNotEmpty;
}
if (!hasPendingChanges) {
return const SizedBox.shrink();
}
return Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {
context.read<SettingGenerationBloc>().add(
const CancelPendingChangesEvent(),
);
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('取消', style: TextStyle(fontSize: 12)),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton(
onPressed: () {
context.read<SettingGenerationBloc>().add(
const ApplyPendingChangesEvent(),
);
},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('应用', style: TextStyle(fontSize: 12)),
),
),
],
);
},
),
],
);
}
/// 在设定节点树中查找指定ID的节点
SettingNode? _findNodeById(List<SettingNode> nodes, String id) {
for (final node in nodes) {
if (node.id == id) {
return node;
}
if (node.children != null) {
final found = _findNodeById(node.children!, id);
if (found != null) {
return found;
}
}
}
return null;
}
void _handleNodeModification(SettingNode node) {
final currentState = context.read<SettingGenerationBloc>().state;
AppLogger.i('EditorPanelWidget', '🔧 开始节点修改 - 当前状态: ${currentState.runtimeType}, 节点ID: ${node.id}');
// 计算模型配置ID优先使用下拉框选择其次使用会话默认值
final modelConfigId = _getModelConfigId(currentState);
if (modelConfigId == null) {
AppLogger.w('EditorPanelWidget', '❌ 未选择模型且会话中也没有默认模型,无法修改');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请先选择AI模型'), backgroundColor: Colors.orange),
);
return;
}
if (currentState is SettingGenerationInProgress ||
currentState is SettingGenerationCompleted ||
currentState is SettingGenerationNodeUpdating) {
AppLogger.i('EditorPanelWidget', '✅ 发送UpdateNodeEvent - 节点ID: ${node.id}');
context.read<SettingGenerationBloc>().add(
UpdateNodeEvent(
nodeId: node.id,
modificationPrompt: _modificationController.text.trim(),
modelConfigId: modelConfigId,
scope: _selectedScope,
),
);
// 清空修改提示词
_modificationController.clear();
} else {
AppLogger.w('EditorPanelWidget', '❌ 当前状态不支持节点修改: ${currentState.runtimeType}');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('当前状态不支持节点修改,请先生成设定或加载历史记录'),
backgroundColor: Colors.orange,
),
);
}
}
/// 获取当前可用的模型配置ID
/// 优先使用用户在下拉框中选择的模型,其次使用会话的默认模型
String? _getModelConfigId(SettingGenerationState state) {
if (_selectedModel != null) {
return _selectedModel!.id;
}
String? fromSession;
Map<String, dynamic>? meta;
if (state is SettingGenerationInProgress) {
fromSession = state.activeSession.modelConfigId;
meta = state.activeSession.metadata;
} else if (state is SettingGenerationCompleted) {
fromSession = state.activeSession.modelConfigId;
meta = state.activeSession.metadata;
} else if (state is SettingGenerationNodeUpdating) {
fromSession = state.activeSession.modelConfigId;
meta = state.activeSession.metadata;
}
// 回退到会话元数据中的 modelConfigId后端通常把它写在metadata里
if (fromSession == null && meta != null) {
final dynamic metaId = meta['modelConfigId'];
if (metaId is String && metaId.isNotEmpty) {
return metaId;
}
}
return null;
}
// ====== 快捷键意图与处理 ======
}
class _GenerateModificationIntent extends Intent {
const _GenerateModificationIntent();
}
class _SaveNodeIntent extends Intent {
const _SaveNodeIntent();
}
extension on _EditorPanelWidgetState {
void _triggerGenerateModificationViaShortcut() {
// 条件:有选中节点 + 有修改提示 + 有模型
if (_currentNodeId == null) return;
if (_modificationController.text.trim().isEmpty) return;
final currentState = context.read<SettingGenerationBloc>().state;
final modelConfigId = _getModelConfigId(currentState);
if (modelConfigId == null) return;
context.read<SettingGenerationBloc>().add(
UpdateNodeEvent(
nodeId: _currentNodeId!,
modificationPrompt: _modificationController.text.trim(),
modelConfigId: modelConfigId,
scope: _selectedScope,
),
);
}
void _triggerSaveNodeContentViaShortcut() {
if (_currentNodeId == null) return;
context.read<SettingGenerationBloc>().add(
UpdateNodeContentEvent(
nodeId: _currentNodeId!,
content: _descriptionController.text,
),
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已提交保存当前节点内容')),
);
}
}

View File

@@ -0,0 +1,681 @@
import 'package:flutter/material.dart';
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../blocs/setting_generation/setting_generation_bloc.dart';
import '../../../blocs/setting_generation/setting_generation_event.dart';
import '../../../blocs/setting_generation/setting_generation_state.dart';
import '../../../models/unified_ai_model.dart';
import '../../../models/strategy_template_info.dart';
import '../../../models/setting_generation_session.dart';
import '../../../widgets/common/model_display_selector.dart';
import '../../../blocs/ai_config/ai_config_bloc.dart';
import 'strategy_selector_dropdown.dart';
/// 生成控制面板
class GenerationControlPanel extends StatefulWidget {
final String? initialPrompt;
final UnifiedAIModel? selectedModel;
final String? initialStrategy;
final Function(String prompt, String strategy, String modelConfigId)? onGenerationStart;
const GenerationControlPanel({
Key? key,
this.initialPrompt,
this.selectedModel,
this.initialStrategy,
this.onGenerationStart,
}) : super(key: key);
@override
State<GenerationControlPanel> createState() => _GenerationControlPanelState();
}
class _GenerationControlPanelState extends State<GenerationControlPanel> {
late TextEditingController _promptController;
late TextEditingController _adjustmentController;
UnifiedAIModel? _selectedModel;
StrategyTemplateInfo? _selectedStrategy;
// 防抖计时器,降低输入频率带来的状态分发与重建
Timer? _adjustmentDebounce;
// 🔧 新增跟踪当前活动的会话ID用于检测会话切换
String? _currentActiveSessionId;
// 🔧 新增:跟踪用户是否手动修改了原始创意,避免覆盖用户输入
bool _userHasModifiedPrompt = false;
@override
void initState() {
super.initState();
_promptController = TextEditingController(text: widget.initialPrompt ?? '');
_adjustmentController = TextEditingController();
// 注意_selectedStrategy 将在策略加载完成后根据 widget.initialStrategy 设置
// 获取用户默认模型配置
final defaultConfig = context.read<AiConfigBloc>().state.defaultConfig ??
(context.read<AiConfigBloc>().state.validatedConfigs.isNotEmpty
? context.read<AiConfigBloc>().state.validatedConfigs.first
: null);
_selectedModel = widget.selectedModel ??
(defaultConfig != null ? PrivateAIModel(defaultConfig) : null);
// 🔧 新增:在初始化时同步当前活动会话的原始创意
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
final currentState = context.read<SettingGenerationBloc>().state;
_handleActiveSessionChange(currentState);
}
});
}
@override
void dispose() {
_promptController.dispose();
_adjustmentController.dispose();
_adjustmentDebounce?.cancel();
super.dispose();
}
/// 🔧 新增:处理活动会话变化,自动填充原始创意
void _handleActiveSessionChange(SettingGenerationState state) {
String? activeSessionId;
SettingGenerationSession? activeSession;
// 从不同状态中提取活动会话信息
if (state is SettingGenerationReady) {
activeSessionId = state.activeSessionId;
if (activeSessionId != null) {
try {
activeSession = state.sessions.firstWhere(
(s) => s.sessionId == activeSessionId,
);
} catch (e) {
activeSession = state.sessions.isNotEmpty ? state.sessions.first : null;
}
}
} else if (state is SettingGenerationInProgress) {
activeSessionId = state.activeSessionId;
activeSession = state.activeSession;
} else if (state is SettingGenerationCompleted) {
activeSessionId = state.activeSessionId;
activeSession = state.activeSession;
} else if (state is SettingGenerationError) {
activeSessionId = state.activeSessionId;
if (activeSessionId != null) {
try {
activeSession = state.sessions.firstWhere(
(s) => s.sessionId == activeSessionId,
);
} catch (e) {
activeSession = state.sessions.isNotEmpty ? state.sessions.first : null;
}
}
}
// 检测会话是否发生变化
if (_currentActiveSessionId != activeSessionId && activeSession != null) {
_currentActiveSessionId = activeSessionId;
// 🎯 核心功能:将历史记录的原始提示词填充到原始创意输入框
final newPrompt = activeSession.initialPrompt;
// 🔧 智能填充:只有在用户未手动修改原始创意时才自动填充
// 或者当前输入框为空时总是填充
final shouldUpdatePrompt = !_userHasModifiedPrompt || _promptController.text.trim().isEmpty;
if (newPrompt.isNotEmpty && _promptController.text != newPrompt && shouldUpdatePrompt) {
if (mounted) {
setState(() {
_promptController.text = newPrompt;
// 重置用户修改标记,因为这是系统自动填充
_userHasModifiedPrompt = false;
});
}
// 📝 记录日志用于调试
print('🔄 历史记录切换 - 原始创意已更新: ${newPrompt.substring(0, newPrompt.length > 50 ? 50 : newPrompt.length)}${newPrompt.length > 50 ? "..." : ""}');
} else if (_userHasModifiedPrompt && newPrompt.isNotEmpty) {
// 📝 用户已修改,不覆盖但记录日志
print('🛡️ 历史记录切换 - 检测到用户已修改原始创意,跳过自动填充以保护用户输入');
}
}
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return BlocListener<SettingGenerationBloc, SettingGenerationState>(
listener: (context, state) {
// 🔧 新增:监听活动会话变化,自动填充原始创意
_handleActiveSessionChange(state);
},
child: Card(
elevation: 0,
color: isDark
? const Color(0xFF1F2937).withOpacity(0.5)
: const Color(0xFFF9FAFB).withOpacity(0.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: isDark
? const Color(0xFF1F2937)
: const Color(0xFFE5E7EB),
width: 1,
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'创作控制台',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
// 🔧 修复:自适应高度,紧凑布局
Flexible(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// 提示词输入区域
BlocBuilder<SettingGenerationBloc, SettingGenerationState>(
builder: (context, state) {
return _buildPromptInput(state);
},
),
const SizedBox(height: 16),
// 策略选择器
_buildStrategySelector(),
const SizedBox(height: 16),
// 模型选择器
_buildModelSelector(),
const SizedBox(height: 24), // 适度间距
// 操作按钮
BlocBuilder<SettingGenerationBloc, SettingGenerationState>(
builder: (context, state) {
return _buildActionButtons(state);
},
),
const SizedBox(height: 16), // 底部留白
],
),
),
),
],
),
),
),
);
}
Widget _buildPromptInput(SettingGenerationState state) {
final hasGeneratedSettings = state is SettingGenerationInProgress ||
state is SettingGenerationCompleted;
if (!hasGeneratedSettings) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'你的核心想法',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: Theme.of(context).textTheme.bodySmall?.color,
),
),
const SizedBox(height: 8),
TextField(
controller: _promptController,
decoration: InputDecoration(
hintText: '例如:一个发生在赛博朋克都市的侦探故事',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
filled: true,
fillColor: Theme.of(context).colorScheme.surface,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
),
// 🔧 修复:设置合理的行数范围,避免布局问题
maxLines: 5,
minLines: 2,
textInputAction: TextInputAction.newline,
onChanged: (value) {
// 🔧 新增:标记用户已手动修改原始创意
_userHasModifiedPrompt = true;
},
),
],
);
} else {
// 🔧 修复:生成完成后显示两个输入框 - 原始提示词和调整提示词
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 原始提示词(只读显示,可以编辑用于新建生成)
Text(
'原始创意',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: Theme.of(context).textTheme.bodySmall?.color,
),
),
const SizedBox(height: 8),
TextField(
controller: _promptController,
decoration: InputDecoration(
hintText: '例如:一个发生在赛博朋克都市的侦探故事',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
filled: true,
fillColor: Theme.of(context).colorScheme.surface,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
),
// 🎯 自适应行数根据内容长度调整最多3行
maxLines: 3,
minLines: 1,
textInputAction: TextInputAction.newline,
onChanged: (value) {
// 🔧 新增:标记用户已手动修改原始创意
_userHasModifiedPrompt = true;
},
),
const SizedBox(height: 16),
// 调整提示词
Text(
'调整设定',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: Theme.of(context).textTheme.bodySmall?.color,
),
),
const SizedBox(height: 8),
TextField(
controller: _adjustmentController,
decoration: InputDecoration(
hintText: '例如:将背景改为蒸汽朋克风格',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
filled: true,
fillColor: Theme.of(context).colorScheme.surface,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
),
// 🔧 修复:设置合理的行数范围,避免布局问题
maxLines: 4,
minLines: 2,
textInputAction: TextInputAction.newline,
onChanged: (value) {
// 250ms 防抖,避免每个字符都触发 BLoC 更新与重建
_adjustmentDebounce?.cancel();
_adjustmentDebounce = Timer(const Duration(milliseconds: 250), () {
if (!mounted) return;
context.read<SettingGenerationBloc>().add(
UpdateAdjustmentPromptEvent(_adjustmentController.text),
);
});
},
),
],
);
}
}
Widget _buildStrategySelector() {
return BlocBuilder<SettingGenerationBloc, SettingGenerationState>(
builder: (context, state) {
List<StrategyTemplateInfo> strategies = []; // 策略列表
bool isLoading = false;
if (state is SettingGenerationReady) {
strategies = state.strategies;
} else if (state is SettingGenerationInProgress) {
strategies = state.strategies;
} else if (state is SettingGenerationCompleted) {
strategies = state.strategies;
} else {
isLoading = true;
}
// 🔧 修复:根据 initialStrategy 初始化选中的策略
if (_selectedStrategy == null && strategies.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
StrategyTemplateInfo? initialSelected;
if (widget.initialStrategy != null) {
// 根据名称查找策略
initialSelected = strategies.firstWhere(
(s) => s.name == widget.initialStrategy,
orElse: () => strategies.first,
);
} else {
initialSelected = strategies.first;
}
setState(() {
_selectedStrategy = initialSelected;
});
}
});
}
// 确保当前选中的策略在可用列表中
if (_selectedStrategy != null && !strategies.contains(_selectedStrategy)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && strategies.isNotEmpty) {
setState(() {
_selectedStrategy = strategies.first;
});
}
});
}
return StrategySelectorDropdown(
strategies: strategies,
selectedStrategy: _selectedStrategy,
isLoading: isLoading || strategies.isEmpty,
onChanged: (value) {
if (value != null) {
setState(() {
_selectedStrategy = value;
});
}
},
);
},
);
}
Widget _buildModelSelector() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'AI模型',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: Theme.of(context).textTheme.bodySmall?.color,
),
),
const SizedBox(height: 8),
ModelDisplaySelector(
selectedModel: _selectedModel,
onModelSelected: (model) {
setState(() {
_selectedModel = model;
});
},
size: ModelDisplaySize.medium,
height: 60, // 扩大一倍高度 (36px * 2)
showIcon: true,
showTags: true,
showSettingsButton: false,
placeholder: '选择AI模型',
),
],
);
}
Widget _buildActionButtons(SettingGenerationState state) {
final hasGeneratedSettings = state is SettingGenerationInProgress ||
state is SettingGenerationCompleted;
final isGenerating = state is SettingGenerationInProgress && state.isGenerating;
if (!hasGeneratedSettings) {
return SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: isGenerating || _selectedModel == null || _promptController.text.trim().isEmpty
? null
: () {
final prompt = _promptController.text.trim();
final strategy = _selectedStrategy;
final modelConfigId = _selectedModel!.id;
if (strategy != null) {
// 通知主屏幕更新参数 - 传递策略名称用于显示
widget.onGenerationStart?.call(prompt, strategy.name, modelConfigId);
final model = _selectedModel!;
final bool usePublic = model.isPublic;
final String? publicProvider = usePublic ? model.provider : null;
final String? publicModelId = usePublic ? model.modelId : null;
context.read<SettingGenerationBloc>().add(
StartGenerationEvent(
initialPrompt: prompt,
promptTemplateId: strategy.promptTemplateId, // 🔧 修复使用策略ID而非名称
modelConfigId: modelConfigId,
usePublicTextModel: usePublic,
textPhasePublicProvider: publicProvider,
textPhasePublicModelId: publicModelId,
),
);
}
},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: isGenerating
? Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.onPrimary,
),
),
),
const SizedBox(width: 8),
const Text('生成中...'),
],
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.auto_awesome,
size: 16,
color: Theme.of(context).colorScheme.onPrimary,
),
const SizedBox(width: 8),
const Text('生成设定'),
],
),
),
);
} else {
// 🔧 修复:生成完成后的按钮逻辑
return Column(
children: [
// 新建生成按钮 - 基于当前配置重新生成
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: isGenerating || _selectedModel == null
? null
: () {
// 使用原始提示词和当前配置重新生成
final prompt = _promptController.text.trim();
final strategy = _selectedStrategy;
final modelConfigId = _selectedModel!.id;
if (prompt.isNotEmpty && strategy != null) {
// 通知主屏幕更新参数 - 传递策略名称用于显示
widget.onGenerationStart?.call(prompt, strategy.name, modelConfigId);
final model = _selectedModel!;
final bool usePublic = model.isPublic;
final String? publicProvider = usePublic ? model.provider : null;
final String? publicModelId = usePublic ? model.modelId : null;
context.read<SettingGenerationBloc>().add(
StartGenerationEvent(
initialPrompt: prompt,
promptTemplateId: strategy.promptTemplateId,
modelConfigId: modelConfigId,
usePublicTextModel: usePublic,
textPhasePublicProvider: publicProvider,
textPhasePublicModelId: publicModelId,
),
);
}
},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.auto_awesome,
size: 16,
color: Theme.of(context).colorScheme.onPrimary,
),
const SizedBox(width: 8),
const Text('新建生成'),
],
),
),
),
const SizedBox(height: 8),
// 调整生成按钮行
// Row(
// children: [
// // --- 调整生成按钮(改为基于会话整体调整) ---
// Expanded(
// child: ElevatedButton(
// onPressed: isGenerating || _selectedModel == null || _adjustmentController.text.trim().isEmpty
// ? null
// : () {
// final prompt = _adjustmentController.text.trim();
// final modelConfigId = _selectedModel!.id;
// // 读取当前活跃会话ID
// final currentState = context.read<SettingGenerationBloc>().state;
// String? sessionId;
// if (currentState is SettingGenerationInProgress) {
// sessionId = currentState.activeSessionId;
// } else if (currentState is SettingGenerationCompleted) {
// sessionId = currentState.activeSessionId;
// }
// if (sessionId != null && sessionId.isNotEmpty) {
// // 推测当前策略模板ID若可获取
// String? promptTemplateId;
// final state = context.read<SettingGenerationBloc>().state;
// if (state is SettingGenerationInProgress) {
// promptTemplateId = state.activeSession.metadata['promptTemplateId'] as String?;
// } else if (state is SettingGenerationCompleted) {
// promptTemplateId = state.activeSession.metadata['promptTemplateId'] as String?;
// }
// // 优先使用当前选择的策略模板ID
// if (_selectedStrategy != null) {
// promptTemplateId = _selectedStrategy!.promptTemplateId;
// }
// context.read<SettingGenerationBloc>().add(
// AdjustGenerationEvent(
// sessionId: sessionId,
// adjustmentPrompt: prompt,
// modelConfigId: modelConfigId,
// promptTemplateId: promptTemplateId,
// ),
// );
// }
// },
// style: ElevatedButton.styleFrom(
// padding: const EdgeInsets.symmetric(vertical: 10),
// shape: RoundedRectangleBorder(
// borderRadius: BorderRadius.circular(8),
// ),
// ),
// child: Row(
// mainAxisAlignment: MainAxisAlignment.center,
// children: [
// Icon(
// Icons.refresh,
// size: 14,
// color: Theme.of(context).colorScheme.onPrimary,
// ),
// const SizedBox(width: 4),
// const Text('调整生成', style: TextStyle(fontSize: 12)),
// ],
// ),
// ),
// ),
// const SizedBox(width: 8),
// // --- 创建分支按钮 ---
// Expanded(
// child: Tooltip(
// message: '基于当前设定和调整提示词创建新的历史记录',
// child: ElevatedButton(
// onPressed: isGenerating || _selectedModel == null || _adjustmentController.text.trim().isEmpty
// ? null
// : () {
// final prompt = _adjustmentController.text.trim();
// final strategy = _selectedStrategy;
// final modelConfigId = _selectedModel!.id;
// if (strategy != null) {
// // 通知主屏幕更新参数 - 传递策略名称用于显示
// widget.onGenerationStart?.call(prompt, strategy.name, modelConfigId);
// // 创建分支
// context.read<SettingGenerationBloc>().add(
// StartGenerationEvent(
// initialPrompt: prompt,
// promptTemplateId: strategy.promptTemplateId, // 🔧 修复使用策略ID而非名称
// modelConfigId: modelConfigId,
// ),
// );
// }
// },
// style: ElevatedButton.styleFrom(
// padding: const EdgeInsets.symmetric(vertical: 10),
// shape: RoundedRectangleBorder(
// borderRadius: BorderRadius.circular(8),
// ),
// ),
// child: Row(
// mainAxisAlignment: MainAxisAlignment.center,
// children: [
// Icon(
// Icons.call_split,
// size: 14,
// color: Theme.of(context).colorScheme.onPrimary,
// ),
// const SizedBox(width: 4),
// const Text('创建分支', style: TextStyle(fontSize: 12)),
// ],
// ),
// ),
// ),
// ),
// ],
// ),
],
);
}
}
}

View File

@@ -0,0 +1,529 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:ainoval/models/unified_ai_model.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/widgets/common/index.dart';
import 'package:ainoval/widgets/common/unified_ai_model_dropdown.dart';
import 'package:ainoval/widgets/common/top_toast.dart';
import 'package:ainoval/models/ai_request_models.dart';
import 'package:ainoval/models/user_ai_model_config_model.dart';
import 'package:ainoval/blocs/ai_config/ai_config_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/utils/context_selection_helper.dart';
import 'package:ainoval/models/context_selection_models.dart';
import 'package:ainoval/config/app_config.dart';
import 'package:ainoval/blocs/setting_generation/setting_generation_bloc.dart';
import 'package:ainoval/blocs/setting_generation/setting_generation_state.dart';
import 'package:ainoval/services/api_service/repositories/setting_generation_repository.dart';
// import 'package:ainoval/blocs/novel_list/novel_list_bloc.dart';
import 'package:ainoval/screens/editor/editor_screen.dart';
import 'package:ainoval/models/novel_summary.dart';
import 'package:ainoval/blocs/setting_generation/setting_generation_event.dart';
import 'package:ainoval/utils/logger.dart';
import 'package:ainoval/models/compose_preview.dart';
import 'dart:async';
import 'package:ainoval/models/setting_generation_session.dart';
import 'package:ainoval/widgets/common/compose/chapter_count_field.dart';
import 'package:ainoval/widgets/common/compose/chapter_length_field.dart';
import 'package:ainoval/widgets/common/compose/include_depth_field.dart';
class GoldenThreeChaptersDialog extends StatefulWidget {
const GoldenThreeChaptersDialog({
super.key,
this.novel,
this.settings = const [],
this.settingGroups = const [],
this.snippets = const [],
this.initialSelectedUnifiedModel,
this.settingSessionId,
this.onStarted,
});
final Novel? novel;
final List<NovelSettingItem> settings;
final List<SettingGroup> settingGroups;
final List<NovelSnippet> snippets;
final UnifiedAIModel? initialSelectedUnifiedModel;
final String? settingSessionId;
final VoidCallback? onStarted; // 新增:开始生成回调
@override
State<GoldenThreeChaptersDialog> createState() => _GoldenThreeChaptersDialogState();
}
class _GoldenThreeChaptersDialogState extends State<GoldenThreeChaptersDialog> {
// 基础
final TextEditingController _instructionsController = TextEditingController();
UnifiedAIModel? _selectedModel;
final GlobalKey _modelSelectorKey = GlobalKey();
// 上下文
late ContextSelectionData _contextSelectionData;
bool _enableSmartContext = true;
bool _associateSettingTree = true; // 是否把当前设定Session关联为小说设定
bool _includeWholeSettingTree = true; // 是否将整个设定树纳入上下文
// 章节参数
String _mode = 'chapters'; // outline | chapters | outline_plus_chapters
int _chapterCount = 3;
String _includeDepth = 'summaryOnly';
String? _lengthPreset; // short|medium|long
String _customLength = '';
double _temperature = 0.7;
double _topP = 0.9;
String? _promptTemplateId;
String? _s2sTemplateId; // 仅“先大纲后章节”使用的 SUMMARY_TO_SCENE 模板ID
OverlayEntry? _tempOverlay;
bool _previewRequested = false;
// 写作就绪(由后端发出的 composeReady 信号控制)
ComposeReadyInfo? _composeReady;
StreamSubscription<ComposeReadyInfo>? _composeReadySub;
@override
void initState() {
super.initState();
_selectedModel = widget.initialSelectedUnifiedModel;
_contextSelectionData = ContextSelectionHelper.initializeContextData(
novel: widget.novel,
settings: widget.settings,
settingGroups: widget.settingGroups,
snippets: widget.snippets,
);
// 订阅后端就绪信号,仅当当前对话所对应的 sessionId 匹配时更新
try {
final bloc = context.read<SettingGenerationBloc>();
_composeReadySub = bloc.composeReadyStream.listen((info) {
if (widget.settingSessionId != null && (widget.settingSessionId!.isNotEmpty)) {
if (info.sessionId != widget.settingSessionId) return;
}
if (mounted) {
setState(() => _composeReady = info);
} else {
_composeReady = info;
}
});
} catch (_) {}
}
int _mapLengthToMaxTokens(String? preset, String custom) {
// 简单映射:可按模型上限调整
if (preset == 'short') return 1500;
if (preset == 'medium') return 3000;
if (preset == 'long') return 4500;
// 自定义数字(若用户直接输入数字)
final n = int.tryParse(custom.trim());
if (n != null && n > 0) return n;
// 默认
return 3000;
}
Widget _buildModeSelector() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('生成模式', style: TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 8,
children: [
ChoiceChip(
label: const Text('只生成大纲'),
selected: _mode == 'outline',
onSelected: (_) => setState(() => _mode = 'outline'),
),
ChoiceChip(
label: const Text('直接生成章节'),
selected: _mode == 'chapters',
onSelected: (_) => setState(() => _mode = 'chapters'),
),
ChoiceChip(
label: const Text('先大纲后章节'),
selected: _mode == 'outline_plus_chapters',
onSelected: (_) => setState(() => _mode = 'outline_plus_chapters'),
),
],
),
const SizedBox(height: 4),
Text(
_mode == 'outline'
? '只输出分章节大纲(不生成正文)'
: _mode == 'outline_plus_chapters'
? '先输出大纲,再按大纲逐章生成正文'
: '直接生成章节概要与正文',
style: Theme.of(context).textTheme.bodySmall,
)
],
);
}
@override
void dispose() {
_instructionsController.dispose();
_tempOverlay?.remove();
_composeReadySub?.cancel();
super.dispose();
}
bool _canStartWriting() {
final info = _composeReady;
if (info == null) return false; // 默认为不可用,直到收到服务器就绪信号
if (widget.settingSessionId != null && (widget.settingSessionId!.isNotEmpty)) {
if (info.sessionId != widget.settingSessionId) return false;
}
return info.ready;
}
String _notReadyReasonText() {
final r = (_composeReady?.reason ?? '').trim();
switch (r) {
case 'no_session':
return '未绑定会话(等待会话建立或绑定完成)';
case 'no_novelId':
return '未提供小说ID请确保 novelId 已在请求中传递)';
case 'ok':
return '';
default:
return '内容保存/绑定进行中,请稍候';
}
}
@override
Widget build(BuildContext context) {
return FormDialogTemplate(
title: '生成黄金三章',
tabs: const [
TabItem(id: 'tweak', label: '调整', icon: Icons.edit)
],
tabContents: [
_buildTweakTab(context),
],
showPresets: true,
usePresetDropdown: true,
presetFeatureType: AIRequestType.novelCompose.value,
novelId: widget.novel?.id,
showModelSelector: true,
modelSelectorData: _selectedModel != null
? ModelSelectorData(modelName: _selectedModel!.displayName, maxOutput: '~12000 words', isModerated: true)
: const ModelSelectorData(modelName: '选择模型'),
onModelSelectorTap: _showModelSelectorDropdown,
modelSelectorKey: _modelSelectorKey,
primaryActionLabel: '开始生成',
onPrimaryAction: _handleGenerate,
onClose: () => Navigator.of(context).pop(),
);
}
Widget _buildTweakTab(BuildContext context) {
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FormFieldFactory.createMultiSelectInstructionsWithPresetsField(
controller: _instructionsController,
presets: const [],
title: '生成指令',
description: '说明黄金三章的风格、节奏、冲突等',
placeholder: '例如:家庭悬疑氛围、快节奏、强和弦结尾',
),
const SizedBox(height: 16),
// 生成模式选择
_buildModeSelector(),
const SizedBox(height: 16),
ChapterCountField(value: _chapterCount, onChanged: (v) => setState(() => _chapterCount = v)),
const SizedBox(height: 16),
ChapterLengthField(
preset: _lengthPreset,
customLength: _customLength,
onPresetChanged: (v) => setState(() { _lengthPreset = v; _customLength = ''; }),
onCustomChanged: (v) => setState(() { _lengthPreset = null; _customLength = v; }),
),
const SizedBox(height: 16),
IncludeDepthField(value: _includeDepth, onChanged: (v) => setState(() => _includeDepth = v)),
const SizedBox(height: 16),
SmartContextToggle(
value: _associateSettingTree,
onChanged: (v) => setState(() => _associateSettingTree = v),
title: '关联设定树到小说',
description: '首次生成时将当前设定Session转换为小说设定并与小说关联',
),
const SizedBox(height: 12),
SmartContextToggle(
value: _includeWholeSettingTree,
onChanged: (v) => setState(() => _includeWholeSettingTree = v),
title: '上下文包含整个设定树',
description: '将当前设定Session的全部节点作为上下文配合上方“上下文深度”使用',
),
const SizedBox(height: 16),
FormFieldFactory.createContextSelectionField(
contextData: _contextSelectionData,
onSelectionChanged: (d) => setState(() => _contextSelectionData = d),
title: '附加上下文',
description: '设定/片段等信息作为生成上下文',
initialChapterId: null,
initialSceneId: null,
),
const SizedBox(height: 16),
FormFieldFactory.createPromptTemplateSelectionField(
selectedTemplateId: _promptTemplateId,
onTemplateSelected: (id) => setState(() => _promptTemplateId = id),
aiFeatureType: AIRequestType.novelCompose.value,
title: '提示词模板(可选)',
description: '选择一个模板作为生成基准',
),
if (_mode == 'outline_plus_chapters') ...[
const SizedBox(height: 12),
// 复用公共“关联提示词组件”,指定 SUMMARY_TO_SCENE 类型
FormFieldFactory.createPromptTemplateSelectionField(
selectedTemplateId: _s2sTemplateId,
onTemplateSelected: (id) => setState(() => _s2sTemplateId = id),
aiFeatureType: 'SUMMARY_TO_SCENE',
title: '章节正文模板(摘要转场景)',
description: '仅先大纲后章节时生效,用于生成每章正文',
),
],
const SizedBox(height: 16),
FormFieldFactory.createTemperatureSliderField(
context: context,
value: _temperature,
onChanged: (v) => setState(() => _temperature = v),
onReset: () => setState(() => _temperature = 0.7),
),
const SizedBox(height: 12),
FormFieldFactory.createTopPSliderField(
context: context,
value: _topP,
onChanged: (v) => setState(() => _topP = v),
onReset: () => setState(() => _topP = 0.9),
),
],
),
);
}
void _showModelSelectorDropdown() {
if (_tempOverlay != null) return;
final box = (_modelSelectorKey.currentContext?.findRenderObject() as RenderBox?);
final rect = box != null
? box.localToGlobal(Offset.zero) & box.size
: Rect.fromLTWH(0, 0, 200, 40);
_tempOverlay = UnifiedAIModelDropdown.show(
context: context,
anchorRect: rect,
selectedModel: _selectedModel,
onModelSelected: (m) => setState(() => _selectedModel = m),
showSettingsButton: true,
novel: widget.novel,
settings: widget.settings,
settingGroups: widget.settingGroups,
snippets: widget.snippets,
onClose: () => _tempOverlay = null,
);
}
UniversalAIRequest? _buildPreviewRequest() {
if (_selectedModel == null) return null;
final model = _selectedModel!;
final modelConfig = model.isPublic
? createPublicModelConfig(model)
: (model as PrivateAIModel).userConfig;
final meta = <String, dynamic>{
'modelConfigId': model.id,
};
if (model.isPublic) {
meta['isPublicModel'] = true;
meta['publicModelConfigId'] = model.id;
meta['publicModelId'] = model.id;
}
return UniversalAIRequest(
requestType: AIRequestType.novelCompose,
userId: AppConfig.userId ?? 'unknown',
novelId: widget.novel?.id,
settingSessionId: widget.settingSessionId,
modelConfig: modelConfig,
instructions: _instructionsController.text.trim().isEmpty ? null : _instructionsController.text.trim(),
contextSelections: _contextSelectionData,
enableSmartContext: _enableSmartContext,
parameters: {
'mode': _mode,
'chapterCount': _chapterCount,
'length': _lengthPreset ?? _customLength,
'include': _includeDepth,
'includeWholeSettingTree': _includeWholeSettingTree,
'temperature': _temperature,
'topP': _topP,
'promptTemplateId': _promptTemplateId,
'enableSmartContext': _enableSmartContext,
if (_mode == 'outline_plus_chapters' && _s2sTemplateId != null)
's2sTemplateId': _s2sTemplateId,
},
metadata: meta,
);
}
void _handleGenerate() async {
try {
if (_selectedModel == null) {
TopToast.error(context, '请选择AI模型');
return;
}
final model = _selectedModel!;
// 积分预估(公共模型时)
if (model.isPublic) {
final req = _buildPreviewRequest();
if (req == null) {
TopToast.warning(context, '表单不完整');
return;
}
context.read<UniversalAIBloc>().add(EstimateCostEvent(req));
// 简化:不拦截确认,直接继续
}
// 派发到 BLoC由 BLoC 统一组装 UniversalAIRequest 并流式生成)
// UI切换到结果预览
widget.onStarted?.call();
final commonContextSelections = {
'contextSelections': _contextSelectionData.selectedItems.values
.map((e) => {
'id': e.id,
'title': e.title,
'type': e.type.value,
'metadata': e.metadata,
'parentId': e.parentId,
})
.toList(),
'enableSmartContext': _enableSmartContext,
};
final commonParams = {
'length': _lengthPreset ?? _customLength,
'include': _includeDepth,
'includeWholeSettingTree': _includeWholeSettingTree,
'temperature': _temperature,
'topP': _topP,
'promptTemplateId': _promptTemplateId,
'enableSmartContext': _enableSmartContext,
// 根据长度预设/自定义映射合理的maxTokens减少LENGTH截断
'maxTokens': _mapLengthToMaxTokens(_lengthPreset, _customLength),
};
switch (_mode) {
case 'outline':
context.read<SettingGenerationBloc>().add(StartComposeOutlineEvent(
userId: AppConfig.userId ?? 'unknown',
modelConfigId: model.id,
isPublicModel: model.isPublic,
publicModelConfigId: model.isPublic ? model.id : null,
novelId: widget.novel?.id,
settingSessionId: _associateSettingTree ? widget.settingSessionId : null,
contextSelections: commonContextSelections,
instructions: _instructionsController.text.trim().isEmpty ? null : _instructionsController.text.trim(),
chapterCount: _chapterCount,
parameters: commonParams,
));
break;
case 'outline_plus_chapters':
final bundleParams = {
...commonParams,
if (_s2sTemplateId != null) 's2sTemplateId': _s2sTemplateId,
};
context.read<SettingGenerationBloc>().add(StartComposeBundleEvent(
userId: AppConfig.userId ?? 'unknown',
modelConfigId: model.id,
isPublicModel: model.isPublic,
publicModelConfigId: model.isPublic ? model.id : null,
novelId: widget.novel?.id,
settingSessionId: _associateSettingTree ? widget.settingSessionId : null,
contextSelections: commonContextSelections,
instructions: _instructionsController.text.trim().isEmpty ? null : _instructionsController.text.trim(),
chapterCount: _chapterCount,
parameters: bundleParams,
));
break;
case 'chapters':
default:
context.read<SettingGenerationBloc>().add(StartComposeChaptersEvent(
userId: AppConfig.userId ?? 'unknown',
modelConfigId: model.id,
isPublicModel: model.isPublic,
publicModelConfigId: model.isPublic ? model.id : null,
novelId: widget.novel?.id,
settingSessionId: _associateSettingTree ? widget.settingSessionId : null,
contextSelections: commonContextSelections,
instructions: _instructionsController.text.trim().isEmpty ? null : _instructionsController.text.trim(),
chapterCount: _chapterCount,
parameters: commonParams,
));
}
Navigator.of(context).pop();
TopToast.success(context, '已开始生成黄金三章');
} catch (e, st) {
AppLogger.e('GoldenThreeChaptersDialog', '启动生成失败', e, st);
TopToast.error(context, '启动生成失败:$e');
}
}
// 为公共模型创建临时配置
UserAIModelConfigModel createPublicModelConfig(UnifiedAIModel model) {
final public = (model as PublicAIModel).publicConfig;
return UserAIModelConfigModel.fromJson({
'id': public.id,
'userId': AppConfig.userId ?? 'unknown',
'alias': public.displayName,
'modelName': public.modelId,
'provider': public.provider,
'apiEndpoint': '',
'isDefault': false,
'isValidated': true,
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
});
}
}
void showGoldenThreeChaptersDialog(
BuildContext context, {
Novel? novel,
List<NovelSettingItem> settings = const [],
List<SettingGroup> settingGroups = const [],
List<NovelSnippet> snippets = const [],
UnifiedAIModel? initialSelectedUnifiedModel,
String? settingSessionId,
VoidCallback? onStarted,
}) {
showDialog(
context: context,
barrierDismissible: true,
builder: (dialogContext) => MultiBlocProvider(
providers: [
BlocProvider.value(value: context.read<AiConfigBloc>()),
BlocProvider.value(value: context.read<UniversalAIBloc>()),
],
child: GoldenThreeChaptersDialog(
novel: novel,
settings: settings,
settingGroups: settingGroups,
snippets: snippets,
initialSelectedUnifiedModel: initialSelectedUnifiedModel,
settingSessionId: settingSessionId,
onStarted: onStarted,
),
),
);
}

View File

@@ -0,0 +1,275 @@
import 'package:flutter/material.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../blocs/setting_generation/setting_generation_bloc.dart';
import '../../../blocs/setting_generation/setting_generation_event.dart';
import '../../../blocs/setting_generation/setting_generation_state.dart';
import '../../../models/setting_generation_session.dart';
/// 历史面板组件
class HistoryPanelWidget extends StatelessWidget {
const HistoryPanelWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Card(
elevation: 0,
color: Theme.of(context).cardColor.withOpacity(0.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
),
child: Column(
children: [
_buildHeader(context),
Expanded(
child: BlocBuilder<SettingGenerationBloc, SettingGenerationState>(
builder: (context, state) {
return _buildSessionList(context, state);
},
),
),
],
),
);
}
Widget _buildHeader(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Text(
'历史记录',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const Spacer(),
IconButton(
onPressed: () {
context.read<SettingGenerationBloc>().add(
const CreateNewSessionEvent(),
);
},
icon: const Icon(Icons.add_circle_outline),
iconSize: 20,
tooltip: '新建会话',
),
],
),
);
}
Widget _buildSessionList(BuildContext context, SettingGenerationState state) {
List<SettingGenerationSession> sessions = [];
String? activeSessionId;
if (state is SettingGenerationReady) {
sessions = state.sessions;
activeSessionId = state.activeSessionId;
} else if (state is SettingGenerationInProgress) {
sessions = state.sessions;
activeSessionId = state.activeSessionId;
} else if (state is SettingGenerationCompleted) {
sessions = state.sessions;
activeSessionId = state.activeSessionId;
} else if (state is SettingGenerationNodeUpdating) {
sessions = state.sessions;
activeSessionId = state.activeSessionId;
} else if (state is SettingGenerationSaved) {
sessions = state.sessions;
activeSessionId = state.activeSessionId;
} else if (state is SettingGenerationError) {
sessions = state.sessions;
activeSessionId = state.activeSessionId;
}
if (sessions.isEmpty) {
return _buildEmptyView(context);
}
return Padding(
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: ListView.builder(
itemCount: sessions.length,
itemBuilder: (context, index) {
final session = sessions[index];
final isActive = session.sessionId == activeSessionId;
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: _buildSessionItem(
context,
session,
isActive,
),
);
},
),
);
}
Widget _buildEmptyView(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.history,
size: 32,
color: Theme.of(context).textTheme.bodySmall?.color,
),
const SizedBox(height: 12),
Text(
'暂无历史记录',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color,
),
),
],
),
);
}
Widget _buildSessionItem(
BuildContext context,
SettingGenerationSession session,
bool isActive,
) {
return InkWell(
onTap: () {
// 判断是否为历史会话(已保存的会话)
final isHistorySession = session.status == SessionStatus.saved;
final needFetch = session.rootNodes.isEmpty;
if (isHistorySession || needFetch) {
// saved 会话 或者 节点为空的会话,都尝试从后端拉取完整数据
context.read<SettingGenerationBloc>().add(
CreateSessionFromHistoryEvent(
historyId: session.sessionId,
userId: session.userId,
editReason: '查看历史设定',
modelConfigId: session.modelConfigId ?? 'default',
),
);
} else {
// 本地已有节点数据,直接切换
context.read<SettingGenerationBloc>().add(
SelectSessionEvent(session.sessionId, isHistorySession: false),
);
}
},
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isActive
? WebTheme.getPrimaryColor(context).withOpacity(0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: isActive
? Border.all(
color: WebTheme.getPrimaryColor(context).withOpacity(0.3),
width: 1,
)
: null,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildStatusIcon(session.status),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_getSessionTitle(session),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: isActive
? WebTheme.getPrimaryColor(context)
: null,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
_formatDateTime(session.createdAt),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color,
),
),
],
),
),
],
),
),
);
}
Widget _buildStatusIcon(SessionStatus status) {
IconData icon;
Color color;
switch (status) {
case SessionStatus.initializing:
icon = Icons.pending;
color = Colors.orange;
break;
case SessionStatus.generating:
icon = Icons.autorenew;
color = Colors.blue;
break;
case SessionStatus.completed:
icon = Icons.check_circle;
color = Colors.green;
break;
case SessionStatus.error:
icon = Icons.error;
color = Colors.red;
break;
case SessionStatus.saved:
icon = Icons.cloud_done;
color = Colors.teal;
break;
}
return Icon(
icon,
size: 16,
color: color,
);
}
String _getSessionTitle(SettingGenerationSession session) {
final prompt = session.initialPrompt;
if (prompt.length > 30) {
return '${prompt.substring(0, 27)}...';
}
return prompt.isEmpty ? '新的创作...' : prompt;
}
String _formatDateTime(DateTime dateTime) {
final now = DateTime.now();
final difference = now.difference(dateTime);
if (difference.inMinutes < 1) {
return '刚刚';
} else if (difference.inMinutes < 60) {
return '${difference.inMinutes}分钟前';
} else if (difference.inHours < 24) {
return '${difference.inHours}小时前';
} else {
return '${dateTime.month}/${dateTime.day} ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
}
}
}

View File

@@ -0,0 +1,249 @@
import 'package:flutter/material.dart';
import 'ai_shimmer_placeholder.dart';
class ChapterPreviewData {
final String title;
final String outline;
final String content;
const ChapterPreviewData({
required this.title,
required this.outline,
required this.content,
});
ChapterPreviewData copyWith({String? title, String? outline, String? content}) {
return ChapterPreviewData(
title: title ?? this.title,
outline: outline ?? this.outline,
content: content ?? this.content,
);
}
}
class ResultsPreviewPanel extends StatefulWidget {
final List<ChapterPreviewData> chapters;
final bool isGenerating;
final void Function(int index, ChapterPreviewData updated) onChapterChanged;
const ResultsPreviewPanel({
Key? key,
required this.chapters,
required this.isGenerating,
required this.onChapterChanged,
}) : super(key: key);
@override
State<ResultsPreviewPanel> createState() => _ResultsPreviewPanelState();
}
class _ResultsPreviewPanelState extends State<ResultsPreviewPanel> with TickerProviderStateMixin {
TabController? _tabController; // 允许为空:当无章节时不创建
List<TextEditingController> _outlineCtrls = const [];
List<TextEditingController> _contentCtrls = const [];
int _selectedTabIndex = 0;
@override
void initState() {
super.initState();
// 仅当有章节时初始化控制器,避免 TabController 长度为 0 的错误
if (widget.chapters.isNotEmpty) {
_initControllers();
}
}
@override
void didUpdateWidget(covariant ResultsPreviewPanel oldWidget) {
super.didUpdateWidget(oldWidget);
// 当从无到有或长度变化时,重建控制器
if (oldWidget.chapters.length != widget.chapters.length) {
_disposeControllers();
if (widget.chapters.isNotEmpty) {
_initControllers();
}
return;
}
// 同步内容(有章节时)
if (widget.chapters.isNotEmpty &&
_outlineCtrls.length == widget.chapters.length &&
_contentCtrls.length == widget.chapters.length) {
for (int i = 0; i < widget.chapters.length; i++) {
_outlineCtrls[i].text = widget.chapters[i].outline;
_contentCtrls[i].text = widget.chapters[i].content;
}
}
}
void _initControllers() {
final tabLen = (widget.chapters.length * 2).clamp(1, 1000); // 至少为1
_tabController = TabController(length: tabLen, vsync: this);
_tabController!.addListener(() {
final currentIndex = _tabController?.index ?? _selectedTabIndex;
if (_selectedTabIndex != currentIndex) {
setState(() {
_selectedTabIndex = currentIndex;
});
}
});
_outlineCtrls = List.generate(widget.chapters.length, (i) => TextEditingController(text: widget.chapters[i].outline));
_contentCtrls = List.generate(widget.chapters.length, (i) => TextEditingController(text: widget.chapters[i].content));
}
void _disposeControllers() {
_tabController?.dispose();
_tabController = null;
for (final c in _outlineCtrls) {
c.dispose();
}
for (final c in _contentCtrls) {
c.dispose();
}
_outlineCtrls = const [];
_contentCtrls = const [];
}
@override
void dispose() {
_disposeControllers();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.chapters.isEmpty) {
return widget.isGenerating
? const AIShimmerPlaceholder()
: _buildEmptyResults(context, '暂无结果,点击右上角生成');
}
// 确保在首次有章节时已初始化控制器(防御性)
if (_tabController == null) {
_initControllers();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 多行自适应子Tab
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: _buildMultiLineTabs(context),
),
Expanded(
child: TabBarView(
controller: _tabController!,
children: _buildTabViews(context),
),
),
],
);
}
// 多行自适应标签头
Widget _buildMultiLineTabs(BuildContext context) {
final chips = <Widget>[];
for (int i = 0; i < widget.chapters.length; i++) {
final title = (widget.chapters[i].title.isNotEmpty) ? widget.chapters[i].title : '无标题';
chips.add(_buildTabChip(context, index: i * 2, label: '${i + 1}章-$title-大纲'));
chips.add(_buildTabChip(context, index: i * 2 + 1, label: '${i + 1}章-$title-正文'));
}
return Wrap(
spacing: 8,
runSpacing: 8,
children: chips,
);
}
Widget _buildTabChip(BuildContext context, {required int index, required String label}) {
final bool selected = index == _selectedTabIndex;
final theme = Theme.of(context);
final selectedBg = theme.colorScheme.primary.withOpacity(0.12);
final borderColor = selected ? theme.colorScheme.primary : theme.dividerColor;
final textStyle = selected
? theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.primary, fontWeight: FontWeight.w600)
: theme.textTheme.bodyMedium;
return InkWell(
onTap: () {
setState(() {
_selectedTabIndex = index;
_tabController?.animateTo(index);
});
},
borderRadius: BorderRadius.circular(10),
child: Container(
constraints: const BoxConstraints(maxWidth: 220),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: selected ? selectedBg : Colors.transparent,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: borderColor, width: 1),
),
child: Text(
label,
softWrap: true,
overflow: TextOverflow.fade,
maxLines: 2,
style: textStyle,
),
),
);
}
List<Widget> _buildTabViews(BuildContext context) {
final List<Widget> views = [];
for (int i = 0; i < widget.chapters.length; i++) {
views.add(_buildPlainEditor(context, i, isOutline: true));
views.add(_buildPlainEditor(context, i, isOutline: false));
}
return views;
}
// 极简编辑器:
// - 无背景、无内边距
// - 自适应高度minLines=1, maxLines=null
// - 无头部小标签
Widget _buildPlainEditor(BuildContext context, int index, {required bool isOutline}) {
final controller = isOutline ? _outlineCtrls[index] : _contentCtrls[index];
final onChanged = (String text) {
if (isOutline) {
widget.onChapterChanged(index, widget.chapters[index].copyWith(outline: text));
} else {
widget.onChapterChanged(index, widget.chapters[index].copyWith(content: text));
}
};
return SingleChildScrollView(
padding: EdgeInsets.zero,
child: TextField(
controller: controller,
decoration: const InputDecoration(
border: InputBorder.none,
isCollapsed: true,
contentPadding: EdgeInsets.zero,
hintText: '',
),
keyboardType: TextInputType.multiline,
minLines: 1,
maxLines: null,
onChanged: onChanged,
),
);
}
Widget _buildEmptyResults(BuildContext context, String message) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.menu_book_outlined, size: 48, color: isDark ? const Color(0xFF9CA3AF) : const Color(0xFF6B7280)),
const SizedBox(height: 12),
Text(message, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium),
],
),
),
);
}
}

View File

@@ -0,0 +1,432 @@
import 'package:flutter/material.dart';
import 'package:ainoval/utils/web_theme.dart';
import '../../../models/setting_node.dart';
import '../../../blocs/setting_generation/setting_generation_state.dart'; // 导入渲染状态
/// 设定节点组件
class SettingNodeWidget extends StatefulWidget {
final SettingNode node;
final String? selectedNodeId;
final String viewMode;
final int level;
final Function(String nodeId) onTap;
// 渲染状态参数
final Set<String> renderedNodeIds;
final Map<String, NodeRenderInfo> nodeRenderStates;
// 是否渲染子节点(用于流式列表避免重复渲染)
final bool renderChildren;
const SettingNodeWidget({
Key? key,
required this.node,
this.selectedNodeId,
required this.viewMode,
required this.level,
required this.onTap,
this.renderedNodeIds = const {},
this.nodeRenderStates = const {},
this.renderChildren = true,
}) : super(key: key);
@override
State<SettingNodeWidget> createState() => _SettingNodeWidgetState();
}
class _SettingNodeWidgetState extends State<SettingNodeWidget>
with TickerProviderStateMixin {
bool _isExpanded = true;
late AnimationController _renderingController; // 渲染动画控制器
late Animation<double> _renderingAnimation;
@override
void initState() {
super.initState();
// 渲染动画控制器
_renderingController = AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
);
_renderingAnimation = CurvedAnimation(
parent: _renderingController,
curve: Curves.easeOutBack,
);
// 检查初始渲染状态
_checkRenderingState();
}
@override
void dispose() {
_renderingController.dispose();
super.dispose();
}
@override
void didUpdateWidget(SettingNodeWidget oldWidget) {
super.didUpdateWidget(oldWidget);
// 检查渲染状态变化
_checkRenderingState();
}
/// 检查并处理渲染状态变化
void _checkRenderingState() {
final renderInfo = widget.nodeRenderStates[widget.node.id];
if (renderInfo?.state == NodeRenderState.rendering) {
// 开始渲染动画
_renderingController.forward();
} else if (renderInfo?.state == NodeRenderState.rendered) {
// 确保渲染动画完成
_renderingController.value = 1.0;
}
}
@override
Widget build(BuildContext context) {
// 🔧 关键修复始终返回相同的widget结构用Opacity控制可见性
return _buildAlwaysStableWidget();
}
/// 🔧 核心修复构建绝对稳定的widget永远不改变结构
Widget _buildAlwaysStableWidget() {
final renderInfo = widget.nodeRenderStates[widget.node.id];
final isRendering = renderInfo?.state == NodeRenderState.rendering;
final isRendered = widget.renderedNodeIds.contains(widget.node.id);
// 🔧 关键确定最终可见性但不改变widget树结构
final shouldShow = isRendered || isRendering;
final opacity = shouldShow ? 1.0 : 0.0;
// 🔧 绝对稳定的widget结构始终存在只改变可见性
Widget nodeContent = Column(
key: ValueKey('stable_node_${widget.node.id}'),
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_buildNodeHeader(),
// 🔧 子节点容器:始终存在,只改变内容可见性
if (widget.renderChildren && widget.node.children != null && widget.node.children!.isNotEmpty)
_buildStableChildrenContainer(),
],
);
// 🔧 使用Opacity + IgnorePointer确保不可见时完全不可交互
Widget result = Opacity(
opacity: opacity,
child: IgnorePointer(
ignoring: !shouldShow,
child: nodeContent,
),
);
// 🔧 只有在渲染中时才应用动画效果
if (isRendering) {
result = AnimatedBuilder(
animation: _renderingAnimation,
builder: (context, child) {
return Transform.scale(
scale: 0.95 + (_renderingAnimation.value * 0.05),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: const Color(0xFF3B82F6).withOpacity(0.15 * _renderingAnimation.value),
blurRadius: 4 * _renderingAnimation.value,
spreadRadius: 1 * _renderingAnimation.value,
),
],
),
child: child,
),
);
},
child: result,
);
}
return result;
}
/// 🔧 构建稳定的子节点容器:始终分配所有空间
Widget _buildStableChildrenContainer() {
// 使用 AnimatedSize + ClipRect 避免从 null 到 0 的高度动画在 Web 上导致异常
return ClipRect(
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
alignment: Alignment.topLeft,
curve: Curves.easeInOut,
child: _isExpanded
? Container(
padding: const EdgeInsets.only(top: 4),
child: _buildAbsolutelyStableChildrenList(),
)
: const SizedBox.shrink(),
),
);
}
/// 🔧 终极修复:构建绝对稳定的子节点列表
Widget _buildAbsolutelyStableChildrenList() {
if (widget.node.children == null || widget.node.children!.isEmpty) {
return const SizedBox.shrink();
}
// 🔧 终极方案:为所有子节点预分配固定空间,每个子节点自己控制可见性
// 这确保Column的children数量和类型永远不变
return Column(
key: ValueKey('stable_children_${widget.node.id}'),
mainAxisSize: MainAxisSize.min,
children: widget.node.children!.map((child) {
return Container(
key: ValueKey('stable_child_container_${child.id}'),
margin: const EdgeInsets.only(bottom: 4),
child: SettingNodeWidget(
key: ValueKey('stable_child_widget_${child.id}'),
node: child,
selectedNodeId: widget.selectedNodeId,
viewMode: widget.viewMode,
level: widget.level + 1,
onTap: widget.onTap,
renderedNodeIds: widget.renderedNodeIds,
nodeRenderStates: widget.nodeRenderStates,
),
);
}).toList(),
);
}
Widget _buildNodeHeader() {
final isDark = Theme.of(context).brightness == Brightness.dark;
final renderInfo = widget.nodeRenderStates[widget.node.id];
final isRendering = renderInfo?.state == NodeRenderState.rendering;
// 只有当前节点被选中时才显示选中状态,子节点不继承
final isCurrentNodeSelected = widget.selectedNodeId == widget.node.id;
// 根据Node.js版本的 paddingLeft: `${level * 1.5 + 0.5}rem`
final leftPadding = widget.level * 24.0 + 8.0; // 1rem = 16px, 1.5rem = 24px
return InkWell(
onTap: () => widget.onTap(widget.node.id),
borderRadius: BorderRadius.circular(6),
child: Container(
width: double.infinity,
padding: EdgeInsets.only(
left: leftPadding,
right: 8,
top: widget.viewMode == 'compact' ? 8 : 12,
bottom: widget.viewMode == 'compact' ? 8 : 12,
),
decoration: BoxDecoration(
color: _getBackgroundColor(),
borderRadius: BorderRadius.circular(6),
border: isCurrentNodeSelected
? Border.all(
color: const Color(0xFF6366F1), // indigo-500
width: 2,
)
: isRendering
? Border.all(
color: const Color(0xFF3B82F6), // blue-500
width: 1,
)
: null,
),
child: Row(
crossAxisAlignment: widget.viewMode == 'compact'
? CrossAxisAlignment.center
: CrossAxisAlignment.start,
children: [
// Rendering indicator
if (isRendering)
Container(
width: 8,
height: 8,
margin: const EdgeInsets.only(right: 8, top: 2),
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
const Color(0xFF3B82F6), // blue-500
),
),
),
// Expand/collapse icon
InkWell(
onTap: _toggleExpanded,
borderRadius: BorderRadius.circular(4),
child: Container(
width: 16,
height: 16,
margin: EdgeInsets.only(
right: 8,
top: widget.viewMode == 'detailed' ? 4 : 0,
),
child: (widget.renderChildren && widget.node.children != null && widget.node.children!.isNotEmpty)
? AnimatedRotation(
turns: _isExpanded ? 0.25 : 0,
duration: const Duration(milliseconds: 200),
child: Icon(
Icons.chevron_right,
size: 16,
color: const Color(0xFF6B7280), // gray-500
),
)
: Icon(
Icons.description,
size: 16,
color: isDark
? const Color(0xFF4B5563) // gray-600 dark
: const Color(0xFF9CA3AF), // gray-400
),
),
),
// Node content
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// 状态图标(小)
Padding(
padding: const EdgeInsets.only(right: 6),
child: _buildStatusIcon(),
),
Expanded(
child: Text(
widget.node.name,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isCurrentNodeSelected
? const Color(0xFF6366F1) // indigo-500
: isRendering
? const Color(0xFF3B82F6) // blue-500
: (isDark
? const Color(0xFFF9FAFB)
: const Color(0xFF111827)),
),
),
),
const SizedBox(width: 6),
_buildTypeChip(),
if (isRendering)
Text(
'生成中...',
style: TextStyle(
fontSize: 12,
color: const Color(0xFF3B82F6),
fontStyle: FontStyle.italic,
),
),
],
),
if (widget.viewMode == 'detailed' && widget.node.description.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
widget.node.description,
style: TextStyle(
fontSize: 14,
color: isDark
? const Color(0xFF9CA3AF) // gray-400 dark
: const Color(0xFF6B7280), // gray-500
height: 1.5,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
),
),
);
}
Widget _buildStatusIcon() {
// 移除“待处理”状态下的时钟图标
if (widget.node.generationStatus == GenerationStatus.pending) {
return const SizedBox.shrink();
}
IconData icon;
Color color;
switch (widget.node.generationStatus) {
case GenerationStatus.generating:
icon = Icons.autorenew;
color = Colors.blue;
break;
case GenerationStatus.completed:
icon = Icons.check_circle;
color = Colors.green;
break;
case GenerationStatus.failed:
icon = Icons.error;
color = Colors.red;
break;
case GenerationStatus.modified:
icon = Icons.edit;
color = Colors.purple;
break;
case GenerationStatus.pending:
// 已在上方提前返回
icon = Icons.check_circle; // 占位,不会被使用
color = Colors.transparent;
break;
}
return Icon(
icon,
size: 14,
color: color,
);
}
Widget _buildTypeChip() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: WebTheme.getPrimaryColor(context).withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: WebTheme.getPrimaryColor(context).withOpacity(0.3),
width: 1,
),
),
child: Text(
widget.node.type.displayName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: WebTheme.getPrimaryColor(context),
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
);
}
Color _getBackgroundColor() {
final isDark = Theme.of(context).brightness == Brightness.dark;
if (widget.selectedNodeId == widget.node.id) {
return isDark
? const Color(0xFF1E1B4B) // indigo-900/50 dark
: const Color(0xFFE0E7FF); // indigo-100
} else {
return Colors.transparent;
}
}
void _toggleExpanded() {
setState(() {
_isExpanded = !_isExpanded;
});
}
}

View File

@@ -0,0 +1,649 @@
import 'package:flutter/material.dart';
import 'package:ainoval/utils/web_theme.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../blocs/setting_generation/setting_generation_bloc.dart';
import '../../../blocs/setting_generation/setting_generation_event.dart';
import '../../../blocs/setting_generation/setting_generation_state.dart';
import '../../../models/setting_node.dart';
import 'setting_node_widget.dart';
import 'ai_shimmer_placeholder.dart';
import '../../../utils/logger.dart';
import '../../../widgets/common/top_toast.dart';
/// 节点与层级信息的包装类
class _NodeWithLevel {
final SettingNode node;
final int level;
const _NodeWithLevel({
required this.node,
required this.level,
});
}
/// 设定树组件
class SettingsTreeWidget extends StatelessWidget {
final String? lastInitialPrompt;
final String? lastStrategy;
final String? lastModelConfigId;
final String? novelId;
final String? userId;
const SettingsTreeWidget({
Key? key,
this.lastInitialPrompt,
this.lastStrategy,
this.lastModelConfigId,
this.novelId,
this.userId,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlocBuilder<SettingGenerationBloc, SettingGenerationState>(
buildWhen: (previous, current) {
// 类型变化:一定重建
if (previous.runtimeType != current.runtimeType) return true;
// 进行中:当节点树/渲染相关/选中/视图模式或操作文案改变时才重建
if (previous is SettingGenerationInProgress && current is SettingGenerationInProgress) {
return previous.activeSession.rootNodes != current.activeSession.rootNodes ||
previous.renderedNodeIds != current.renderedNodeIds ||
previous.selectedNodeId != current.selectedNodeId ||
previous.viewMode != current.viewMode ||
previous.currentOperation != current.currentOperation;
}
// 完成:当节点树/渲染集合/选中/视图模式/活跃会话切换时才重建
if (previous is SettingGenerationCompleted && current is SettingGenerationCompleted) {
return previous.activeSession.rootNodes != current.activeSession.rootNodes ||
previous.renderedNodeIds != current.renderedNodeIds ||
previous.selectedNodeId != current.selectedNodeId ||
previous.viewMode != current.viewMode ||
previous.activeSessionId != current.activeSessionId;
}
// 修改中:当节点树/渲染集合/选中/修改目标/是否更新中变化时才重建
if (previous is SettingGenerationNodeUpdating && current is SettingGenerationNodeUpdating) {
return previous.activeSession.rootNodes != current.activeSession.rootNodes ||
previous.renderedNodeIds != current.renderedNodeIds ||
previous.selectedNodeId != current.selectedNodeId ||
previous.updatingNodeId != current.updatingNodeId ||
previous.isUpdating != current.isUpdating;
}
// 就绪:会话/活跃会话/视图模式变化
if (previous is SettingGenerationReady && current is SettingGenerationReady) {
return previous.sessions != current.sessions ||
previous.activeSessionId != current.activeSessionId ||
previous.viewMode != current.viewMode;
}
// 其他状态:保守起见重建
return true;
},
builder: (context, state) {
// 🔧 新增:详细的状态日志
AppLogger.i('SettingsTreeWidget', '🔄 状态变更: ${state.runtimeType}');
// 加载状态
if (state is SettingGenerationLoading) {
AppLogger.i('SettingsTreeWidget', '⏳ 显示加载状态');
return const AIShimmerPlaceholder();
}
// 生成进行中状态
if (state is SettingGenerationInProgress) {
AppLogger.i('SettingsTreeWidget', '🚀 显示生成进行中状态 - 已渲染节点: ${state.renderedNodeIds.length}');
return _buildInProgressView(context, state);
}
// 🔧 新增:节点修改中状态
if (state is SettingGenerationNodeUpdating) {
AppLogger.i('SettingsTreeWidget', '🔧 显示节点修改中状态 - 修改节点: ${state.updatingNodeId}');
return _buildNodeUpdatingView(context, state);
}
// 生成完成状态
if (state is SettingGenerationCompleted) {
AppLogger.i('SettingsTreeWidget', '✅ 显示完成状态 - 会话: ${state.activeSessionId}');
return _buildCompletedView(context, state);
}
// 保存成功状态 - 仍然显示完成视图,避免界面闪烁
if (state is SettingGenerationSaved) {
AppLogger.i('SettingsTreeWidget', '💾 显示保存成功状态,会话数: ${state.sessions.length}');
return _buildSavedView(context, state);
}
// 无会话状态
if (state is SettingGenerationReady) {
AppLogger.i('SettingsTreeWidget', '🎯 显示就绪状态,会话数: ${state.sessions.length}');
return _buildNoSessionView(context, state);
}
// 错误状态
if (state is SettingGenerationError) {
AppLogger.w('SettingsTreeWidget', '❌ 显示错误状态: ${state.message}');
return _buildErrorView(context, state);
}
// 默认状态(初始状态等)
AppLogger.w('SettingsTreeWidget', '🤔 未知状态: ${state.runtimeType}');
return _buildNoSessionView(context, state);
},
);
}
Widget _buildInProgressView(BuildContext context, SettingGenerationInProgress state) {
// 如果没有任何已渲染的节点(不管渲染状态如何),显示等待状态
if (state.renderedNodeIds.isEmpty) {
return const AIShimmerPlaceholder();
}
// 显示流式渲染界面(进度/提示统一由父级状态条显示,避免重复)
return Column(
children: [
Expanded(
child: _buildStreamingTreeView(context, state),
),
],
);
}
Widget _buildCompletedView(BuildContext context, SettingGenerationCompleted state) {
// 🔧 新增:详细的渲染日志
AppLogger.i('SettingsTreeWidget', '🎨 渲染完成状态视图 - 节点数: ${state.activeSession.rootNodes.length}, 会话ID: ${state.activeSessionId}');
// 🔧 修复:当没有节点数据时,显示空状态提示
if (state.activeSession.rootNodes.isEmpty) {
AppLogger.w('SettingsTreeWidget', '⚠️ 会话中没有设定节点数据,显示空状态提示');
return _buildEmptyStateView(context, '此历史记录暂无设定数据');
}
return _buildTreeView(
context,
state.activeSession.rootNodes,
state.selectedNodeId,
state.viewMode,
state.renderedNodeIds,
);
}
Widget _buildStreamingTreeView(BuildContext context, SettingGenerationInProgress state) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Container(
decoration: BoxDecoration(
color: isDark
? const Color(0xFF1F2937).withOpacity(0.3)
: const Color(0xFFF9FAFB).withOpacity(0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isDark
? const Color(0xFF1F2937)
: const Color(0xFFE5E7EB),
width: 1,
),
),
child: state.renderedNodeIds.isEmpty
? _buildWaitingForFirstNode(context)
: _buildRenderableNodesListView(
context,
state.activeSession.rootNodes,
state.selectedNodeId,
state.viewMode,
state.renderedNodeIds,
state.nodeRenderStates,
),
);
}
Widget _buildWaitingForFirstNode(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(
WebTheme.getPrimaryColor(context),
),
),
),
const SizedBox(height: 16),
Text(
'AI 正在构思第一个设定节点...',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
/// 构建可渲染的节点列表视图
Widget _buildRenderableNodesListView(
BuildContext context,
List<SettingNode> nodes,
String? selectedNodeId,
String viewMode,
Set<String> renderedNodeIds,
Map<String, NodeRenderInfo> nodeRenderStates,
) {
// 获取所有需要渲染的节点(扁平化列表)
final renderableNodes = _getRenderableNodesList(
nodes,
renderedNodeIds,
nodeRenderStates,
);
return ListView.builder(
padding: const EdgeInsets.all(4),
itemCount: renderableNodes.length,
itemBuilder: (context, index) {
final nodeInfo = renderableNodes[index];
final node = nodeInfo.node;
final level = nodeInfo.level;
return Padding(
padding: EdgeInsets.only(bottom: index < renderableNodes.length - 1 ? 4 : 0),
child: SettingNodeWidget(
node: node,
selectedNodeId: selectedNodeId,
viewMode: viewMode,
level: level,
renderedNodeIds: renderedNodeIds,
nodeRenderStates: nodeRenderStates,
renderChildren: false,
onTap: (nodeId) {
context.read<SettingGenerationBloc>().add(
SelectNodeEvent(nodeId),
);
},
),
);
},
);
}
/// 获取所有需要渲染的节点列表(扁平化,包含层级信息)
List<_NodeWithLevel> _getRenderableNodesList(
List<SettingNode> nodes,
Set<String> renderedNodeIds,
Map<String, NodeRenderInfo> nodeRenderStates,
{
int level = 0,
}) {
final List<_NodeWithLevel> result = [];
for (final node in nodes) {
// 只添加已经渲染的节点或正在渲染的节点
if (renderedNodeIds.contains(node.id) ||
nodeRenderStates[node.id]?.state == NodeRenderState.rendering) {
result.add(_NodeWithLevel(node: node, level: level));
// 递归添加子节点
if (node.children != null && node.children!.isNotEmpty) {
result.addAll(_getRenderableNodesList(
node.children!,
renderedNodeIds,
nodeRenderStates,
level: level + 1,
));
}
}
}
return result;
}
Widget _buildTreeView(
BuildContext context,
List<SettingNode> nodes,
String? selectedNodeId,
String viewMode,
Set<String> renderedNodeIds,
) {
// 🔧 新增:日志和空状态处理
AppLogger.i('SettingsTreeWidget', '🌳 构建设定树视图 - 节点数: ${nodes.length}, 选中节点: $selectedNodeId');
// 🔧 修复:当节点列表为空时,显示空状态提示
if (nodes.isEmpty) {
AppLogger.w('SettingsTreeWidget', '⚠️ 节点列表为空,显示空状态提示');
return _buildEmptyStateView(context, '暂无设定数据');
}
// 🔧 如果 renderedNodeIds 为空(通常发生在生成已完成的状态),
// 将所有可见节点都视为已渲染,避免由于 Opacity=0 导致的内容不可见。
Set<String> effectiveRenderedIds = renderedNodeIds;
if (effectiveRenderedIds.isEmpty) {
effectiveRenderedIds = _collectAllNodeIds(nodes).toSet();
AppLogger.i('SettingsTreeWidget', '🔧 renderedNodeIds 为空自动填充所有节点ID (${effectiveRenderedIds.length})');
}
final isDark = Theme.of(context).brightness == Brightness.dark;
return Container(
decoration: BoxDecoration(
color: isDark
? const Color(0xFF1F2937).withOpacity(0.3)
: const Color(0xFFF9FAFB).withOpacity(0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isDark
? const Color(0xFF1F2937)
: const Color(0xFFE5E7EB),
width: 1,
),
),
child: ListView.builder(
padding: const EdgeInsets.all(4),
itemCount: nodes.length,
itemBuilder: (context, index) {
final node = nodes[index];
return Padding(
padding: EdgeInsets.only(bottom: index < nodes.length - 1 ? 4 : 0),
child: SettingNodeWidget(
node: node,
selectedNodeId: selectedNodeId,
viewMode: viewMode,
level: 0,
renderedNodeIds: effectiveRenderedIds,
nodeRenderStates: const {}, // 完成状态下不需要渲染状态
onTap: (nodeId) {
context.read<SettingGenerationBloc>().add(
SelectNodeEvent(nodeId),
);
},
),
);
},
),
);
}
/// 递归收集所有节点 ID
List<String> _collectAllNodeIds(List<SettingNode> nodes) {
final List<String> ids = [];
for (final node in nodes) {
ids.add(node.id);
if (node.children != null && node.children!.isNotEmpty) {
ids.addAll(_collectAllNodeIds(node.children!));
}
}
return ids;
}
Widget _buildErrorView(BuildContext context, SettingGenerationError state) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Container(
decoration: BoxDecoration(
color: isDark
? const Color(0xFF1F2937).withOpacity(0.3)
: const Color(0xFFF9FAFB).withOpacity(0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isDark
? const Color(0xFF1F2937)
: const Color(0xFFE5E7EB),
width: 1,
),
),
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 48,
color: Colors.red.withOpacity(0.7),
),
const SizedBox(height: 16),
Text(
'生成失败',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
_getFriendlyErrorMessage(state.message),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: isDark ? const Color(0xFF9CA3AF) : const Color(0xFF6B7280),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
// 重试按钮
if (state.isRecoverable && _canRetry())
Row(
mainAxisSize: MainAxisSize.min,
children: [
OutlinedButton.icon(
onPressed: () => _retryGeneration(context),
icon: const Icon(Icons.refresh, size: 18),
label: const Text('重试生成'),
style: OutlinedButton.styleFrom(
foregroundColor: isDark
? const Color(0xFFF9FAFB)
: const Color(0xFF111827),
side: BorderSide(
color: isDark
? const Color(0xFF374151)
: const Color(0xFFD1D5DB),
),
),
),
const SizedBox(width: 12),
TextButton.icon(
onPressed: () => _resetAndReload(context),
icon: const Icon(Icons.settings_backup_restore, size: 18),
label: const Text('重新开始'),
style: TextButton.styleFrom(
foregroundColor: isDark
? const Color(0xFF9CA3AF)
: const Color(0xFF6B7280),
),
),
],
),
],
),
),
),
);
}
/// 将后端错误信息转换为用户友好的提示
String _getFriendlyErrorMessage(String originalMessage) {
// 检查常见的错误模式并返回友好提示
final message = originalMessage.toLowerCase();
if (message.contains('timeout') || message.contains('超时')) {
return 'AI生成响应时间过长请稍后重试';
}
if (message.contains('network') || message.contains('connection') ||
message.contains('网络') || message.contains('连接')) {
return '网络连接不稳定,请检查网络后重试';
}
if (message.contains('rate limit') || message.contains('too many') ||
message.contains('频率') || message.contains('限制')) {
return '请求过于频繁,请稍等片刻后重试';
}
if (message.contains('invalid') || message.contains('无效') ||
message.contains('bad request')) {
return '请求参数有误,请重新配置后重试';
}
if (message.contains('unauthorized') || message.contains('permission') ||
message.contains('未授权') || message.contains('权限')) {
return '授权已过期,请重新登录后重试';
}
if (message.contains('server error') || message.contains('internal') ||
message.contains('服务器') || message.contains('内部错误')) {
return '服务器暂时无法处理请求,请稍后重试';
}
if (message.contains('model') || message.contains('模型')) {
return 'AI模型暂时不可用请尝试切换其他模型';
}
if (message.contains('quota') || message.contains('balance') ||
message.contains('额度') || message.contains('余额')) {
return '账户余额不足或已达到使用限额';
}
// 如果无法识别具体错误类型,返回通用友好提示
return '生成过程中遇到问题,请重试或联系客服';
}
/// 检查是否可以重试
bool _canRetry() {
return lastInitialPrompt != null &&
lastStrategy != null &&
lastModelConfigId != null;
}
/// 重试生成
void _retryGeneration(BuildContext context) {
if (!_canRetry()) return;
// 重试时无法保证仍保留公共模型对象这里仅传基础参数若有需要可在Bloc中从上次session metadata取回
context.read<SettingGenerationBloc>().add(
StartGenerationEvent(
initialPrompt: lastInitialPrompt!,
promptTemplateId: lastStrategy!,
novelId: novelId,
modelConfigId: lastModelConfigId!,
userId: userId ?? 'current_user',
),
);
}
/// 重置并重新加载
void _resetAndReload(BuildContext context) {
context.read<SettingGenerationBloc>().add(const LoadStrategiesEvent());
}
/// 构建空状态提示视图
Widget _buildEmptyStateView(BuildContext context, String message) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.info_outline,
size: 48,
color: isDark ? const Color(0xFF9CA3AF) : const Color(0xFF6B7280),
),
const SizedBox(height: 16),
Text(
message,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: isDark ? const Color(0xFF9CA3AF) : const Color(0xFF6B7280),
),
textAlign: TextAlign.center,
),
],
),
),
);
}
/// 🔧 新增:构建节点修改中视图
Widget _buildNodeUpdatingView(BuildContext context, SettingGenerationNodeUpdating state) {
AppLogger.i('SettingsTreeWidget', '🔧 渲染节点修改中状态 - 修改节点: ${state.updatingNodeId}');
// 使用TopToast显示修改提示
if (state.isUpdating && state.message.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
TopToast.info(
context,
state.message,
);
});
}
// 显示设定树,突出显示正在修改的节点
return _buildTreeView(
context,
state.activeSession.rootNodes,
state.selectedNodeId,
state.viewMode,
state.renderedNodeIds,
);
}
/// 🔧 新增:构建保存成功视图
Widget _buildSavedView(BuildContext context, SettingGenerationSaved state) {
AppLogger.i('SettingsTreeWidget', '💾 渲染保存成功状态');
// 尝试从sessions中找到当前活跃会话以渲染
if (state.sessions.isNotEmpty && state.activeSessionId != null) {
final session = state.sessions.firstWhere(
(s) => s.sessionId == state.activeSessionId,
orElse: () => state.sessions.first,
);
return _buildTreeView(
context,
session.rootNodes,
null, // 保存操作后保持原选中节点逻辑,可根据需要扩展
'compact',
const {},
);
}
// 如果找不到会话,显示空状态
AppLogger.w('SettingsTreeWidget', '⚠️ 保存状态下找不到活跃会话,显示空状态');
return _buildEmptyStateView(context, '设定已保存,但无法显示内容');
}
/// 🔧 新增:构建无会话视图
Widget _buildNoSessionView(BuildContext context, dynamic state) {
// 检查是否有活跃会话
if (state is SettingGenerationReady) {
AppLogger.i('SettingsTreeWidget', '📋 渲染就绪状态 - 活跃会话: ${state.activeSessionId}');
// 如果有活跃会话,显示对应的设定树
if (state.activeSessionId != null && state.sessions.isNotEmpty) {
final session = state.sessions.firstWhere(
(s) => s.sessionId == state.activeSessionId,
orElse: () => state.sessions.first,
);
// 如果会话有内容,显示设定树
if (session.rootNodes.isNotEmpty) {
AppLogger.i('SettingsTreeWidget', '🌳 就绪状态下显示设定树 - 节点数: ${session.rootNodes.length}');
return _buildTreeView(
context,
session.rootNodes,
null, // SettingGenerationReady 没有 selectedNodeId
state.viewMode,
const {},
);
}
}
}
// 默认显示无会话提示
AppLogger.i('SettingsTreeWidget', '📝 显示无会话提示');
return _buildEmptyStateView(context, '请开始生成设定或选择已有历史记录');
}
}

View File

@@ -0,0 +1,354 @@
import 'package:flutter/material.dart';
import '../../../models/strategy_template_info.dart';
import '../../../utils/web_theme.dart';
/// 自定义策略选择下拉框组件
class StrategySelectorDropdown extends StatefulWidget {
final List<StrategyTemplateInfo> strategies;
final StrategyTemplateInfo? selectedStrategy;
final ValueChanged<StrategyTemplateInfo?>? onChanged;
final bool isLoading;
const StrategySelectorDropdown({
Key? key,
required this.strategies,
this.selectedStrategy,
this.onChanged,
this.isLoading = false,
}) : super(key: key);
@override
State<StrategySelectorDropdown> createState() => _StrategySelectorDropdownState();
}
class _StrategySelectorDropdownState extends State<StrategySelectorDropdown> {
final LayerLink _layerLink = LayerLink();
final GlobalKey _buttonKey = GlobalKey();
OverlayEntry? _overlayEntry;
bool _isOpen = false;
@override
void dispose() {
_removeOverlay();
super.dispose();
}
void _toggleDropdown() {
if (_isOpen) {
_removeOverlay();
} else {
_showOverlay();
}
}
void _showOverlay() {
if (widget.strategies.isEmpty || widget.isLoading) return;
final RenderBox? renderBox = _buttonKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox == null) return;
final size = renderBox.size;
final overlay = Overlay.of(context);
setState(() {
_isOpen = true;
});
_overlayEntry = OverlayEntry(
builder: (context) => Stack(
children: [
// 透明背景,点击关闭
Positioned.fill(
child: GestureDetector(
onTap: _removeOverlay,
child: Container(color: Colors.transparent),
),
),
// 下拉菜单内容
CompositedTransformFollower(
link: _layerLink,
showWhenUnlinked: false,
offset: Offset(0, size.height + 4),
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(12),
color: WebTheme.getSurfaceColor(context),
shadowColor: WebTheme.getShadowColor(context, opacity: 0.2),
child: Container(
width: size.width,
constraints: const BoxConstraints(
maxHeight: 320,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: WebTheme.getBorderColor(context).withOpacity(0.3),
width: 1,
),
),
child: _buildDropdownContent(),
),
),
),
],
),
);
overlay.insert(_overlayEntry!);
}
void _removeOverlay() {
if (_overlayEntry != null) {
_overlayEntry!.remove();
_overlayEntry = null;
}
if (mounted) {
setState(() {
_isOpen = false;
});
}
}
Widget _buildDropdownContent() {
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 标题栏
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: WebTheme.getSecondaryBorderColor(context).withOpacity(0.1),
border: Border(
bottom: BorderSide(
color: WebTheme.getBorderColor(context).withOpacity(0.2),
width: 1,
),
),
),
child: Text(
'选择生成策略',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: WebTheme.getSecondaryTextColor(context),
),
),
),
// 策略列表
...widget.strategies.asMap().entries.map((entry) {
final index = entry.key;
final strategy = entry.value;
final isSelected = widget.selectedStrategy?.promptTemplateId == strategy.promptTemplateId;
final isLast = index == widget.strategies.length - 1;
return _buildStrategyItem(strategy, isSelected, isLast);
}).toList(),
],
),
),
);
}
Widget _buildStrategyItem(StrategyTemplateInfo strategy, bool isSelected, bool isLast) {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.vertical(
bottom: isLast ? const Radius.circular(12) : Radius.zero,
),
onTap: () {
widget.onChanged?.call(strategy);
_removeOverlay();
},
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: isSelected
? WebTheme.getPrimaryColor(context).withOpacity(0.08)
: Colors.transparent,
border: !isLast ? Border(
bottom: BorderSide(
color: WebTheme.getBorderColor(context).withOpacity(0.1),
width: 1,
),
) : null,
),
child: Row(
children: [
// 策略信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// 策略名称
Text(
strategy.name,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
color: isSelected
? WebTheme.getPrimaryColor(context)
: WebTheme.getTextColor(context),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// 策略描述
if (strategy.description.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
strategy.description,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: WebTheme.getSecondaryTextColor(context),
height: 1.3,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
// 选中状态指示器
if (isSelected) ...[
const SizedBox(width: 12),
Icon(
Icons.check_circle,
size: 20,
color: WebTheme.getPrimaryColor(context),
),
],
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'生成策略',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: WebTheme.getSecondaryTextColor(context),
),
),
const SizedBox(height: 8),
CompositedTransformTarget(
link: _layerLink,
child: Material(
color: Colors.transparent,
child: InkWell(
key: _buttonKey,
borderRadius: BorderRadius.circular(8),
onTap: widget.isLoading ? null : _toggleDropdown,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
decoration: BoxDecoration(
color: WebTheme.getSurfaceColor(context),
border: Border.all(
color: _isOpen
? WebTheme.getPrimaryColor(context).withOpacity(0.5)
: WebTheme.getBorderColor(context),
width: _isOpen ? 2 : 1,
),
borderRadius: BorderRadius.circular(8),
),
child: widget.isLoading
? _buildLoadingContent()
: _buildButtonContent(),
),
),
),
),
],
);
}
Widget _buildLoadingContent() {
return Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
WebTheme.getSecondaryTextColor(context),
),
),
),
const SizedBox(width: 12),
Text(
'加载策略中...',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: WebTheme.getSecondaryTextColor(context),
),
),
],
);
}
Widget _buildButtonContent() {
return Row(
children: [
Expanded(
child: widget.selectedStrategy != null
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.selectedStrategy!.name,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: WebTheme.getTextColor(context),
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
if (widget.selectedStrategy!.description.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
widget.selectedStrategy!.description,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: WebTheme.getSecondaryTextColor(context),
height: 1.2,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
],
],
)
: Text(
'选择生成策略',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: WebTheme.getSecondaryTextColor(context),
),
),
),
const SizedBox(width: 8),
AnimatedRotation(
turns: _isOpen ? 0.5 : 0,
duration: const Duration(milliseconds: 200),
child: Icon(
Icons.keyboard_arrow_down,
color: WebTheme.getSecondaryTextColor(context),
size: 20,
),
),
],
);
}
}