马良AI写作初始化仓库
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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('已提交保存当前节点内容')),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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')}';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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, '请开始生成设定或选择已有历史记录');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user