马良AI写作初始化仓库
This commit is contained in:
795
AINoval/lib/screens/chat/widgets/ai_chat_sidebar.dart
Normal file
795
AINoval/lib/screens/chat/widgets/ai_chat_sidebar.dart
Normal 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事件到ChatBloc,BLoC实例: ${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为null,sessionId=${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));
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
953
AINoval/lib/screens/chat/widgets/chat_input.dart
Normal file
953
AINoval/lib/screens/chat/widgets/chat_input.dart
Normal 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();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
136
AINoval/lib/screens/chat/widgets/chat_message_actions_bar.dart
Normal file
136
AINoval/lib/screens/chat/widgets/chat_message_actions_bar.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
330
AINoval/lib/screens/chat/widgets/chat_message_bubble.dart
Normal file
330
AINoval/lib/screens/chat/widgets/chat_message_bubble.dart
Normal 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, // 加粗错误文本
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
1150
AINoval/lib/screens/chat/widgets/chat_settings_dialog.dart
Normal file
1150
AINoval/lib/screens/chat/widgets/chat_settings_dialog.dart
Normal file
File diff suppressed because it is too large
Load Diff
200
AINoval/lib/screens/chat/widgets/context_panel.dart
Normal file
200
AINoval/lib/screens/chat/widgets/context_panel.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
106
AINoval/lib/screens/chat/widgets/typing_indicator.dart
Normal file
106
AINoval/lib/screens/chat/widgets/typing_indicator.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user