mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2025-12-28 13:41:42 +00:00
feat: optimize chat animation (#7990)
* feat: optimize chat animation * feat: optimize ai chat animation * chore: move calculate min height logic to manager * chore: remove unused code * chore: adjust the animation on mobile
This commit is contained in:
parent
c78564ea79
commit
ad86e1f55a
@ -124,12 +124,17 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
|
||||
// Streaming completion
|
||||
didFinishAnswerStream: () async => emit(
|
||||
state.copyWith(promptResponseState: PromptResponseState.ready),
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.ready,
|
||||
),
|
||||
),
|
||||
|
||||
// Related questions
|
||||
didReceiveRelatedQuestions: (questions) async =>
|
||||
_handleRelatedQuestions(questions),
|
||||
_handleRelatedQuestions(
|
||||
questions,
|
||||
emit,
|
||||
),
|
||||
|
||||
// Message management
|
||||
deleteMessage: (message) async => chatController.remove(message),
|
||||
@ -277,7 +282,10 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
}
|
||||
|
||||
// Related questions handler
|
||||
void _handleRelatedQuestions(List<String> questions) {
|
||||
void _handleRelatedQuestions(
|
||||
List<String> questions,
|
||||
Emitter<ChatState> emit,
|
||||
) {
|
||||
if (questions.isEmpty) {
|
||||
return;
|
||||
}
|
||||
@ -297,6 +305,12 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||
);
|
||||
|
||||
chatController.insert(message);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
promptResponseState: PromptResponseState.relatedQuestionsReady,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _startListening() {
|
||||
|
||||
@ -58,6 +58,9 @@ enum PromptResponseState {
|
||||
ready,
|
||||
sendingQuestion,
|
||||
streamingAnswer,
|
||||
relatedQuestionsReady;
|
||||
|
||||
bool get isReady => this == ready || this == relatedQuestionsReady;
|
||||
}
|
||||
|
||||
class ChatFile extends Equatable {
|
||||
|
||||
@ -0,0 +1,160 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:universal_platform/universal_platform.dart';
|
||||
|
||||
class MessageHeightConstants {
|
||||
static const String answerSuffix = '_ans';
|
||||
|
||||
static const String withoutMinHeightSuffix = '_without_min_height';
|
||||
|
||||
// This offset comes from the chat input box height + navigation bar height
|
||||
// It's used to calculate the minimum height for answer messages
|
||||
//
|
||||
// navigation bar height + last user message height
|
||||
// + last AI message height + chat input box height = screen height
|
||||
static const double defaultDesktopScreenOffset = 220.0;
|
||||
static const double defaultMobileScreenOffset = 304.0;
|
||||
|
||||
static const double relatedQuestionOffset = 72.0;
|
||||
}
|
||||
|
||||
class ChatMessageHeightManager {
|
||||
factory ChatMessageHeightManager() => _instance;
|
||||
|
||||
ChatMessageHeightManager._();
|
||||
|
||||
static final ChatMessageHeightManager _instance =
|
||||
ChatMessageHeightManager._();
|
||||
|
||||
final Map<String, double> _heightCache = <String, double>{};
|
||||
|
||||
double get defaultScreenOffset {
|
||||
if (UniversalPlatform.isMobile) {
|
||||
return MessageHeightConstants.defaultMobileScreenOffset;
|
||||
}
|
||||
return MessageHeightConstants.defaultDesktopScreenOffset;
|
||||
}
|
||||
|
||||
/// Cache a message height
|
||||
void cacheHeight({
|
||||
required String messageId,
|
||||
required double height,
|
||||
}) {
|
||||
if (messageId.isEmpty || height <= 0) {
|
||||
assert(false, 'messageId or height is invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
_heightCache[messageId] = height;
|
||||
}
|
||||
|
||||
void cacheWithoutMinHeight({
|
||||
required String messageId,
|
||||
required double height,
|
||||
}) {
|
||||
if (messageId.isEmpty || height <= 0) {
|
||||
assert(false, 'messageId or height is invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
_heightCache[messageId + MessageHeightConstants.withoutMinHeightSuffix] =
|
||||
height;
|
||||
}
|
||||
|
||||
double? getCachedHeight({
|
||||
required String messageId,
|
||||
}) {
|
||||
if (messageId.isEmpty) return null;
|
||||
|
||||
final height = _heightCache[messageId];
|
||||
return height;
|
||||
}
|
||||
|
||||
double? getCachedWithoutMinHeight({
|
||||
required String messageId,
|
||||
}) {
|
||||
if (messageId.isEmpty) return null;
|
||||
final height =
|
||||
_heightCache[messageId + MessageHeightConstants.withoutMinHeightSuffix];
|
||||
return height;
|
||||
}
|
||||
|
||||
/// Calculate minimum height for AI answer messages
|
||||
///
|
||||
/// For the user message, we don't need to calculate the minimum height
|
||||
double calculateMinHeight({
|
||||
required String messageId,
|
||||
required double screenHeight,
|
||||
}) {
|
||||
if (!isAnswerMessage(messageId)) return 0.0;
|
||||
|
||||
final originalMessageId = getOriginalMessageId(
|
||||
messageId: messageId,
|
||||
);
|
||||
final cachedHeight = getCachedHeight(
|
||||
messageId: originalMessageId,
|
||||
);
|
||||
|
||||
if (cachedHeight == null) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
final calculatedHeight = screenHeight - cachedHeight - defaultScreenOffset;
|
||||
return max(calculatedHeight, 0.0);
|
||||
}
|
||||
|
||||
/// Calculate minimum height for related question messages
|
||||
///
|
||||
/// For the user message, we don't need to calculate the minimum height
|
||||
double calculateRelatedQuestionMinHeight({
|
||||
required String messageId,
|
||||
}) {
|
||||
final cacheHeight = getCachedHeight(
|
||||
messageId: messageId,
|
||||
);
|
||||
final cacheHeightWithoutMinHeight = getCachedWithoutMinHeight(
|
||||
messageId: messageId,
|
||||
);
|
||||
double minHeight = 0;
|
||||
if (cacheHeight != null && cacheHeightWithoutMinHeight != null) {
|
||||
minHeight = cacheHeight -
|
||||
cacheHeightWithoutMinHeight -
|
||||
MessageHeightConstants.relatedQuestionOffset;
|
||||
}
|
||||
minHeight = max(minHeight, 0);
|
||||
return minHeight;
|
||||
}
|
||||
|
||||
bool isAnswerMessage(String messageId) {
|
||||
return messageId.endsWith(MessageHeightConstants.answerSuffix);
|
||||
}
|
||||
|
||||
/// Get the original message ID from an answer message ID
|
||||
///
|
||||
/// Answer message ID is like: "message_id_ans"
|
||||
/// Original message ID is like: "message_id"
|
||||
String getOriginalMessageId({
|
||||
required String messageId,
|
||||
}) {
|
||||
if (!isAnswerMessage(messageId)) {
|
||||
return messageId;
|
||||
}
|
||||
|
||||
return messageId.replaceAll(MessageHeightConstants.answerSuffix, '');
|
||||
}
|
||||
|
||||
void removeFromCache({
|
||||
required String messageId,
|
||||
}) {
|
||||
if (messageId.isEmpty) return;
|
||||
|
||||
_heightCache.remove(messageId);
|
||||
|
||||
final answerMessageId = messageId + MessageHeightConstants.answerSuffix;
|
||||
_heightCache.remove(answerMessageId);
|
||||
}
|
||||
|
||||
void clearCache() {
|
||||
_heightCache.clear();
|
||||
}
|
||||
}
|
||||
@ -8,7 +8,6 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import 'application/chat_bloc.dart';
|
||||
import 'application/chat_entity.dart';
|
||||
import 'application/chat_member_bloc.dart';
|
||||
|
||||
class AIChatPage extends StatelessWidget {
|
||||
@ -69,8 +68,7 @@ class AIChatPage extends StatelessWidget {
|
||||
event.logicalKey == LogicalKeyboardKey.keyC &&
|
||||
HardwareKeyboard.instance.isControlPressed) {
|
||||
final chatBloc = context.read<ChatBloc>();
|
||||
if (chatBloc.state.promptResponseState !=
|
||||
PromptResponseState.ready) {
|
||||
if (!chatBloc.state.promptResponseState.isReady) {
|
||||
chatBloc.add(ChatEvent.stopStream());
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
@ -5,7 +5,6 @@ import 'dart:async';
|
||||
import 'package:appflowy/util/debounce.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:diffutil_dart/diffutil.dart' as diffutil;
|
||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||
import 'package:flutter_chat_ui/src/scroll_to_bottom.dart';
|
||||
@ -13,6 +12,9 @@ import 'package:flutter_chat_ui/src/utils/message_list_diff.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
import '../application/chat_message_height_manager.dart';
|
||||
import 'widgets/message_height_calculator.dart';
|
||||
|
||||
class ChatAnimatedList extends StatefulWidget {
|
||||
const ChatAnimatedList({
|
||||
super.key,
|
||||
@ -38,21 +40,21 @@ class ChatAnimatedList extends StatefulWidget {
|
||||
final double scrollBottomPadding;
|
||||
|
||||
@override
|
||||
ChatAnimatedListState createState() => ChatAnimatedListState();
|
||||
State<ChatAnimatedList> createState() => _ChatAnimatedListState();
|
||||
}
|
||||
|
||||
class ChatAnimatedListState extends State<ChatAnimatedList>
|
||||
class _ChatAnimatedListState extends State<ChatAnimatedList>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final ChatController _chatController = Provider.of<ChatController>(
|
||||
late final ChatController chatController = Provider.of<ChatController>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
late List<Message> _oldList;
|
||||
late StreamSubscription<ChatOperation> _operationsSubscription;
|
||||
late List<Message> oldList;
|
||||
late StreamSubscription<ChatOperation> operationsSubscription;
|
||||
|
||||
late final AnimationController _scrollToBottomController;
|
||||
late final Animation<double> _scrollToBottomAnimation;
|
||||
Timer? _scrollToBottomShowTimer;
|
||||
late final AnimationController scrollToBottomController;
|
||||
late final Animation<double> scrollToBottomAnimation;
|
||||
Timer? scrollToBottomShowTimer;
|
||||
|
||||
final ScrollOffsetController scrollOffsetController =
|
||||
ScrollOffsetController();
|
||||
@ -65,10 +67,10 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
|
||||
final ItemPositionsListener itemPositionsListener =
|
||||
ItemPositionsListener.create();
|
||||
|
||||
int _lastUserMessageIndex = 0;
|
||||
bool _isScrollingToBottom = false;
|
||||
int lastUserMessageIndex = 0;
|
||||
bool isScrollingToBottom = false;
|
||||
|
||||
final _loadPreviousMessagesDebounce = Debounce(
|
||||
final loadPreviousMessagesDebounce = Debounce(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
);
|
||||
|
||||
@ -76,15 +78,17 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
|
||||
double initialAlignment = 1.0;
|
||||
List<Message> messages = [];
|
||||
|
||||
final ChatMessageHeightManager heightManager = ChatMessageHeightManager();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// TODO: Add assert for messages having same id
|
||||
_oldList = List.from(_chatController.messages);
|
||||
_operationsSubscription = _chatController.operationsStream.listen((event) {
|
||||
oldList = List.from(chatController.messages);
|
||||
operationsSubscription = chatController.operationsStream.listen((event) {
|
||||
setState(() {
|
||||
messages = _chatController.messages;
|
||||
messages = chatController.messages;
|
||||
});
|
||||
switch (event.type) {
|
||||
case ChatOperationType.insert:
|
||||
@ -98,7 +102,7 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
|
||||
);
|
||||
|
||||
_onInserted(event.index!, event.message!);
|
||||
_oldList = List.from(_chatController.messages);
|
||||
oldList = List.from(chatController.messages);
|
||||
break;
|
||||
case ChatOperationType.remove:
|
||||
assert(
|
||||
@ -111,14 +115,14 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
|
||||
);
|
||||
|
||||
_onRemoved(event.index!, event.message!);
|
||||
_oldList = List.from(_chatController.messages);
|
||||
oldList = List.from(chatController.messages);
|
||||
break;
|
||||
case ChatOperationType.set:
|
||||
final newList = _chatController.messages;
|
||||
final newList = chatController.messages;
|
||||
|
||||
final updates = diffutil
|
||||
.calculateDiff<Message>(
|
||||
MessageListDiff(_oldList, newList),
|
||||
MessageListDiff(oldList, newList),
|
||||
)
|
||||
.getUpdatesWithData();
|
||||
|
||||
@ -126,22 +130,22 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
|
||||
_onDiffUpdate(updates.elementAt(i));
|
||||
}
|
||||
|
||||
_oldList = List.from(newList);
|
||||
oldList = List.from(newList);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
messages = _chatController.messages;
|
||||
messages = chatController.messages;
|
||||
|
||||
_scrollToBottomController = AnimationController(
|
||||
scrollToBottomController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
|
||||
_scrollToBottomAnimation = CurvedAnimation(
|
||||
parent: _scrollToBottomController,
|
||||
scrollToBottomAnimation = CurvedAnimation(
|
||||
parent: scrollToBottomController,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
|
||||
@ -152,15 +156,15 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
|
||||
itemPositionsListener.itemPositions.addListener(() {
|
||||
_handleLoadPreviousMessages();
|
||||
});
|
||||
|
||||
// A trick to avoid the first message being scrolled to the top
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollToBottomShowTimer?.cancel();
|
||||
_scrollToBottomController.dispose();
|
||||
_operationsSubscription.cancel();
|
||||
scrollToBottomShowTimer?.cancel();
|
||||
scrollToBottomController.dispose();
|
||||
operationsSubscription.cancel();
|
||||
|
||||
_clearMessageHeightCache();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
@ -168,9 +172,8 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final builders = context.watch<Builders>();
|
||||
final height = MediaQuery.of(context).size.height;
|
||||
// A trick to avoid the first message being scrolled to the top
|
||||
|
||||
// A trick to avoid the first message being scrolled to the top
|
||||
initialScrollIndex = messages.length;
|
||||
initialAlignment = 1.0;
|
||||
if (messages.length <= 2) {
|
||||
@ -198,29 +201,33 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
|
||||
}
|
||||
|
||||
if (index == messages.length) {
|
||||
return VSpace(height - 400);
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final message = messages[index];
|
||||
return widget.itemBuilder(
|
||||
context,
|
||||
Tween<double>(begin: 1, end: 1).animate(
|
||||
CurvedAnimation(
|
||||
parent: _scrollToBottomController,
|
||||
curve: Curves.easeInOut,
|
||||
return MessageHeightCalculator(
|
||||
messageId: message.id,
|
||||
onHeightMeasured: _cacheMessageHeight,
|
||||
child: widget.itemBuilder(
|
||||
context,
|
||||
Tween<double>(begin: 1, end: 1).animate(
|
||||
CurvedAnimation(
|
||||
parent: scrollToBottomController,
|
||||
curve: Curves.easeInOut,
|
||||
),
|
||||
),
|
||||
message,
|
||||
),
|
||||
message,
|
||||
);
|
||||
},
|
||||
),
|
||||
builders.scrollToBottomBuilder?.call(
|
||||
context,
|
||||
_scrollToBottomAnimation,
|
||||
scrollToBottomAnimation,
|
||||
_handleScrollToBottom,
|
||||
) ??
|
||||
ScrollToBottom(
|
||||
animation: _scrollToBottomAnimation,
|
||||
animation: scrollToBottomAnimation,
|
||||
onPressed: _handleScrollToBottom,
|
||||
),
|
||||
],
|
||||
@ -235,11 +242,13 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
|
||||
(message) => message.author.id == user.id,
|
||||
);
|
||||
|
||||
if (lastUserMessageIndex == -1) {
|
||||
// waiting for the ai answer message to be inserted
|
||||
if (lastUserMessageIndex == -1 ||
|
||||
lastUserMessageIndex + 1 >= messages.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_lastUserMessageIndex != lastUserMessageIndex) {
|
||||
if (this.lastUserMessageIndex != lastUserMessageIndex) {
|
||||
// scroll the current message to the top
|
||||
await itemScrollController.scrollTo(
|
||||
index: lastUserMessageIndex,
|
||||
@ -248,15 +257,15 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
|
||||
);
|
||||
}
|
||||
|
||||
_lastUserMessageIndex = lastUserMessageIndex;
|
||||
this.lastUserMessageIndex = lastUserMessageIndex;
|
||||
}
|
||||
|
||||
Future<void> _handleScrollToBottom() async {
|
||||
_isScrollingToBottom = true;
|
||||
isScrollingToBottom = true;
|
||||
|
||||
_scrollToBottomShowTimer?.cancel();
|
||||
scrollToBottomShowTimer?.cancel();
|
||||
|
||||
await _scrollToBottomController.reverse();
|
||||
await scrollToBottomController.reverse();
|
||||
|
||||
await itemScrollController.scrollTo(
|
||||
index: messages.length + 1,
|
||||
@ -265,11 +274,11 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
|
||||
_isScrollingToBottom = false;
|
||||
isScrollingToBottom = false;
|
||||
}
|
||||
|
||||
void _handleToggleScrollToBottom() {
|
||||
if (_isScrollingToBottom) {
|
||||
if (isScrollingToBottom) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -285,15 +294,15 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
|
||||
if (maxItem.index > messages.length - 1 ||
|
||||
(maxItem.index == messages.length - 1 &&
|
||||
maxItem.itemTrailingEdge <= 1.01)) {
|
||||
_scrollToBottomShowTimer?.cancel();
|
||||
_scrollToBottomController.reverse();
|
||||
scrollToBottomShowTimer?.cancel();
|
||||
scrollToBottomController.reverse();
|
||||
return;
|
||||
}
|
||||
|
||||
_scrollToBottomShowTimer?.cancel();
|
||||
_scrollToBottomShowTimer = Timer(widget.scrollToBottomAppearanceDelay, () {
|
||||
scrollToBottomShowTimer?.cancel();
|
||||
scrollToBottomShowTimer = Timer(widget.scrollToBottomAppearanceDelay, () {
|
||||
if (mounted) {
|
||||
_scrollToBottomController.forward();
|
||||
scrollToBottomController.forward();
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -307,21 +316,34 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
|
||||
return;
|
||||
}
|
||||
|
||||
_loadPreviousMessagesDebounce.call(
|
||||
loadPreviousMessagesDebounce.call(
|
||||
() {
|
||||
widget.onLoadPreviousMessages?.call();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _cacheMessageHeight(String messageId, double height) {
|
||||
heightManager.cacheHeight(messageId: messageId, height: height);
|
||||
}
|
||||
|
||||
void _clearMessageHeightCache() {
|
||||
heightManager.clearCache();
|
||||
}
|
||||
|
||||
Future<void> _onInserted(final int position, final Message data) async {
|
||||
// scroll the last user message to the top if it's the last message
|
||||
if (position == _oldList.length) {
|
||||
if (position == oldList.length) {
|
||||
await _scrollLastUserMessageToTop();
|
||||
}
|
||||
}
|
||||
|
||||
void _onRemoved(final int position, final Message data) {}
|
||||
void _onRemoved(final int position, final Message data) {
|
||||
// Clean up cached height for removed message
|
||||
heightManager.removeFromCache(messageId: data.id);
|
||||
}
|
||||
|
||||
void _onDiffUpdate(diffutil.DataDiffUpdate<Message> update) {}
|
||||
void _onDiffUpdate(diffutil.DataDiffUpdate<Message> update) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,8 +49,7 @@ class _ChatFooterState extends State<ChatFooter> {
|
||||
padding: AIChatUILayout.safeAreaInsets(context),
|
||||
child: BlocSelector<ChatBloc, ChatState, bool>(
|
||||
selector: (state) {
|
||||
return state.promptResponseState ==
|
||||
PromptResponseState.ready;
|
||||
return state.promptResponseState.isReady;
|
||||
},
|
||||
builder: (context, canSendMessage) {
|
||||
final chatBloc = context.read<ChatBloc>();
|
||||
|
||||
@ -2,6 +2,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:appflowy/ai/ai.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/ai_chat_prelude.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_message_height_manager.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/chat_related_question.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/message/ai_text_message.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/message/error_text_message.dart';
|
||||
@ -45,20 +46,35 @@ class TextMessageWidget extends StatelessWidget {
|
||||
}
|
||||
|
||||
if (messageType == OnetimeShotType.relatedQuestion) {
|
||||
return RelatedQuestionList(
|
||||
relatedQuestions: message.metadata!['questions'],
|
||||
onQuestionSelected: (question) {
|
||||
final bloc = context.read<AIPromptInputBloc>();
|
||||
final showPredefinedFormats = bloc.state.showPredefinedFormats;
|
||||
final predefinedFormat = bloc.state.predefinedFormat;
|
||||
final messages = context.read<ChatBloc>().chatController.messages;
|
||||
final lastAIMessage = messages.lastWhere(
|
||||
(e) =>
|
||||
onetimeMessageTypeFromMeta(e.metadata) == null &&
|
||||
(e.author.id == aiResponseUserId || e.author.id == systemUserId),
|
||||
);
|
||||
final minHeight =
|
||||
ChatMessageHeightManager().calculateRelatedQuestionMinHeight(
|
||||
messageId: lastAIMessage.id,
|
||||
);
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: minHeight,
|
||||
),
|
||||
child: RelatedQuestionList(
|
||||
relatedQuestions: message.metadata!['questions'],
|
||||
onQuestionSelected: (question) {
|
||||
final bloc = context.read<AIPromptInputBloc>();
|
||||
final showPredefinedFormats = bloc.state.showPredefinedFormats;
|
||||
final predefinedFormat = bloc.state.predefinedFormat;
|
||||
|
||||
context.read<ChatBloc>().add(
|
||||
ChatEvent.sendMessage(
|
||||
message: question,
|
||||
format: showPredefinedFormats ? predefinedFormat : null,
|
||||
),
|
||||
);
|
||||
},
|
||||
context.read<ChatBloc>().add(
|
||||
ChatEvent.sendMessage(
|
||||
message: question,
|
||||
format: showPredefinedFormats ? predefinedFormat : null,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -87,6 +103,8 @@ class TextMessageWidget extends StatelessWidget {
|
||||
.where((e) => onetimeMessageTypeFromMeta(e.metadata) == null);
|
||||
final isLastMessage =
|
||||
messages.isEmpty ? false : messages.last.id == message.id;
|
||||
final hasRelatedQuestions = state.promptResponseState ==
|
||||
PromptResponseState.relatedQuestionsReady;
|
||||
return ChatAIMessageWidget(
|
||||
user: message.author,
|
||||
messageUserId: message.id,
|
||||
@ -95,9 +113,9 @@ class TextMessageWidget extends StatelessWidget {
|
||||
questionId: questionId,
|
||||
chatId: view.id,
|
||||
refSourceJsonString: refSourceJsonString,
|
||||
isStreaming:
|
||||
state.promptResponseState != PromptResponseState.ready,
|
||||
isStreaming: !state.promptResponseState.isReady,
|
||||
isLastMessage: isLastMessage,
|
||||
hasRelatedQuestions: hasRelatedQuestions,
|
||||
isSelectingMessages: isSelectingMessages,
|
||||
enableAnimation: enableAnimation,
|
||||
onSelectedMetadata: (metadata) =>
|
||||
|
||||
@ -187,9 +187,12 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown>
|
||||
}
|
||||
|
||||
if (!_animations.containsKey(node.id)) {
|
||||
final duration = UniversalPlatform.isMobile
|
||||
? const Duration(milliseconds: 800)
|
||||
: const Duration(milliseconds: 1600);
|
||||
final controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1600),
|
||||
duration: duration,
|
||||
);
|
||||
final fade = Tween<double>(
|
||||
begin: 0,
|
||||
|
||||
@ -3,7 +3,9 @@ import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_message_height_manager.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
|
||||
import 'package:appflowy/plugins/ai_chat/presentation/widgets/message_height_calculator.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:fixnum/fixnum.dart';
|
||||
@ -42,6 +44,7 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
this.isStreaming = false,
|
||||
this.isSelectingMessages = false,
|
||||
this.enableAnimation = true,
|
||||
this.hasRelatedQuestions = false,
|
||||
});
|
||||
|
||||
final User user;
|
||||
@ -61,6 +64,7 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
final bool isLastMessage;
|
||||
final bool isSelectingMessages;
|
||||
final bool enableAnimation;
|
||||
final bool hasRelatedQuestions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -79,74 +83,98 @@ class ChatAIMessageWidget extends StatelessWidget {
|
||||
final loadingText = blocState.progress?.step ??
|
||||
LocaleKeys.chat_generatingResponse.tr();
|
||||
|
||||
return Padding(
|
||||
padding: AIChatUILayout.messageMargin,
|
||||
child: blocState.messageState.when(
|
||||
loading: () => ChatAIMessageBubble(
|
||||
message: message,
|
||||
showActions: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: AILoadingIndicator(text: loadingText),
|
||||
),
|
||||
),
|
||||
ready: () {
|
||||
return blocState.text.isEmpty
|
||||
? _LoadingMessage(
|
||||
message: message,
|
||||
loadingText: loadingText,
|
||||
)
|
||||
: _NonEmptyMessage(
|
||||
user: user,
|
||||
messageUserId: messageUserId,
|
||||
message: message,
|
||||
stream: stream,
|
||||
questionId: questionId,
|
||||
chatId: chatId,
|
||||
refSourceJsonString: refSourceJsonString,
|
||||
onStopStream: onStopStream,
|
||||
onSelectedMetadata: onSelectedMetadata,
|
||||
onRegenerate: onRegenerate,
|
||||
onChangeFormat: onChangeFormat,
|
||||
onChangeModel: onChangeModel,
|
||||
isLastMessage: isLastMessage,
|
||||
isStreaming: isStreaming,
|
||||
isSelectingMessages: isSelectingMessages,
|
||||
enableAnimation: enableAnimation,
|
||||
);
|
||||
},
|
||||
onError: (error) {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: LocaleKeys.chat_aiServerUnavailable.tr(),
|
||||
);
|
||||
},
|
||||
onAIResponseLimit: () {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage:
|
||||
LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(),
|
||||
);
|
||||
},
|
||||
onAIImageResponseLimit: () {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: LocaleKeys.sideBar_purchaseAIMax.tr(),
|
||||
);
|
||||
},
|
||||
onAIMaxRequired: (message) {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: message,
|
||||
);
|
||||
},
|
||||
onInitializingLocalAI: () {
|
||||
onStopStream();
|
||||
// Calculate minimum height only for the last AI answer message
|
||||
double minHeight = 0;
|
||||
if (isLastMessage && !hasRelatedQuestions) {
|
||||
final screenHeight = MediaQuery.of(context).size.height;
|
||||
minHeight = ChatMessageHeightManager().calculateMinHeight(
|
||||
messageId: message.id,
|
||||
screenHeight: screenHeight,
|
||||
);
|
||||
}
|
||||
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage:
|
||||
LocaleKeys.settings_aiPage_keys_localAIInitializing.tr(),
|
||||
return Container(
|
||||
alignment: Alignment.topLeft,
|
||||
constraints: BoxConstraints(
|
||||
minHeight: minHeight,
|
||||
),
|
||||
padding: AIChatUILayout.messageMargin,
|
||||
child: MessageHeightCalculator(
|
||||
messageId: message.id,
|
||||
onHeightMeasured: (messageId, height) {
|
||||
ChatMessageHeightManager().cacheWithoutMinHeight(
|
||||
messageId: messageId,
|
||||
height: height,
|
||||
);
|
||||
},
|
||||
aiFollowUp: (followUpData) {
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
child: blocState.messageState.when(
|
||||
loading: () => ChatAIMessageBubble(
|
||||
message: message,
|
||||
showActions: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: AILoadingIndicator(text: loadingText),
|
||||
),
|
||||
),
|
||||
ready: () {
|
||||
return blocState.text.isEmpty
|
||||
? _LoadingMessage(
|
||||
message: message,
|
||||
loadingText: loadingText,
|
||||
)
|
||||
: _NonEmptyMessage(
|
||||
user: user,
|
||||
messageUserId: messageUserId,
|
||||
message: message,
|
||||
stream: stream,
|
||||
questionId: questionId,
|
||||
chatId: chatId,
|
||||
refSourceJsonString: refSourceJsonString,
|
||||
onStopStream: onStopStream,
|
||||
onSelectedMetadata: onSelectedMetadata,
|
||||
onRegenerate: onRegenerate,
|
||||
onChangeFormat: onChangeFormat,
|
||||
onChangeModel: onChangeModel,
|
||||
isLastMessage: isLastMessage,
|
||||
isStreaming: isStreaming,
|
||||
isSelectingMessages: isSelectingMessages,
|
||||
enableAnimation: enableAnimation,
|
||||
);
|
||||
},
|
||||
onError: (error) {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: LocaleKeys.chat_aiServerUnavailable.tr(),
|
||||
);
|
||||
},
|
||||
onAIResponseLimit: () {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage:
|
||||
LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(),
|
||||
);
|
||||
},
|
||||
onAIImageResponseLimit: () {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: LocaleKeys.sideBar_purchaseAIMax.tr(),
|
||||
);
|
||||
},
|
||||
onAIMaxRequired: (message) {
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: message,
|
||||
);
|
||||
},
|
||||
onInitializingLocalAI: () {
|
||||
onStopStream();
|
||||
|
||||
return ChatErrorMessageWidget(
|
||||
errorMessage: LocaleKeys
|
||||
.settings_aiPage_keys_localAIInitializing
|
||||
.tr(),
|
||||
);
|
||||
},
|
||||
aiFollowUp: (followUpData) {
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
/// Callback type for height measurement
|
||||
typedef HeightMeasuredCallback = void Function(String messageId, double height);
|
||||
|
||||
/// Widget that measures and caches message heights with proper lifecycle management
|
||||
class MessageHeightCalculator extends StatefulWidget {
|
||||
const MessageHeightCalculator({
|
||||
super.key,
|
||||
required this.messageId,
|
||||
required this.child,
|
||||
this.onHeightMeasured,
|
||||
});
|
||||
|
||||
final String messageId;
|
||||
final Widget child;
|
||||
final HeightMeasuredCallback? onHeightMeasured;
|
||||
|
||||
@override
|
||||
State<MessageHeightCalculator> createState() =>
|
||||
_MessageHeightCalculatorState();
|
||||
}
|
||||
|
||||
class _MessageHeightCalculatorState extends State<MessageHeightCalculator>
|
||||
with WidgetsBindingObserver {
|
||||
final GlobalKey measureKey = GlobalKey();
|
||||
|
||||
double? lastMeasuredHeight;
|
||||
bool isMeasuring = false;
|
||||
int measurementAttempts = 0;
|
||||
static const int maxMeasurementAttempts = 3;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_scheduleMeasurement();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(MessageHeightCalculator oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
if (oldWidget.messageId != widget.messageId) {
|
||||
_resetMeasurement();
|
||||
_scheduleMeasurement();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeMetrics() {
|
||||
if (mounted) {
|
||||
_scheduleMeasurement();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return KeyedSubtree(
|
||||
key: measureKey,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
||||
void _resetMeasurement() {
|
||||
lastMeasuredHeight = null;
|
||||
isMeasuring = false;
|
||||
measurementAttempts = 0;
|
||||
}
|
||||
|
||||
void _scheduleMeasurement() {
|
||||
if (isMeasuring || !mounted) return;
|
||||
|
||||
isMeasuring = true;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_measureHeight();
|
||||
});
|
||||
}
|
||||
|
||||
void _measureHeight() {
|
||||
if (!mounted || measurementAttempts >= maxMeasurementAttempts) {
|
||||
isMeasuring = false;
|
||||
return;
|
||||
}
|
||||
|
||||
measurementAttempts++;
|
||||
|
||||
try {
|
||||
final renderBox =
|
||||
measureKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
|
||||
if (renderBox == null || !renderBox.hasSize) {
|
||||
// Retry measurement in next frame if render box is not ready
|
||||
if (measurementAttempts < maxMeasurementAttempts) {
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _measureHeight();
|
||||
});
|
||||
} else {
|
||||
isMeasuring = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final height = renderBox.size.height;
|
||||
|
||||
if (lastMeasuredHeight == null ||
|
||||
(height - (lastMeasuredHeight ?? 0)).abs() > 1.0) {
|
||||
lastMeasuredHeight = height;
|
||||
|
||||
widget.onHeightMeasured?.call(widget.messageId, height);
|
||||
}
|
||||
|
||||
isMeasuring = false;
|
||||
} catch (e) {
|
||||
isMeasuring = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user