mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-11-23 10:07:28 +03:00
chore: bump flutter chat ui version (#6835)
This commit is contained in:
parent
09717d92c5
commit
f82dabcc75
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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(
|
||||
|
@ -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';
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
@ -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() {
|
||||
|
@ -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() {
|
||||
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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';
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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 |
Loading…
Reference in New Issue
Block a user