mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-11-23 19:25:10 +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:async';
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
|
||||||
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
|
import 'package:appflowy/util/int64_extension.dart';
|
||||||
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
import 'package:appflowy_backend/dispatch/dispatch.dart';
|
||||||
import 'package:appflowy_backend/log.dart';
|
import 'package:appflowy_backend/log.dart';
|
||||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
|
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
|
||||||
import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart';
|
import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart';
|
||||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
import 'package:appflowy_result/appflowy_result.dart';
|
||||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
|
||||||
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:fixnum/fixnum.dart';
|
import 'package:fixnum/fixnum.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
import 'package:nanoid/nanoid.dart';
|
import 'package:nanoid/nanoid.dart';
|
||||||
|
|
||||||
import 'chat_entity.dart';
|
import 'chat_entity.dart';
|
||||||
import 'chat_message_listener.dart';
|
import 'chat_message_listener.dart';
|
||||||
import 'chat_message_service.dart';
|
import 'chat_message_service.dart';
|
||||||
|
import 'chat_message_stream.dart';
|
||||||
|
|
||||||
part 'chat_bloc.freezed.dart';
|
part 'chat_bloc.freezed.dart';
|
||||||
|
|
||||||
class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
||||||
ChatBloc({
|
ChatBloc({
|
||||||
required ViewPB view,
|
required this.chatId,
|
||||||
required UserProfilePB userProfile,
|
required this.userId,
|
||||||
}) : listener = ChatMessageListener(chatId: view.id),
|
}) : chatController = InMemoryChatController(),
|
||||||
chatId = view.id,
|
listener = ChatMessageListener(chatId: chatId),
|
||||||
super(
|
super(ChatState.initial()) {
|
||||||
ChatState.initial(view, userProfile),
|
|
||||||
) {
|
|
||||||
_startListening();
|
_startListening();
|
||||||
_dispatch();
|
_dispatch();
|
||||||
|
_init();
|
||||||
}
|
}
|
||||||
|
|
||||||
final ChatMessageListener listener;
|
|
||||||
final String chatId;
|
final String chatId;
|
||||||
|
final String userId;
|
||||||
|
final ChatMessageListener listener;
|
||||||
|
|
||||||
|
final ChatController chatController;
|
||||||
|
|
||||||
/// The last streaming message id
|
/// The last streaming message id
|
||||||
String answerStreamMessageId = '';
|
String answerStreamMessageId = '';
|
||||||
String questionStreamMessageId = '';
|
String questionStreamMessageId = '';
|
||||||
|
|
||||||
|
ChatMessagePB? lastSentMessage;
|
||||||
|
|
||||||
/// Using a temporary map to associate the real message ID with the last streaming message ID.
|
/// Using a temporary map to associate the real message ID with the last streaming message ID.
|
||||||
///
|
///
|
||||||
/// When a message is streaming, it does not have a real message ID. To maintain the relationship
|
/// When a message is streaming, it does not have a real message ID. To maintain the relationship
|
||||||
@ -51,11 +54,13 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
|||||||
/// is 3 (AI response).
|
/// is 3 (AI response).
|
||||||
final HashMap<String, String> temporaryMessageIDMap = HashMap();
|
final HashMap<String, String> temporaryMessageIDMap = HashMap();
|
||||||
|
|
||||||
|
bool isLoadingPreviousMessages = false;
|
||||||
|
bool hasMorePreviousMessages = true;
|
||||||
|
AnswerStream? answerStream;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> close() async {
|
Future<void> close() async {
|
||||||
if (state.answerStream != null) {
|
await answerStream?.dispose();
|
||||||
await state.answerStream?.dispose();
|
|
||||||
}
|
|
||||||
await listener.stop();
|
await listener.stop();
|
||||||
return super.close();
|
return super.close();
|
||||||
}
|
}
|
||||||
@ -64,195 +69,159 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
|||||||
on<ChatEvent>(
|
on<ChatEvent>(
|
||||||
(event, emit) async {
|
(event, emit) async {
|
||||||
await event.when(
|
await event.when(
|
||||||
initialLoad: () {
|
|
||||||
final payload = LoadNextChatMessagePB(
|
|
||||||
chatId: state.view.id,
|
|
||||||
limit: Int64(10),
|
|
||||||
);
|
|
||||||
AIEventLoadNextMessage(payload).send().then(
|
|
||||||
(result) {
|
|
||||||
result.fold((list) {
|
|
||||||
if (!isClosed) {
|
|
||||||
final messages =
|
|
||||||
list.messages.map(_createTextMessage).toList();
|
|
||||||
add(ChatEvent.didLoadLatestMessages(messages));
|
|
||||||
}
|
|
||||||
}, (err) {
|
|
||||||
Log.error("Failed to load messages: $err");
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
// Loading messages
|
// Loading messages
|
||||||
startLoadingPrevMessage: () async {
|
didLoadLatestMessages: (List<Message> messages) async {
|
||||||
Int64? beforeMessageId;
|
for (final message in messages) {
|
||||||
final oldestMessage = _getOldestMessage();
|
await chatController.insert(message, index: 0);
|
||||||
if (oldestMessage != null) {
|
|
||||||
try {
|
|
||||||
beforeMessageId = Int64.parseInt(oldestMessage.id);
|
|
||||||
} 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final payload = StopStreamPB(chatId: chatId);
|
if (state.loadingState.isLoading) {
|
||||||
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.
|
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(loadingState: const ChatLoadingState.finish()),
|
||||||
messages: allMessages,
|
|
||||||
answerStream: null,
|
|
||||||
streamingState: const StreamingState.done(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
receiveMessage: (Message message) {
|
loadPreviousMessages: () {
|
||||||
final allMessages = _permanentMessages();
|
if (isLoadingPreviousMessages) {
|
||||||
// remove message with the same id
|
return;
|
||||||
allMessages.removeWhere((element) => element.id == message.id);
|
}
|
||||||
allMessages.insert(0, message);
|
|
||||||
|
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(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(promptResponseState: PromptResponseState.ready),
|
||||||
messages: allMessages,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
startAnswerStreaming: (Message message) {
|
didReceiveRelatedQuestions: (List<String> questions) {
|
||||||
final allMessages = _permanentMessages();
|
|
||||||
allMessages.insert(0, message);
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
messages: allMessages,
|
|
||||||
streamingState: const StreamingState.streaming(),
|
|
||||||
canSendMessage: false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
sendMessage: (String message, Map<String, dynamic>? metadata) async {
|
|
||||||
unawaited(_startStreamingMessage(message, metadata, emit));
|
|
||||||
final allMessages = _permanentMessages();
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
lastSentMessage: null,
|
|
||||||
messages: allMessages,
|
|
||||||
relatedQuestions: [],
|
|
||||||
acceptRelatedQuestion: false,
|
|
||||||
sendingState: const SendMessageState.sending(),
|
|
||||||
canSendMessage: false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
finishSending: (ChatMessagePB message) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
lastSentMessage: message,
|
|
||||||
sendingState: const SendMessageState.done(),
|
|
||||||
canSendMessage:
|
|
||||||
state.streamingState == const StreamingState.done(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
failedSending: () {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
messages: _permanentMessages()..removeAt(0),
|
|
||||||
sendingState: const SendMessageState.done(),
|
|
||||||
canSendMessage: true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
// related question
|
|
||||||
didReceiveRelatedQuestion: (List<RelatedQuestionPB> questions) {
|
|
||||||
if (questions.isEmpty) {
|
if (questions.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final allMessages = _permanentMessages();
|
final metatdata = OnetimeShotType.relatedQuestion.toMap();
|
||||||
final message = CustomMessage(
|
metatdata['questions'] = questions;
|
||||||
metadata: OnetimeShotType.relatedQuestion.toMap(),
|
|
||||||
|
final message = TextMessage(
|
||||||
|
text: '',
|
||||||
|
metadata: metatdata,
|
||||||
author: const User(id: systemUserId),
|
author: const User(id: systemUserId),
|
||||||
showStatus: false,
|
|
||||||
id: systemUserId,
|
id: systemUserId,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
);
|
);
|
||||||
allMessages.insert(0, message);
|
|
||||||
|
chatController.insert(message);
|
||||||
|
},
|
||||||
|
receiveMessage: (Message message) {
|
||||||
|
final oldMessage = chatController.messages
|
||||||
|
.firstWhereOrNull((m) => m.id == message.id);
|
||||||
|
if (oldMessage == null) {
|
||||||
|
chatController.insert(message);
|
||||||
|
} else {
|
||||||
|
chatController.update(oldMessage, message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sendMessage: (
|
||||||
|
String message,
|
||||||
|
Map<String, dynamic>? metadata,
|
||||||
|
) {
|
||||||
|
final relatedQuestionMessages = chatController.messages.where(
|
||||||
|
(message) {
|
||||||
|
return onetimeMessageTypeFromMeta(message.metadata) ==
|
||||||
|
OnetimeShotType.relatedQuestion;
|
||||||
|
},
|
||||||
|
).toList();
|
||||||
|
|
||||||
|
for (final message in relatedQuestionMessages) {
|
||||||
|
chatController.remove(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
_startStreamingMessage(message, metadata);
|
||||||
|
lastSentMessage = null;
|
||||||
|
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
messages: allMessages,
|
promptResponseState: PromptResponseState.sendingQuestion,
|
||||||
relatedQuestions: questions,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
clearRelatedQuestions: () {
|
finishSending: (ChatMessagePB message) {
|
||||||
|
lastSentMessage = message;
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
relatedQuestions: [],
|
promptResponseState: PromptResponseState.awaitingAnswer,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
stopStream: () async {
|
||||||
|
if (answerStream == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// tell backend to stop
|
||||||
|
final payload = StopStreamPB(chatId: chatId);
|
||||||
|
await AIEventStopStream(payload).send();
|
||||||
|
|
||||||
|
// allow user input
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
promptResponseState: PromptResponseState.ready,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// no need to remove old message if stream has started already
|
||||||
|
if (answerStream!.hasStarted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove the non-started message from the list
|
||||||
|
final message = chatController.messages.lastWhereOrNull(
|
||||||
|
(e) => e.id == answerStreamMessageId,
|
||||||
|
);
|
||||||
|
if (message != null) {
|
||||||
|
await chatController.remove(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// set answer stream to null
|
||||||
|
await answerStream?.dispose();
|
||||||
|
answerStream = null;
|
||||||
|
answerStreamMessageId = '';
|
||||||
|
},
|
||||||
|
startAnswerStreaming: (Message message) {
|
||||||
|
chatController.insert(message);
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
promptResponseState: PromptResponseState.streamingAnswer,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
failedSending: () {
|
||||||
|
final lastMessage = chatController.messages.lastOrNull;
|
||||||
|
if (lastMessage != null) {
|
||||||
|
chatController.remove(lastMessage);
|
||||||
|
}
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
promptResponseState: PromptResponseState.ready,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -264,29 +233,31 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
|||||||
void _startListening() {
|
void _startListening() {
|
||||||
listener.start(
|
listener.start(
|
||||||
chatMessageCallback: (pb) {
|
chatMessageCallback: (pb) {
|
||||||
if (!isClosed) {
|
if (isClosed) {
|
||||||
// 3 mean message response from AI
|
return;
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
chatErrorMessageCallback: (err) {
|
||||||
if (!isClosed) {
|
if (!isClosed) {
|
||||||
Log.error("chat error: ${err.errorMessage}");
|
Log.error("chat error: ${err.errorMessage}");
|
||||||
add(const ChatEvent.finishAnswerStreaming());
|
add(const ChatEvent.didFinishAnswerStream());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
latestMessageCallback: (list) {
|
latestMessageCallback: (list) {
|
||||||
@ -301,65 +272,70 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
|||||||
add(ChatEvent.didLoadPreviousMessages(messages, list.hasMore));
|
add(ChatEvent.didLoadPreviousMessages(messages, list.hasMore));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
finishStreamingCallback: () {
|
finishStreamingCallback: () async {
|
||||||
if (!isClosed) {
|
if (isClosed) {
|
||||||
add(const ChatEvent.finishAnswerStreaming());
|
return;
|
||||||
// The answer strema will bet set to null after the streaming is finished or canceled.
|
|
||||||
// so if the answer stream is null, we will not get related question.
|
|
||||||
if (state.lastSentMessage != null && state.answerStream != null) {
|
|
||||||
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");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
void _init() async {
|
||||||
List<Message> _permanentMessages() {
|
final payload = LoadNextChatMessagePB(
|
||||||
final allMessages = state.messages.where((element) {
|
chatId: chatId,
|
||||||
return !(element.metadata?.containsKey(onetimeShotType) == true);
|
limit: Int64(10),
|
||||||
}).toList();
|
);
|
||||||
|
await AIEventLoadNextMessage(payload).send().fold(
|
||||||
return allMessages;
|
(list) {
|
||||||
|
if (!isClosed) {
|
||||||
|
final messages = list.messages.map(_createTextMessage).toList();
|
||||||
|
add(ChatEvent.didLoadLatestMessages(messages));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(err) => Log.error("Failed to load messages: $err"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Message> _getOnetimeMessages() {
|
bool _isOneTimeMessage(Message message) {
|
||||||
final messages = state.messages.where((element) {
|
return message.metadata != null &&
|
||||||
return (element.metadata?.containsKey(onetimeShotType) == true);
|
message.metadata!.containsKey(onetimeShotType);
|
||||||
}).toList();
|
|
||||||
|
|
||||||
return messages;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// get the last message that is not a one-time message
|
||||||
Message? _getOldestMessage() {
|
Message? _getOldestMessage() {
|
||||||
// get the last message that is not a one-time message
|
return chatController.messages
|
||||||
final message = state.messages.lastWhereOrNull((element) {
|
.firstWhereOrNull((message) => !_isOneTimeMessage(message));
|
||||||
return !(element.metadata?.containsKey(onetimeShotType) == true);
|
|
||||||
});
|
|
||||||
return message;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _loadPrevMessage(Int64? beforeMessageId) {
|
void _loadPreviousMessages(Int64? beforeMessageId) {
|
||||||
final payload = LoadPrevChatMessagePB(
|
final payload = LoadPrevChatMessagePB(
|
||||||
chatId: state.view.id,
|
chatId: chatId,
|
||||||
limit: Int64(10),
|
limit: Int64(10),
|
||||||
beforeMessageId: beforeMessageId,
|
beforeMessageId: beforeMessageId,
|
||||||
);
|
);
|
||||||
@ -369,43 +345,36 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
|||||||
Future<void> _startStreamingMessage(
|
Future<void> _startStreamingMessage(
|
||||||
String message,
|
String message,
|
||||||
Map<String, dynamic>? metadata,
|
Map<String, dynamic>? metadata,
|
||||||
Emitter<ChatState> emit,
|
|
||||||
) async {
|
) async {
|
||||||
if (state.answerStream != null) {
|
await answerStream?.dispose();
|
||||||
await state.answerStream?.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
final answerStream = AnswerStream();
|
answerStream = AnswerStream();
|
||||||
final questionStream = QuestionStream();
|
final questionStream = QuestionStream();
|
||||||
add(ChatEvent.didUpdateAnswerStream(answerStream));
|
|
||||||
|
|
||||||
final payload = StreamChatPayloadPB(
|
|
||||||
chatId: state.view.id,
|
|
||||||
message: message,
|
|
||||||
messageType: ChatMessageTypePB.User,
|
|
||||||
questionStreamPort: Int64(questionStream.nativePort),
|
|
||||||
answerStreamPort: Int64(answerStream.nativePort),
|
|
||||||
metadata: await metadataPBFromMetadata(metadata),
|
|
||||||
);
|
|
||||||
|
|
||||||
|
// add a streaming question message
|
||||||
final questionStreamMessage = _createQuestionStreamMessage(
|
final questionStreamMessage = _createQuestionStreamMessage(
|
||||||
questionStream,
|
questionStream,
|
||||||
metadata,
|
metadata,
|
||||||
);
|
);
|
||||||
add(ChatEvent.receiveMessage(questionStreamMessage));
|
add(ChatEvent.receiveMessage(questionStreamMessage));
|
||||||
|
|
||||||
// Stream message to the server
|
final payload = StreamChatPayloadPB(
|
||||||
final result = await AIEventStreamMessage(payload).send();
|
chatId: chatId,
|
||||||
result.fold(
|
message: message,
|
||||||
(ChatMessagePB question) {
|
messageType: ChatMessageTypePB.User,
|
||||||
|
questionStreamPort: Int64(questionStream.nativePort),
|
||||||
|
answerStreamPort: Int64(answerStream!.nativePort),
|
||||||
|
metadata: await metadataPBFromMetadata(metadata),
|
||||||
|
);
|
||||||
|
|
||||||
|
// stream the question to the server
|
||||||
|
await AIEventStreamMessage(payload).send().fold(
|
||||||
|
(question) {
|
||||||
if (!isClosed) {
|
if (!isClosed) {
|
||||||
add(ChatEvent.finishSending(question));
|
add(ChatEvent.finishSending(question));
|
||||||
|
|
||||||
// final message = _createTextMessage(question);
|
|
||||||
// add(ChatEvent.receiveMessage(message));
|
|
||||||
|
|
||||||
final streamAnswer =
|
final streamAnswer =
|
||||||
_createAnswerStreamMessage(answerStream, question.messageId);
|
_createAnswerStreamMessage(answerStream!, question.messageId);
|
||||||
add(ChatEvent.startAnswerStreaming(streamAnswer));
|
add(ChatEvent.startAnswerStreaming(streamAnswer));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -417,11 +386,12 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
|||||||
metadata[sendMessageErrorKey] = err.msg;
|
metadata[sendMessageErrorKey] = err.msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
final error = CustomMessage(
|
final error = TextMessage(
|
||||||
|
text: '',
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
author: const User(id: systemUserId),
|
author: const User(id: systemUserId),
|
||||||
showStatus: false,
|
|
||||||
id: systemUserId,
|
id: systemUserId,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
);
|
);
|
||||||
|
|
||||||
add(const ChatEvent.failedSending());
|
add(const ChatEvent.failedSending());
|
||||||
@ -446,8 +416,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
|||||||
"chatId": chatId,
|
"chatId": chatId,
|
||||||
},
|
},
|
||||||
id: streamMessageId,
|
id: streamMessageId,
|
||||||
showStatus: false,
|
createdAt: DateTime.now(),
|
||||||
createdAt: DateTime.now().millisecondsSinceEpoch,
|
|
||||||
text: '',
|
text: '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -457,24 +426,19 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
|||||||
Map<String, dynamic>? sentMetadata,
|
Map<String, dynamic>? sentMetadata,
|
||||||
) {
|
) {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final timestamp = now.millisecondsSinceEpoch;
|
questionStreamMessageId = (now.millisecondsSinceEpoch ~/ 1000).toString();
|
||||||
questionStreamMessageId = timestamp.toString();
|
|
||||||
final Map<String, dynamic> metadata = {};
|
|
||||||
|
|
||||||
// if (sentMetadata != null) {
|
final Map<String, dynamic> metadata = {
|
||||||
// metadata[messageMetadataJsonStringKey] = sentMetadata;
|
"$QuestionStream": stream,
|
||||||
// }
|
"chatId": chatId,
|
||||||
|
messageChatFileListKey: chatFilesFromMessageMetadata(sentMetadata),
|
||||||
|
};
|
||||||
|
|
||||||
metadata["$QuestionStream"] = stream;
|
|
||||||
metadata["chatId"] = chatId;
|
|
||||||
metadata[messageChatFileListKey] =
|
|
||||||
chatFilesFromMessageMetadata(sentMetadata);
|
|
||||||
return TextMessage(
|
return TextMessage(
|
||||||
author: User(id: state.userProfile.id.toString()),
|
author: User(id: userId),
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
id: questionStreamMessageId,
|
id: questionStreamMessageId,
|
||||||
showStatus: false,
|
createdAt: now,
|
||||||
createdAt: DateTime.now().millisecondsSinceEpoch,
|
|
||||||
text: '',
|
text: '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -491,8 +455,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
|||||||
author: User(id: message.authorId),
|
author: User(id: message.authorId),
|
||||||
id: messageId,
|
id: messageId,
|
||||||
text: message.content,
|
text: message.content,
|
||||||
createdAt: message.createdAt.toInt() * 1000,
|
createdAt: message.createdAt.toDateTime(),
|
||||||
showStatus: false,
|
|
||||||
metadata: {
|
metadata: {
|
||||||
messageRefSourceJsonStringKey: message.metadata,
|
messageRefSourceJsonStringKey: message.metadata,
|
||||||
},
|
},
|
||||||
@ -502,8 +465,6 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
|
|||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
class ChatEvent with _$ChatEvent {
|
class ChatEvent with _$ChatEvent {
|
||||||
const factory ChatEvent.initialLoad() = _InitialLoadMessage;
|
|
||||||
|
|
||||||
// send message
|
// send message
|
||||||
const factory ChatEvent.sendMessage({
|
const factory ChatEvent.sendMessage({
|
||||||
required String message,
|
required String message,
|
||||||
@ -513,14 +474,14 @@ class ChatEvent with _$ChatEvent {
|
|||||||
_FinishSendMessage;
|
_FinishSendMessage;
|
||||||
const factory ChatEvent.failedSending() = _FailSendMessage;
|
const factory ChatEvent.failedSending() = _FailSendMessage;
|
||||||
|
|
||||||
// receive message
|
// receive message
|
||||||
const factory ChatEvent.startAnswerStreaming(Message message) =
|
const factory ChatEvent.startAnswerStreaming(Message message) =
|
||||||
_StartAnswerStreaming;
|
_StartAnswerStreaming;
|
||||||
const factory ChatEvent.receiveMessage(Message message) = _ReceiveMessage;
|
const factory ChatEvent.receiveMessage(Message message) = _ReceiveMessage;
|
||||||
const factory ChatEvent.finishAnswerStreaming() = _FinishAnswerStreaming;
|
const factory ChatEvent.didFinishAnswerStream() = _DidFinishAnswerStream;
|
||||||
|
|
||||||
// loading messages
|
// loading messages
|
||||||
const factory ChatEvent.startLoadingPrevMessage() = _StartLoadPrevMessage;
|
const factory ChatEvent.loadPreviousMessages() = _LoadPreviousMessages;
|
||||||
const factory ChatEvent.didLoadPreviousMessages(
|
const factory ChatEvent.didLoadPreviousMessages(
|
||||||
List<Message> messages,
|
List<Message> messages,
|
||||||
bool hasMore,
|
bool hasMore,
|
||||||
@ -528,56 +489,24 @@ class ChatEvent with _$ChatEvent {
|
|||||||
const factory ChatEvent.didLoadLatestMessages(List<Message> messages) =
|
const factory ChatEvent.didLoadLatestMessages(List<Message> messages) =
|
||||||
_DidLoadMessages;
|
_DidLoadMessages;
|
||||||
|
|
||||||
// related questions
|
// related questions
|
||||||
const factory ChatEvent.didReceiveRelatedQuestion(
|
const factory ChatEvent.didReceiveRelatedQuestions(
|
||||||
List<RelatedQuestionPB> questions,
|
List<String> questions,
|
||||||
) = _DidReceiveRelatedQueston;
|
) = _DidReceiveRelatedQueston;
|
||||||
const factory ChatEvent.clearRelatedQuestions() = _ClearRelatedQuestions;
|
|
||||||
|
|
||||||
const factory ChatEvent.didUpdateAnswerStream(
|
|
||||||
AnswerStream stream,
|
|
||||||
) = _DidUpdateAnswerStream;
|
|
||||||
const factory ChatEvent.stopStream() = _StopStream;
|
const factory ChatEvent.stopStream() = _StopStream;
|
||||||
}
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
class ChatState with _$ChatState {
|
class ChatState with _$ChatState {
|
||||||
const factory ChatState({
|
const factory ChatState({
|
||||||
required ViewPB view,
|
required ChatLoadingState loadingState,
|
||||||
required List<Message> messages,
|
required PromptResponseState promptResponseState,
|
||||||
required UserProfilePB userProfile,
|
|
||||||
// When opening the chat, the initial loading status will be set as loading.
|
|
||||||
//After the initial loading is done, the status will be set as finished.
|
|
||||||
required ChatLoadingState initialLoadingStatus,
|
|
||||||
// When loading previous messages, the status will be set as loading.
|
|
||||||
// After the loading is done, the status will be set as finished.
|
|
||||||
required ChatLoadingState loadingPreviousStatus,
|
|
||||||
// When sending a user message, the status will be set as loading.
|
|
||||||
// After the message is sent, the status will be set as finished.
|
|
||||||
required StreamingState streamingState,
|
|
||||||
required SendMessageState sendingState,
|
|
||||||
// Indicate whether there are more previous messages to load.
|
|
||||||
required bool hasMorePrevMessage,
|
|
||||||
// The related questions that are received after the user message is sent.
|
|
||||||
required List<RelatedQuestionPB> relatedQuestions,
|
|
||||||
@Default(false) bool acceptRelatedQuestion,
|
|
||||||
// The last user message that is sent to the server.
|
|
||||||
ChatMessagePB? lastSentMessage,
|
|
||||||
AnswerStream? answerStream,
|
|
||||||
@Default(true) bool canSendMessage,
|
|
||||||
}) = _ChatState;
|
}) = _ChatState;
|
||||||
|
|
||||||
factory ChatState.initial(ViewPB view, UserProfilePB userProfile) =>
|
factory ChatState.initial() => const ChatState(
|
||||||
ChatState(
|
loadingState: ChatLoadingState.loading(),
|
||||||
view: view,
|
promptResponseState: PromptResponseState.ready,
|
||||||
messages: [],
|
|
||||||
userProfile: userProfile,
|
|
||||||
initialLoadingStatus: const ChatLoadingState.finish(),
|
|
||||||
loadingPreviousStatus: const ChatLoadingState.finish(),
|
|
||||||
streamingState: const StreamingState.done(),
|
|
||||||
sendingState: const SendMessageState.done(),
|
|
||||||
hasMorePrevMessage: true,
|
|
||||||
relatedQuestions: [],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -40,16 +40,11 @@ class ChatMessageRefSource {
|
|||||||
Map<String, dynamic> toJson() => _$ChatMessageRefSourceToJson(this);
|
Map<String, dynamic> toJson() => _$ChatMessageRefSourceToJson(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@freezed
|
enum PromptResponseState {
|
||||||
class StreamingState with _$StreamingState {
|
ready,
|
||||||
const factory StreamingState.streaming() = _Streaming;
|
sendingQuestion,
|
||||||
const factory StreamingState.done({FlowyError? error}) = _StreamDone;
|
awaitingAnswer,
|
||||||
}
|
streamingAnswer,
|
||||||
|
|
||||||
@freezed
|
|
||||||
class SendMessageState with _$SendMessageState {
|
|
||||||
const factory SendMessageState.sending() = _Sending;
|
|
||||||
const factory SendMessageState.done({FlowyError? error}) = _SendDone;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class ChatFile extends Equatable {
|
class ChatFile extends Equatable {
|
||||||
|
@ -7,7 +7,7 @@ import 'package:appflowy_backend/dispatch/dispatch.dart';
|
|||||||
import 'package:appflowy_backend/log.dart';
|
import 'package:appflowy_backend/log.dart';
|
||||||
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
|
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
|
||||||
import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart';
|
import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart';
|
||||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
import 'package:appflowy_result/appflowy_result.dart';
|
||||||
import 'package:nanoid/nanoid.dart';
|
import 'package:nanoid/nanoid.dart';
|
||||||
|
|
||||||
/// Indicate file source from appflowy document
|
/// Indicate file source from appflowy document
|
||||||
@ -103,41 +103,44 @@ List<ChatMessageRefSource> messageReferenceSource(String? s) {
|
|||||||
Future<List<ChatMessageMetaPB>> metadataPBFromMetadata(
|
Future<List<ChatMessageMetaPB>> metadataPBFromMetadata(
|
||||||
Map<String, dynamic>? map,
|
Map<String, dynamic>? map,
|
||||||
) async {
|
) async {
|
||||||
|
if (map == null) return [];
|
||||||
|
|
||||||
final List<ChatMessageMetaPB> metadata = [];
|
final List<ChatMessageMetaPB> metadata = [];
|
||||||
if (map != null) {
|
|
||||||
for (final entry in map.entries) {
|
for (final value in map.values) {
|
||||||
if (entry.value is ViewActionPage) {
|
switch (value) {
|
||||||
if (entry.value.page is ViewPB) {
|
case ViewActionPage(view: final view) when view.layout.isDocumentView:
|
||||||
final view = entry.value.page as ViewPB;
|
final payload = OpenDocumentPayloadPB(documentId: view.id);
|
||||||
if (view.layout.isDocumentView) {
|
await DocumentEventGetDocumentText(payload).send().fold(
|
||||||
final payload = OpenDocumentPayloadPB(documentId: view.id);
|
(pb) {
|
||||||
final result = await DocumentEventGetDocumentText(payload).send();
|
metadata.add(
|
||||||
result.fold((pb) {
|
ChatMessageMetaPB(
|
||||||
metadata.add(
|
id: view.id,
|
||||||
ChatMessageMetaPB(
|
name: view.name,
|
||||||
id: view.id,
|
data: pb.text,
|
||||||
name: view.name,
|
dataType: ChatMessageMetaTypePB.Txt,
|
||||||
data: pb.text,
|
source: appflowySource,
|
||||||
dataType: ChatMessageMetaTypePB.Txt,
|
),
|
||||||
source: appflowySource,
|
);
|
||||||
),
|
},
|
||||||
);
|
(err) => Log.error('Failed to get document text: $err'),
|
||||||
}, (err) {
|
);
|
||||||
Log.error('Failed to get document text: $err');
|
break;
|
||||||
});
|
case ChatFile(
|
||||||
}
|
filePath: final filePath,
|
||||||
}
|
fileName: final fileName,
|
||||||
} else if (entry.value is ChatFile) {
|
fileType: final fileType,
|
||||||
|
):
|
||||||
metadata.add(
|
metadata.add(
|
||||||
ChatMessageMetaPB(
|
ChatMessageMetaPB(
|
||||||
id: nanoid(8),
|
id: nanoid(8),
|
||||||
name: entry.value.fileName,
|
name: fileName,
|
||||||
data: entry.value.filePath,
|
data: filePath,
|
||||||
dataType: entry.value.fileType,
|
dataType: fileType,
|
||||||
source: entry.value.filePath,
|
source: filePath,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -9,11 +9,7 @@ class ChatUserMessageBloc
|
|||||||
extends Bloc<ChatUserMessageEvent, ChatUserMessageState> {
|
extends Bloc<ChatUserMessageEvent, ChatUserMessageState> {
|
||||||
ChatUserMessageBloc({
|
ChatUserMessageBloc({
|
||||||
required dynamic message,
|
required dynamic message,
|
||||||
}) : super(
|
}) : super(ChatUserMessageState.initial(message)) {
|
||||||
ChatUserMessageState.initial(
|
|
||||||
message,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
on<ChatUserMessageEvent>(
|
on<ChatUserMessageEvent>(
|
||||||
(event, emit) {
|
(event, emit) {
|
||||||
event.when(
|
event.when(
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
import 'chat_message_service.dart';
|
import 'chat_message_service.dart';
|
||||||
|
@ -9,27 +9,27 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
|||||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||||
import 'package:desktop_drop/desktop_drop.dart';
|
import 'package:desktop_drop/desktop_drop.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flowy_infra/theme_extension.dart';
|
|
||||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
|
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
import 'package:flutter_chat_ui/flutter_chat_ui.dart'
|
||||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart' show Chat;
|
hide ChatAnimatedListReversed;
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
import 'package:universal_platform/universal_platform.dart';
|
import 'package:universal_platform/universal_platform.dart';
|
||||||
|
|
||||||
import 'application/chat_member_bloc.dart';
|
import 'application/chat_member_bloc.dart';
|
||||||
import 'application/chat_side_panel_bloc.dart';
|
import 'application/chat_side_panel_bloc.dart';
|
||||||
|
import 'presentation/animated_chat_list.dart';
|
||||||
import 'presentation/chat_input/desktop_ai_prompt_input.dart';
|
import 'presentation/chat_input/desktop_ai_prompt_input.dart';
|
||||||
import 'presentation/chat_input/mobile_ai_prompt_input.dart';
|
import 'presentation/chat_input/mobile_ai_prompt_input.dart';
|
||||||
import 'presentation/chat_side_panel.dart';
|
import 'presentation/chat_side_panel.dart';
|
||||||
import 'presentation/chat_theme.dart';
|
|
||||||
import 'presentation/chat_user_invalid_message.dart';
|
import 'presentation/chat_user_invalid_message.dart';
|
||||||
import 'presentation/chat_welcome_page.dart';
|
import 'presentation/chat_welcome_page.dart';
|
||||||
import 'presentation/layout_define.dart';
|
import 'presentation/layout_define.dart';
|
||||||
import 'presentation/message/ai_text_message.dart';
|
import 'presentation/message/ai_text_message.dart';
|
||||||
import 'presentation/message/user_text_message.dart';
|
import 'presentation/message/user_text_message.dart';
|
||||||
|
import 'presentation/scroll_to_bottom.dart';
|
||||||
|
|
||||||
class AIChatPage extends StatelessWidget {
|
class AIChatPage extends StatelessWidget {
|
||||||
const AIChatPage({
|
const AIChatPage({
|
||||||
@ -59,9 +59,9 @@ class AIChatPage extends StatelessWidget {
|
|||||||
/// [ChatBloc] is used to handle chat messages including send/receive message
|
/// [ChatBloc] is used to handle chat messages including send/receive message
|
||||||
BlocProvider(
|
BlocProvider(
|
||||||
create: (_) => ChatBloc(
|
create: (_) => ChatBloc(
|
||||||
view: view,
|
chatId: view.id,
|
||||||
userProfile: userProfile,
|
userId: userProfile.id.toString(),
|
||||||
)..add(const ChatEvent.initialLoad()),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
/// [AIPromptInputBloc] is used to handle the user prompt
|
/// [AIPromptInputBloc] is used to handle the user prompt
|
||||||
@ -113,7 +113,7 @@ class _ChatContentPage extends StatelessWidget {
|
|||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Center(
|
Center(
|
||||||
child: buildChatWidget()
|
child: buildChatWidget(context)
|
||||||
.constrained(
|
.constrained(
|
||||||
maxWidth: 784,
|
maxWidth: 784,
|
||||||
)
|
)
|
||||||
@ -148,7 +148,7 @@ class _ChatContentPage extends StatelessWidget {
|
|||||||
Flexible(
|
Flexible(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 784),
|
constraints: const BoxConstraints(maxWidth: 784),
|
||||||
child: buildChatWidget(),
|
child: buildChatWidget(context),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -167,187 +167,207 @@ class _ChatContentPage extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget buildChatWidget() {
|
Widget buildChatWidget(BuildContext context) {
|
||||||
return BlocBuilder<ChatBloc, ChatState>(
|
return ScrollConfiguration(
|
||||||
builder: (context, state) {
|
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
|
||||||
return ScrollConfiguration(
|
child: BlocBuilder<ChatBloc, ChatState>(
|
||||||
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
|
builder: (context, state) {
|
||||||
child: BlocBuilder<ChatBloc, ChatState>(
|
return state.loadingState.when(
|
||||||
builder: (_, state) => state.initialLoadingStatus.isFinish
|
loading: () {
|
||||||
? Chat(
|
return const Center(
|
||||||
messages: state.messages,
|
child: CircularProgressIndicator.adaptive(),
|
||||||
dateHeaderBuilder: (_) => const SizedBox.shrink(),
|
);
|
||||||
onSendPressed: (_) {
|
},
|
||||||
// We use custom bottom widget for chat input, so
|
finish: (_) {
|
||||||
// do not need to handle this event.
|
final chatController = context.read<ChatBloc>().chatController;
|
||||||
},
|
return Column(
|
||||||
customBottomWidget: _buildBottom(context),
|
children: [
|
||||||
user: types.User(id: userProfile.id.toString()),
|
Expanded(
|
||||||
theme: _buildTheme(context),
|
child: Chat(
|
||||||
onEndReached: () async {
|
chatController: chatController,
|
||||||
if (state.hasMorePrevMessage &&
|
user: User(id: userProfile.id.toString()),
|
||||||
state.loadingPreviousStatus.isFinish) {
|
darkTheme: ChatTheme.fromThemeData(Theme.of(context)),
|
||||||
context
|
theme: ChatTheme.fromThemeData(Theme.of(context)),
|
||||||
.read<ChatBloc>()
|
builders: Builders(
|
||||||
.add(const ChatEvent.startLoadingPrevMessage());
|
inputBuilder: (_) => const SizedBox.shrink(),
|
||||||
}
|
textMessageBuilder: _buildTextMessage,
|
||||||
},
|
chatMessageBuilder: _buildChatMessage,
|
||||||
emptyState: TextFieldTapRegion(
|
scrollToBottomBuilder: _buildScrollToBottom,
|
||||||
child: ChatWelcomePage(
|
chatAnimatedListBuilder: _buildChatAnimatedList,
|
||||||
userProfile: userProfile,
|
|
||||||
onSelectedQuestion: (question) => context
|
|
||||||
.read<ChatBloc>()
|
|
||||||
.add(ChatEvent.sendMessage(message: question)),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
messageWidthRatio: AIChatUILayout.messageWidthRatio,
|
|
||||||
textMessageBuilder: (
|
|
||||||
textMessage, {
|
|
||||||
required messageWidth,
|
|
||||||
required showName,
|
|
||||||
}) =>
|
|
||||||
_buildTextMessage(context, textMessage, state),
|
|
||||||
customMessageBuilder: (message, {required messageWidth}) {
|
|
||||||
final messageType = onetimeMessageTypeFromMeta(
|
|
||||||
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(
|
Widget _buildTextMessage(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
TextMessage message,
|
TextMessage message,
|
||||||
ChatState state,
|
|
||||||
) {
|
) {
|
||||||
if (message.author.id == userProfile.id.toString()) {
|
final messageType = onetimeMessageTypeFromMeta(
|
||||||
final stream = message.metadata?["$QuestionStream"];
|
message.metadata,
|
||||||
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?;
|
|
||||||
|
|
||||||
return BlocSelector<ChatBloc, ChatState, bool>(
|
if (messageType == OnetimeShotType.invalidSendMesssage) {
|
||||||
key: ValueKey(message.id),
|
return ChatInvalidUserMessage(
|
||||||
selector: (state) {
|
message: message,
|
||||||
final messages = state.messages.where((e) {
|
);
|
||||||
final oneTimeMessageType = onetimeMessageTypeFromMeta(e.metadata);
|
}
|
||||||
if (oneTimeMessageType == null) {
|
|
||||||
return true;
|
if (messageType == OnetimeShotType.relatedQuestion) {
|
||||||
}
|
return RelatedQuestionList(
|
||||||
if (oneTimeMessageType
|
relatedQuestions: message.metadata!['questions'],
|
||||||
case OnetimeShotType.relatedQuestion ||
|
onQuestionSelected: (question) {
|
||||||
OnetimeShotType.sendingMessage ||
|
context
|
||||||
OnetimeShotType.invalidSendMesssage) {
|
.read<ChatBloc>()
|
||||||
return false;
|
.add(ChatEvent.sendMessage(message: question));
|
||||||
}
|
|
||||||
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));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildBubble(
|
|
||||||
BuildContext context,
|
|
||||||
Message message,
|
|
||||||
Widget child,
|
|
||||||
) {
|
|
||||||
if (message.author.id == userProfile.id.toString()) {
|
if (message.author.id == userProfile.id.toString()) {
|
||||||
|
final stream = message.metadata?["$QuestionStream"];
|
||||||
return ChatUserMessageBubble(
|
return ChatUserMessageBubble(
|
||||||
|
key: ValueKey(message.id),
|
||||||
message: message,
|
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(
|
return ChatUserMessageBubble(
|
||||||
|
key: ValueKey(message.id),
|
||||||
message: message,
|
message: message,
|
||||||
isCurrentUser: false,
|
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(
|
return Padding(
|
||||||
padding: AIChatUILayout.safeAreaInsets(context),
|
padding: AIChatUILayout.safeAreaInsets(context),
|
||||||
child: BlocSelector<ChatBloc, ChatState, bool>(
|
child: BlocSelector<ChatBloc, ChatState, bool>(
|
||||||
selector: (state) => state.canSendMessage,
|
selector: (state) {
|
||||||
|
return state.promptResponseState == PromptResponseState.ready;
|
||||||
|
},
|
||||||
builder: (context, canSendMessage) {
|
builder: (context, canSendMessage) {
|
||||||
return UniversalPlatform.isDesktop
|
return UniversalPlatform.isDesktop
|
||||||
? DesktopAIPromptInput(
|
? DesktopAIPromptInput(
|
||||||
chatId: view.id,
|
chatId: view.id,
|
||||||
indicateFocus: true,
|
indicateFocus: true,
|
||||||
onSubmitted: (message) {
|
onSubmitted: (text, metadata) {
|
||||||
context.read<ChatBloc>().add(
|
context.read<ChatBloc>().add(
|
||||||
ChatEvent.sendMessage(
|
ChatEvent.sendMessage(
|
||||||
message: message.text,
|
message: text,
|
||||||
metadata: message.metadata,
|
metadata: metadata,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -358,11 +378,11 @@ class _ChatContentPage extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
: MobileAIPromptInput(
|
: MobileAIPromptInput(
|
||||||
chatId: view.id,
|
chatId: view.id,
|
||||||
onSubmitted: (message) {
|
onSubmitted: (text, metadata) {
|
||||||
context.read<ChatBloc>().add(
|
context.read<ChatBloc>().add(
|
||||||
ChatEvent.sendMessage(
|
ChatEvent.sendMessage(
|
||||||
message: message.text,
|
message: text,
|
||||||
metadata: message.metadata,
|
metadata: metadata,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -375,11 +395,4 @@ class _ChatContentPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
AFDefaultChatTheme _buildTheme(BuildContext context) {
|
|
||||||
return AFDefaultChatTheme(
|
|
||||||
primaryColor: Theme.of(context).colorScheme.primary,
|
|
||||||
secondaryColor: AFThemeExtension.of(context).tint1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
|
|
||||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
|
|
||||||
import 'package:universal_platform/universal_platform.dart';
|
import 'package:universal_platform/universal_platform.dart';
|
||||||
|
|
||||||
import 'ai_prompt_buttons.dart';
|
import 'ai_prompt_buttons.dart';
|
||||||
@ -25,7 +23,6 @@ class DesktopAIPromptInput extends StatefulWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.chatId,
|
required this.chatId,
|
||||||
required this.indicateFocus,
|
required this.indicateFocus,
|
||||||
this.options = const InputOptions(),
|
|
||||||
required this.isStreaming,
|
required this.isStreaming,
|
||||||
required this.onStopStreaming,
|
required this.onStopStreaming,
|
||||||
required this.onSubmitted,
|
required this.onSubmitted,
|
||||||
@ -33,10 +30,9 @@ class DesktopAIPromptInput extends StatefulWidget {
|
|||||||
|
|
||||||
final String chatId;
|
final String chatId;
|
||||||
final bool indicateFocus;
|
final bool indicateFocus;
|
||||||
final InputOptions options;
|
|
||||||
final bool isStreaming;
|
final bool isStreaming;
|
||||||
final void Function() onStopStreaming;
|
final void Function() onStopStreaming;
|
||||||
final void Function(types.PartialText) onSubmitted;
|
final void Function(String, Map<String, dynamic>) onSubmitted;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<DesktopAIPromptInput> createState() => _DesktopAIPromptInputState();
|
State<DesktopAIPromptInput> createState() => _DesktopAIPromptInputState();
|
||||||
@ -56,7 +52,7 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
_textController = InputTextFieldController()
|
_textController = TextEditingController()
|
||||||
..addListener(_handleTextControllerChange);
|
..addListener(_handleTextControllerChange);
|
||||||
|
|
||||||
_inputFocusNode = FocusNode(
|
_inputFocusNode = FocusNode(
|
||||||
@ -118,6 +114,7 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
|
|||||||
borderRadius: DesktopAIPromptSizes.promptFrameRadius,
|
borderRadius: DesktopAIPromptSizes.promptFrameRadius,
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
ConstrainedBox(
|
ConstrainedBox(
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
@ -209,11 +206,7 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
|
|||||||
..addAll(mentionPageMetadata)
|
..addAll(mentionPageMetadata)
|
||||||
..addAll(fileMetadata);
|
..addAll(fileMetadata);
|
||||||
|
|
||||||
final partialText = types.PartialText(
|
widget.onSubmitted(trimmedText, metadata);
|
||||||
text: trimmedText,
|
|
||||||
metadata: metadata,
|
|
||||||
);
|
|
||||||
widget.onSubmitted(partialText);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleTextControllerChange() {
|
void _handleTextControllerChange() {
|
||||||
|
@ -12,8 +12,6 @@ import 'package:extended_text_field/extended_text_field.dart';
|
|||||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
|
|
||||||
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
|
|
||||||
|
|
||||||
import 'ai_prompt_buttons.dart';
|
import 'ai_prompt_buttons.dart';
|
||||||
import 'chat_input_span.dart';
|
import 'chat_input_span.dart';
|
||||||
@ -23,17 +21,15 @@ class MobileAIPromptInput extends StatefulWidget {
|
|||||||
const MobileAIPromptInput({
|
const MobileAIPromptInput({
|
||||||
super.key,
|
super.key,
|
||||||
required this.chatId,
|
required this.chatId,
|
||||||
this.options = const InputOptions(),
|
|
||||||
required this.isStreaming,
|
required this.isStreaming,
|
||||||
required this.onStopStreaming,
|
required this.onStopStreaming,
|
||||||
required this.onSubmitted,
|
required this.onSubmitted,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String chatId;
|
final String chatId;
|
||||||
final InputOptions options;
|
|
||||||
final bool isStreaming;
|
final bool isStreaming;
|
||||||
final void Function() onStopStreaming;
|
final void Function() onStopStreaming;
|
||||||
final void Function(types.PartialText) onSubmitted;
|
final void Function(String, Map<String, dynamic>) onSubmitted;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<MobileAIPromptInput> createState() => _MobileAIPromptInputState();
|
State<MobileAIPromptInput> createState() => _MobileAIPromptInputState();
|
||||||
@ -50,7 +46,7 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
_textController = InputTextFieldController()
|
_textController = TextEditingController()
|
||||||
..addListener(_handleTextControllerChange);
|
..addListener(_handleTextControllerChange);
|
||||||
|
|
||||||
_inputFocusNode = FocusNode();
|
_inputFocusNode = FocusNode();
|
||||||
@ -166,11 +162,7 @@ class _MobileAIPromptInputState extends State<MobileAIPromptInput> {
|
|||||||
..addAll(mentionPageMetadata)
|
..addAll(mentionPageMetadata)
|
||||||
..addAll(fileMetadata);
|
..addAll(fileMetadata);
|
||||||
|
|
||||||
final partialText = types.PartialText(
|
widget.onSubmitted(trimmedText, metadata);
|
||||||
text: trimmedText,
|
|
||||||
metadata: metadata,
|
|
||||||
);
|
|
||||||
widget.onSubmitted(partialText);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleTextControllerChange() {
|
void _handleTextControllerChange() {
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||||
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
|
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flowy_infra_ui/style_widget/button.dart';
|
import 'package:flowy_infra_ui/style_widget/button.dart';
|
||||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||||
@ -16,7 +15,7 @@ class RelatedQuestionList extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final Function(String) onQuestionSelected;
|
final Function(String) onQuestionSelected;
|
||||||
final List<RelatedQuestionPB> relatedQuestions;
|
final List<String> relatedQuestions;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -57,14 +56,14 @@ class RelatedQuestionItem extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final RelatedQuestionPB question;
|
final String question;
|
||||||
final Function(String) onQuestionSelected;
|
final Function(String) onQuestionSelected;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return FlowyButton(
|
return FlowyButton(
|
||||||
text: FlowyText(
|
text: FlowyText(
|
||||||
question.content,
|
question,
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
@ -76,7 +75,7 @@ class RelatedQuestionItem extends StatelessWidget {
|
|||||||
color: Theme.of(context).colorScheme.primary,
|
color: Theme.of(context).colorScheme.primary,
|
||||||
size: const Size.square(16.0),
|
size: const Size.square(16.0),
|
||||||
),
|
),
|
||||||
onTap: () => onQuestionSelected(question.content),
|
onTap: () => onQuestionSelected(question),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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:appflowy/util/theme_extension.dart';
|
||||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||||
import 'package:universal_platform/universal_platform.dart';
|
import 'package:universal_platform/universal_platform.dart';
|
||||||
|
|
||||||
class ChatInvalidUserMessage extends StatelessWidget {
|
class ChatInvalidUserMessage extends StatelessWidget {
|
||||||
|
@ -2,8 +2,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:universal_platform/universal_platform.dart';
|
import 'package:universal_platform/universal_platform.dart';
|
||||||
|
|
||||||
class AIChatUILayout {
|
class AIChatUILayout {
|
||||||
static double get messageWidthRatio => 0.94; // Chat adds extra 0.06
|
|
||||||
|
|
||||||
static EdgeInsets safeAreaInsets(BuildContext context) {
|
static EdgeInsets safeAreaInsets(BuildContext context) {
|
||||||
final query = MediaQuery.of(context);
|
final query = MediaQuery.of(context);
|
||||||
return UniversalPlatform.isMobile
|
return UniversalPlatform.isMobile
|
||||||
|
@ -14,7 +14,7 @@ import 'package:easy_localization/easy_localization.dart';
|
|||||||
import 'package:flowy_infra/theme_extension.dart';
|
import 'package:flowy_infra/theme_extension.dart';
|
||||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||||
import 'package:universal_platform/universal_platform.dart';
|
import 'package:universal_platform/universal_platform.dart';
|
||||||
|
|
||||||
import '../layout_define.dart';
|
import '../layout_define.dart';
|
||||||
@ -341,7 +341,6 @@ class CopyButton extends StatelessWidget {
|
|||||||
child: FlowyIconButton(
|
child: FlowyIconButton(
|
||||||
width: DesktopAIConvoSizes.actionBarIconSize,
|
width: DesktopAIConvoSizes.actionBarIconSize,
|
||||||
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
hoverColor: AFThemeExtension.of(context).lightGreyHover,
|
||||||
fillColor: Theme.of(context).cardColor,
|
|
||||||
radius: DesktopAIConvoSizes.actionBarIconRadius,
|
radius: DesktopAIConvoSizes.actionBarIconRadius,
|
||||||
icon: FlowySvg(
|
icon: FlowySvg(
|
||||||
FlowySvgs.copy_s,
|
FlowySvgs.copy_s,
|
||||||
|
@ -13,7 +13,7 @@ import 'package:flowy_infra_ui/widget/spacing.dart';
|
|||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||||
import 'package:universal_platform/universal_platform.dart';
|
import 'package:universal_platform/universal_platform.dart';
|
||||||
|
|
||||||
import 'ai_message_bubble.dart';
|
import 'ai_message_bubble.dart';
|
||||||
|
@ -8,7 +8,7 @@ import 'package:flowy_infra_ui/style_widget/text.dart';
|
|||||||
import 'package:flowy_infra_ui/widget/spacing.dart';
|
import 'package:flowy_infra_ui/widget/spacing.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||||
import 'package:universal_platform/universal_platform.dart';
|
import 'package:universal_platform/universal_platform.dart';
|
||||||
|
|
||||||
class ChatUserMessageBubble extends StatelessWidget {
|
class ChatUserMessageBubble extends StatelessWidget {
|
||||||
|
@ -3,7 +3,7 @@ import 'package:flowy_infra/theme_extension.dart';
|
|||||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_chat_types/flutter_chat_types.dart';
|
import 'package:flutter_chat_core/flutter_chat_core.dart';
|
||||||
|
|
||||||
class ChatUserMessageWidget extends StatelessWidget {
|
class ChatUserMessageWidget extends StatelessWidget {
|
||||||
const ChatUserMessageWidget({
|
const ChatUserMessageWidget({
|
||||||
@ -22,20 +22,11 @@ class ChatUserMessageWidget extends StatelessWidget {
|
|||||||
..add(const ChatUserMessageEvent.initial()),
|
..add(const ChatUserMessageEvent.initial()),
|
||||||
child: BlocBuilder<ChatUserMessageBloc, ChatUserMessageState>(
|
child: BlocBuilder<ChatUserMessageBloc, ChatUserMessageState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
return Row(
|
return Opacity(
|
||||||
mainAxisSize: MainAxisSize.min,
|
opacity: state.messageState.isFinish ? 1.0 : 0.8,
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
child: TextMessageText(
|
||||||
children: [
|
text: state.text,
|
||||||
Flexible(
|
),
|
||||||
child: TextMessageText(
|
|
||||||
text: state.text,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (!state.messageState.isFinish) ...[
|
|
||||||
const HSpace(6),
|
|
||||||
const CircularProgressIndicator.adaptive(),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
@ -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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.2"
|
version: "1.9.2"
|
||||||
|
cross_cache:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cross_cache
|
||||||
|
sha256: ed30348320a7fefe4195c26cfcbabc76b7108ce3d364c4dd7c1b1c681a4cfe28
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.0.2"
|
||||||
cross_file:
|
cross_file:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -459,13 +467,29 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.4.1"
|
version: "0.4.1"
|
||||||
diffutil_dart:
|
diffutil_dart:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: diffutil_dart
|
name: diffutil_dart
|
||||||
sha256: "5e74883aedf87f3b703cb85e815bdc1ed9208b33501556e4a8a5572af9845c81"
|
sha256: "5e74883aedf87f3b703cb85e815bdc1ed9208b33501556e4a8a5572af9845c81"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.1"
|
version: "4.0.1"
|
||||||
|
dio:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dio
|
||||||
|
sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.7.0"
|
||||||
|
dio_web_adapter:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dio_web_adapter
|
||||||
|
sha256: "33259a9276d6cea88774a0000cfae0d861003497755969c92faa223108620dc8"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.0"
|
||||||
dotted_border:
|
dotted_border:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -692,8 +716,16 @@ packages:
|
|||||||
url: "https://github.com/LucasXu0/flutter_cache_manager.git"
|
url: "https://github.com/LucasXu0/flutter_cache_manager.git"
|
||||||
source: git
|
source: git
|
||||||
version: "3.3.1"
|
version: "3.3.1"
|
||||||
flutter_chat_types:
|
flutter_chat_core:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_chat_core
|
||||||
|
sha256: "14557aaac7c71b80c279eca41781d214853940cf01727934c742b5845c42dd1e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.0.2"
|
||||||
|
flutter_chat_types:
|
||||||
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_chat_types
|
name: flutter_chat_types
|
||||||
sha256: e285b588f6d19d907feb1f6d912deaf22e223656769c34093b64e1c59b094fb9
|
sha256: e285b588f6d19d907feb1f6d912deaf22e223656769c34093b64e1c59b094fb9
|
||||||
@ -704,10 +736,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_chat_ui
|
name: flutter_chat_ui
|
||||||
sha256: "168a4231464ad00a17ea5f0813f1b58393bdd4035683ea4dc37bbe26be62891e"
|
sha256: "2afd22eaebaf0f6ec8425048921479c3dd1a229604015dca05b174c6e8e44292"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.6.15"
|
version: "2.0.0-dev.1"
|
||||||
flutter_driver:
|
flutter_driver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@ -767,14 +799,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.2"
|
version: "0.7.2"
|
||||||
flutter_parsed_text:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: flutter_parsed_text
|
|
||||||
sha256: "529cf5793b7acdf16ee0f97b158d0d4ba0bf06e7121ef180abe1a5b59e32c1e2"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.2.1"
|
|
||||||
flutter_plugin_android_lifecycle:
|
flutter_plugin_android_lifecycle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -1523,14 +1547,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.2"
|
version: "6.0.2"
|
||||||
photo_view:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: photo_view
|
|
||||||
sha256: "1fc3d970a91295fbd1364296575f854c9863f225505c28c46e0a03e48960c75e"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.15.0"
|
|
||||||
pixel_snap:
|
pixel_snap:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -45,6 +45,7 @@ dependencies:
|
|||||||
# Desktop Drop uses Cross File (XFile) data type
|
# Desktop Drop uses Cross File (XFile) data type
|
||||||
desktop_drop: ^0.4.4
|
desktop_drop: ^0.4.4
|
||||||
device_info_plus:
|
device_info_plus:
|
||||||
|
diffutil_dart: ^4.0.1
|
||||||
dotted_border: ^2.0.0+3
|
dotted_border: ^2.0.0+3
|
||||||
easy_localization: ^3.0.2
|
easy_localization: ^3.0.2
|
||||||
envied: ^0.5.2
|
envied: ^0.5.2
|
||||||
@ -66,8 +67,8 @@ dependencies:
|
|||||||
flutter_animate: ^4.5.0
|
flutter_animate: ^4.5.0
|
||||||
flutter_bloc: ^8.1.3
|
flutter_bloc: ^8.1.3
|
||||||
flutter_cache_manager: ^3.3.1
|
flutter_cache_manager: ^3.3.1
|
||||||
flutter_chat_types: ^3.6.2
|
flutter_chat_core: ^0.0.2
|
||||||
flutter_chat_ui: ^1.6.13
|
flutter_chat_ui: 2.0.0-dev.1
|
||||||
flutter_emoji_mart:
|
flutter_emoji_mart:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/LucasXu0/emoji_mart.git
|
url: https://github.com/LucasXu0/emoji_mart.git
|
||||||
|
@ -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