chore: bump flutter chat ui version (#6835)

This commit is contained in:
Richard Shiue 2024-11-20 10:47:35 +03:00 committed by GitHub
parent 09717d92c5
commit f82dabcc75
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1036 additions and 700 deletions

View File

@ -1,47 +1,50 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart'; import 'package:appflowy/util/int64_extension.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:fixnum/fixnum.dart'; import 'package:fixnum/fixnum.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:nanoid/nanoid.dart'; import 'package:nanoid/nanoid.dart';
import 'chat_entity.dart'; import 'chat_entity.dart';
import 'chat_message_listener.dart'; import 'chat_message_listener.dart';
import 'chat_message_service.dart'; import 'chat_message_service.dart';
import 'chat_message_stream.dart';
part 'chat_bloc.freezed.dart'; part 'chat_bloc.freezed.dart';
class ChatBloc extends Bloc<ChatEvent, ChatState> { class ChatBloc extends Bloc<ChatEvent, ChatState> {
ChatBloc({ ChatBloc({
required ViewPB view, required this.chatId,
required UserProfilePB userProfile, required this.userId,
}) : listener = ChatMessageListener(chatId: view.id), }) : chatController = InMemoryChatController(),
chatId = view.id, listener = ChatMessageListener(chatId: chatId),
super( super(ChatState.initial()) {
ChatState.initial(view, userProfile),
) {
_startListening(); _startListening();
_dispatch(); _dispatch();
_init();
} }
final ChatMessageListener listener;
final String chatId; final String chatId;
final String userId;
final ChatMessageListener listener;
final ChatController chatController;
/// The last streaming message id /// The last streaming message id
String answerStreamMessageId = ''; String answerStreamMessageId = '';
String questionStreamMessageId = ''; String questionStreamMessageId = '';
ChatMessagePB? lastSentMessage;
/// Using a temporary map to associate the real message ID with the last streaming message ID. /// Using a temporary map to associate the real message ID with the last streaming message ID.
/// ///
/// When a message is streaming, it does not have a real message ID. To maintain the relationship /// When a message is streaming, it does not have a real message ID. To maintain the relationship
@ -51,11 +54,13 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
/// is 3 (AI response). /// is 3 (AI response).
final HashMap<String, String> temporaryMessageIDMap = HashMap(); final HashMap<String, String> temporaryMessageIDMap = HashMap();
bool isLoadingPreviousMessages = false;
bool hasMorePreviousMessages = true;
AnswerStream? answerStream;
@override @override
Future<void> close() async { Future<void> close() async {
if (state.answerStream != null) { await answerStream?.dispose();
await state.answerStream?.dispose();
}
await listener.stop(); await listener.stop();
return super.close(); return super.close();
} }
@ -64,195 +69,159 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
on<ChatEvent>( on<ChatEvent>(
(event, emit) async { (event, emit) async {
await event.when( await event.when(
initialLoad: () {
final payload = LoadNextChatMessagePB(
chatId: state.view.id,
limit: Int64(10),
);
AIEventLoadNextMessage(payload).send().then(
(result) {
result.fold((list) {
if (!isClosed) {
final messages =
list.messages.map(_createTextMessage).toList();
add(ChatEvent.didLoadLatestMessages(messages));
}
}, (err) {
Log.error("Failed to load messages: $err");
});
},
);
},
// Loading messages // Loading messages
startLoadingPrevMessage: () async { didLoadLatestMessages: (List<Message> messages) async {
Int64? beforeMessageId; for (final message in messages) {
final oldestMessage = _getOldestMessage(); await chatController.insert(message, index: 0);
if (oldestMessage != null) { }
try {
beforeMessageId = Int64.parseInt(oldestMessage.id); if (state.loadingState.isLoading) {
} catch (e) { emit(
Log.error( state.copyWith(loadingState: const ChatLoadingState.finish()),
"Failed to parse message id: $e, messaeg_id: ${oldestMessage.id}",
); );
} }
}
_loadPrevMessage(beforeMessageId);
emit(
state.copyWith(
loadingPreviousStatus: const ChatLoadingState.loading(),
),
);
}, },
didLoadPreviousMessages: (List<Message> messages, bool hasMore) { loadPreviousMessages: () {
Log.debug("did load previous messages: ${messages.length}"); if (isLoadingPreviousMessages) {
final onetimeMessages = _getOnetimeMessages();
final allMessages = _permanentMessages();
final uniqueMessages = {...allMessages, ...messages}.toList();
uniqueMessages.insertAll(0, onetimeMessages);
emit(
state.copyWith(
messages: uniqueMessages,
loadingPreviousStatus: const ChatLoadingState.finish(),
hasMorePrevMessage: hasMore,
),
);
},
didLoadLatestMessages: (List<Message> messages) {
final onetimeMessages = _getOnetimeMessages();
final allMessages = _permanentMessages();
final uniqueMessages = {...allMessages, ...messages}.toList();
uniqueMessages.insertAll(0, onetimeMessages);
emit(
state.copyWith(
messages: uniqueMessages,
initialLoadingStatus: const ChatLoadingState.finish(),
),
);
},
// streaming message
finishAnswerStreaming: () {
emit(
state.copyWith(
streamingState: const StreamingState.done(),
acceptRelatedQuestion: true,
canSendMessage:
state.sendingState == const SendMessageState.done(),
),
);
},
didUpdateAnswerStream: (AnswerStream stream) {
emit(state.copyWith(answerStream: stream));
},
stopStream: () async {
if (state.answerStream == null) {
return; return;
} }
final payload = StopStreamPB(chatId: chatId); final oldestMessage = _getOldestMessage();
await AIEventStopStream(payload).send();
final allMessages = _permanentMessages(); if (oldestMessage != null) {
if (state.streamingState != const StreamingState.done()) { final oldestMessageId = Int64.tryParseInt(oldestMessage.id);
// If the streaming is not started, remove the message from the list if (oldestMessageId == null) {
if (!state.answerStream!.hasStarted) { Log.error("Failed to parse message_id: ${oldestMessage.id}");
allMessages.removeWhere( return;
(element) => element.id == answerStreamMessageId, }
); isLoadingPreviousMessages = true;
answerStreamMessageId = ""; _loadPreviousMessages(oldestMessageId);
}
},
didLoadPreviousMessages: (messages, hasMore) {
Log.debug("did load previous messages: ${messages.length}");
for (final message in messages) {
chatController.insert(message, index: 0);
} }
// when stop stream, we will set the answer stream to null. Which means the streaming isLoadingPreviousMessages = false;
// is finished or canceled. hasMorePreviousMessages = hasMore;
emit(
state.copyWith(
messages: allMessages,
answerStream: null,
streamingState: const StreamingState.done(),
),
);
}
}, },
receiveMessage: (Message message) { didFinishAnswerStream: () {
final allMessages = _permanentMessages();
// remove message with the same id
allMessages.removeWhere((element) => element.id == message.id);
allMessages.insert(0, message);
emit( emit(
state.copyWith( state.copyWith(promptResponseState: PromptResponseState.ready),
messages: allMessages,
),
); );
}, },
startAnswerStreaming: (Message message) { didReceiveRelatedQuestions: (List<String> questions) {
final allMessages = _permanentMessages();
allMessages.insert(0, message);
emit(
state.copyWith(
messages: allMessages,
streamingState: const StreamingState.streaming(),
canSendMessage: false,
),
);
},
sendMessage: (String message, Map<String, dynamic>? metadata) async {
unawaited(_startStreamingMessage(message, metadata, emit));
final allMessages = _permanentMessages();
emit(
state.copyWith(
lastSentMessage: null,
messages: allMessages,
relatedQuestions: [],
acceptRelatedQuestion: false,
sendingState: const SendMessageState.sending(),
canSendMessage: false,
),
);
},
finishSending: (ChatMessagePB message) {
emit(
state.copyWith(
lastSentMessage: message,
sendingState: const SendMessageState.done(),
canSendMessage:
state.streamingState == const StreamingState.done(),
),
);
},
failedSending: () {
emit(
state.copyWith(
messages: _permanentMessages()..removeAt(0),
sendingState: const SendMessageState.done(),
canSendMessage: true,
),
);
},
// related question
didReceiveRelatedQuestion: (List<RelatedQuestionPB> questions) {
if (questions.isEmpty) { if (questions.isEmpty) {
return; return;
} }
final allMessages = _permanentMessages(); final metatdata = OnetimeShotType.relatedQuestion.toMap();
final message = CustomMessage( metatdata['questions'] = questions;
metadata: OnetimeShotType.relatedQuestion.toMap(),
final message = TextMessage(
text: '',
metadata: metatdata,
author: const User(id: systemUserId), author: const User(id: systemUserId),
showStatus: false,
id: systemUserId, id: systemUserId,
createdAt: DateTime.now(),
); );
allMessages.insert(0, message);
chatController.insert(message);
},
receiveMessage: (Message message) {
final oldMessage = chatController.messages
.firstWhereOrNull((m) => m.id == message.id);
if (oldMessage == null) {
chatController.insert(message);
} else {
chatController.update(oldMessage, message);
}
},
sendMessage: (
String message,
Map<String, dynamic>? metadata,
) {
final relatedQuestionMessages = chatController.messages.where(
(message) {
return onetimeMessageTypeFromMeta(message.metadata) ==
OnetimeShotType.relatedQuestion;
},
).toList();
for (final message in relatedQuestionMessages) {
chatController.remove(message);
}
_startStreamingMessage(message, metadata);
lastSentMessage = null;
emit( emit(
state.copyWith( state.copyWith(
messages: allMessages, promptResponseState: PromptResponseState.sendingQuestion,
relatedQuestions: questions,
), ),
); );
}, },
clearRelatedQuestions: () { finishSending: (ChatMessagePB message) {
lastSentMessage = message;
emit( emit(
state.copyWith( state.copyWith(
relatedQuestions: [], promptResponseState: PromptResponseState.awaitingAnswer,
),
);
},
stopStream: () async {
if (answerStream == null) {
return;
}
// tell backend to stop
final payload = StopStreamPB(chatId: chatId);
await AIEventStopStream(payload).send();
// allow user input
emit(
state.copyWith(
promptResponseState: PromptResponseState.ready,
),
);
// no need to remove old message if stream has started already
if (answerStream!.hasStarted) {
return;
}
// remove the non-started message from the list
final message = chatController.messages.lastWhereOrNull(
(e) => e.id == answerStreamMessageId,
);
if (message != null) {
await chatController.remove(message);
}
// set answer stream to null
await answerStream?.dispose();
answerStream = null;
answerStreamMessageId = '';
},
startAnswerStreaming: (Message message) {
chatController.insert(message);
emit(
state.copyWith(
promptResponseState: PromptResponseState.streamingAnswer,
),
);
},
failedSending: () {
final lastMessage = chatController.messages.lastOrNull;
if (lastMessage != null) {
chatController.remove(lastMessage);
}
emit(
state.copyWith(
promptResponseState: PromptResponseState.ready,
), ),
); );
}, },
@ -264,29 +233,31 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
void _startListening() { void _startListening() {
listener.start( listener.start(
chatMessageCallback: (pb) { chatMessageCallback: (pb) {
if (!isClosed) { if (isClosed) {
return;
}
// 3 mean message response from AI // 3 mean message response from AI
if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) { if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) {
temporaryMessageIDMap[pb.messageId.toString()] = temporaryMessageIDMap[pb.messageId.toString()] =
answerStreamMessageId; answerStreamMessageId;
answerStreamMessageId = ""; answerStreamMessageId = '';
} }
// 1 mean message response from User // 1 mean message response from User
if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) { if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) {
temporaryMessageIDMap[pb.messageId.toString()] = temporaryMessageIDMap[pb.messageId.toString()] =
questionStreamMessageId; questionStreamMessageId;
questionStreamMessageId = ""; questionStreamMessageId = '';
} }
final message = _createTextMessage(pb); final message = _createTextMessage(pb);
add(ChatEvent.receiveMessage(message)); add(ChatEvent.receiveMessage(message));
}
}, },
chatErrorMessageCallback: (err) { chatErrorMessageCallback: (err) {
if (!isClosed) { if (!isClosed) {
Log.error("chat error: ${err.errorMessage}"); Log.error("chat error: ${err.errorMessage}");
add(const ChatEvent.finishAnswerStreaming()); add(const ChatEvent.didFinishAnswerStream());
} }
}, },
latestMessageCallback: (list) { latestMessageCallback: (list) {
@ -301,65 +272,70 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
add(ChatEvent.didLoadPreviousMessages(messages, list.hasMore)); add(ChatEvent.didLoadPreviousMessages(messages, list.hasMore));
} }
}, },
finishStreamingCallback: () { finishStreamingCallback: () async {
if (!isClosed) { if (isClosed) {
add(const ChatEvent.finishAnswerStreaming()); return;
// The answer strema will bet set to null after the streaming is finished or canceled. }
// so if the answer stream is null, we will not get related question.
if (state.lastSentMessage != null && state.answerStream != null) { add(const ChatEvent.didFinishAnswerStream());
// The answer stream will bet set to null after the streaming has
// finished, got cancelled, or errored. In this case, don't retrieve
// related questions.
if (answerStream == null || lastSentMessage == null) {
return;
}
final payload = ChatMessageIdPB( final payload = ChatMessageIdPB(
chatId: chatId, chatId: chatId,
messageId: state.lastSentMessage!.messageId, messageId: lastSentMessage!.messageId,
); );
// When user message was sent to the server, we start gettting related question await AIEventGetRelatedQuestion(payload).send().fold(
AIEventGetRelatedQuestion(payload).send().then((result) {
if (!isClosed) {
result.fold(
(list) { (list) {
if (state.acceptRelatedQuestion) { if (!isClosed) {
add(ChatEvent.didReceiveRelatedQuestion(list.items)); add(
} ChatEvent.didReceiveRelatedQuestions(
}, list.items.map((e) => e.content).toList(),
(err) { ),
Log.error("Failed to get related question: $err");
},
); );
} }
}); },
} (err) => Log.error("Failed to get related questions: $err"),
} );
}, },
); );
} }
// Returns the list of messages that are not include one-time messages. void _init() async {
List<Message> _permanentMessages() { final payload = LoadNextChatMessagePB(
final allMessages = state.messages.where((element) { chatId: chatId,
return !(element.metadata?.containsKey(onetimeShotType) == true); limit: Int64(10),
}).toList(); );
await AIEventLoadNextMessage(payload).send().fold(
return allMessages; (list) {
if (!isClosed) {
final messages = list.messages.map(_createTextMessage).toList();
add(ChatEvent.didLoadLatestMessages(messages));
}
},
(err) => Log.error("Failed to load messages: $err"),
);
} }
List<Message> _getOnetimeMessages() { bool _isOneTimeMessage(Message message) {
final messages = state.messages.where((element) { return message.metadata != null &&
return (element.metadata?.containsKey(onetimeShotType) == true); message.metadata!.containsKey(onetimeShotType);
}).toList();
return messages;
} }
/// get the last message that is not a one-time message
Message? _getOldestMessage() { Message? _getOldestMessage() {
// get the last message that is not a one-time message return chatController.messages
final message = state.messages.lastWhereOrNull((element) { .firstWhereOrNull((message) => !_isOneTimeMessage(message));
return !(element.metadata?.containsKey(onetimeShotType) == true);
});
return message;
} }
void _loadPrevMessage(Int64? beforeMessageId) { void _loadPreviousMessages(Int64? beforeMessageId) {
final payload = LoadPrevChatMessagePB( final payload = LoadPrevChatMessagePB(
chatId: state.view.id, chatId: chatId,
limit: Int64(10), limit: Int64(10),
beforeMessageId: beforeMessageId, beforeMessageId: beforeMessageId,
); );
@ -369,43 +345,36 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
Future<void> _startStreamingMessage( Future<void> _startStreamingMessage(
String message, String message,
Map<String, dynamic>? metadata, Map<String, dynamic>? metadata,
Emitter<ChatState> emit,
) async { ) async {
if (state.answerStream != null) { await answerStream?.dispose();
await state.answerStream?.dispose();
}
final answerStream = AnswerStream(); answerStream = AnswerStream();
final questionStream = QuestionStream(); final questionStream = QuestionStream();
add(ChatEvent.didUpdateAnswerStream(answerStream));
final payload = StreamChatPayloadPB(
chatId: state.view.id,
message: message,
messageType: ChatMessageTypePB.User,
questionStreamPort: Int64(questionStream.nativePort),
answerStreamPort: Int64(answerStream.nativePort),
metadata: await metadataPBFromMetadata(metadata),
);
// add a streaming question message
final questionStreamMessage = _createQuestionStreamMessage( final questionStreamMessage = _createQuestionStreamMessage(
questionStream, questionStream,
metadata, metadata,
); );
add(ChatEvent.receiveMessage(questionStreamMessage)); add(ChatEvent.receiveMessage(questionStreamMessage));
// Stream message to the server final payload = StreamChatPayloadPB(
final result = await AIEventStreamMessage(payload).send(); chatId: chatId,
result.fold( message: message,
(ChatMessagePB question) { messageType: ChatMessageTypePB.User,
questionStreamPort: Int64(questionStream.nativePort),
answerStreamPort: Int64(answerStream!.nativePort),
metadata: await metadataPBFromMetadata(metadata),
);
// stream the question to the server
await AIEventStreamMessage(payload).send().fold(
(question) {
if (!isClosed) { if (!isClosed) {
add(ChatEvent.finishSending(question)); add(ChatEvent.finishSending(question));
// final message = _createTextMessage(question);
// add(ChatEvent.receiveMessage(message));
final streamAnswer = final streamAnswer =
_createAnswerStreamMessage(answerStream, question.messageId); _createAnswerStreamMessage(answerStream!, question.messageId);
add(ChatEvent.startAnswerStreaming(streamAnswer)); add(ChatEvent.startAnswerStreaming(streamAnswer));
} }
}, },
@ -417,11 +386,12 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
metadata[sendMessageErrorKey] = err.msg; metadata[sendMessageErrorKey] = err.msg;
} }
final error = CustomMessage( final error = TextMessage(
text: '',
metadata: metadata, metadata: metadata,
author: const User(id: systemUserId), author: const User(id: systemUserId),
showStatus: false,
id: systemUserId, id: systemUserId,
createdAt: DateTime.now(),
); );
add(const ChatEvent.failedSending()); add(const ChatEvent.failedSending());
@ -446,8 +416,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
"chatId": chatId, "chatId": chatId,
}, },
id: streamMessageId, id: streamMessageId,
showStatus: false, createdAt: DateTime.now(),
createdAt: DateTime.now().millisecondsSinceEpoch,
text: '', text: '',
); );
} }
@ -457,24 +426,19 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
Map<String, dynamic>? sentMetadata, Map<String, dynamic>? sentMetadata,
) { ) {
final now = DateTime.now(); final now = DateTime.now();
final timestamp = now.millisecondsSinceEpoch; questionStreamMessageId = (now.millisecondsSinceEpoch ~/ 1000).toString();
questionStreamMessageId = timestamp.toString();
final Map<String, dynamic> metadata = {};
// if (sentMetadata != null) { final Map<String, dynamic> metadata = {
// metadata[messageMetadataJsonStringKey] = sentMetadata; "$QuestionStream": stream,
// } "chatId": chatId,
messageChatFileListKey: chatFilesFromMessageMetadata(sentMetadata),
};
metadata["$QuestionStream"] = stream;
metadata["chatId"] = chatId;
metadata[messageChatFileListKey] =
chatFilesFromMessageMetadata(sentMetadata);
return TextMessage( return TextMessage(
author: User(id: state.userProfile.id.toString()), author: User(id: userId),
metadata: metadata, metadata: metadata,
id: questionStreamMessageId, id: questionStreamMessageId,
showStatus: false, createdAt: now,
createdAt: DateTime.now().millisecondsSinceEpoch,
text: '', text: '',
); );
} }
@ -491,8 +455,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
author: User(id: message.authorId), author: User(id: message.authorId),
id: messageId, id: messageId,
text: message.content, text: message.content,
createdAt: message.createdAt.toInt() * 1000, createdAt: message.createdAt.toDateTime(),
showStatus: false,
metadata: { metadata: {
messageRefSourceJsonStringKey: message.metadata, messageRefSourceJsonStringKey: message.metadata,
}, },
@ -502,8 +465,6 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
@freezed @freezed
class ChatEvent with _$ChatEvent { class ChatEvent with _$ChatEvent {
const factory ChatEvent.initialLoad() = _InitialLoadMessage;
// send message // send message
const factory ChatEvent.sendMessage({ const factory ChatEvent.sendMessage({
required String message, required String message,
@ -513,14 +474,14 @@ class ChatEvent with _$ChatEvent {
_FinishSendMessage; _FinishSendMessage;
const factory ChatEvent.failedSending() = _FailSendMessage; const factory ChatEvent.failedSending() = _FailSendMessage;
// receive message // receive message
const factory ChatEvent.startAnswerStreaming(Message message) = const factory ChatEvent.startAnswerStreaming(Message message) =
_StartAnswerStreaming; _StartAnswerStreaming;
const factory ChatEvent.receiveMessage(Message message) = _ReceiveMessage; const factory ChatEvent.receiveMessage(Message message) = _ReceiveMessage;
const factory ChatEvent.finishAnswerStreaming() = _FinishAnswerStreaming; const factory ChatEvent.didFinishAnswerStream() = _DidFinishAnswerStream;
// loading messages // loading messages
const factory ChatEvent.startLoadingPrevMessage() = _StartLoadPrevMessage; const factory ChatEvent.loadPreviousMessages() = _LoadPreviousMessages;
const factory ChatEvent.didLoadPreviousMessages( const factory ChatEvent.didLoadPreviousMessages(
List<Message> messages, List<Message> messages,
bool hasMore, bool hasMore,
@ -528,56 +489,24 @@ class ChatEvent with _$ChatEvent {
const factory ChatEvent.didLoadLatestMessages(List<Message> messages) = const factory ChatEvent.didLoadLatestMessages(List<Message> messages) =
_DidLoadMessages; _DidLoadMessages;
// related questions // related questions
const factory ChatEvent.didReceiveRelatedQuestion( const factory ChatEvent.didReceiveRelatedQuestions(
List<RelatedQuestionPB> questions, List<String> questions,
) = _DidReceiveRelatedQueston; ) = _DidReceiveRelatedQueston;
const factory ChatEvent.clearRelatedQuestions() = _ClearRelatedQuestions;
const factory ChatEvent.didUpdateAnswerStream(
AnswerStream stream,
) = _DidUpdateAnswerStream;
const factory ChatEvent.stopStream() = _StopStream; const factory ChatEvent.stopStream() = _StopStream;
} }
@freezed @freezed
class ChatState with _$ChatState { class ChatState with _$ChatState {
const factory ChatState({ const factory ChatState({
required ViewPB view, required ChatLoadingState loadingState,
required List<Message> messages, required PromptResponseState promptResponseState,
required UserProfilePB userProfile,
// When opening the chat, the initial loading status will be set as loading.
//After the initial loading is done, the status will be set as finished.
required ChatLoadingState initialLoadingStatus,
// When loading previous messages, the status will be set as loading.
// After the loading is done, the status will be set as finished.
required ChatLoadingState loadingPreviousStatus,
// When sending a user message, the status will be set as loading.
// After the message is sent, the status will be set as finished.
required StreamingState streamingState,
required SendMessageState sendingState,
// Indicate whether there are more previous messages to load.
required bool hasMorePrevMessage,
// The related questions that are received after the user message is sent.
required List<RelatedQuestionPB> relatedQuestions,
@Default(false) bool acceptRelatedQuestion,
// The last user message that is sent to the server.
ChatMessagePB? lastSentMessage,
AnswerStream? answerStream,
@Default(true) bool canSendMessage,
}) = _ChatState; }) = _ChatState;
factory ChatState.initial(ViewPB view, UserProfilePB userProfile) => factory ChatState.initial() => const ChatState(
ChatState( loadingState: ChatLoadingState.loading(),
view: view, promptResponseState: PromptResponseState.ready,
messages: [],
userProfile: userProfile,
initialLoadingStatus: const ChatLoadingState.finish(),
loadingPreviousStatus: const ChatLoadingState.finish(),
streamingState: const StreamingState.done(),
sendingState: const SendMessageState.done(),
hasMorePrevMessage: true,
relatedQuestions: [],
); );
} }

View File

@ -40,16 +40,11 @@ class ChatMessageRefSource {
Map<String, dynamic> toJson() => _$ChatMessageRefSourceToJson(this); Map<String, dynamic> toJson() => _$ChatMessageRefSourceToJson(this);
} }
@freezed enum PromptResponseState {
class StreamingState with _$StreamingState { ready,
const factory StreamingState.streaming() = _Streaming; sendingQuestion,
const factory StreamingState.done({FlowyError? error}) = _StreamDone; awaitingAnswer,
} streamingAnswer,
@freezed
class SendMessageState with _$SendMessageState {
const factory SendMessageState.sending() = _Sending;
const factory SendMessageState.done({FlowyError? error}) = _SendDone;
} }
class ChatFile extends Equatable { class ChatFile extends Equatable {

View File

@ -7,7 +7,7 @@ import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_result/appflowy_result.dart';
import 'package:nanoid/nanoid.dart'; import 'package:nanoid/nanoid.dart';
/// Indicate file source from appflowy document /// Indicate file source from appflowy document
@ -103,16 +103,16 @@ List<ChatMessageRefSource> messageReferenceSource(String? s) {
Future<List<ChatMessageMetaPB>> metadataPBFromMetadata( Future<List<ChatMessageMetaPB>> metadataPBFromMetadata(
Map<String, dynamic>? map, Map<String, dynamic>? map,
) async { ) async {
if (map == null) return [];
final List<ChatMessageMetaPB> metadata = []; final List<ChatMessageMetaPB> metadata = [];
if (map != null) {
for (final entry in map.entries) { for (final value in map.values) {
if (entry.value is ViewActionPage) { switch (value) {
if (entry.value.page is ViewPB) { case ViewActionPage(view: final view) when view.layout.isDocumentView:
final view = entry.value.page as ViewPB;
if (view.layout.isDocumentView) {
final payload = OpenDocumentPayloadPB(documentId: view.id); final payload = OpenDocumentPayloadPB(documentId: view.id);
final result = await DocumentEventGetDocumentText(payload).send(); await DocumentEventGetDocumentText(payload).send().fold(
result.fold((pb) { (pb) {
metadata.add( metadata.add(
ChatMessageMetaPB( ChatMessageMetaPB(
id: view.id, id: view.id,
@ -122,22 +122,25 @@ Future<List<ChatMessageMetaPB>> metadataPBFromMetadata(
source: appflowySource, source: appflowySource,
), ),
); );
}, (err) { },
Log.error('Failed to get document text: $err'); (err) => Log.error('Failed to get document text: $err'),
}); );
} break;
} case ChatFile(
} else if (entry.value is ChatFile) { filePath: final filePath,
fileName: final fileName,
fileType: final fileType,
):
metadata.add( metadata.add(
ChatMessageMetaPB( ChatMessageMetaPB(
id: nanoid(8), id: nanoid(8),
name: entry.value.fileName, name: fileName,
data: entry.value.filePath, data: filePath,
dataType: entry.value.fileType, dataType: fileType,
source: entry.value.filePath, source: filePath,
), ),
); );
} break;
} }
} }

View File

@ -9,11 +9,7 @@ class ChatUserMessageBloc
extends Bloc<ChatUserMessageEvent, ChatUserMessageState> { extends Bloc<ChatUserMessageEvent, ChatUserMessageState> {
ChatUserMessageBloc({ ChatUserMessageBloc({
required dynamic message, required dynamic message,
}) : super( }) : super(ChatUserMessageState.initial(message)) {
ChatUserMessageState.initial(
message,
),
) {
on<ChatUserMessageEvent>( on<ChatUserMessageEvent>(
(event, emit) { (event, emit) {
event.when( event.when(

View File

@ -1,6 +1,6 @@
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'chat_message_service.dart'; import 'chat_message_service.dart';

View File

@ -9,27 +9,27 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:desktop_drop/desktop_drop.dart'; import 'package:desktop_drop/desktop_drop.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart' as types; import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart'; import 'package:flutter_chat_ui/flutter_chat_ui.dart'
import 'package:flutter_chat_ui/flutter_chat_ui.dart' show Chat; hide ChatAnimatedListReversed;
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:universal_platform/universal_platform.dart'; import 'package:universal_platform/universal_platform.dart';
import 'application/chat_member_bloc.dart'; import 'application/chat_member_bloc.dart';
import 'application/chat_side_panel_bloc.dart'; import 'application/chat_side_panel_bloc.dart';
import 'presentation/animated_chat_list.dart';
import 'presentation/chat_input/desktop_ai_prompt_input.dart'; import 'presentation/chat_input/desktop_ai_prompt_input.dart';
import 'presentation/chat_input/mobile_ai_prompt_input.dart'; import 'presentation/chat_input/mobile_ai_prompt_input.dart';
import 'presentation/chat_side_panel.dart'; import 'presentation/chat_side_panel.dart';
import 'presentation/chat_theme.dart';
import 'presentation/chat_user_invalid_message.dart'; import 'presentation/chat_user_invalid_message.dart';
import 'presentation/chat_welcome_page.dart'; import 'presentation/chat_welcome_page.dart';
import 'presentation/layout_define.dart'; import 'presentation/layout_define.dart';
import 'presentation/message/ai_text_message.dart'; import 'presentation/message/ai_text_message.dart';
import 'presentation/message/user_text_message.dart'; import 'presentation/message/user_text_message.dart';
import 'presentation/scroll_to_bottom.dart';
class AIChatPage extends StatelessWidget { class AIChatPage extends StatelessWidget {
const AIChatPage({ const AIChatPage({
@ -59,9 +59,9 @@ class AIChatPage extends StatelessWidget {
/// [ChatBloc] is used to handle chat messages including send/receive message /// [ChatBloc] is used to handle chat messages including send/receive message
BlocProvider( BlocProvider(
create: (_) => ChatBloc( create: (_) => ChatBloc(
view: view, chatId: view.id,
userProfile: userProfile, userId: userProfile.id.toString(),
)..add(const ChatEvent.initialLoad()), ),
), ),
/// [AIPromptInputBloc] is used to handle the user prompt /// [AIPromptInputBloc] is used to handle the user prompt
@ -113,7 +113,7 @@ class _ChatContentPage extends StatelessWidget {
return Row( return Row(
children: [ children: [
Center( Center(
child: buildChatWidget() child: buildChatWidget(context)
.constrained( .constrained(
maxWidth: 784, maxWidth: 784,
) )
@ -148,7 +148,7 @@ class _ChatContentPage extends StatelessWidget {
Flexible( Flexible(
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 784), constraints: const BoxConstraints(maxWidth: 784),
child: buildChatWidget(), child: buildChatWidget(context),
), ),
), ),
], ],
@ -167,47 +167,50 @@ class _ChatContentPage extends StatelessWidget {
); );
} }
Widget buildChatWidget() { Widget buildChatWidget(BuildContext context) {
return BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
return ScrollConfiguration( return ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: BlocBuilder<ChatBloc, ChatState>( child: BlocBuilder<ChatBloc, ChatState>(
builder: (_, state) => state.initialLoadingStatus.isFinish builder: (context, state) {
? Chat( return state.loadingState.when(
messages: state.messages, loading: () {
dateHeaderBuilder: (_) => const SizedBox.shrink(), return const Center(
onSendPressed: (_) { child: CircularProgressIndicator.adaptive(),
// We use custom bottom widget for chat input, so );
// do not need to handle this event.
}, },
customBottomWidget: _buildBottom(context), finish: (_) {
user: types.User(id: userProfile.id.toString()), final chatController = context.read<ChatBloc>().chatController;
theme: _buildTheme(context), return Column(
onEndReached: () async { children: [
if (state.hasMorePrevMessage && Expanded(
state.loadingPreviousStatus.isFinish) { child: Chat(
context chatController: chatController,
.read<ChatBloc>() user: User(id: userProfile.id.toString()),
.add(const ChatEvent.startLoadingPrevMessage()); darkTheme: ChatTheme.fromThemeData(Theme.of(context)),
theme: ChatTheme.fromThemeData(Theme.of(context)),
builders: Builders(
inputBuilder: (_) => const SizedBox.shrink(),
textMessageBuilder: _buildTextMessage,
chatMessageBuilder: _buildChatMessage,
scrollToBottomBuilder: _buildScrollToBottom,
chatAnimatedListBuilder: _buildChatAnimatedList,
),
),
),
_buildInput(context),
],
);
},
);
},
),
);
} }
},
emptyState: TextFieldTapRegion( Widget _buildTextMessage(
child: ChatWelcomePage( BuildContext context,
userProfile: userProfile, TextMessage message,
onSelectedQuestion: (question) => context ) {
.read<ChatBloc>()
.add(ChatEvent.sendMessage(message: question)),
),
),
messageWidthRatio: AIChatUILayout.messageWidthRatio,
textMessageBuilder: (
textMessage, {
required messageWidth,
required showName,
}) =>
_buildTextMessage(context, textMessage, state),
customMessageBuilder: (message, {required messageWidth}) {
final messageType = onetimeMessageTypeFromMeta( final messageType = onetimeMessageTypeFromMeta(
message.metadata, message.metadata,
); );
@ -217,56 +220,43 @@ class _ChatContentPage extends StatelessWidget {
message: message, message: message,
); );
} }
if (messageType == OnetimeShotType.relatedQuestion) { if (messageType == OnetimeShotType.relatedQuestion) {
return RelatedQuestionList( return RelatedQuestionList(
relatedQuestions: state.relatedQuestions, relatedQuestions: message.metadata!['questions'],
onQuestionSelected: (question) { onQuestionSelected: (question) {
final bloc = context.read<ChatBloc>(); context
bloc .read<ChatBloc>()
..add(ChatEvent.sendMessage(message: question)) .add(ChatEvent.sendMessage(message: question));
..add(const ChatEvent.clearRelatedQuestions());
}, },
); );
} }
return const SizedBox.shrink();
},
bubbleBuilder: (
child, {
required message,
required nextMessageInGroup,
}) =>
_buildBubble(context, message, child),
)
: const Center(
child: CircularProgressIndicator.adaptive(),
),
),
);
},
);
}
Widget _buildTextMessage(
BuildContext context,
TextMessage message,
ChatState state,
) {
if (message.author.id == userProfile.id.toString()) { if (message.author.id == userProfile.id.toString()) {
final stream = message.metadata?["$QuestionStream"]; final stream = message.metadata?["$QuestionStream"];
return ChatUserMessageWidget( return ChatUserMessageBubble(
key: ValueKey(message.id), key: ValueKey(message.id),
message: message,
child: ChatUserMessageWidget(
user: message.author, user: message.author,
message: stream is QuestionStream ? stream : message.text, message: stream is QuestionStream ? stream : message.text,
),
); );
} else if (isOtherUserMessage(message)) { }
if (isOtherUserMessage(message)) {
final stream = message.metadata?["$QuestionStream"]; final stream = message.metadata?["$QuestionStream"];
return ChatUserMessageWidget( return ChatUserMessageBubble(
key: ValueKey(message.id), key: ValueKey(message.id),
message: message,
isCurrentUser: false,
child: ChatUserMessageWidget(
user: message.author, user: message.author,
message: stream is QuestionStream ? stream : message.text, message: stream is QuestionStream ? stream : message.text,
),
); );
} else { }
final stream = message.metadata?["$AnswerStream"]; final stream = message.metadata?["$AnswerStream"];
final questionId = message.metadata?[messageQuestionIdKey]; final questionId = message.metadata?[messageQuestionIdKey];
final refSourceJsonString = final refSourceJsonString =
@ -275,7 +265,8 @@ class _ChatContentPage extends StatelessWidget {
return BlocSelector<ChatBloc, ChatState, bool>( return BlocSelector<ChatBloc, ChatState, bool>(
key: ValueKey(message.id), key: ValueKey(message.id),
selector: (state) { selector: (state) {
final messages = state.messages.where((e) { final chatController = context.read<ChatBloc>().chatController;
final messages = chatController.messages.where((e) {
final oneTimeMessageType = onetimeMessageTypeFromMeta(e.metadata); final oneTimeMessageType = onetimeMessageTypeFromMeta(e.metadata);
if (oneTimeMessageType == null) { if (oneTimeMessageType == null) {
return true; return true;
@ -288,7 +279,7 @@ class _ChatContentPage extends StatelessWidget {
} }
return true; return true;
}); });
return messages.isEmpty ? false : messages.first.id == message.id; return messages.isEmpty ? false : messages.last.id == message.id;
}, },
builder: (context, isLastMessage) { builder: (context, isLastMessage) {
return ChatAIMessageWidget( return ChatAIMessageWidget(
@ -309,45 +300,74 @@ class _ChatContentPage extends StatelessWidget {
}, },
); );
} }
}
Widget _buildBubble( Widget _buildChatMessage(
BuildContext context, BuildContext context,
Message message, Message message,
Animation<double> animation,
Widget child, Widget child,
) { ) {
if (message.author.id == userProfile.id.toString()) { return ChatMessage(
return ChatUserMessageBubble(
message: message, message: message,
animation: animation,
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: child, child: child,
); );
} else if (isOtherUserMessage(message)) {
return ChatUserMessageBubble(
message: message,
isCurrentUser: false,
child: child,
);
} else {
// The bubble is rendered in the child already
return child;
}
} }
Widget _buildBottom(BuildContext context) { Widget _buildScrollToBottom(
BuildContext context,
Animation<double> animation,
VoidCallback onPressed,
) {
return CustomScrollToBottom(
animation: animation,
onPressed: onPressed,
);
}
Widget _buildChatAnimatedList(
BuildContext context,
ScrollController scrollController,
ChatItem itemBuilder,
) {
final bloc = context.read<ChatBloc>();
if (bloc.chatController.messages.isEmpty) {
return ChatWelcomePage(
userProfile: userProfile,
onSelectedQuestion: (question) {
bloc.add(ChatEvent.sendMessage(message: question));
},
);
}
return ChatAnimatedListReversed(
scrollController: scrollController,
itemBuilder: itemBuilder,
onLoadPreviousMessages: () {
bloc.add(const ChatEvent.loadPreviousMessages());
},
);
}
Widget _buildInput(BuildContext context) {
return Padding( return Padding(
padding: AIChatUILayout.safeAreaInsets(context), padding: AIChatUILayout.safeAreaInsets(context),
child: BlocSelector<ChatBloc, ChatState, bool>( child: BlocSelector<ChatBloc, ChatState, bool>(
selector: (state) => state.canSendMessage, selector: (state) {
return state.promptResponseState == PromptResponseState.ready;
},
builder: (context, canSendMessage) { builder: (context, canSendMessage) {
return UniversalPlatform.isDesktop return UniversalPlatform.isDesktop
? DesktopAIPromptInput( ? DesktopAIPromptInput(
chatId: view.id, chatId: view.id,
indicateFocus: true, indicateFocus: true,
onSubmitted: (message) { onSubmitted: (text, metadata) {
context.read<ChatBloc>().add( context.read<ChatBloc>().add(
ChatEvent.sendMessage( ChatEvent.sendMessage(
message: message.text, message: text,
metadata: message.metadata, metadata: metadata,
), ),
); );
}, },
@ -358,11 +378,11 @@ class _ChatContentPage extends StatelessWidget {
) )
: MobileAIPromptInput( : MobileAIPromptInput(
chatId: view.id, chatId: view.id,
onSubmitted: (message) { onSubmitted: (text, metadata) {
context.read<ChatBloc>().add( context.read<ChatBloc>().add(
ChatEvent.sendMessage( ChatEvent.sendMessage(
message: message.text, message: text,
metadata: message.metadata, metadata: metadata,
), ),
); );
}, },
@ -375,11 +395,4 @@ class _ChatContentPage extends StatelessWidget {
), ),
); );
} }
AFDefaultChatTheme _buildTheme(BuildContext context) {
return AFDefaultChatTheme(
primaryColor: Theme.of(context).colorScheme.primary,
secondaryColor: AFThemeExtension.of(context).tint1,
);
}
} }

View File

@ -0,0 +1,405 @@
// ignore_for_file: implementation_imports
import 'dart:async';
import 'dart:math';
import 'package:diffutil_dart/diffutil.dart' as diffutil;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:provider/provider.dart';
import 'package:flutter_chat_ui/src/scroll_to_bottom.dart';
import 'package:flutter_chat_ui/src/utils/chat_input_height_notifier.dart';
import 'package:flutter_chat_ui/src/utils/message_list_diff.dart';
class ChatAnimatedListReversed extends StatefulWidget {
const ChatAnimatedListReversed({
super.key,
required this.scrollController,
required this.itemBuilder,
this.insertAnimationDuration = const Duration(milliseconds: 250),
this.removeAnimationDuration = const Duration(milliseconds: 250),
this.scrollToEndAnimationDuration = const Duration(milliseconds: 250),
this.scrollToBottomAppearanceDelay = const Duration(milliseconds: 250),
this.bottomPadding = 8,
this.onLoadPreviousMessages,
});
final ScrollController scrollController;
final ChatItem itemBuilder;
final Duration insertAnimationDuration;
final Duration removeAnimationDuration;
final Duration scrollToEndAnimationDuration;
final Duration scrollToBottomAppearanceDelay;
final double? bottomPadding;
final VoidCallback? onLoadPreviousMessages;
@override
ChatAnimatedListReversedState createState() =>
ChatAnimatedListReversedState();
}
class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
with SingleTickerProviderStateMixin {
final GlobalKey<SliverAnimatedListState> _listKey = GlobalKey();
late ChatController _chatController;
late List<Message> _oldList;
late StreamSubscription<ChatOperation> _operationsSubscription;
late AnimationController _scrollToBottomController;
late Animation<double> _scrollToBottomAnimation;
Timer? _scrollToBottomShowTimer;
bool _userHasScrolled = false;
bool _isScrollingToBottom = false;
String _lastInsertedMessageId = '';
@override
void initState() {
super.initState();
_chatController = Provider.of<ChatController>(context, listen: false);
// TODO: Add assert for messages having same id
_oldList = List.from(_chatController.messages);
_operationsSubscription = _chatController.operationsStream.listen((event) {
switch (event.type) {
case ChatOperationType.insert:
assert(
event.index != null,
'Index must be provided when inserting a message.',
);
assert(
event.message != null,
'Message must be provided when inserting a message.',
);
_onInserted(event.index!, event.message!);
_oldList = List.from(_chatController.messages);
break;
case ChatOperationType.remove:
assert(
event.index != null,
'Index must be provided when removing a message.',
);
assert(
event.message != null,
'Message must be provided when removing a message.',
);
_onRemoved(event.index!, event.message!);
_oldList = List.from(_chatController.messages);
break;
case ChatOperationType.set:
final newList = _chatController.messages;
final updates = diffutil
.calculateDiff<Message>(
MessageListDiff(_oldList, newList),
)
.getUpdatesWithData();
for (var i = updates.length - 1; i >= 0; i--) {
_onDiffUpdate(updates.elementAt(i));
}
_oldList = List.from(newList);
break;
default:
break;
}
});
_scrollToBottomController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_scrollToBottomAnimation = CurvedAnimation(
parent: _scrollToBottomController,
curve: Curves.easeInOut,
);
widget.scrollController.addListener(_handleLoadPreviousMessages);
WidgetsBinding.instance.addPostFrameCallback((_) {
_handleLoadPreviousMessages();
});
}
@override
void dispose() {
super.dispose();
_scrollToBottomShowTimer?.cancel();
_scrollToBottomController.dispose();
_operationsSubscription.cancel();
widget.scrollController.removeListener(_handleLoadPreviousMessages);
}
@override
Widget build(BuildContext context) {
final builders = context.watch<Builders>();
return NotificationListener<Notification>(
onNotification: (notification) {
if (notification is UserScrollNotification) {
// When user scrolls up, save it to `_userHasScrolled`
if (notification.direction == ScrollDirection.forward) {
_userHasScrolled = true;
} else {
// When user overscolls to the bottom or stays idle at the bottom, set `_userHasScrolled` to false
if (notification.metrics.pixels ==
notification.metrics.maxScrollExtent) {
_userHasScrolled = false;
}
}
}
if (notification is ScrollUpdateNotification) {
_handleToggleScrollToBottom();
}
// Allow other listeners to get the notification
return false;
},
child: Stack(
children: [
CustomScrollView(
reverse: true,
controller: widget.scrollController,
slivers: <Widget>[
Consumer<ChatInputHeightNotifier>(
builder: (context, heightNotifier, child) {
return SliverPadding(
padding: EdgeInsets.only(
top: heightNotifier.height + (widget.bottomPadding ?? 0),
),
);
},
),
SliverAnimatedList(
key: _listKey,
initialItemCount: _chatController.messages.length,
itemBuilder: (
BuildContext context,
int index,
Animation<double> animation,
) {
final message = _chatController.messages[
max(_chatController.messages.length - 1 - index, 0)];
return widget.itemBuilder(
context,
animation,
message,
);
},
),
],
),
builders.scrollToBottomBuilder?.call(
context,
_scrollToBottomAnimation,
_handleScrollToBottom,
) ??
ScrollToBottom(
animation: _scrollToBottomAnimation,
onPressed: _handleScrollToBottom,
),
],
),
);
}
void _initialScrollToEnd() async {
// Delay the scroll to the end animation so new message is painted, otherwise
// maxScrollExtent is not yet updated and the animation might not work.
await Future.delayed(widget.insertAnimationDuration);
if (!widget.scrollController.hasClients || !mounted) return;
if (widget.scrollController.offset >
widget.scrollController.position.minScrollExtent) {
if (widget.scrollToEndAnimationDuration == Duration.zero) {
widget.scrollController
.jumpTo(widget.scrollController.position.minScrollExtent);
} else {
await widget.scrollController.animateTo(
widget.scrollController.position.minScrollExtent,
duration: widget.scrollToEndAnimationDuration,
curve: Curves.linearToEaseOut,
);
}
}
}
void _subsequentScrollToEnd(Message data) async {
final user = Provider.of<User>(context, listen: false);
// In this case we only want to scroll to the bottom if user has not scrolled up
// or if the message is sent by the current user.
if (data.id == _lastInsertedMessageId &&
widget.scrollController.offset >
widget.scrollController.position.minScrollExtent &&
(user.id == data.author.id || !_userHasScrolled)) {
if (widget.scrollToEndAnimationDuration == Duration.zero) {
widget.scrollController
.jumpTo(widget.scrollController.position.minScrollExtent);
} else {
await widget.scrollController.animateTo(
widget.scrollController.position.minScrollExtent,
duration: widget.scrollToEndAnimationDuration,
curve: Curves.linearToEaseOut,
);
}
if (!widget.scrollController.hasClients || !mounted) return;
// Because of the issue I have opened here https://github.com/flutter/flutter/issues/129768
// we need an additional jump to the end. Sometimes Flutter
// will not scroll to the very end. Sometimes it will not scroll to the
// very end even with this, so this is something that needs to be
// addressed by the Flutter team.
//
// Additionally here we have a check for the message id, because
// if new message arrives in the meantime it will trigger another
// scroll to the end animation, making this logic redundant.
if (data.id == _lastInsertedMessageId &&
widget.scrollController.offset >
widget.scrollController.position.minScrollExtent &&
(user.id == data.author.id || !_userHasScrolled)) {
widget.scrollController
.jumpTo(widget.scrollController.position.minScrollExtent);
}
}
}
void _scrollToEnd(Message data) {
WidgetsBinding.instance.addPostFrameCallback(
(_) {
if (!widget.scrollController.hasClients || !mounted) return;
// We need this condition because if scroll view is not yet scrollable,
// we want to wait for the insert animation to finish before scrolling to the end.
if (widget.scrollController.position.maxScrollExtent == 0) {
// Scroll view is not yet scrollable, scroll to the end if
// new message makes it scrollable.
_initialScrollToEnd();
} else {
_subsequentScrollToEnd(data);
}
},
);
}
void _handleScrollToBottom() {
_isScrollingToBottom = true;
_scrollToBottomController.reverse();
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!widget.scrollController.hasClients || !mounted) return;
if (widget.scrollToEndAnimationDuration == Duration.zero) {
widget.scrollController
.jumpTo(widget.scrollController.position.minScrollExtent);
} else {
await widget.scrollController.animateTo(
widget.scrollController.position.minScrollExtent,
duration: widget.scrollToEndAnimationDuration,
curve: Curves.linearToEaseOut,
);
}
if (!widget.scrollController.hasClients || !mounted) return;
if (widget.scrollController.offset <
widget.scrollController.position.minScrollExtent) {
widget.scrollController.jumpTo(
widget.scrollController.position.minScrollExtent,
);
}
_isScrollingToBottom = false;
});
}
void _handleToggleScrollToBottom() {
if (!_isScrollingToBottom) {
_scrollToBottomShowTimer?.cancel();
if (widget.scrollController.offset >
widget.scrollController.position.minScrollExtent) {
_scrollToBottomShowTimer =
Timer(widget.scrollToBottomAppearanceDelay, () {
if (mounted) {
_scrollToBottomController.forward();
}
});
} else {
if (_scrollToBottomController.status == AnimationStatus.completed) {
_scrollToBottomController.reverse();
}
}
}
}
void _onInserted(final int position, final Message data) {
final user = Provider.of<User>(context, listen: false);
// There is a scroll notification listener the controls the `_userHasScrolled` variable.
// However, when a new message is sent by the current user we want to
// set `_userHasScrolled` to false so that the scroll animation is triggered.
//
// Also, if for some reason `_userHasScrolled` is true and the user is not at the bottom of the list,
// set `_userHasScrolled` to false so that the scroll animation is triggered.
if (user.id == data.author.id ||
(_userHasScrolled == true &&
widget.scrollController.offset >=
widget.scrollController.position.maxScrollExtent)) {
_userHasScrolled = false;
}
_listKey.currentState!.insertItem(
position,
duration: widget.insertAnimationDuration,
);
// Used later to trigger scroll to end only for the last inserted message.
_lastInsertedMessageId = data.id;
if (user.id == data.author.id && position == _oldList.length) {
_scrollToEnd(data);
}
}
void _onRemoved(final int position, final Message data) {
final visualPosition = max(_oldList.length - position - 1, 0);
_listKey.currentState!.removeItem(
visualPosition,
(context, animation) => widget.itemBuilder(
context,
animation,
data,
isRemoved: true,
),
duration: widget.removeAnimationDuration,
);
}
void _onChanged(int position, Message oldData, Message newData) {
_onRemoved(position, oldData);
_listKey.currentState!.insertItem(
max(_oldList.length - position - 1, 0),
duration: widget.insertAnimationDuration,
);
}
void _onDiffUpdate(diffutil.DataDiffUpdate<Message> update) {
update.when<void>(
insert: (pos, data) => _onInserted(max(_oldList.length - pos, 0), data),
remove: (pos, data) => _onRemoved(pos, data),
change: (pos, oldData, newData) => _onChanged(pos, oldData, newData),
move: (_, __, ___) => throw UnimplementedError('unused'),
);
}
void _handleLoadPreviousMessages() {
if (widget.scrollController.offset >=
widget.scrollController.position.maxScrollExtent) {
widget.onLoadPreviousMessages?.call();
}
}
}

View File

@ -12,8 +12,6 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
import 'package:universal_platform/universal_platform.dart'; import 'package:universal_platform/universal_platform.dart';
import 'ai_prompt_buttons.dart'; import 'ai_prompt_buttons.dart';
@ -25,7 +23,6 @@ class DesktopAIPromptInput extends StatefulWidget {
super.key, super.key,
required this.chatId, required this.chatId,
required this.indicateFocus, required this.indicateFocus,
this.options = const InputOptions(),
required this.isStreaming, required this.isStreaming,
required this.onStopStreaming, required this.onStopStreaming,
required this.onSubmitted, required this.onSubmitted,
@ -33,10 +30,9 @@ class DesktopAIPromptInput extends StatefulWidget {
final String chatId; final String chatId;
final bool indicateFocus; final bool indicateFocus;
final InputOptions options;
final bool isStreaming; final bool isStreaming;
final void Function() onStopStreaming; final void Function() onStopStreaming;
final void Function(types.PartialText) onSubmitted; final void Function(String, Map<String, dynamic>) onSubmitted;
@override @override
State<DesktopAIPromptInput> createState() => _DesktopAIPromptInputState(); State<DesktopAIPromptInput> createState() => _DesktopAIPromptInputState();
@ -56,7 +52,7 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
void initState() { void initState() {
super.initState(); super.initState();
_textController = InputTextFieldController() _textController = TextEditingController()
..addListener(_handleTextControllerChange); ..addListener(_handleTextControllerChange);
_inputFocusNode = FocusNode( _inputFocusNode = FocusNode(
@ -118,6 +114,7 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
borderRadius: DesktopAIPromptSizes.promptFrameRadius, borderRadius: DesktopAIPromptSizes.promptFrameRadius,
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
ConstrainedBox( ConstrainedBox(
constraints: BoxConstraints( constraints: BoxConstraints(
@ -209,11 +206,7 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
..addAll(mentionPageMetadata) ..addAll(mentionPageMetadata)
..addAll(fileMetadata); ..addAll(fileMetadata);
final partialText = types.PartialText( widget.onSubmitted(trimmedText, metadata);
text: trimmedText,
metadata: metadata,
);
widget.onSubmitted(partialText);
} }
void _handleTextControllerChange() { void _handleTextControllerChange() {

View File

@ -12,8 +12,6 @@ import 'package:extended_text_field/extended_text_field.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
import 'ai_prompt_buttons.dart'; import 'ai_prompt_buttons.dart';
import 'chat_input_span.dart'; import 'chat_input_span.dart';
@ -23,17 +21,15 @@ class MobileAIPromptInput extends StatefulWidget {
const MobileAIPromptInput({ const MobileAIPromptInput({
super.key, super.key,
required this.chatId, required this.chatId,
this.options = const InputOptions(),
required this.isStreaming, required this.isStreaming,
required this.onStopStreaming, required this.onStopStreaming,
required this.onSubmitted, required this.onSubmitted,
}); });
final String chatId; final String chatId;
final InputOptions options;
final bool isStreaming; final bool isStreaming;
final void Function() onStopStreaming; final void Function() onStopStreaming;
final void Function(types.PartialText) onSubmitted; final void Function(String, Map<String, dynamic>) onSubmitted;
@override @override
State<MobileAIPromptInput> createState() => _MobileAIPromptInputState(); State<MobileAIPromptInput> createState() => _MobileAIPromptInputState();
@ -50,7 +46,7 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
void initState() { void initState() {
super.initState(); super.initState();
_textController = InputTextFieldController() _textController = TextEditingController()
..addListener(_handleTextControllerChange); ..addListener(_handleTextControllerChange);
_inputFocusNode = FocusNode(); _inputFocusNode = FocusNode();
@ -166,11 +162,7 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
..addAll(mentionPageMetadata) ..addAll(mentionPageMetadata)
..addAll(fileMetadata); ..addAll(fileMetadata);
final partialText = types.PartialText( widget.onSubmitted(trimmedText, metadata);
text: trimmedText,
metadata: metadata,
);
widget.onSubmitted(partialText);
} }
void _handleTextControllerChange() { void _handleTextControllerChange() {

View File

@ -1,6 +1,5 @@
import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/style_widget/text.dart';
@ -16,7 +15,7 @@ class RelatedQuestionList extends StatelessWidget {
}); });
final Function(String) onQuestionSelected; final Function(String) onQuestionSelected;
final List<RelatedQuestionPB> relatedQuestions; final List<String> relatedQuestions;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -57,14 +56,14 @@ class RelatedQuestionItem extends StatelessWidget {
super.key, super.key,
}); });
final RelatedQuestionPB question; final String question;
final Function(String) onQuestionSelected; final Function(String) onQuestionSelected;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return FlowyButton( return FlowyButton(
text: FlowyText( text: FlowyText(
question.content, question,
lineHeight: 1.4, lineHeight: 1.4,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@ -76,7 +75,7 @@ class RelatedQuestionItem extends StatelessWidget {
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
size: const Size.square(16.0), size: const Size.square(16.0),
), ),
onTap: () => onQuestionSelected(question.content), onTap: () => onQuestionSelected(question),
); );
} }
} }

View File

@ -1,85 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
/// Default chat theme which extends [ChatTheme].
@immutable
class AFDefaultChatTheme extends ChatTheme {
const AFDefaultChatTheme({
required super.primaryColor,
required super.secondaryColor,
}) : super(
backgroundColor: Colors.transparent,
// TODO: think how to offset the default 12 pixels set by chat package
bubbleMargin: const EdgeInsets.symmetric(vertical: 4.0),
messageMaxWidth: double.infinity,
// unused
dateDividerMargin: EdgeInsets.zero,
dateDividerTextStyle: const TextStyle(),
attachmentButtonIcon: null,
attachmentButtonMargin: null,
deliveredIcon: null,
documentIcon: null,
emptyChatPlaceholderTextStyle: const TextStyle(),
errorColor: error,
errorIcon: null,
inputBackgroundColor: neutral0,
inputSurfaceTintColor: neutral0,
inputElevation: 0,
inputBorderRadius: BorderRadius.zero,
inputContainerDecoration: null,
inputMargin: EdgeInsets.zero,
inputPadding: EdgeInsets.zero,
inputTextColor: neutral7,
inputTextCursorColor: null,
inputTextDecoration: const InputDecoration(),
inputTextStyle: const TextStyle(),
messageBorderRadius: 0,
messageInsetsHorizontal: 0,
messageInsetsVertical: 0,
receivedEmojiMessageTextStyle: const TextStyle(),
receivedMessageBodyBoldTextStyle: null,
receivedMessageBodyCodeTextStyle: null,
receivedMessageBodyLinkTextStyle: null,
receivedMessageBodyTextStyle: const TextStyle(),
receivedMessageDocumentIconColor: primary,
receivedMessageCaptionTextStyle: const TextStyle(),
receivedMessageLinkDescriptionTextStyle: const TextStyle(),
receivedMessageLinkTitleTextStyle: const TextStyle(),
seenIcon: null,
sendButtonIcon: null,
sendButtonMargin: null,
sendingIcon: null,
sentEmojiMessageTextStyle: const TextStyle(),
sentMessageBodyBoldTextStyle: null,
sentMessageBodyCodeTextStyle: null,
sentMessageBodyLinkTextStyle: null,
sentMessageBodyTextStyle: const TextStyle(),
sentMessageCaptionTextStyle: const TextStyle(),
sentMessageDocumentIconColor: neutral7,
sentMessageLinkDescriptionTextStyle: const TextStyle(),
sentMessageLinkTitleTextStyle: const TextStyle(),
statusIconPadding: EdgeInsets.zero,
systemMessageTheme: const SystemMessageTheme(
margin: EdgeInsets.zero,
textStyle: TextStyle(),
),
typingIndicatorTheme: const TypingIndicatorTheme(
animatedCirclesColor: neutral1,
animatedCircleSize: 0.0,
bubbleBorder: BorderRadius.zero,
bubbleColor: neutral7,
countAvatarColor: primary,
countTextColor: secondary,
multipleUserTextStyle: TextStyle(),
),
unreadHeaderTheme: const UnreadHeaderTheme(
color: secondary,
textStyle: TextStyle(),
),
userAvatarImageBackgroundColor: Colors.transparent,
userAvatarNameColors: colors,
userAvatarTextStyle: const TextStyle(),
userNameTextStyle: const TextStyle(),
highlightMessageColor: null,
);
}

View File

@ -3,7 +3,7 @@ import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/util/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:universal_platform/universal_platform.dart'; import 'package:universal_platform/universal_platform.dart';
class ChatInvalidUserMessage extends StatelessWidget { class ChatInvalidUserMessage extends StatelessWidget {

View File

@ -2,8 +2,6 @@ import 'package:flutter/material.dart';
import 'package:universal_platform/universal_platform.dart'; import 'package:universal_platform/universal_platform.dart';
class AIChatUILayout { class AIChatUILayout {
static double get messageWidthRatio => 0.94; // Chat adds extra 0.06
static EdgeInsets safeAreaInsets(BuildContext context) { static EdgeInsets safeAreaInsets(BuildContext context) {
final query = MediaQuery.of(context); final query = MediaQuery.of(context);
return UniversalPlatform.isMobile return UniversalPlatform.isMobile

View File

@ -14,7 +14,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:universal_platform/universal_platform.dart'; import 'package:universal_platform/universal_platform.dart';
import '../layout_define.dart'; import '../layout_define.dart';
@ -341,7 +341,6 @@ class CopyButton extends StatelessWidget {
child: FlowyIconButton( child: FlowyIconButton(
width: DesktopAIConvoSizes.actionBarIconSize, width: DesktopAIConvoSizes.actionBarIconSize,
hoverColor: AFThemeExtension.of(context).lightGreyHover, hoverColor: AFThemeExtension.of(context).lightGreyHover,
fillColor: Theme.of(context).cardColor,
radius: DesktopAIConvoSizes.actionBarIconRadius, radius: DesktopAIConvoSizes.actionBarIconRadius,
icon: FlowySvg( icon: FlowySvg(
FlowySvgs.copy_s, FlowySvgs.copy_s,

View File

@ -13,7 +13,7 @@ import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:universal_platform/universal_platform.dart'; import 'package:universal_platform/universal_platform.dart';
import 'ai_message_bubble.dart'; import 'ai_message_bubble.dart';

View File

@ -8,7 +8,7 @@ import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart'; import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:universal_platform/universal_platform.dart'; import 'package:universal_platform/universal_platform.dart';
class ChatUserMessageBubble extends StatelessWidget { class ChatUserMessageBubble extends StatelessWidget {

View File

@ -3,7 +3,7 @@ import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart';
class ChatUserMessageWidget extends StatelessWidget { class ChatUserMessageWidget extends StatelessWidget {
const ChatUserMessageWidget({ const ChatUserMessageWidget({
@ -22,20 +22,11 @@ class ChatUserMessageWidget extends StatelessWidget {
..add(const ChatUserMessageEvent.initial()), ..add(const ChatUserMessageEvent.initial()),
child: BlocBuilder<ChatUserMessageBloc, ChatUserMessageState>( child: BlocBuilder<ChatUserMessageBloc, ChatUserMessageState>(
builder: (context, state) { builder: (context, state) {
return Row( return Opacity(
mainAxisSize: MainAxisSize.min, opacity: state.messageState.isFinish ? 1.0 : 0.8,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Flexible(
child: TextMessageText( child: TextMessageText(
text: state.text, text: state.text,
), ),
),
if (!state.messageState.isFinish) ...[
const HSpace(6),
const CircularProgressIndicator.adaptive(),
],
],
); );
}, },
), ),

View File

@ -0,0 +1,88 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flutter/material.dart';
const BorderRadius _borderRadius = BorderRadius.all(Radius.circular(16));
class CustomScrollToBottom extends StatelessWidget {
const CustomScrollToBottom({
super.key,
required this.animation,
required this.onPressed,
});
final Animation<double> animation;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final isLightMode = Theme.of(context).isLightMode;
return Positioned(
bottom: 24,
left: 0,
right: 0,
child: Center(
child: ScaleTransition(
scale: animation,
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border.all(
color: Theme.of(context).dividerColor,
strokeAlign: BorderSide.strokeAlignOutside,
),
borderRadius: _borderRadius,
boxShadow: [
BoxShadow(
offset: const Offset(0, 8),
blurRadius: 16,
spreadRadius: 8,
color: isLightMode
? const Color(0x0F1F2329)
: Theme.of(context).shadowColor.withOpacity(0.06),
),
BoxShadow(
offset: const Offset(0, 4),
blurRadius: 8,
color: isLightMode
? const Color(0x141F2329)
: Theme.of(context).shadowColor.withOpacity(0.08),
),
BoxShadow(
offset: const Offset(0, 2),
blurRadius: 4,
color: isLightMode
? const Color(0x1F1F2329)
: Theme.of(context).shadowColor.withOpacity(0.12),
),
],
),
child: Material(
borderRadius: _borderRadius,
color: Colors.transparent,
borderOnForeground: false,
child: InkWell(
overlayColor: WidgetStateProperty.all(
AFThemeExtension.of(context).lightGreyHover,
),
borderRadius: _borderRadius,
onTap: onPressed,
child: const SizedBox.square(
dimension: 32,
child: Center(
child: FlowySvg(
FlowySvgs.ai_scroll_to_bottom_s,
size: Size.square(20),
),
),
),
),
),
),
),
),
);
}
}

View File

@ -386,6 +386,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.2" version: "1.9.2"
cross_cache:
dependency: transitive
description:
name: cross_cache
sha256: ed30348320a7fefe4195c26cfcbabc76b7108ce3d364c4dd7c1b1c681a4cfe28
url: "https://pub.dev"
source: hosted
version: "0.0.2"
cross_file: cross_file:
dependency: "direct main" dependency: "direct main"
description: description:
@ -459,13 +467,29 @@ packages:
source: hosted source: hosted
version: "0.4.1" version: "0.4.1"
diffutil_dart: diffutil_dart:
dependency: transitive dependency: "direct main"
description: description:
name: diffutil_dart name: diffutil_dart
sha256: "5e74883aedf87f3b703cb85e815bdc1ed9208b33501556e4a8a5572af9845c81" sha256: "5e74883aedf87f3b703cb85e815bdc1ed9208b33501556e4a8a5572af9845c81"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.1" version: "4.0.1"
dio:
dependency: transitive
description:
name: dio
sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260"
url: "https://pub.dev"
source: hosted
version: "5.7.0"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "33259a9276d6cea88774a0000cfae0d861003497755969c92faa223108620dc8"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
dotted_border: dotted_border:
dependency: "direct main" dependency: "direct main"
description: description:
@ -692,8 +716,16 @@ packages:
url: "https://github.com/LucasXu0/flutter_cache_manager.git" url: "https://github.com/LucasXu0/flutter_cache_manager.git"
source: git source: git
version: "3.3.1" version: "3.3.1"
flutter_chat_types: flutter_chat_core:
dependency: "direct main" dependency: "direct main"
description:
name: flutter_chat_core
sha256: "14557aaac7c71b80c279eca41781d214853940cf01727934c742b5845c42dd1e"
url: "https://pub.dev"
source: hosted
version: "0.0.2"
flutter_chat_types:
dependency: transitive
description: description:
name: flutter_chat_types name: flutter_chat_types
sha256: e285b588f6d19d907feb1f6d912deaf22e223656769c34093b64e1c59b094fb9 sha256: e285b588f6d19d907feb1f6d912deaf22e223656769c34093b64e1c59b094fb9
@ -704,10 +736,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_chat_ui name: flutter_chat_ui
sha256: "168a4231464ad00a17ea5f0813f1b58393bdd4035683ea4dc37bbe26be62891e" sha256: "2afd22eaebaf0f6ec8425048921479c3dd1a229604015dca05b174c6e8e44292"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.6.15" version: "2.0.0-dev.1"
flutter_driver: flutter_driver:
dependency: transitive dependency: transitive
description: flutter description: flutter
@ -767,14 +799,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.2" version: "0.7.2"
flutter_parsed_text:
dependency: transitive
description:
name: flutter_parsed_text
sha256: "529cf5793b7acdf16ee0f97b158d0d4ba0bf06e7121ef180abe1a5b59e32c1e2"
url: "https://pub.dev"
source: hosted
version: "2.2.1"
flutter_plugin_android_lifecycle: flutter_plugin_android_lifecycle:
dependency: transitive dependency: transitive
description: description:
@ -1523,14 +1547,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.2" version: "6.0.2"
photo_view:
dependency: transitive
description:
name: photo_view
sha256: "1fc3d970a91295fbd1364296575f854c9863f225505c28c46e0a03e48960c75e"
url: "https://pub.dev"
source: hosted
version: "0.15.0"
pixel_snap: pixel_snap:
dependency: transitive dependency: transitive
description: description:

View File

@ -45,6 +45,7 @@ dependencies:
# Desktop Drop uses Cross File (XFile) data type # Desktop Drop uses Cross File (XFile) data type
desktop_drop: ^0.4.4 desktop_drop: ^0.4.4
device_info_plus: device_info_plus:
diffutil_dart: ^4.0.1
dotted_border: ^2.0.0+3 dotted_border: ^2.0.0+3
easy_localization: ^3.0.2 easy_localization: ^3.0.2
envied: ^0.5.2 envied: ^0.5.2
@ -66,8 +67,8 @@ dependencies:
flutter_animate: ^4.5.0 flutter_animate: ^4.5.0
flutter_bloc: ^8.1.3 flutter_bloc: ^8.1.3
flutter_cache_manager: ^3.3.1 flutter_cache_manager: ^3.3.1
flutter_chat_types: ^3.6.2 flutter_chat_core: ^0.0.2
flutter_chat_ui: ^1.6.13 flutter_chat_ui: 2.0.0-dev.1
flutter_emoji_mart: flutter_emoji_mart:
git: git:
url: https://github.com/LucasXu0/emoji_mart.git url: https://github.com/LucasXu0/emoji_mart.git

View File

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 3.75V16.25M10 16.25L14.6875 11.5625M10 16.25L5.3125 11.5625" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 261 B