马良AI写作初始化仓库

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

View File

@@ -0,0 +1,795 @@
import 'package:ainoval/utils/logger.dart';
import 'package:ainoval/widgets/common/top_toast.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart'; // 引入 intl 包用于日期格式化
import '../../../blocs/chat/chat_bloc.dart';
import '../../../blocs/chat/chat_event.dart';
import '../../../blocs/chat/chat_state.dart';
import '../../../blocs/editor/editor_bloc.dart';
import '../../../models/user_ai_model_config_model.dart'; // Import the model config
import '../../../models/novel_structure.dart';
import '../../../models/context_selection_models.dart';
import '../../../models/novel_setting_item.dart';
import '../../../models/novel_snippet.dart';
import '../../../models/setting_group.dart';
import 'chat_input.dart'; // 引入 ChatInput
import 'chat_message_bubble.dart'; // 引入 ChatMessageBubble
// 🚀 移除 TypingIndicator 导入,不再使用单独的等待指示器
/// AI聊天侧边栏组件用于在编辑器右侧显示聊天功能
class AIChatSidebar extends StatefulWidget {
const AIChatSidebar({
Key? key,
required this.novelId,
this.chapterId,
this.onClose,
this.isCardMode = false,
this.editorController, // 🚀 新增接收EditorScreenController参数
}) : super(key: key);
final String novelId;
final String? chapterId;
final VoidCallback? onClose;
final bool isCardMode; // 是否以卡片模式显示
final dynamic editorController; // 🚀 新增EditorScreenController实例
@override
State<AIChatSidebar> createState() => _AIChatSidebarState();
}
class _AIChatSidebarState extends State<AIChatSidebar> {
final TextEditingController _messageController = TextEditingController();
final ScrollController _scrollController = ScrollController();
// 记录已经完成上下文数据初始化的会话,避免重复检查
final Set<String> _contextInitializedSessions = {};
@override
void initState() {
super.initState();
// --- Add initState Log ---
AppLogger.i('AIChatSidebar',
'initState called. Widget hash: ${identityHashCode(widget)}, State hash: ${identityHashCode(this)}');
// Get the Bloc instance WITHOUT triggering a rebuild if already present
final chatBloc = BlocProvider.of<ChatBloc>(context, listen: false);
AppLogger.i('AIChatSidebar',
'initState: Associated ChatBloc hash: ${identityHashCode(chatBloc)}');
// --- End Add Log ---
// 每次初始化侧边栏都强制重新加载指定小说的会话列表,防止沿用上一部小说的数据
chatBloc.add(LoadChatSessions(novelId: widget.novelId));
// 同时重新加载上下文数据(设定、片段等)
chatBloc.add(LoadContextData(novelId: widget.novelId));
}
@override
void didUpdateWidget(covariant AIChatSidebar oldWidget) {
super.didUpdateWidget(oldWidget);
// 如果小说发生切换,重新拉取该小说的会话及上下文
if (widget.novelId != oldWidget.novelId) {
AppLogger.i('AIChatSidebar',
'didUpdateWidget: novelId changed from \\${oldWidget.novelId} to \\${widget.novelId}, reloading sessions & context');
final chatBloc = BlocProvider.of<ChatBloc>(context, listen: false);
// 重新加载聊天会话列表
chatBloc.add(LoadChatSessions(novelId: widget.novelId));
// 重新加载上下文数据(设定、片段等)
chatBloc.add(LoadContextData(novelId: widget.novelId));
}
}
@override
void dispose() {
// --- Add dispose Log ---
AppLogger.w('AIChatSidebar',
'dispose() called. Widget hash: ${identityHashCode(widget)}, State hash: ${identityHashCode(this)}');
// --- End Add Log ---
_messageController.dispose();
_scrollController.dispose();
super.dispose();
}
// 滚动到底部
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
// 发送消息
void _sendMessage() {
final message = _messageController.text.trim();
AppLogger.i('AIChatSidebar', '🚀 _sendMessage被调用消息内容: "$message"');
if (message.isNotEmpty) {
final chatBloc = context.read<ChatBloc>();
final currentState = chatBloc.state;
AppLogger.i('AIChatSidebar', '🚀 当前ChatBloc状态: ${currentState.runtimeType}');
if (currentState is ChatSessionActive) {
AppLogger.i('AIChatSidebar', '🚀 当前会话ID: ${currentState.session.id}, isGenerating: ${currentState.isGenerating}');
}
AppLogger.i('AIChatSidebar', '🚀 发送SendMessage事件到ChatBlocBLoC实例: ${identityHashCode(chatBloc)}, isClosed: ${chatBloc.isClosed}');
chatBloc.add(SendMessage(content: message));
_messageController.clear();
AppLogger.i('AIChatSidebar', '🚀 SendMessage事件已发送输入框已清空');
} else {
AppLogger.w('AIChatSidebar', '🚀 消息为空,不发送');
}
}
// 选择会话
void _selectSession(String sessionId) {
context.read<ChatBloc>().add(SelectChatSession(sessionId: sessionId, novelId: widget.novelId));
}
// 创建新会话
void _createNewThread() {
context.read<ChatBloc>().add(CreateChatSession(
title: '新对话 ${DateFormat('MM-dd HH:mm').format(DateTime.now())}',
novelId: widget.novelId,
chapterId: widget.chapterId,
));
}
// 🚀 已移除 _hasStreamingMessage 方法,不再需要检查流式消息
/// 🚀 构建并更新上下文数据
void _buildAndUpdateContextData(Novel novel, ChatSessionActive state) {
final novelSettings = state.cachedSettings.cast<NovelSettingItem>();
final novelSettingGroups = state.cachedSettingGroups.cast<SettingGroup>();
final novelSnippets = state.cachedSnippets.cast<NovelSnippet>();
AppLogger.i('AIChatSidebar', '🔧 构建上下文数据 - 设定: ${novelSettings.length}, 设定组: ${novelSettingGroups.length}, 片段: ${novelSnippets.length}');
final newContextData = ContextSelectionDataBuilder.fromNovelWithContext(
novel,
settings: novelSettings,
settingGroups: novelSettingGroups,
snippets: novelSnippets,
);
AppLogger.i('AIChatSidebar', '🔧 构建的上下文数据包含 ${newContextData.availableItems.length} 个可用项目');
// 获取当前会话配置并更新
final chatBloc = context.read<ChatBloc>();
final currentConfig = chatBloc.getSessionConfig(state.session.id, widget.novelId);
if (currentConfig != null) {
final updatedConfig = currentConfig.copyWith(
contextSelections: newContextData,
);
AppLogger.i('AIChatSidebar', '🔧 更新ChatBloc配置上下文项目: ${newContextData.availableItems.length} → ChatBloc');
// 使用 Future.microtask 避免在 build 过程中直接调用 add
Future.microtask(() {
if (mounted) {
chatBloc.add(UpdateChatConfiguration(
sessionId: state.session.id,
config: updatedConfig,
));
}
});
} else {
AppLogger.w('AIChatSidebar', '🚨 无法更新上下文数据currentConfig为nullsessionId=${state.session.id}');
}
}
@override
Widget build(BuildContext context) {
// Log the associated Bloc hash on build too, might be helpful
final chatBloc = BlocProvider.of<ChatBloc>(context, listen: false);
AppLogger.d('AIChatSidebar',
'build called. Associated ChatBloc hash: ${identityHashCode(chatBloc)}');
AppLogger.i('Screens/chat/widgets/ai_chat_sidebar',
'Building AIChatSidebar widget');
return Material(
elevation: 4.0,
child: Container(
// 移除固定宽度让父组件SizedBox控制宽度
color: Theme.of(context).colorScheme.surfaceContainerLow,
child: Column(
children: [
// 顶部标题栏 - 在卡片模式下隐藏,因为多面板视图有自己的拖拽把手
if (!widget.isCardMode)
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
border: Border(
bottom: BorderSide(
color: Theme.of(context)
.colorScheme
.outlineVariant
.withOpacity(0.5),
width: 1.0,
),
),
),
child: Row(
children: [
Expanded(
child: BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
String title = 'AI 聊天助手';
if (state is ChatSessionActive) {
title = state.session.title;
} else if (state is ChatSessionsLoaded) {
title = '聊天列表';
}
return Text(
title,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
},
),
),
BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
if (state is ChatSessionActive) {
return IconButton(
icon: const Icon(Icons.list),
tooltip: '返回列表',
onPressed: () {
context
.read<ChatBloc>()
.add(LoadChatSessions(novelId: widget.novelId));
},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
);
}
return const SizedBox.shrink();
},
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close),
onPressed: widget.onClose,
tooltip: '关闭侧边栏',
padding: const EdgeInsets.all(8.0),
constraints: const BoxConstraints(),
),
],
),
),
// 聊天内容区域
Expanded(
child: BlocConsumer<ChatBloc, ChatState>(
listener: (context, state) {
// 🚀 当会话激活且有缓存数据时,构建完整的上下文数据(仅限首次)
if (state is ChatSessionActive &&
!_contextInitializedSessions.contains(state.session.id)) {
final editorState = context.read<EditorBloc>().state;
if (editorState is EditorLoaded) {
final novel = editorState.novel;
// 检查是否需要构建上下文数据
final chatBloc = context.read<ChatBloc>();
final currentConfig = chatBloc.getSessionConfig(state.session.id, widget.novelId);
final hasContextData = state.cachedSettings.isNotEmpty ||
state.cachedSettingGroups.isNotEmpty ||
state.cachedSnippets.isNotEmpty;
final needsContextData =
(currentConfig?.contextSelections?.availableItems ?? const []).isEmpty;
final shouldBuildContext = hasContextData && needsContextData;
if (shouldBuildContext) {
AppLogger.i('AIChatSidebar',
'🚀 构建完整的上下文数据,缓存数据: ${state.cachedSettings.length}设定, ${state.cachedSettingGroups.length}组, ${state.cachedSnippets.length}片段');
_buildAndUpdateContextData(novel, state);
}
// 无论是否真正构建,只要检查过一次就标记,避免后续重复评估
_contextInitializedSessions.add(state.session.id);
}
}
// 显示会话加载错误
if (state is ChatSessionsLoaded && state.error != null) {
TopToast.error(context, state.error!);
}
// 显示活动会话错误(例如加载历史失败或发送失败后)
if (state is ChatSessionActive && state.error != null) {
TopToast.error(context, state.error!);
}
// 滚动到底部逻辑保持不变
if (state is ChatSessionActive && !state.isLoadingHistory) {
// 仅在历史加载完成后滚动
_scrollToBottom();
}
},
// buildWhen 优化:避免不必要的重建,例如仅在关键状态或错误变化时重建
buildWhen: (previous, current) {
// Always rebuild if state type changed completely
if (previous.runtimeType != current.runtimeType) return true;
// --- ChatSessionActive -> ChatSessionActive ---
if (previous is ChatSessionActive && current is ChatSessionActive) {
// 1. New / removed message
final bool lengthChanged =
previous.messages.length != current.messages.length;
// 2. Generation / loading flag flips
final bool flagChanged =
previous.isGenerating != current.isGenerating ||
previous.isLoadingHistory != current.isLoadingHistory;
final bool idChanged = previous.session.id != current.session.id;
// 3. Severe error / model switch / cached data updates
final bool metaChanged = idChanged ||
previous.error != current.error ||
previous.selectedModel?.id != current.selectedModel?.id ||
previous.cachedSettings != current.cachedSettings ||
previous.cachedSettingGroups != current.cachedSettingGroups ||
previous.cachedSnippets != current.cachedSnippets;
// NOTE: Streaming content updates keep the list length the same, so
// lengthChanged will be false in that situation, effectively
// preventing a rebuild on every token.
return lengthChanged || flagChanged || metaChanged;
}
// --- ChatSessionsLoaded -> ChatSessionsLoaded ---
if (previous is ChatSessionsLoaded && current is ChatSessionsLoaded) {
return previous.sessions != current.sessions || previous.error != current.error;
}
// Fallback: rebuild for other transitions we did not explicitly handle
return true;
},
builder: (context, state) {
AppLogger.i('Screens/chat/widgets/ai_chat_sidebar',
'Building chat UI for state: ${state.runtimeType}');
// --- 加载状态处理 ---
if (state is ChatSessionsLoading ||
state is ChatSessionLoading) {
AppLogger.d('AIChatSidebar builder',
'State is Loading, showing indicator.');
return const Center(child: CircularProgressIndicator());
}
// --- 错误状态处理 ---
else if (state is ChatError) {
AppLogger.d('AIChatSidebar builder',
'State is ChatError, showing error message.');
return Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text('错误: ${state.message}',
style: TextStyle(color: Theme.of(context).colorScheme.error)),
),
);
}
// --- 会话列表状态 ---
else if (state is ChatSessionsLoaded) {
AppLogger.d('AIChatSidebar builder',
'State is ChatSessionsLoaded with ${state.sessions.length} sessions.');
return _buildThreadsList(
context, state); // _buildThreadsList 会处理空列表
}
// --- 活动会话状态 ---
else if (state is ChatSessionActive) {
AppLogger.d('AIChatSidebar builder',
'State is ChatSessionActive. isLoadingHistory: ${state.isLoadingHistory}, isGenerating: ${state.isGenerating}');
return _buildChatView(context, state);
}
// --- 初始或其他状态 ---
else {
AppLogger.d('AIChatSidebar builder',
'State is Initial or unexpected, showing empty state.');
// 初始状态可以显示空状态或者加载列表
// context.read<ChatBloc>().add(LoadChatSessions(novelId: widget.novelId)); // 如果希望初始时自动加载
return _buildEmptyState(); // 或者 return const Center(child: CircularProgressIndicator()); 看设计需求
}
},
),
),
],
),
),
);
}
// 构建空状态
Widget _buildEmptyState() {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.chat_bubble_outline,
size: 56, color: Theme.of(context).colorScheme.secondary),
const SizedBox(height: 20),
Text(
'开始一个新的对话',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
'与AI助手交流获取写作灵感、建议或进行头脑风暴',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _createNewThread,
icon: const Icon(Icons.add_comment_outlined),
label: const Text('新建对话'),
style: ElevatedButton.styleFrom(
padding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
textStyle: Theme.of(context)
.textTheme
.titleSmall
?.copyWith(fontWeight: FontWeight.bold),
foregroundColor: Theme.of(context).colorScheme.onPrimary,
backgroundColor: Theme.of(context).colorScheme.primary,
),
),
],
),
),
);
}
// 构建会话列表
Widget _buildThreadsList(BuildContext context, ChatSessionsLoaded state) {
// 现在接收整个 state 以便访问 error
final sessions = state.sessions;
if (sessions.isEmpty) {
// 即使列表为空,也不显示加载,显示空状态
return _buildEmptyState();
}
return Column(
children: [
// 新建对话按钮
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
child: OutlinedButton.icon(
onPressed: _createNewThread,
icon: const Icon(Icons.add_comment_outlined),
label: const Text('新建对话'),
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(44),
foregroundColor: Theme.of(context).colorScheme.primary,
side: BorderSide(
color:
Theme.of(context).colorScheme.outline.withOpacity(0.8)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
textStyle: Theme.of(context)
.textTheme
.titleSmall
?.copyWith(fontWeight: FontWeight.w600),
),
),
),
const Divider(height: 1, thickness: 1, indent: 16, endIndent: 16),
// 列表视图
Expanded(
child: ListView.separated(
itemCount: sessions.length,
separatorBuilder: (context, index) => Divider(
height: 1,
thickness: 1,
indent: 16,
endIndent: 16,
color:
Theme.of(context).colorScheme.outlineVariant.withOpacity(0.3),
),
itemBuilder: (context, index) {
final session = sessions[index];
// 获取当前活动会话 ID (需要 ChatBloc 的状态信息,这里假设可以从 context 获取)
String? activeSessionId;
final currentState = context.read<ChatBloc>().state;
if (currentState is ChatSessionActive) {
activeSessionId = currentState.session.id;
}
final bool isSelected = session.id == activeSessionId;
return ListTile(
leading: Icon(
isSelected ? Icons.chat_bubble : Icons.chat_bubble_outline,
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
title: Text(
session.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.w500,
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
),
),
subtitle: Text(
'最后更新: ${DateFormat('MM-dd HH:mm').format(session.lastUpdatedAt)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withOpacity(0.8),
),
),
trailing: IconButton(
icon: Icon(Icons.delete_outline,
color: Theme.of(context).colorScheme.onSurfaceVariant, size: 20),
onPressed: () {
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text('确认删除'),
content:
Text('确定要删除会话 "${session.title}" 吗?此操作无法撤销。'),
actions: <Widget>[
TextButton(
child: const Text('取消'),
onPressed: () {
Navigator.of(dialogContext).pop();
},
),
TextButton(
child: Text('删除',
style: TextStyle(
color:
Theme.of(context).colorScheme.error)),
onPressed: () {
context.read<ChatBloc>().add(
DeleteChatSession(sessionId: session.id));
Navigator.of(dialogContext).pop();
},
),
],
);
},
);
},
tooltip: '删除会话',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
selected: isSelected,
selectedTileColor: Theme.of(context)
.colorScheme
.primaryContainer
.withOpacity(0.1),
onTap: () => _selectSession(session.id),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
);
},
),
),
],
);
}
// 构建聊天视图
Widget _buildChatView(BuildContext context, ChatSessionActive state) {
// --- 获取当前会话选择的模型 ---
// 现在可以直接从 state 获取 selectedModel
final UserAIModelConfigModel? currentChatModel = state.selectedModel;
return Column(
children: [
// 在卡片模式下显示简洁的返回按钮
if (widget.isCardMode)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer.withValues(alpha: 0.5),
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.3),
width: 0.5,
),
),
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, size: 18),
tooltip: '返回列表',
onPressed: () {
context.read<ChatBloc>().add(LoadChatSessions(novelId: widget.novelId));
},
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
),
Expanded(
child: Text(
state.session.title,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
// 显示历史加载指示器
if (state.isLoadingHistory)
const Padding(
padding: EdgeInsets.symmetric(vertical: 8.0),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))),
),
// 显示加载历史或发送消息时的错误信息(如果需要更持久的提示)
// if (state.error != null)
// Padding(
// padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
// child: Text(state.error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
// ),
Expanded(
child: ChatMessagesList(scrollController: _scrollController),
),
// ChatInput 背景应与聊天视图背景一致或略有区分
Container(
color: Theme.of(context).colorScheme.surface,
child: BlocBuilder<EditorBloc, EditorState>(
builder: (context, editorState) {
Novel? novel;
if (editorState is EditorLoaded) {
novel = editorState.novel;
}
// 🚀 使用BlocBuilder获取当前会话的配置
return BlocBuilder<ChatBloc, ChatState>(
buildWhen: (previous, current) {
// 只有当与当前会话相关的配置发生实际变化时才重建,避免流式 token 触发
if (previous is ChatSessionActive && current is ChatSessionActive) {
// 不同会话 → 必须重建
if (previous.session.id != current.session.id) return true;
// ChatBloc 在更新配置(模型或上下文)时会带上 configUpdateTimestamp
if (previous.configUpdateTimestamp != current.configUpdateTimestamp) {
return true;
}
return false; // 同会话且配置没变 → 不重建
}
// 其它类型转变,例如从活动回到列表或错误,再由父 BlocConsumer 处理
return false;
},
builder: (context, chatState) {
final chatBloc = context.read<ChatBloc>();
final currentConfig = chatBloc.getSessionConfig(state.session.id, widget.novelId);
// 配置获取完成
return ChatInput(
key: ValueKey('chat_input_${state.session.id}_${currentConfig?.contextSelections?.selectedCount ?? 0}'), // 🚀 添加key确保Widget正确更新
controller: _messageController,
onSend: _sendMessage,
isGenerating: state.isGenerating,
onCancel: () {
context.read<ChatBloc>().add(const CancelOngoingRequest());
},
initialModel: currentChatModel,
novel: novel, // 传入从EditorBloc获取的novel数据
contextData: widget.editorController?.cascadeMenuData, // 🚀 使用EditorScreenController维护的级联菜单数据死的结构
onContextChanged: (newContextData) {
// 🚀 如果需要通知EditorScreenController级联菜单数据变化可以在这里处理
// 但通常不需要因为EditorScreenController维护的是结构数据不是选择状态
print('🔧 [AIChatSidebar] 级联菜单数据变化通知: ${newContextData.selectedCount}个选择');
},
settings: state.cachedSettings.cast<NovelSettingItem>(),
settingGroups: state.cachedSettingGroups.cast<SettingGroup>(),
snippets: state.cachedSnippets.cast<NovelSnippet>(),
// 🚀 添加聊天配置支持,确保设置对话框能够同步
chatConfig: currentConfig,
onConfigChanged: (updatedConfig) {
print('🔧 [AIChatSidebar] 聊天配置已更新发送到ChatBloc');
print('🔧 [AIChatSidebar] 更新后配置上下文: ${updatedConfig.contextSelections?.selectedCount ?? 0}');
// 发送配置更新事件到ChatBloc
context.read<ChatBloc>().add(UpdateChatConfiguration(
sessionId: state.session.id,
config: updatedConfig,
));
},
// 🚀 初始定位到当前章节/场景
initialChapterId: widget.chapterId,
initialSceneId: null,
onModelSelected: (selectedModel) {
if (selectedModel != null &&
selectedModel.id != currentChatModel?.id) {
// 使用正确的事件类
context.read<ChatBloc>().add(UpdateChatModel(
sessionId: state.session.id,
modelConfigId: selectedModel.id,
));
AppLogger.i('AIChatSidebar',
'Model selected event dispatched: ${selectedModel.id} for session ${state.session.id}');
}
},
);
},
);
},
),
),
],
);
}
}
class ChatMessagesList extends StatelessWidget {
final ScrollController scrollController;
const ChatMessagesList({super.key, required this.scrollController});
@override
Widget build(BuildContext context) {
return BlocBuilder<ChatBloc, ChatState>(
buildWhen: (previous, current) {
if (previous is ChatSessionActive && current is ChatSessionActive) {
// 仅当消息列表实例或长度发生变化时重建,实现流式刷新
return previous.messages != current.messages;
}
return false;
},
builder: (context, state) {
if (state is! ChatSessionActive) {
return const SizedBox.shrink();
}
final messages = state.messages;
return Container(
color: Theme.of(context).colorScheme.surface,
child: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.all(16),
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
return ChatMessageBubble(
message: message,
onActionSelected: (action) {
context.read<ChatBloc>().add(ExecuteAction(action: action));
},
);
},
),
);
},
);
}
}

View File

@@ -0,0 +1,953 @@
import 'package:ainoval/models/user_ai_model_config_model.dart';
import 'package:ainoval/models/context_selection_models.dart';
import 'package:ainoval/models/novel_setting_item.dart';
import 'package:ainoval/models/setting_group.dart';
import 'package:ainoval/models/novel_snippet.dart';
import 'package:ainoval/models/ai_request_models.dart';
import 'package:ainoval/models/preset_models.dart';
import 'package:ainoval/models/unified_ai_model.dart';
import 'package:ainoval/services/ai_preset_service.dart';
import 'package:ainoval/config/app_config.dart';
import 'package:ainoval/utils/logger.dart';
import 'package:ainoval/widgets/common/top_toast.dart';
import 'package:ainoval/widgets/common/model_display_selector.dart';
import 'package:ainoval/widgets/common/context_selection_dropdown_menu_anchor.dart';
import 'package:ainoval/widgets/common/credit_display.dart';
import 'package:flutter/material.dart';
import 'package:ainoval/utils/web_theme.dart';
// import 'package:flutter_bloc/flutter_bloc.dart';
class ChatInput extends StatefulWidget {
const ChatInput({
Key? key,
required this.controller,
required this.onSend,
this.isGenerating = false,
this.onCancel,
this.onModelSelected,
this.initialModel,
this.novel,
this.contextData,
this.onContextChanged,
this.settings = const [],
this.settingGroups = const [],
this.snippets = const [],
this.chatConfig,
this.onConfigChanged,
this.onCreditError, // 🚀 新增:积分不足错误回调
this.initialChapterId,
this.initialSceneId,
}) : super(key: key);
final TextEditingController controller;
final VoidCallback onSend;
final Function(String)? onCreditError; // 🚀 新增:积分不足错误回调
final bool isGenerating;
final VoidCallback? onCancel;
final Function(UserAIModelConfigModel?)? onModelSelected;
final UserAIModelConfigModel? initialModel;
final dynamic novel;
final ContextSelectionData? contextData;
final ValueChanged<ContextSelectionData>? onContextChanged;
final List<NovelSettingItem> settings;
final List<SettingGroup> settingGroups;
final List<NovelSnippet> snippets;
final UniversalAIRequest? chatConfig;
final ValueChanged<UniversalAIRequest>? onConfigChanged;
final String? initialChapterId;
final String? initialSceneId;
@override
State<ChatInput> createState() => _ChatInputState();
}
class _ChatInputState extends State<ChatInput> {
OverlayEntry? _presetOverlay;
final LayerLink _layerLink = LayerLink();
bool _isComposing = false;
// 预设相关状态
// final GlobalKey _presetButtonKey = GlobalKey();
List<AIPromptPreset> _availablePresets = [];
bool _isLoadingPresets = false;
AIPromptPreset? _currentPreset;
@override
void initState() {
super.initState();
widget.controller.addListener(_handleTextChange);
_handleTextChange();
_loadPresets();
}
@override
void dispose() {
widget.controller.removeListener(_handleTextChange);
_removePresetOverlay();
super.dispose();
}
/// 加载预设数据
Future<void> _loadPresets() async {
if (_isLoadingPresets) return;
setState(() {
_isLoadingPresets = true;
});
try {
final presetService = AIPresetService();
// 直接获取AI_CHAT类型的预设
final chatPresets = await presetService.getUserPresets(featureType: 'AI_CHAT');
setState(() {
_availablePresets = chatPresets;
_isLoadingPresets = false;
});
AppLogger.i('ChatInput', '加载了 ${_availablePresets.length} 个聊天预设');
} catch (e) {
setState(() {
_isLoadingPresets = false;
});
AppLogger.e('ChatInput', '加载预设失败', e);
}
}
void _handleTextChange() {
final bool composingNow = widget.controller.text.trim().isNotEmpty;
if (composingNow != _isComposing) {
// 只有从空 → 非空 或 非空 → 空 时才重建,避免输入过程中频繁 setState
setState(() {
_isComposing = composingNow;
});
}
}
/// 显示预设下拉菜单
void _showPresetOverlay() {
if (_presetOverlay != null) {
_removePresetOverlay();
return;
}
_presetOverlay = OverlayEntry(
builder: (context) => Stack(
children: [
Positioned.fill(
child: GestureDetector(
onTap: _removePresetOverlay,
child: Container(color: Colors.transparent),
),
),
CompositedTransformFollower(
link: _layerLink,
showWhenUnlinked: false,
targetAnchor: Alignment.topRight,
followerAnchor: Alignment.bottomRight,
offset: const Offset(0, -8),
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(12),
color: Theme.of(context).colorScheme.surfaceContainer,
shadowColor: WebTheme.getShadowColor(context, opacity: 0.15),
child: Container(
width: 240,
constraints: const BoxConstraints(maxHeight: 320),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant.withOpacity(0.3),
),
),
child: _buildPresetMenuContent(),
),
),
),
],
),
);
Overlay.of(context).insert(_presetOverlay!);
}
/// 移除预设下拉菜单
void _removePresetOverlay() {
_presetOverlay?.remove();
_presetOverlay = null;
}
/// 构建预设菜单内容
Widget _buildPresetMenuContent() {
if (_isLoadingPresets) {
return Container(
height: 120,
padding: const EdgeInsets.all(16),
child: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(height: 8),
Text(
'加载预设中...',
style: TextStyle(fontSize: 12),
),
],
),
),
);
}
if (_availablePresets.isEmpty) {
return Container(
height: 120,
padding: const EdgeInsets.all(16),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.auto_awesome_outlined,
size: 32,
color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.5),
),
const SizedBox(height: 8),
Text(
'暂无可用预设',
style: TextStyle(
fontSize: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
'可在设置中创建预设',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.7),
),
),
],
),
),
);
}
// 对预设进行分组
final Map<String, List<AIPromptPreset>> groupedPresets = {
'最近使用': _availablePresets.where((p) => p.lastUsedAt != null).take(3).toList(),
'收藏预设': _availablePresets.where((p) => p.isFavorite).toList(),
'所有预设': _availablePresets,
};
return ListView(
padding: const EdgeInsets.all(8),
shrinkWrap: true,
children: [
// 标题
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
child: Row(
children: [
Icon(
Icons.auto_awesome,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'快速预设',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
],
),
),
const Divider(height: 1),
// 预设分组列表
...groupedPresets.entries.where((entry) => entry.value.isNotEmpty).map((entry) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (entry.key != '所有预设' || (entry.key == '所有预设' && groupedPresets['最近使用']!.isEmpty && groupedPresets['收藏预设']!.isEmpty))
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
child: Text(
entry.key,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurfaceVariant,
letterSpacing: 0.5,
),
),
),
...entry.value.map((preset) => _buildPresetMenuItem(preset)).toList(),
],
);
}).toList(),
],
);
}
/// 构建预设菜单项
Widget _buildPresetMenuItem(AIPromptPreset preset) {
final colorScheme = Theme.of(context).colorScheme;
final isSelected = _currentPreset?.presetId == preset.presetId;
return InkWell(
onTap: () => _handlePresetSelected(preset),
borderRadius: BorderRadius.circular(8),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: isSelected ? colorScheme.primaryContainer.withOpacity(0.3) : null,
borderRadius: BorderRadius.circular(6),
),
child: Row(
children: [
// 预设图标
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Icon(
Icons.auto_awesome,
size: 12,
color: colorScheme.primary,
),
),
const SizedBox(width: 8),
// 预设信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
preset.displayName,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
color: isSelected ? colorScheme.primary : colorScheme.onSurface,
),
overflow: TextOverflow.ellipsis,
),
),
if (preset.isFavorite) ...[
const SizedBox(width: 4),
Icon(
Icons.star,
size: 10,
color: Colors.amber.shade600,
),
],
],
),
if (preset.presetDescription != null && preset.presetDescription!.isNotEmpty)
Text(
preset.presetDescription!,
style: TextStyle(
fontSize: 10,
color: colorScheme.onSurfaceVariant.withOpacity(0.7),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
// 选中标识
if (isSelected)
Icon(
Icons.check_circle,
size: 14,
color: colorScheme.primary,
),
],
),
),
);
}
/// 处理预设选择
void _handlePresetSelected(AIPromptPreset preset) {
_removePresetOverlay();
try {
setState(() {
_currentPreset = preset;
});
// 解析预设并应用到聊天配置
final parsedRequest = preset.parsedRequest;
if (parsedRequest != null && widget.onConfigChanged != null) {
// 创建新的配置,保留现有的基础信息
final baseConfig = widget.chatConfig ?? UniversalAIRequest(
requestType: AIRequestType.chat,
userId: AppConfig.userId ?? 'unknown',
novelId: widget.novel?.id,
);
// 应用预设配置
final updatedConfig = baseConfig.copyWith(
modelConfig: parsedRequest.modelConfig ?? baseConfig.modelConfig,
instructions: parsedRequest.instructions?.isNotEmpty == true
? parsedRequest.instructions
: preset.effectiveUserPrompt.isNotEmpty ? preset.effectiveUserPrompt : null,
contextSelections: parsedRequest.contextSelections ?? baseConfig.contextSelections,
enableSmartContext: parsedRequest.enableSmartContext,
parameters: {
...baseConfig.parameters,
...parsedRequest.parameters,
},
metadata: {
...baseConfig.metadata,
'appliedPreset': preset.presetId,
'presetName': preset.presetName,
'lastPresetApplied': DateTime.now().toIso8601String(),
},
);
widget.onConfigChanged!(updatedConfig);
// 如果预设包含模型配置,也要通知模型选择器
if (parsedRequest.modelConfig != null) {
widget.onModelSelected?.call(parsedRequest.modelConfig);
}
AppLogger.i('ChatInput', '预设已应用: ${preset.displayName}');
// 记录预设使用
AIPresetService().applyPreset(preset.presetId);
// 显示成功提示
TopToast.success(context, '已应用预设: ${preset.displayName}');
} else {
AppLogger.w('ChatInput', '预设解析失败或缺少配置变更回调');
TopToast.error(context, '应用预设失败');
}
} catch (e) {
AppLogger.e('ChatInput', '应用预设失败', e);
TopToast.error(context, '应用预设失败: $e');
}
}
void _updateContextData(ContextSelectionData newData, {bool isAddOperation = true}) {
if (widget.onConfigChanged != null) {
if (widget.chatConfig != null) {
// 🚀 修复使用完整的菜单结构而不是可能不完整的currentSelections
final currentSelections = widget.chatConfig!.contextSelections;
// 🚀 获取完整的菜单结构数据
ContextSelectionData? fullContextData;
if (widget.contextData != null) {
fullContextData = widget.contextData;
} else if (widget.novel != null) {
fullContextData = ContextSelectionDataBuilder.fromNovelWithContext(
widget.novel!,
settings: widget.settings,
settingGroups: widget.settingGroups,
snippets: widget.snippets,
);
}
if (fullContextData != null) {
ContextSelectionData updatedSelections;
if (isAddOperation && currentSelections != null) {
// 🚀 添加操作:将现有选择应用到完整结构,然后添加新选择
// 先应用现有选择到完整结构
updatedSelections = fullContextData.applyPresetSelections(currentSelections);
// 再添加新选择的项目
for (final newItem in newData.selectedItems.values) {
if (!updatedSelections.selectedItems.containsKey(newItem.id)) {
updatedSelections = updatedSelections.selectItem(newItem.id);
}
}
} else if (!isAddOperation && currentSelections != null) {
// 🚀 删除操作:将现有选择应用到完整结构,然后移除指定项目
updatedSelections = fullContextData.applyPresetSelections(currentSelections);
// 找出被删除的项目并移除
for (final existingId in currentSelections.selectedItems.keys) {
if (!newData.selectedItems.containsKey(existingId)) {
updatedSelections = updatedSelections.deselectItem(existingId);
}
}
} else {
// 🚀 如果当前没有选择,直接使用新数据(但保持完整结构)
updatedSelections = fullContextData;
for (final newItem in newData.selectedItems.values) {
updatedSelections = updatedSelections.selectItem(newItem.id);
}
}
final updatedConfig = widget.chatConfig!.copyWith(
contextSelections: updatedSelections,
);
widget.onConfigChanged!(updatedConfig);
} else {
// 如果无法获取完整菜单结构,回退到原来的逻辑
final updatedConfig = widget.chatConfig!.copyWith(
contextSelections: newData,
);
widget.onConfigChanged!(updatedConfig);
}
} else {
// 如果没有chatConfig创建一个基础配置
final newConfig = UniversalAIRequest(
requestType: AIRequestType.chat,
userId: 'unknown', // 这应该从某个地方获取
novelId: widget.novel?.id,
contextSelections: newData,
);
widget.onConfigChanged!(newConfig);
}
} else {
// 🚀 如果没有onConfigChanged回调则使用传统的onContextChanged
widget.onContextChanged?.call(newData);
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final bool canSend = _isComposing && !widget.isGenerating;
ContextSelectionData? currentContextData;
if (widget.contextData != null) {
// 🚀 使用EditorScreenController维护的级联菜单数据静态结构
currentContextData = widget.contextData;
} else if (widget.novel != null) {
// 备用方案如果EditorScreenController还没有准备好数据则临时构建
currentContextData = ContextSelectionDataBuilder.fromNovelWithContext(
widget.novel!,
settings: widget.settings,
settingGroups: widget.settingGroups,
snippets: widget.snippets,
);
}
// final contextSelectionCount = widget.chatConfig?.contextSelections?.selectedCount ?? 0;
return Container(
decoration: BoxDecoration(
color: colorScheme.surface,
border: Border(
top: BorderSide(
color: colorScheme.outlineVariant.withOpacity(0.5),
width: 1.0,
),
),
),
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 10.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 上下文选择区域 - 始终显示,以便用户可以点击添加
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: colorScheme.outline.withOpacity(0.1),
width: 1.0,
),
),
),
child: Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center, // 垂直居中对齐
children: [
// 使用完整的上下文选择组件 - 包含完整的级联菜单
if (currentContextData != null)
ContextSelectionDropdownBuilder.buildMenuAnchor(
data: currentContextData,
onSelectionChanged: _updateContextData,
placeholder: '+ Context',
maxHeight: 400,
initialChapterId: widget.initialChapterId,
initialSceneId: widget.initialSceneId,
)
else
// 当没有数据时显示占位符
Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withOpacity(0.5),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: colorScheme.outline.withOpacity(0.3),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.pending_outlined,
size: 16,
color: colorScheme.onSurface.withOpacity(0.5),
),
const SizedBox(width: 8),
Text(
'等待级联菜单数据...',
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurface.withOpacity(0.5),
),
),
],
),
),
// 🚀 修复:使用完整菜单结构中的已选择项目显示标签
if (currentContextData != null && widget.chatConfig?.contextSelections != null)
..._buildSelectedContextTags(currentContextData, widget.chatConfig!.contextSelections!).map((item) {
return Container(
height: 36,
constraints: const BoxConstraints(maxWidth: 200),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withOpacity(0.75),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: colorScheme.outline.withOpacity(0.2),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
item.type.icon,
size: 16,
color: colorScheme.onSurface.withOpacity(0.7),
),
const SizedBox(width: 8),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
item.title,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
height: 1.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (item.displaySubtitle.isNotEmpty)
Text(
item.displaySubtitle,
style: TextStyle(
fontSize: 9,
color: colorScheme.onSurface.withOpacity(0.6),
height: 1.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 4),
InkWell(
onTap: () {
// 🚀 修复:使用完整菜单结构进行删除操作
if (currentContextData != null && widget.chatConfig!.contextSelections != null) {
// 将当前选择应用到完整结构,然后删除指定项目
final fullDataWithSelections = currentContextData.applyPresetSelections(widget.chatConfig!.contextSelections!);
final newData = fullDataWithSelections.deselectItem(item.id);
_updateContextData(newData, isAddOperation: false);
}
},
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.all(2),
child: Icon(
Icons.close,
size: 14,
color: colorScheme.onSurface.withOpacity(0.5),
),
),
),
],
),
);
}).toList(),
],
),
),
const SizedBox(height: 8.0),
// 输入框行 - 独占一行,去掉圆角,紧贴边缘
Container(
width: double.infinity,
child: TextField(
controller: widget.controller,
decoration: InputDecoration(
hintText: widget.isGenerating ? 'AI 正在回复...' : '输入消息...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(0), // 去掉圆角
borderSide: BorderSide(
color: colorScheme.outline.withOpacity(0.5)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(0), // 去掉圆角
borderSide: BorderSide(
color: colorScheme.outline.withOpacity(0.3)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(0), // 去掉圆角
borderSide:
BorderSide(color: colorScheme.primary, width: 1.5),
),
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 12), // 增加垂直内边距
isDense: false, // 改为false以获得更多空间
disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(0), // 去掉圆角
borderSide: BorderSide(color: Theme.of(context).colorScheme.outline.withOpacity(0.3)),
),
),
readOnly: widget.isGenerating,
minLines: 1,
maxLines: 5,
textInputAction: TextInputAction.newline,
style: TextStyle(fontSize: 14, color: colorScheme.onSurface),
onSubmitted: (_) {
if (canSend) {
widget.onSend();
}
},
),
),
const SizedBox(height: 8.0),
// 预设按钮、积分显示、模型选择器和发送按钮行
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// 预设快捷按钮 - 使用PopupMenuButton实现精准定位
CompositedTransformTarget(
link: _layerLink,
child: GestureDetector(
onTap: _showPresetOverlay,
child: Container(
width: 40,
height: 36, // 与模型选择器保持一致的高度
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.dark
? Theme.of(context).colorScheme.surfaceContainerHighest // 深色容器
: Theme.of(context).colorScheme.surface, // 浅色容器
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.4),
width: 1.0,
),
borderRadius: BorderRadius.circular(20), // rounded-full
boxShadow: [
BoxShadow(
color: WebTheme.getShadowColor(context, opacity: 0.1),
blurRadius: 1,
offset: const Offset(0, 1),
),
],
),
child: Material(
type: MaterialType.transparency,
child: InkWell(
onTap: _showPresetOverlay,
borderRadius: BorderRadius.circular(20),
hoverColor: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.8),
child: Container(
width: 40,
height: 36,
child: Center(
child: Icon(
Icons.auto_awesome,
size: 16,
color: _currentPreset != null
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
),
),
),
),
),
),
const SizedBox(width: 8),
// 🚀 积分显示组件
const CreditDisplay(
size: CreditDisplaySize.small,
showRefreshButton: false,
),
const SizedBox(width: 8),
// 模型选择按钮 - 使用统一的显示/选择组件
Expanded(
child: ModelDisplaySelector(
selectedModel: widget.initialModel != null ? PrivateAIModel(widget.initialModel!) : null,
onModelSelected: (unifiedModel) {
// 将UnifiedAIModel转换为UserAIModelConfigModel以保持兼容性
UserAIModelConfigModel? compatModel;
if (unifiedModel != null) {
if (unifiedModel.isPublic) {
final publicModel = (unifiedModel as PublicAIModel).publicConfig;
compatModel = UserAIModelConfigModel.fromJson({
'id': 'public_${publicModel.id}',
'userId': AppConfig.userId ?? 'unknown',
'alias': publicModel.displayName,
'modelName': publicModel.modelId,
'provider': publicModel.provider,
'apiEndpoint': '',
'isDefault': false,
'isValidated': true,
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
});
} else {
compatModel = (unifiedModel as PrivateAIModel).userConfig;
}
}
widget.onModelSelected?.call(compatModel);
},
chatConfig: widget.chatConfig,
onConfigChanged: widget.onConfigChanged,
novel: widget.novel,
settings: widget.settings,
settingGroups: widget.settingGroups,
snippets: widget.snippets,
size: ModelDisplaySize.medium,
showIcon: true,
showTags: true,
showSettingsButton: true,
placeholder: '选择模型',
),
),
const SizedBox(width: 8),
// 发送/停止按钮 - 改为纯黑/灰黑主题
SizedBox(
height: 36, // 与模型选择器保持一致的高度
width: 36,
child: widget.isGenerating
? Material(
color: colorScheme.primary, // 使用主色
borderRadius: BorderRadius.circular(18),
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: widget.onCancel,
child: Container(
width: 36,
height: 36,
child: const Icon(
Icons.stop_rounded,
size: 20,
color: Colors.white,
),
),
),
)
: Material(
color: canSend
? colorScheme.primary
: colorScheme.onSurfaceVariant,
borderRadius: BorderRadius.circular(18),
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: canSend ? _handleSendWithCreditCheck : null,
child: Container(
width: 36,
height: 36,
child: Icon(
Icons.arrow_upward_rounded,
size: 20,
color: canSend
? colorScheme.onPrimary
: colorScheme.onPrimary.withOpacity(0.5),
),
),
),
),
),
],
),
],
),
);
}
/// 🚀 新增:带积分检查的发送处理
void _handleSendWithCreditCheck() {
try {
// 调用原发送方法,积分校验将在后端处理
widget.onSend();
} catch (e) {
// 如果发送失败,检查是否为积分不足错误
final errorMessage = e.toString();
if (errorMessage.contains('积分不足') || errorMessage.contains('InsufficientCredits')) {
// 积分不足,调用错误回调
widget.onCreditError?.call('积分不足,无法发送消息。请充值后重试。');
// 同时显示Toast提示
TopToast.error(context, '积分不足,无法发送消息');
} else {
// 其他错误,显示通用错误提示
TopToast.error(context, '发送失败: $errorMessage');
}
}
}
/// 🚀 构建已选择的上下文标签,使用完整菜单结构中的数据
List<ContextSelectionItem> _buildSelectedContextTags(
ContextSelectionData fullContextData,
ContextSelectionData currentSelections,
) {
// 将当前选择应用到完整菜单结构中
final updatedContextData = fullContextData.applyPresetSelections(currentSelections);
// 返回应用后的选中项目列表
return updatedContextData.selectedItems.values.toList();
}
}

View File

@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:ainoval/widgets/common/top_toast.dart';
/// 通用的消息操作栏组件
/// - 默认提供“复制”操作,复制整条消息文本
/// - 支持扩展更多自定义操作
/// - 自适应浅/深色主题
class ChatMessageActionsBar extends StatelessWidget {
const ChatMessageActionsBar({
super.key,
required this.textToCopy,
this.alignEnd = false,
this.actions = const [],
this.compact = true,
});
/// 要复制的完整文本
final String textToCopy;
/// 是否尾对齐用户消息用右对齐AI 消息用左对齐)
final bool alignEnd;
/// 额外自定义操作(可选)
final List<Widget> actions;
/// 紧凑模式(更小的尺寸与间距)
final bool compact;
void _copyToClipboard(BuildContext context) async {
if (textToCopy.isEmpty) return;
await Clipboard.setData(ClipboardData(text: textToCopy));
TopToast.success(context, '已复制到剪贴板');
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final iconColor = colorScheme.onSurfaceVariant;
final hoverColor = colorScheme.surfaceContainerHighest.withOpacity(0.6);
return Padding(
padding: EdgeInsets.only(top: compact ? 4.0 : 8.0),
child: Row(
mainAxisAlignment: alignEnd ? MainAxisAlignment.end : MainAxisAlignment.start,
children: [
Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.4)),
),
padding: EdgeInsets.symmetric(
horizontal: compact ? 4 : 6,
vertical: compact ? 2 : 4,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 复制按钮(默认提供)
_IconActionButton(
icon: Icons.copy_rounded,
tooltip: '复制整条消息',
iconColor: iconColor,
hoverColor: hoverColor,
onPressed: () => _copyToClipboard(context),
compact: compact,
),
// 分隔与扩展动作
if (actions.isNotEmpty) ...[
SizedBox(width: compact ? 2 : 4),
..._intersperse(actions, SizedBox(width: compact ? 2 : 4)),
],
],
),
),
],
),
);
}
List<Widget> _intersperse(List<Widget> list, Widget separator) {
if (list.isEmpty) return list;
final result = <Widget>[];
for (var i = 0; i < list.length; i++) {
if (i > 0) result.add(separator);
result.add(list[i]);
}
return result;
}
}
class _IconActionButton extends StatelessWidget {
const _IconActionButton({
required this.icon,
required this.tooltip,
required this.iconColor,
required this.hoverColor,
required this.onPressed,
required this.compact,
});
final IconData icon;
final String tooltip;
final Color iconColor;
final Color hoverColor;
final VoidCallback onPressed;
final bool compact;
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(12),
hoverColor: hoverColor,
child: Padding(
padding: EdgeInsets.all(compact ? 6 : 8),
child: Icon(
icon,
size: compact ? 16 : 18,
color: iconColor,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,330 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import '../../../models/chat_models.dart';
import 'chat_message_actions_bar.dart';
// 🚀 移除了TypewriterText组件简化消息显示逻辑
class ChatMessageBubble extends StatelessWidget {
const ChatMessageBubble({
Key? key,
required this.message,
required this.onActionSelected,
}) : super(key: key);
final ChatMessage message;
final Function(MessageAction) onActionSelected;
@override
Widget build(BuildContext context) {
// 假设 message.role 可以区分用户和 AI (如果用 sender则替换为 message.sender)
final bool isUserMessage = message.role ==
MessageRole.user; // 或者 message.sender == MessageSender.user
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0), // 稍微减少垂直间距
child: Row(
mainAxisAlignment:
isUserMessage ? MainAxisAlignment.end : MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, // 保持顶部对齐
children: [
// AI 头像占位符 (如果需要显示)
if (!isUserMessage) _buildAvatar(context, false),
if (!isUserMessage) const SizedBox(width: 8),
// 消息气泡容器 - 使用LayoutBuilder
Flexible(
child: LayoutBuilder(builder: (context, constraints) {
// 基于LayoutBuilder中的约束计算最大宽度保证气泡不会太宽
final maxWidth = constraints.maxWidth * 0.95;
return Container(
constraints: BoxConstraints(maxWidth: maxWidth),
child: Column(
// 用户消息时间戳靠右AI 消息时间戳靠左
crossAxisAlignment: isUserMessage
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
// 气泡主体
Container(
padding: const EdgeInsets.symmetric(
vertical: 10.0, horizontal: 14.0), // 调整内边距
decoration: BoxDecoration(
color: isUserMessage
? Theme.of(context).colorScheme.primary // 用户消息用主色
: Theme.of(context)
.colorScheme
.surfaceContainer, // AI消息用 surfaceContainer
// 实现"尾巴"效果的圆角
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16.0),
topRight: const Radius.circular(16.0),
bottomLeft: Radius.circular(
isUserMessage ? 16.0 : 4.0), // 用户左下圆角AI左下小圆角/直角
bottomRight: Radius.circular(
isUserMessage ? 4.0 : 16.0), // 用户右下小圆角/直角AI右下圆角
),
// 可以为 AI 消息添加细微边框
border: !isUserMessage
? Border.all(
color: Theme.of(context)
.colorScheme
.outlineVariant
.withOpacity(0.3),
width: 0.5,
)
: null,
),
child: isUserMessage
? _buildUserMessageContent(context)
: _buildAIMessageContent(context),
),
// 时间戳
Padding(
padding: const EdgeInsets.only(
top: 4.0, left: 6.0, right: 6.0),
child: Text(
message.formattedTime,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withOpacity(0.7),
),
),
),
// 通用操作栏(复制等)
ChatMessageActionsBar(
textToCopy: message.content,
alignEnd: isUserMessage,
compact: true,
),
],
),
);
}),
),
// 用户头像占位符 (如果需要显示)
if (isUserMessage) const SizedBox(width: 8),
if (isUserMessage) _buildAvatar(context, true),
],
),
);
}
// 头像构建方法 (可选)
Widget _buildAvatar(BuildContext context, bool isUser) {
// 现在使用 Icon 代替 CircleAvatar
return Icon(
isUser ? Icons.person_outline : Icons.smart_toy_outlined,
color: isUser
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.secondary,
size: 28, // 调整大小
);
/* return CircleAvatar(
radius: 16, // 调整大小
backgroundColor: isUser
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.secondaryContainer,
child: Icon(
isUser ? Icons.person_outline : Icons.smart_toy_outlined, // 使用 outline 图标
size: 18, // 图标大小
color: isUser
? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(context).colorScheme.onSecondaryContainer,
),
); */
}
// 构建用户消息内容
Widget _buildUserMessageContent(BuildContext context) {
return SelectableText(
message.content,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary, // 用户消息文本颜色
fontSize: 14, // 调整字体大小
height: 1.4, // 调整行高
),
);
}
// 构建AI消息内容 (Markdown) - 修改为支持打字机效果
Widget _buildAIMessageContent(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (message.status == MessageStatus.error)
_buildErrorMessage(context)
else if (message.status == MessageStatus.streaming || message.status == MessageStatus.pending)
// 🚀 对于正在生成的消息,显示简单的等待状态
_buildWaitingContent(context)
else
// 🚀 对于已完成的消息,直接使用可选择的 Markdown
MarkdownBody(
data: message.content.isEmpty ? '思考中...' : message.content,
selectable: true,
styleSheet: MarkdownStyleSheet(
p: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant, // AI 消息主要文本颜色
fontSize: 14, // 字体大小
height: 1.4, // 行高
),
h1: textTheme.titleLarge?.copyWith(
color: colorScheme.onSurface, fontWeight: FontWeight.w600),
h2: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface, fontWeight: FontWeight.w600),
h3: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface, fontWeight: FontWeight.w600),
code: textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
backgroundColor: colorScheme.surfaceContainerHighest
.withOpacity(0.5), // 代码背景色
color: colorScheme.onSurfaceVariant, // 代码文字颜色
fontSize: 13,
),
codeblockDecoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest
.withOpacity(0.5), // 代码块背景色
borderRadius: BorderRadius.circular(4),
border: Border.all(
color:
colorScheme.outlineVariant.withOpacity(0.3)), // 代码块边框
),
blockquoteDecoration: BoxDecoration(
// 引用块样式
border: Border(
left: BorderSide(color: colorScheme.primary, width: 4)),
color: colorScheme.primaryContainer.withOpacity(0.1),
borderRadius: const BorderRadius.only(
topRight: Radius.circular(4),
bottomRight: Radius.circular(4)),
),
blockquotePadding: const EdgeInsets.all(12), // 引用块内边距
listBulletPadding: const EdgeInsets.only(right: 4), // 列表标记边距
listIndent: 16, // 列表缩进
),
),
// ActionChip 样式调整
if (message.actions != null && message.actions!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 10.0), // Chip 与上方内容的间距
child: Wrap(
spacing: 8,
runSpacing: 6,
children: message.actions!.map((action) {
return ActionChip(
label: Text(action.label),
onPressed: () => onActionSelected(action),
backgroundColor: colorScheme.secondaryContainer
.withOpacity(0.5), // Chip 背景色
labelStyle: textTheme.bodySmall?.copyWith(
// Chip 文字样式
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w500,
),
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2), // Chip 内边距
side: BorderSide.none, // 移除边框
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16)), // 圆角
);
}).toList(),
),
),
],
);
}
// 🚀 新增:构建等待状态内容,直接显示消息内容
Widget _buildWaitingContent(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
// 🚀 如果有消息内容直接显示为可选择的Markdown否则显示等待提示
if (message.content.isNotEmpty) {
return MarkdownBody(
data: message.content,
selectable: true,
styleSheet: MarkdownStyleSheet(
p: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 14,
height: 1.4,
),
h1: textTheme.titleLarge?.copyWith(
color: colorScheme.onSurface, fontWeight: FontWeight.w600),
h2: textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface, fontWeight: FontWeight.w600),
h3: textTheme.titleSmall?.copyWith(
color: colorScheme.onSurface, fontWeight: FontWeight.w600),
code: textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
backgroundColor: colorScheme.surfaceContainerHighest
.withOpacity(0.5),
color: colorScheme.onSurfaceVariant,
fontSize: 13,
),
codeblockDecoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest
.withOpacity(0.5),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: colorScheme.outlineVariant.withOpacity(0.3)),
),
blockquoteDecoration: BoxDecoration(
border: Border(
left: BorderSide(color: colorScheme.primary, width: 4)),
color: colorScheme.primaryContainer.withOpacity(0.1),
borderRadius: const BorderRadius.only(
topRight: Radius.circular(4),
bottomRight: Radius.circular(4)),
),
blockquotePadding: const EdgeInsets.all(12),
listBulletPadding: const EdgeInsets.only(right: 4),
listIndent: 16,
),
);
} else {
// 🚀 只有在没有内容时才显示简单的等待提示
return SelectableText(
'AI正在思考...',
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 14,
height: 1.4,
fontStyle: FontStyle.italic,
),
);
}
}
// 构建错误消息 (样式微调)
Widget _buildErrorMessage(BuildContext context) {
return Row(
children: [
Icon(
Icons.error_outline,
color: Theme.of(context).colorScheme.error,
size: 18, // 调整图标大小
),
const SizedBox(width: 8),
Expanded(
child: SelectableText(
message.content.isEmpty ? '发生错误' : message.content, // 默认错误消息
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.error,
fontWeight: FontWeight.w500, // 加粗错误文本
),
),
),
],
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,200 @@
import 'package:flutter/material.dart';
import '../../../models/chat_models.dart';
class ContextPanel extends StatelessWidget {
const ContextPanel({
Key? key,
required this.context,
required this.onClose,
}) : super(key: key);
final ChatContext context;
final VoidCallback onClose;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
// 使用 surfaceContainerLow 作为背景,与 ai_chat_sidebar 区分但又协调
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow,
border: Border(
left: BorderSide(
color: colorScheme.outlineVariant.withOpacity(0.5), // 更细微的边框
width: 1,
),
),
),
child: Column(
children: [
// 面板标题
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
// 使用 surfaceContainer 作为标题背景
color: colorScheme.surfaceContainer,
// 底部边框调整
border: Border(
bottom: BorderSide(
color: colorScheme.outlineVariant.withOpacity(0.5),
width: 1,
),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'上下文信息',
style: Theme.of(context).textTheme.titleMedium,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: onClose,
tooltip: '关闭面板',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
// 调整关闭按钮颜色
color: colorScheme.onSurfaceVariant,
),
],
),
),
// 上下文项目列表
Expanded(
child: this.context.relevantItems.isEmpty
? Center(
child: Text(
'无相关上下文信息',
style: TextStyle(
color: colorScheme.onSurfaceVariant), // 调整空状态文本颜色
))
: ListView.builder(
padding: const EdgeInsets.all(8.0), // 为列表添加整体边距
itemCount: this.context.relevantItems.length,
itemBuilder: (context, index) {
final item = this.context.relevantItems[index];
return _buildContextItem(context, item);
},
),
),
],
),
);
}
// 构建上下文项目卡片
Widget _buildContextItem(BuildContext context, ContextItem item) {
final colorScheme = Theme.of(context).colorScheme;
return Card(
elevation: 0.5, // 减少卡片阴影
margin: const EdgeInsets.only(bottom: 8), // 只保留底部间距
// 卡片背景色
color: colorScheme.surfaceContainerHigh, // 使用比面板背景稍亮的颜色
shape: RoundedRectangleBorder(
// 圆角和边框
borderRadius: BorderRadius.circular(8),
side: BorderSide(
color: colorScheme.outlineVariant.withOpacity(0.3), width: 0.5)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_buildContextTypeIcon(item.type),
const SizedBox(width: 8),
Expanded(
child: Text(
item.title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600, // 加粗标题
color: colorScheme.onSurface, // 标题颜色
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8), // 图标和相关度之间的间距
// 相关度标签样式调整
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 3), // 内边距
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withOpacity(0.5), // 背景色
borderRadius: BorderRadius.circular(12), // 圆角
),
child: Text(
'${(item.relevanceScore * 100).toInt()}% 相关', // 添加 "相关" 文字
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onPrimaryContainer, // 文字颜色
fontWeight: FontWeight.w500,
fontSize: 11, // 稍小字体
),
),
),
],
),
const SizedBox(height: 8), // 标题和分割线间距
Divider(
height: 1,
color: colorScheme.outlineVariant.withOpacity(0.3)), // 分割线样式
const SizedBox(height: 8), // 分割线和内容间距
Text(
item.content,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant, // 内容文字颜色
height: 1.4, // 行高
),
maxLines: 5,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
}
// 根据上下文类型返回对应图标
Widget _buildContextTypeIcon(ContextItemType type) {
IconData iconData;
Color color;
switch (type) {
case ContextItemType.character:
iconData = Icons.person;
color = Colors.blue;
break;
case ContextItemType.location:
iconData = Icons.place;
color = Colors.green;
break;
case ContextItemType.plot:
iconData = Icons.auto_stories;
color = Colors.purple;
break;
case ContextItemType.chapter:
iconData = Icons.bookmark;
color = Colors.orange;
break;
case ContextItemType.scene:
iconData = Icons.movie;
color = Colors.red;
break;
case ContextItemType.note:
iconData = Icons.note;
color = Colors.teal;
break;
case ContextItemType.lore:
iconData = Icons.history_edu;
color = Colors.brown;
break;
}
return CircleAvatar(
radius: 12,
backgroundColor: color.withOpacity(0.2),
child: Icon(iconData, size: 16, color: color),
);
}
}

View File

@@ -0,0 +1,106 @@
import 'dart:math' show sin;
import 'package:flutter/material.dart';
class TypingIndicator extends StatefulWidget {
const TypingIndicator({Key? key}) : super(key: key);
@override
State<TypingIndicator> createState() => _TypingIndicatorState();
}
class _TypingIndicatorState extends State<TypingIndicator>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0), // 与消息气泡垂直间距一致
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// AI 头像占位符
Icon(
Icons.smart_toy_outlined,
color: colorScheme.secondary,
size: 28,
),
const SizedBox(width: 8),
// 指示器气泡
Container(
padding: const EdgeInsets.symmetric(
horizontal: 14.0, vertical: 12.0), // 内边距调整
decoration: BoxDecoration(
color: colorScheme.surfaceContainer, // 与 AI 气泡背景一致
// 圆角与 AI 气泡一致
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16.0),
topRight: Radius.circular(16.0),
bottomLeft: Radius.circular(4.0), // 左下小圆角
bottomRight: Radius.circular(16.0), // 右下圆角
),
border: Border.all(
// 细微边框
color: Theme.of(context)
.colorScheme
.outlineVariant
.withOpacity(0.3),
width: 0.5,
),
),
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(3, (i) {
// 使用 List.generate
// 调整动画,使其更平滑
final double value =
_controller.value * 2.0 * 3.14159; // 完整周期
final double offset = i * 3.14159 / 3.0; // 相位偏移
// 使用正弦函数创建上下浮动效果
final double yOffset = sin(value - offset) * 2.0; // 调整浮动幅度
return Transform.translate(
offset: Offset(0, yOffset),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 3), // 点间距
child: CircleAvatar(
radius: 4, // 点大小
// 使用更柔和的颜色
backgroundColor:
colorScheme.onSurfaceVariant.withOpacity(0.6),
),
),
);
}),
);
},
),
),
],
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}