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: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/log.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-folder/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:collection/collection.dart';
import 'package:fixnum/fixnum.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:nanoid/nanoid.dart';
import 'chat_entity.dart';
import 'chat_message_listener.dart';
import 'chat_message_service.dart';
import 'chat_message_stream.dart';
part 'chat_bloc.freezed.dart';
class ChatBloc extends Bloc<ChatEvent, ChatState> {
ChatBloc({
required ViewPB view,
required UserProfilePB userProfile,
}) : listener = ChatMessageListener(chatId: view.id),
chatId = view.id,
super(
ChatState.initial(view, userProfile),
) {
required this.chatId,
required this.userId,
}) : chatController = InMemoryChatController(),
listener = ChatMessageListener(chatId: chatId),
super(ChatState.initial()) {
_startListening();
_dispatch();
_init();
}
final ChatMessageListener listener;
final String chatId;
final String userId;
final ChatMessageListener listener;
final ChatController chatController;
/// The last streaming message id
String answerStreamMessageId = '';
String questionStreamMessageId = '';
ChatMessagePB? lastSentMessage;
/// 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
@ -51,11 +54,13 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
/// is 3 (AI response).
final HashMap<String, String> temporaryMessageIDMap = HashMap();
bool isLoadingPreviousMessages = false;
bool hasMorePreviousMessages = true;
AnswerStream? answerStream;
@override
Future<void> close() async {
if (state.answerStream != null) {
await state.answerStream?.dispose();
}
await answerStream?.dispose();
await listener.stop();
return super.close();
}
@ -64,195 +69,159 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
on<ChatEvent>(
(event, emit) async {
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
startLoadingPrevMessage: () async {
Int64? beforeMessageId;
final oldestMessage = _getOldestMessage();
if (oldestMessage != null) {
try {
beforeMessageId = Int64.parseInt(oldestMessage.id);
} catch (e) {
Log.error(
"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) {
Log.debug("did load previous messages: ${messages.length}");
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;
didLoadLatestMessages: (List<Message> messages) async {
for (final message in messages) {
await chatController.insert(message, index: 0);
}
final payload = StopStreamPB(chatId: chatId);
await AIEventStopStream(payload).send();
final allMessages = _permanentMessages();
if (state.streamingState != const StreamingState.done()) {
// If the streaming is not started, remove the message from the list
if (!state.answerStream!.hasStarted) {
allMessages.removeWhere(
(element) => element.id == answerStreamMessageId,
);
answerStreamMessageId = "";
}
// when stop stream, we will set the answer stream to null. Which means the streaming
// is finished or canceled.
if (state.loadingState.isLoading) {
emit(
state.copyWith(
messages: allMessages,
answerStream: null,
streamingState: const StreamingState.done(),
),
state.copyWith(loadingState: const ChatLoadingState.finish()),
);
}
},
receiveMessage: (Message message) {
final allMessages = _permanentMessages();
// remove message with the same id
allMessages.removeWhere((element) => element.id == message.id);
allMessages.insert(0, message);
loadPreviousMessages: () {
if (isLoadingPreviousMessages) {
return;
}
final oldestMessage = _getOldestMessage();
if (oldestMessage != null) {
final oldestMessageId = Int64.tryParseInt(oldestMessage.id);
if (oldestMessageId == null) {
Log.error("Failed to parse message_id: ${oldestMessage.id}");
return;
}
isLoadingPreviousMessages = true;
_loadPreviousMessages(oldestMessageId);
}
},
didLoadPreviousMessages: (messages, hasMore) {
Log.debug("did load previous messages: ${messages.length}");
for (final message in messages) {
chatController.insert(message, index: 0);
}
isLoadingPreviousMessages = false;
hasMorePreviousMessages = hasMore;
},
didFinishAnswerStream: () {
emit(
state.copyWith(
messages: allMessages,
),
state.copyWith(promptResponseState: PromptResponseState.ready),
);
},
startAnswerStreaming: (Message message) {
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) {
didReceiveRelatedQuestions: (List<String> questions) {
if (questions.isEmpty) {
return;
}
final allMessages = _permanentMessages();
final message = CustomMessage(
metadata: OnetimeShotType.relatedQuestion.toMap(),
final metatdata = OnetimeShotType.relatedQuestion.toMap();
metatdata['questions'] = questions;
final message = TextMessage(
text: '',
metadata: metatdata,
author: const User(id: systemUserId),
showStatus: false,
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(
state.copyWith(
messages: allMessages,
relatedQuestions: questions,
promptResponseState: PromptResponseState.sendingQuestion,
),
);
},
clearRelatedQuestions: () {
finishSending: (ChatMessagePB message) {
lastSentMessage = message;
emit(
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() {
listener.start(
chatMessageCallback: (pb) {
if (!isClosed) {
// 3 mean message response from AI
if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) {
temporaryMessageIDMap[pb.messageId.toString()] =
answerStreamMessageId;
answerStreamMessageId = "";
}
// 1 mean message response from User
if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) {
temporaryMessageIDMap[pb.messageId.toString()] =
questionStreamMessageId;
questionStreamMessageId = "";
}
final message = _createTextMessage(pb);
add(ChatEvent.receiveMessage(message));
if (isClosed) {
return;
}
// 3 mean message response from AI
if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) {
temporaryMessageIDMap[pb.messageId.toString()] =
answerStreamMessageId;
answerStreamMessageId = '';
}
// 1 mean message response from User
if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) {
temporaryMessageIDMap[pb.messageId.toString()] =
questionStreamMessageId;
questionStreamMessageId = '';
}
final message = _createTextMessage(pb);
add(ChatEvent.receiveMessage(message));
},
chatErrorMessageCallback: (err) {
if (!isClosed) {
Log.error("chat error: ${err.errorMessage}");
add(const ChatEvent.finishAnswerStreaming());
add(const ChatEvent.didFinishAnswerStream());
}
},
latestMessageCallback: (list) {
@ -301,65 +272,70 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
add(ChatEvent.didLoadPreviousMessages(messages, list.hasMore));
}
},
finishStreamingCallback: () {
if (!isClosed) {
add(const ChatEvent.finishAnswerStreaming());
// 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) {
final payload = ChatMessageIdPB(
chatId: chatId,
messageId: state.lastSentMessage!.messageId,
);
// When user message was sent to the server, we start gettting related question
AIEventGetRelatedQuestion(payload).send().then((result) {
if (!isClosed) {
result.fold(
(list) {
if (state.acceptRelatedQuestion) {
add(ChatEvent.didReceiveRelatedQuestion(list.items));
}
},
(err) {
Log.error("Failed to get related question: $err");
},
);
}
});
}
finishStreamingCallback: () async {
if (isClosed) {
return;
}
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(
chatId: chatId,
messageId: lastSentMessage!.messageId,
);
await AIEventGetRelatedQuestion(payload).send().fold(
(list) {
if (!isClosed) {
add(
ChatEvent.didReceiveRelatedQuestions(
list.items.map((e) => e.content).toList(),
),
);
}
},
(err) => Log.error("Failed to get related questions: $err"),
);
},
);
}
// Returns the list of messages that are not include one-time messages.
List<Message> _permanentMessages() {
final allMessages = state.messages.where((element) {
return !(element.metadata?.containsKey(onetimeShotType) == true);
}).toList();
return allMessages;
void _init() async {
final payload = LoadNextChatMessagePB(
chatId: chatId,
limit: Int64(10),
);
await AIEventLoadNextMessage(payload).send().fold(
(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() {
final messages = state.messages.where((element) {
return (element.metadata?.containsKey(onetimeShotType) == true);
}).toList();
return messages;
bool _isOneTimeMessage(Message message) {
return message.metadata != null &&
message.metadata!.containsKey(onetimeShotType);
}
/// get the last message that is not a one-time message
Message? _getOldestMessage() {
// get the last message that is not a one-time message
final message = state.messages.lastWhereOrNull((element) {
return !(element.metadata?.containsKey(onetimeShotType) == true);
});
return message;
return chatController.messages
.firstWhereOrNull((message) => !_isOneTimeMessage(message));
}
void _loadPrevMessage(Int64? beforeMessageId) {
void _loadPreviousMessages(Int64? beforeMessageId) {
final payload = LoadPrevChatMessagePB(
chatId: state.view.id,
chatId: chatId,
limit: Int64(10),
beforeMessageId: beforeMessageId,
);
@ -369,43 +345,36 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
Future<void> _startStreamingMessage(
String message,
Map<String, dynamic>? metadata,
Emitter<ChatState> emit,
) async {
if (state.answerStream != null) {
await state.answerStream?.dispose();
}
await answerStream?.dispose();
final answerStream = AnswerStream();
answerStream = AnswerStream();
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(
questionStream,
metadata,
);
add(ChatEvent.receiveMessage(questionStreamMessage));
// Stream message to the server
final result = await AIEventStreamMessage(payload).send();
result.fold(
(ChatMessagePB question) {
final payload = StreamChatPayloadPB(
chatId: chatId,
message: message,
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) {
add(ChatEvent.finishSending(question));
// final message = _createTextMessage(question);
// add(ChatEvent.receiveMessage(message));
final streamAnswer =
_createAnswerStreamMessage(answerStream, question.messageId);
_createAnswerStreamMessage(answerStream!, question.messageId);
add(ChatEvent.startAnswerStreaming(streamAnswer));
}
},
@ -417,11 +386,12 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
metadata[sendMessageErrorKey] = err.msg;
}
final error = CustomMessage(
final error = TextMessage(
text: '',
metadata: metadata,
author: const User(id: systemUserId),
showStatus: false,
id: systemUserId,
createdAt: DateTime.now(),
);
add(const ChatEvent.failedSending());
@ -446,8 +416,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
"chatId": chatId,
},
id: streamMessageId,
showStatus: false,
createdAt: DateTime.now().millisecondsSinceEpoch,
createdAt: DateTime.now(),
text: '',
);
}
@ -457,24 +426,19 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
Map<String, dynamic>? sentMetadata,
) {
final now = DateTime.now();
final timestamp = now.millisecondsSinceEpoch;
questionStreamMessageId = timestamp.toString();
final Map<String, dynamic> metadata = {};
questionStreamMessageId = (now.millisecondsSinceEpoch ~/ 1000).toString();
// if (sentMetadata != null) {
// metadata[messageMetadataJsonStringKey] = sentMetadata;
// }
final Map<String, dynamic> metadata = {
"$QuestionStream": stream,
"chatId": chatId,
messageChatFileListKey: chatFilesFromMessageMetadata(sentMetadata),
};
metadata["$QuestionStream"] = stream;
metadata["chatId"] = chatId;
metadata[messageChatFileListKey] =
chatFilesFromMessageMetadata(sentMetadata);
return TextMessage(
author: User(id: state.userProfile.id.toString()),
author: User(id: userId),
metadata: metadata,
id: questionStreamMessageId,
showStatus: false,
createdAt: DateTime.now().millisecondsSinceEpoch,
createdAt: now,
text: '',
);
}
@ -491,8 +455,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
author: User(id: message.authorId),
id: messageId,
text: message.content,
createdAt: message.createdAt.toInt() * 1000,
showStatus: false,
createdAt: message.createdAt.toDateTime(),
metadata: {
messageRefSourceJsonStringKey: message.metadata,
},
@ -502,8 +465,6 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
@freezed
class ChatEvent with _$ChatEvent {
const factory ChatEvent.initialLoad() = _InitialLoadMessage;
// send message
const factory ChatEvent.sendMessage({
required String message,
@ -513,14 +474,14 @@ class ChatEvent with _$ChatEvent {
_FinishSendMessage;
const factory ChatEvent.failedSending() = _FailSendMessage;
// receive message
// receive message
const factory ChatEvent.startAnswerStreaming(Message message) =
_StartAnswerStreaming;
const factory ChatEvent.receiveMessage(Message message) = _ReceiveMessage;
const factory ChatEvent.finishAnswerStreaming() = _FinishAnswerStreaming;
const factory ChatEvent.didFinishAnswerStream() = _DidFinishAnswerStream;
// loading messages
const factory ChatEvent.startLoadingPrevMessage() = _StartLoadPrevMessage;
// loading messages
const factory ChatEvent.loadPreviousMessages() = _LoadPreviousMessages;
const factory ChatEvent.didLoadPreviousMessages(
List<Message> messages,
bool hasMore,
@ -528,56 +489,24 @@ class ChatEvent with _$ChatEvent {
const factory ChatEvent.didLoadLatestMessages(List<Message> messages) =
_DidLoadMessages;
// related questions
const factory ChatEvent.didReceiveRelatedQuestion(
List<RelatedQuestionPB> questions,
// related questions
const factory ChatEvent.didReceiveRelatedQuestions(
List<String> questions,
) = _DidReceiveRelatedQueston;
const factory ChatEvent.clearRelatedQuestions() = _ClearRelatedQuestions;
const factory ChatEvent.didUpdateAnswerStream(
AnswerStream stream,
) = _DidUpdateAnswerStream;
const factory ChatEvent.stopStream() = _StopStream;
}
@freezed
class ChatState with _$ChatState {
const factory ChatState({
required ViewPB view,
required List<Message> messages,
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,
required ChatLoadingState loadingState,
required PromptResponseState promptResponseState,
}) = _ChatState;
factory ChatState.initial(ViewPB view, UserProfilePB userProfile) =>
ChatState(
view: view,
messages: [],
userProfile: userProfile,
initialLoadingStatus: const ChatLoadingState.finish(),
loadingPreviousStatus: const ChatLoadingState.finish(),
streamingState: const StreamingState.done(),
sendingState: const SendMessageState.done(),
hasMorePrevMessage: true,
relatedQuestions: [],
factory ChatState.initial() => const ChatState(
loadingState: ChatLoadingState.loading(),
promptResponseState: PromptResponseState.ready,
);
}

View File

@ -40,16 +40,11 @@ class ChatMessageRefSource {
Map<String, dynamic> toJson() => _$ChatMessageRefSourceToJson(this);
}
@freezed
class StreamingState with _$StreamingState {
const factory StreamingState.streaming() = _Streaming;
const factory StreamingState.done({FlowyError? error}) = _StreamDone;
}
@freezed
class SendMessageState with _$SendMessageState {
const factory SendMessageState.sending() = _Sending;
const factory SendMessageState.done({FlowyError? error}) = _SendDone;
enum PromptResponseState {
ready,
sendingQuestion,
awaitingAnswer,
streamingAnswer,
}
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/protobuf/flowy-ai/protobuf.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';
/// Indicate file source from appflowy document
@ -103,41 +103,44 @@ List<ChatMessageRefSource> messageReferenceSource(String? s) {
Future<List<ChatMessageMetaPB>> metadataPBFromMetadata(
Map<String, dynamic>? map,
) async {
if (map == null) return [];
final List<ChatMessageMetaPB> metadata = [];
if (map != null) {
for (final entry in map.entries) {
if (entry.value is ViewActionPage) {
if (entry.value.page is ViewPB) {
final view = entry.value.page as ViewPB;
if (view.layout.isDocumentView) {
final payload = OpenDocumentPayloadPB(documentId: view.id);
final result = await DocumentEventGetDocumentText(payload).send();
result.fold((pb) {
metadata.add(
ChatMessageMetaPB(
id: view.id,
name: view.name,
data: pb.text,
dataType: ChatMessageMetaTypePB.Txt,
source: appflowySource,
),
);
}, (err) {
Log.error('Failed to get document text: $err');
});
}
}
} else if (entry.value is ChatFile) {
for (final value in map.values) {
switch (value) {
case ViewActionPage(view: final view) when view.layout.isDocumentView:
final payload = OpenDocumentPayloadPB(documentId: view.id);
await DocumentEventGetDocumentText(payload).send().fold(
(pb) {
metadata.add(
ChatMessageMetaPB(
id: view.id,
name: view.name,
data: pb.text,
dataType: ChatMessageMetaTypePB.Txt,
source: appflowySource,
),
);
},
(err) => Log.error('Failed to get document text: $err'),
);
break;
case ChatFile(
filePath: final filePath,
fileName: final fileName,
fileType: final fileType,
):
metadata.add(
ChatMessageMetaPB(
id: nanoid(8),
name: entry.value.fileName,
data: entry.value.filePath,
dataType: entry.value.fileType,
source: entry.value.filePath,
name: fileName,
data: filePath,
dataType: fileType,
source: filePath,
),
);
}
break;
}
}

View File

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

View File

@ -1,6 +1,6 @@
import 'package:appflowy/plugins/ai_chat/application/chat_entity.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 '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:desktop_drop/desktop_drop.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:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
import 'package:flutter_chat_types/flutter_chat_types.dart';
import 'package:flutter_chat_ui/flutter_chat_ui.dart' show Chat;
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:flutter_chat_ui/flutter_chat_ui.dart'
hide ChatAnimatedListReversed;
import 'package:styled_widget/styled_widget.dart';
import 'package:universal_platform/universal_platform.dart';
import 'application/chat_member_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/mobile_ai_prompt_input.dart';
import 'presentation/chat_side_panel.dart';
import 'presentation/chat_theme.dart';
import 'presentation/chat_user_invalid_message.dart';
import 'presentation/chat_welcome_page.dart';
import 'presentation/layout_define.dart';
import 'presentation/message/ai_text_message.dart';
import 'presentation/message/user_text_message.dart';
import 'presentation/scroll_to_bottom.dart';
class AIChatPage extends StatelessWidget {
const AIChatPage({
@ -59,9 +59,9 @@ class AIChatPage extends StatelessWidget {
/// [ChatBloc] is used to handle chat messages including send/receive message
BlocProvider(
create: (_) => ChatBloc(
view: view,
userProfile: userProfile,
)..add(const ChatEvent.initialLoad()),
chatId: view.id,
userId: userProfile.id.toString(),
),
),
/// [AIPromptInputBloc] is used to handle the user prompt
@ -113,7 +113,7 @@ class _ChatContentPage extends StatelessWidget {
return Row(
children: [
Center(
child: buildChatWidget()
child: buildChatWidget(context)
.constrained(
maxWidth: 784,
)
@ -148,7 +148,7 @@ class _ChatContentPage extends StatelessWidget {
Flexible(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 784),
child: buildChatWidget(),
child: buildChatWidget(context),
),
),
],
@ -167,187 +167,207 @@ class _ChatContentPage extends StatelessWidget {
);
}
Widget buildChatWidget() {
return BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
return ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: BlocBuilder<ChatBloc, ChatState>(
builder: (_, state) => state.initialLoadingStatus.isFinish
? Chat(
messages: state.messages,
dateHeaderBuilder: (_) => const SizedBox.shrink(),
onSendPressed: (_) {
// We use custom bottom widget for chat input, so
// do not need to handle this event.
},
customBottomWidget: _buildBottom(context),
user: types.User(id: userProfile.id.toString()),
theme: _buildTheme(context),
onEndReached: () async {
if (state.hasMorePrevMessage &&
state.loadingPreviousStatus.isFinish) {
context
.read<ChatBloc>()
.add(const ChatEvent.startLoadingPrevMessage());
}
},
emptyState: TextFieldTapRegion(
child: ChatWelcomePage(
userProfile: userProfile,
onSelectedQuestion: (question) => context
.read<ChatBloc>()
.add(ChatEvent.sendMessage(message: question)),
Widget buildChatWidget(BuildContext context) {
return ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
return state.loadingState.when(
loading: () {
return const Center(
child: CircularProgressIndicator.adaptive(),
);
},
finish: (_) {
final chatController = context.read<ChatBloc>().chatController;
return Column(
children: [
Expanded(
child: Chat(
chatController: chatController,
user: User(id: userProfile.id.toString()),
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,
),
),
messageWidthRatio: AIChatUILayout.messageWidthRatio,
textMessageBuilder: (
textMessage, {
required messageWidth,
required showName,
}) =>
_buildTextMessage(context, textMessage, state),
customMessageBuilder: (message, {required messageWidth}) {
final messageType = onetimeMessageTypeFromMeta(
message.metadata,
);
if (messageType == OnetimeShotType.invalidSendMesssage) {
return ChatInvalidUserMessage(
message: message,
);
}
if (messageType == OnetimeShotType.relatedQuestion) {
return RelatedQuestionList(
relatedQuestions: state.relatedQuestions,
onQuestionSelected: (question) {
final bloc = context.read<ChatBloc>();
bloc
..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(),
),
),
);
},
_buildInput(context),
],
);
},
);
},
),
);
}
Widget _buildTextMessage(
BuildContext context,
TextMessage message,
ChatState state,
) {
if (message.author.id == userProfile.id.toString()) {
final stream = message.metadata?["$QuestionStream"];
return ChatUserMessageWidget(
key: ValueKey(message.id),
user: message.author,
message: stream is QuestionStream ? stream : message.text,
);
} else if (isOtherUserMessage(message)) {
final stream = message.metadata?["$QuestionStream"];
return ChatUserMessageWidget(
key: ValueKey(message.id),
user: message.author,
message: stream is QuestionStream ? stream : message.text,
);
} else {
final stream = message.metadata?["$AnswerStream"];
final questionId = message.metadata?[messageQuestionIdKey];
final refSourceJsonString =
message.metadata?[messageRefSourceJsonStringKey] as String?;
final messageType = onetimeMessageTypeFromMeta(
message.metadata,
);
return BlocSelector<ChatBloc, ChatState, bool>(
key: ValueKey(message.id),
selector: (state) {
final messages = state.messages.where((e) {
final oneTimeMessageType = onetimeMessageTypeFromMeta(e.metadata);
if (oneTimeMessageType == null) {
return true;
}
if (oneTimeMessageType
case OnetimeShotType.relatedQuestion ||
OnetimeShotType.sendingMessage ||
OnetimeShotType.invalidSendMesssage) {
return false;
}
return true;
});
return messages.isEmpty ? false : messages.first.id == message.id;
},
builder: (context, isLastMessage) {
return ChatAIMessageWidget(
user: message.author,
messageUserId: message.id,
message: message,
stream: stream is AnswerStream ? stream : null,
questionId: questionId,
chatId: view.id,
refSourceJsonString: refSourceJsonString,
isLastMessage: isLastMessage,
onSelectedMetadata: (metadata) {
context
.read<ChatSidePanelBloc>()
.add(ChatSidePanelEvent.selectedMetadata(metadata));
},
);
if (messageType == OnetimeShotType.invalidSendMesssage) {
return ChatInvalidUserMessage(
message: message,
);
}
if (messageType == OnetimeShotType.relatedQuestion) {
return RelatedQuestionList(
relatedQuestions: message.metadata!['questions'],
onQuestionSelected: (question) {
context
.read<ChatBloc>()
.add(ChatEvent.sendMessage(message: question));
},
);
}
}
Widget _buildBubble(
BuildContext context,
Message message,
Widget child,
) {
if (message.author.id == userProfile.id.toString()) {
final stream = message.metadata?["$QuestionStream"];
return ChatUserMessageBubble(
key: ValueKey(message.id),
message: message,
child: child,
child: ChatUserMessageWidget(
user: message.author,
message: stream is QuestionStream ? stream : message.text,
),
);
} else if (isOtherUserMessage(message)) {
}
if (isOtherUserMessage(message)) {
final stream = message.metadata?["$QuestionStream"];
return ChatUserMessageBubble(
key: ValueKey(message.id),
message: message,
isCurrentUser: false,
child: child,
child: ChatUserMessageWidget(
user: message.author,
message: stream is QuestionStream ? stream : message.text,
),
);
} else {
// The bubble is rendered in the child already
return child;
}
final stream = message.metadata?["$AnswerStream"];
final questionId = message.metadata?[messageQuestionIdKey];
final refSourceJsonString =
message.metadata?[messageRefSourceJsonStringKey] as String?;
return BlocSelector<ChatBloc, ChatState, bool>(
key: ValueKey(message.id),
selector: (state) {
final chatController = context.read<ChatBloc>().chatController;
final messages = chatController.messages.where((e) {
final oneTimeMessageType = onetimeMessageTypeFromMeta(e.metadata);
if (oneTimeMessageType == null) {
return true;
}
if (oneTimeMessageType
case OnetimeShotType.relatedQuestion ||
OnetimeShotType.sendingMessage ||
OnetimeShotType.invalidSendMesssage) {
return false;
}
return true;
});
return messages.isEmpty ? false : messages.last.id == message.id;
},
builder: (context, isLastMessage) {
return ChatAIMessageWidget(
user: message.author,
messageUserId: message.id,
message: message,
stream: stream is AnswerStream ? stream : null,
questionId: questionId,
chatId: view.id,
refSourceJsonString: refSourceJsonString,
isLastMessage: isLastMessage,
onSelectedMetadata: (metadata) {
context
.read<ChatSidePanelBloc>()
.add(ChatSidePanelEvent.selectedMetadata(metadata));
},
);
},
);
}
Widget _buildBottom(BuildContext context) {
Widget _buildChatMessage(
BuildContext context,
Message message,
Animation<double> animation,
Widget child,
) {
return ChatMessage(
message: message,
animation: animation,
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: child,
);
}
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(
padding: AIChatUILayout.safeAreaInsets(context),
child: BlocSelector<ChatBloc, ChatState, bool>(
selector: (state) => state.canSendMessage,
selector: (state) {
return state.promptResponseState == PromptResponseState.ready;
},
builder: (context, canSendMessage) {
return UniversalPlatform.isDesktop
? DesktopAIPromptInput(
chatId: view.id,
indicateFocus: true,
onSubmitted: (message) {
onSubmitted: (text, metadata) {
context.read<ChatBloc>().add(
ChatEvent.sendMessage(
message: message.text,
metadata: message.metadata,
message: text,
metadata: metadata,
),
);
},
@ -358,11 +378,11 @@ class _ChatContentPage extends StatelessWidget {
)
: MobileAIPromptInput(
chatId: view.id,
onSubmitted: (message) {
onSubmitted: (text, metadata) {
context.read<ChatBloc>().add(
ChatEvent.sendMessage(
message: message.text,
metadata: message.metadata,
message: text,
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/services.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 'ai_prompt_buttons.dart';
@ -25,7 +23,6 @@ class DesktopAIPromptInput extends StatefulWidget {
super.key,
required this.chatId,
required this.indicateFocus,
this.options = const InputOptions(),
required this.isStreaming,
required this.onStopStreaming,
required this.onSubmitted,
@ -33,10 +30,9 @@ class DesktopAIPromptInput extends StatefulWidget {
final String chatId;
final bool indicateFocus;
final InputOptions options;
final bool isStreaming;
final void Function() onStopStreaming;
final void Function(types.PartialText) onSubmitted;
final void Function(String, Map<String, dynamic>) onSubmitted;
@override
State<DesktopAIPromptInput> createState() => _DesktopAIPromptInputState();
@ -56,7 +52,7 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
void initState() {
super.initState();
_textController = InputTextFieldController()
_textController = TextEditingController()
..addListener(_handleTextControllerChange);
_inputFocusNode = FocusNode(
@ -118,6 +114,7 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
borderRadius: DesktopAIPromptSizes.promptFrameRadius,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ConstrainedBox(
constraints: BoxConstraints(
@ -209,11 +206,7 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
..addAll(mentionPageMetadata)
..addAll(fileMetadata);
final partialText = types.PartialText(
text: trimmedText,
metadata: metadata,
);
widget.onSubmitted(partialText);
widget.onSubmitted(trimmedText, metadata);
}
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:flutter/material.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 'chat_input_span.dart';
@ -23,17 +21,15 @@ class MobileAIPromptInput extends StatefulWidget {
const MobileAIPromptInput({
super.key,
required this.chatId,
this.options = const InputOptions(),
required this.isStreaming,
required this.onStopStreaming,
required this.onSubmitted,
});
final String chatId;
final InputOptions options;
final bool isStreaming;
final void Function() onStopStreaming;
final void Function(types.PartialText) onSubmitted;
final void Function(String, Map<String, dynamic>) onSubmitted;
@override
State<MobileAIPromptInput> createState() => _MobileAIPromptInputState();
@ -50,7 +46,7 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
void initState() {
super.initState();
_textController = InputTextFieldController()
_textController = TextEditingController()
..addListener(_handleTextControllerChange);
_inputFocusNode = FocusNode();
@ -166,11 +162,7 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
..addAll(mentionPageMetadata)
..addAll(fileMetadata);
final partialText = types.PartialText(
text: trimmedText,
metadata: metadata,
);
widget.onSubmitted(partialText);
widget.onSubmitted(trimmedText, metadata);
}
void _handleTextControllerChange() {

View File

@ -1,6 +1,5 @@
import 'package:appflowy/generated/flowy_svgs.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:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
@ -16,7 +15,7 @@ class RelatedQuestionList extends StatelessWidget {
});
final Function(String) onQuestionSelected;
final List<RelatedQuestionPB> relatedQuestions;
final List<String> relatedQuestions;
@override
Widget build(BuildContext context) {
@ -57,14 +56,14 @@ class RelatedQuestionItem extends StatelessWidget {
super.key,
});
final RelatedQuestionPB question;
final String question;
final Function(String) onQuestionSelected;
@override
Widget build(BuildContext context) {
return FlowyButton(
text: FlowyText(
question.content,
question,
lineHeight: 1.4,
overflow: TextOverflow.ellipsis,
),
@ -76,7 +75,7 @@ class RelatedQuestionItem extends StatelessWidget {
color: Theme.of(context).colorScheme.primary,
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:flowy_infra_ui/flowy_infra_ui.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';
class ChatInvalidUserMessage extends StatelessWidget {

View File

@ -2,8 +2,6 @@ import 'package:flutter/material.dart';
import 'package:universal_platform/universal_platform.dart';
class AIChatUILayout {
static double get messageWidthRatio => 0.94; // Chat adds extra 0.06
static EdgeInsets safeAreaInsets(BuildContext context) {
final query = MediaQuery.of(context);
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_ui/flowy_infra_ui.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 '../layout_define.dart';
@ -341,7 +341,6 @@ class CopyButton extends StatelessWidget {
child: FlowyIconButton(
width: DesktopAIConvoSizes.actionBarIconSize,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
fillColor: Theme.of(context).cardColor,
radius: DesktopAIConvoSizes.actionBarIconRadius,
icon: FlowySvg(
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/material.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 '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:flutter/material.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';
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:flutter/material.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 {
const ChatUserMessageWidget({
@ -22,20 +22,11 @@ class ChatUserMessageWidget extends StatelessWidget {
..add(const ChatUserMessageEvent.initial()),
child: BlocBuilder<ChatUserMessageBloc, ChatUserMessageState>(
builder: (context, state) {
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Flexible(
child: TextMessageText(
text: state.text,
),
),
if (!state.messageState.isFinish) ...[
const HSpace(6),
const CircularProgressIndicator.adaptive(),
],
],
return Opacity(
opacity: state.messageState.isFinish ? 1.0 : 0.8,
child: TextMessageText(
text: state.text,
),
);
},
),

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"
source: hosted
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:
dependency: "direct main"
description:
@ -459,13 +467,29 @@ packages:
source: hosted
version: "0.4.1"
diffutil_dart:
dependency: transitive
dependency: "direct main"
description:
name: diffutil_dart
sha256: "5e74883aedf87f3b703cb85e815bdc1ed9208b33501556e4a8a5572af9845c81"
url: "https://pub.dev"
source: hosted
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:
dependency: "direct main"
description:
@ -692,8 +716,16 @@ packages:
url: "https://github.com/LucasXu0/flutter_cache_manager.git"
source: git
version: "3.3.1"
flutter_chat_types:
flutter_chat_core:
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:
name: flutter_chat_types
sha256: e285b588f6d19d907feb1f6d912deaf22e223656769c34093b64e1c59b094fb9
@ -704,10 +736,10 @@ packages:
dependency: "direct main"
description:
name: flutter_chat_ui
sha256: "168a4231464ad00a17ea5f0813f1b58393bdd4035683ea4dc37bbe26be62891e"
sha256: "2afd22eaebaf0f6ec8425048921479c3dd1a229604015dca05b174c6e8e44292"
url: "https://pub.dev"
source: hosted
version: "1.6.15"
version: "2.0.0-dev.1"
flutter_driver:
dependency: transitive
description: flutter
@ -767,14 +799,6 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@ -1523,14 +1547,6 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:

View File

@ -45,6 +45,7 @@ dependencies:
# Desktop Drop uses Cross File (XFile) data type
desktop_drop: ^0.4.4
device_info_plus:
diffutil_dart: ^4.0.1
dotted_border: ^2.0.0+3
easy_localization: ^3.0.2
envied: ^0.5.2
@ -66,8 +67,8 @@ dependencies:
flutter_animate: ^4.5.0
flutter_bloc: ^8.1.3
flutter_cache_manager: ^3.3.1
flutter_chat_types: ^3.6.2
flutter_chat_ui: ^1.6.13
flutter_chat_core: ^0.0.2
flutter_chat_ui: 2.0.0-dev.1
flutter_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