chore: Merge pull request #7515 from AppFlowy-IO/local_ai_opti

chore: disable input when local ai is initializing
This commit is contained in:
Nathan.fooo 2025-03-12 21:20:48 +08:00 committed by GitHub
commit 3aa55f83b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 364 additions and 289 deletions

View File

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart'; import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart';
@ -7,6 +8,7 @@ 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-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart'; import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
@ -17,14 +19,14 @@ part 'ai_prompt_input_bloc.freezed.dart';
class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> { class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
AIPromptInputBloc({ AIPromptInputBloc({
required PredefinedFormat? predefinedFormat, required PredefinedFormat? predefinedFormat,
}) : _listener = LocalLLMListener(), }) : _listener = LocalAIStateListener(),
super(AIPromptInputState.initial(predefinedFormat)) { super(AIPromptInputState.initial(predefinedFormat)) {
_dispatch(); _dispatch();
_startListening(); _startListening();
_init(); _init();
} }
final LocalLLMListener _listener; final LocalAIStateListener _listener;
@override @override
Future<void> close() async { Future<void> close() async {
@ -41,16 +43,32 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
bool supportChatWithFile = bool supportChatWithFile =
aiType.isLocal && localAIState.state == RunningStatePB.Running; aiType.isLocal && localAIState.state == RunningStatePB.Running;
// If local ai is enabled, user can only send messages when the AI is running
final editable = localAIState.enabled
? localAIState.state == RunningStatePB.Running
: true;
if (localAIState.hasLackOfResource()) { if (localAIState.hasLackOfResource()) {
aiType = AiType.cloud; aiType = AiType.cloud;
supportChatWithFile = false; supportChatWithFile = false;
} }
var hintText = aiType.isLocal
? LocaleKeys.chat_inputLocalAIMessageHint.tr()
: LocaleKeys.chat_inputMessageHint.tr();
if (editable == false && aiType.isLocal) {
hintText =
LocaleKeys.settings_aiPage_keys_localAIInitializing.tr();
}
emit( emit(
state.copyWith( state.copyWith(
aiType: aiType, aiType: aiType,
supportChatWithFile: supportChatWithFile, supportChatWithFile: supportChatWithFile,
localAIState: localAIState, localAIState: localAIState,
editable: editable,
hintText: hintText,
), ),
); );
}, },
@ -179,6 +197,8 @@ class AIPromptInputState with _$AIPromptInputState {
required LocalAIPB? localAIState, required LocalAIPB? localAIState,
required List<ChatFile> attachedFiles, required List<ChatFile> attachedFiles,
required List<ViewPB> mentionedPages, required List<ViewPB> mentionedPages,
required bool editable,
required String hintText,
}) = _AIPromptInputState; }) = _AIPromptInputState;
factory AIPromptInputState.initial(PredefinedFormat? format) => factory AIPromptInputState.initial(PredefinedFormat? format) =>
@ -190,6 +210,8 @@ class AIPromptInputState with _$AIPromptInputState {
localAIState: null, localAIState: null,
attachedFiles: [], attachedFiles: [],
mentionedPages: [], mentionedPages: [],
editable: true,
hintText: '',
); );
} }

View File

@ -1,11 +1,9 @@
import 'package:appflowy/ai/ai.dart'; import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart';
import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart'; import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart';
import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/theme_extension.dart'; import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:extended_text_field/extended_text_field.dart'; import 'package:extended_text_field/extended_text_field.dart';
import 'package:flowy_infra/file_picker/file_picker_service.dart'; import 'package:flowy_infra/file_picker/file_picker_service.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@ -52,7 +50,6 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
super.initState(); super.initState();
textController.addListener(handleTextControllerChanged); textController.addListener(handleTextControllerChanged);
focusNode.addListener( focusNode.addListener(
() { () {
if (!widget.hideDecoration) { if (!widget.hideDecoration) {
@ -377,15 +374,13 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
builder: (context, state) { builder: (context, state) {
return PromptInputTextField( return PromptInputTextField(
key: textFieldKey, key: textFieldKey,
editable: state.editable,
cubit: inputControlCubit, cubit: inputControlCubit,
textController: textController, textController: textController,
textFieldFocusNode: focusNode, textFieldFocusNode: focusNode,
contentPadding: contentPadding:
calculateContentPadding(state.showPredefinedFormats), calculateContentPadding(state.showPredefinedFormats),
hintText: switch (state.aiType) { hintText: state.hintText,
AiType.cloud => LocaleKeys.chat_inputMessageHint.tr(),
AiType.local => LocaleKeys.chat_inputLocalAIMessageHint.tr()
},
); );
}, },
), ),
@ -491,6 +486,7 @@ class _FocusNextItemIntent extends Intent {
class PromptInputTextField extends StatelessWidget { class PromptInputTextField extends StatelessWidget {
const PromptInputTextField({ const PromptInputTextField({
super.key, super.key,
required this.editable,
required this.cubit, required this.cubit,
required this.textController, required this.textController,
required this.textFieldFocusNode, required this.textFieldFocusNode,
@ -502,6 +498,7 @@ class PromptInputTextField extends StatelessWidget {
final TextEditingController textController; final TextEditingController textController;
final FocusNode textFieldFocusNode; final FocusNode textFieldFocusNode;
final EdgeInsetsGeometry contentPadding; final EdgeInsetsGeometry contentPadding;
final bool editable;
final String hintText; final String hintText;
@override @override
@ -509,6 +506,8 @@ class PromptInputTextField extends StatelessWidget {
return ExtendedTextField( return ExtendedTextField(
controller: textController, controller: textController,
focusNode: textFieldFocusNode, focusNode: textFieldFocusNode,
readOnly: !editable,
enabled: editable,
decoration: InputDecoration( decoration: InputDecoration(
border: InputBorder.none, border: InputBorder.none,
enabledBorder: InputBorder.none, enabledBorder: InputBorder.none,

View File

@ -23,11 +23,126 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
parseMetadata(refSourceJsonString), parseMetadata(refSourceJsonString),
), ),
) { ) {
_dispatch(); _registerEventHandlers();
_initializeStreamListener();
_checkInitialStreamState();
}
final String chatId;
final Int64? questionId;
void _registerEventHandlers() {
on<_UpdateText>((event, emit) {
emit(
state.copyWith(
text: event.text,
messageState: const MessageState.ready(),
),
);
});
on<_ReceiveError>((event, emit) {
emit(state.copyWith(messageState: MessageState.onError(event.error)));
});
on<_Retry>((event, emit) async {
if (questionId == null) {
Log.error("Question id is not valid: $questionId");
return;
}
emit(state.copyWith(messageState: const MessageState.loading()));
final payload = ChatMessageIdPB(
chatId: chatId,
messageId: questionId,
);
final result = await AIEventGetAnswerForQuestion(payload).send();
if (!isClosed) {
result.fold(
(answer) => add(ChatAIMessageEvent.retryResult(answer.content)),
(err) {
Log.error("Failed to get answer: $err");
add(ChatAIMessageEvent.receiveError(err.toString()));
},
);
}
});
on<_RetryResult>((event, emit) {
emit(
state.copyWith(
text: event.text,
messageState: const MessageState.ready(),
),
);
});
on<_OnAIResponseLimit>((event, emit) {
emit(
state.copyWith(
messageState: const MessageState.onAIResponseLimit(),
),
);
});
on<_OnAIImageResponseLimit>((event, emit) {
emit(
state.copyWith(
messageState: const MessageState.onAIImageResponseLimit(),
),
);
});
on<_OnAIMaxRquired>((event, emit) {
emit(
state.copyWith(
messageState: MessageState.onAIMaxRequired(event.message),
),
);
});
on<_OnLocalAIInitializing>((event, emit) {
emit(
state.copyWith(
messageState: const MessageState.onInitializingLocalAI(),
),
);
});
on<_ReceiveMetadata>((event, emit) {
Log.debug("AI Steps: ${event.metadata.progress?.step}");
emit(
state.copyWith(
sources: event.metadata.sources,
progress: event.metadata.progress,
),
);
});
}
void _initializeStreamListener() {
if (state.stream != null) { if (state.stream != null) {
_startListening(); state.stream!.listen(
onData: (text) => _safeAdd(ChatAIMessageEvent.updateText(text)),
onError: (error) =>
_safeAdd(ChatAIMessageEvent.receiveError(error.toString())),
onAIResponseLimit: () =>
_safeAdd(const ChatAIMessageEvent.onAIResponseLimit()),
onAIImageResponseLimit: () =>
_safeAdd(const ChatAIMessageEvent.onAIImageResponseLimit()),
onMetadata: (metadata) =>
_safeAdd(ChatAIMessageEvent.receiveMetadata(metadata)),
onAIMaxRequired: (message) {
Log.info(message);
_safeAdd(ChatAIMessageEvent.onAIMaxRequired(message));
},
onLocalAIInitializing: () =>
_safeAdd(const ChatAIMessageEvent.onLocalAIInitializing()),
);
}
}
void _checkInitialStreamState() {
if (state.stream != null) {
if (state.stream!.aiLimitReached) { if (state.stream!.aiLimitReached) {
add(const ChatAIMessageEvent.onAIResponseLimit()); add(const ChatAIMessageEvent.onAIResponseLimit());
} else if (state.stream!.error != null) { } else if (state.stream!.error != null) {
@ -36,130 +151,10 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
} }
} }
final String chatId; void _safeAdd(ChatAIMessageEvent event) {
final Int64? questionId;
void _dispatch() {
on<ChatAIMessageEvent>(
(event, emit) {
event.when(
updateText: (newText) {
emit(
state.copyWith(
text: newText,
messageState: const MessageState.ready(),
),
);
},
receiveError: (error) {
emit(state.copyWith(messageState: MessageState.onError(error)));
},
retry: () {
if (questionId is! Int64) {
Log.error("Question id is not Int64: $questionId");
return;
}
emit(
state.copyWith(
messageState: const MessageState.loading(),
),
);
final payload = ChatMessageIdPB(
chatId: chatId,
messageId: questionId,
);
AIEventGetAnswerForQuestion(payload).send().then((result) {
if (!isClosed) { if (!isClosed) {
result.fold( add(event);
(answer) {
add(ChatAIMessageEvent.retryResult(answer.content));
},
(err) {
Log.error("Failed to get answer: $err");
add(ChatAIMessageEvent.receiveError(err.toString()));
},
);
} }
});
},
retryResult: (String text) {
emit(
state.copyWith(
text: text,
messageState: const MessageState.ready(),
),
);
},
onAIResponseLimit: () {
emit(
state.copyWith(
messageState: const MessageState.onAIResponseLimit(),
),
);
},
onAIImageResponseLimit: () {
emit(
state.copyWith(
messageState: const MessageState.onAIImageResponseLimit(),
),
);
},
onAIMaxRequired: (message) {
emit(
state.copyWith(
messageState: MessageState.onAIMaxRequired(message),
),
);
},
receiveMetadata: (metadata) {
Log.debug("AI Steps: ${metadata.progress?.step}");
emit(
state.copyWith(
sources: metadata.sources,
progress: metadata.progress,
),
);
},
);
},
);
}
void _startListening() {
state.stream!.listen(
onData: (text) {
if (!isClosed) {
add(ChatAIMessageEvent.updateText(text));
}
},
onError: (error) {
if (!isClosed) {
add(ChatAIMessageEvent.receiveError(error.toString()));
}
},
onAIResponseLimit: () {
if (!isClosed) {
add(const ChatAIMessageEvent.onAIResponseLimit());
}
},
onAIImageResponseLimit: () {
if (!isClosed) {
add(const ChatAIMessageEvent.onAIImageResponseLimit());
}
},
onMetadata: (metadata) {
if (!isClosed) {
add(ChatAIMessageEvent.receiveMetadata(metadata));
}
},
onAIMaxRequired: (message) {
if (!isClosed) {
Log.info(message);
add(ChatAIMessageEvent.onAIMaxRequired(message));
}
},
);
} }
} }
@ -174,6 +169,8 @@ class ChatAIMessageEvent with _$ChatAIMessageEvent {
_OnAIImageResponseLimit; _OnAIImageResponseLimit;
const factory ChatAIMessageEvent.onAIMaxRequired(String message) = const factory ChatAIMessageEvent.onAIMaxRequired(String message) =
_OnAIMaxRquired; _OnAIMaxRquired;
const factory ChatAIMessageEvent.onLocalAIInitializing() =
_OnLocalAIInitializing;
const factory ChatAIMessageEvent.receiveMetadata( const factory ChatAIMessageEvent.receiveMetadata(
MetadataCollection metadata, MetadataCollection metadata,
) = _ReceiveMetadata; ) = _ReceiveMetadata;
@ -209,6 +206,7 @@ class MessageState with _$MessageState {
const factory MessageState.onAIResponseLimit() = _AIResponseLimit; const factory MessageState.onAIResponseLimit() = _AIResponseLimit;
const factory MessageState.onAIImageResponseLimit() = _AIImageResponseLimit; const factory MessageState.onAIImageResponseLimit() = _AIImageResponseLimit;
const factory MessageState.onAIMaxRequired(String message) = _AIMaxRequired; const factory MessageState.onAIMaxRequired(String message) = _AIMaxRequired;
const factory MessageState.onInitializingLocalAI() = _LocalAIInitializing;
const factory MessageState.ready() = _Ready; const factory MessageState.ready() = _Ready;
const factory MessageState.loading() = _Loading; const factory MessageState.loading() = _Loading;
} }

View File

@ -4,53 +4,33 @@ import 'dart:isolate';
import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart'; import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart';
/// Constants for event prefixes.
class AnswerEventPrefix {
static const data = 'data:';
static const error = 'error:';
static const metadata = 'metadata:';
static const aiResponseLimit = 'AI_RESPONSE_LIMIT';
static const aiImageResponseLimit = 'AI_IMAGE_RESPONSE_LIMIT';
static const aiMaxRequired = 'AI_MAX_REQUIRED:';
static const localAINotReady = 'LOCAL_AI_NOT_READY';
}
/// A stream that receives answer events from an isolate or external process.
/// It caches events that might occur before a listener is attached.
class AnswerStream { class AnswerStream {
AnswerStream() { AnswerStream() {
_port.handler = _controller.add; _port.handler = _controller.add;
_subscription = _controller.stream.listen( _subscription = _controller.stream.listen(
(event) { _handleEvent,
if (event.startsWith("data:")) { onDone: _onDoneCallback,
_hasStarted = true; onError: _handleError,
final newText = event.substring(5);
_text += newText;
_onData?.call(_text);
} else if (event.startsWith("error:")) {
_error = event.substring(5);
_onError?.call(_error!);
} else if (event.startsWith("metadata:")) {
if (_onMetadata != null) {
final s = event.substring(9);
_onMetadata!(parseMetadata(s));
}
} else if (event == "AI_RESPONSE_LIMIT") {
_aiLimitReached = true;
_onAIResponseLimit?.call();
} else if (event == "AI_IMAGE_RESPONSE_LIMIT") {
_aiImageLimitReached = true;
_onAIImageResponseLimit?.call();
} else if (event.startsWith("AI_MAX_REQUIRED:")) {
final msg = event.substring(16);
// If the callback is not registered yet, add the event to the buffer.
if (_onAIMaxRequired != null) {
_onAIMaxRequired!(msg);
} else {
_pendingAIMaxRequiredEvents.add(msg);
}
}
},
onDone: () {
_onEnd?.call();
},
onError: (error) {
_error = error.toString();
_onError?.call(error.toString());
},
); );
} }
final RawReceivePort _port = RawReceivePort(); final RawReceivePort _port = RawReceivePort();
final StreamController<String> _controller = StreamController.broadcast(); final StreamController<String> _controller = StreamController.broadcast();
late StreamSubscription<String> _subscription; late StreamSubscription<String> _subscription;
bool _hasStarted = false; bool _hasStarted = false;
bool _aiLimitReached = false; bool _aiLimitReached = false;
bool _aiImageLimitReached = false; bool _aiImageLimitReached = false;
@ -62,13 +42,15 @@ class AnswerStream {
void Function()? _onStart; void Function()? _onStart;
void Function()? _onEnd; void Function()? _onEnd;
void Function(String error)? _onError; void Function(String error)? _onError;
void Function()? _onLocalAIInitializing;
void Function()? _onAIResponseLimit; void Function()? _onAIResponseLimit;
void Function()? _onAIImageResponseLimit; void Function()? _onAIImageResponseLimit;
void Function(String message)? _onAIMaxRequired; void Function(String message)? _onAIMaxRequired;
void Function(MetadataCollection metadataCollection)? _onMetadata; void Function(MetadataCollection metadata)? _onMetadata;
// Buffer for events that occur before listen() is called. // Caches for events that occur before listen() is called.
final List<String> _pendingAIMaxRequiredEvents = []; final List<String> _pendingAIMaxRequiredEvents = [];
bool _pendingLocalAINotReady = false;
int get nativePort => _port.sendPort.nativePort; int get nativePort => _port.sendPort.nativePort;
bool get hasStarted => _hasStarted; bool get hasStarted => _hasStarted;
@ -77,12 +59,61 @@ class AnswerStream {
String? get error => _error; String? get error => _error;
String get text => _text; String get text => _text;
/// Releases the resources used by the AnswerStream.
Future<void> dispose() async { Future<void> dispose() async {
await _controller.close(); await _controller.close();
await _subscription.cancel(); await _subscription.cancel();
_port.close(); _port.close();
} }
/// Handles incoming events from the underlying stream.
void _handleEvent(String event) {
if (event.startsWith(AnswerEventPrefix.data)) {
_hasStarted = true;
final newText = event.substring(AnswerEventPrefix.data.length);
_text += newText;
_onData?.call(_text);
} else if (event.startsWith(AnswerEventPrefix.error)) {
_error = event.substring(AnswerEventPrefix.error.length);
_onError?.call(_error!);
} else if (event.startsWith(AnswerEventPrefix.metadata)) {
final s = event.substring(AnswerEventPrefix.metadata.length);
_onMetadata?.call(parseMetadata(s));
} else if (event == AnswerEventPrefix.aiResponseLimit) {
_aiLimitReached = true;
_onAIResponseLimit?.call();
} else if (event == AnswerEventPrefix.aiImageResponseLimit) {
_aiImageLimitReached = true;
_onAIImageResponseLimit?.call();
} else if (event.startsWith(AnswerEventPrefix.aiMaxRequired)) {
final msg = event.substring(AnswerEventPrefix.aiMaxRequired.length);
if (_onAIMaxRequired != null) {
_onAIMaxRequired!(msg);
} else {
_pendingAIMaxRequiredEvents.add(msg);
}
} else if (event.startsWith(AnswerEventPrefix.localAINotReady)) {
if (_onLocalAIInitializing != null) {
_onLocalAIInitializing!();
} else {
_pendingLocalAINotReady = true;
}
}
}
void _onDoneCallback() {
_onEnd?.call();
}
void _handleError(dynamic error) {
_error = error.toString();
_onError?.call(_error!);
}
/// Registers listeners for various events.
///
/// If certain events have already occurred (e.g. AI_MAX_REQUIRED or LOCAL_AI_NOT_READY),
/// they will be flushed immediately.
void listen({ void listen({
void Function(String text)? onData, void Function(String text)? onData,
void Function()? onStart, void Function()? onStart,
@ -92,6 +123,7 @@ class AnswerStream {
void Function()? onAIImageResponseLimit, void Function()? onAIImageResponseLimit,
void Function(String message)? onAIMaxRequired, void Function(String message)? onAIMaxRequired,
void Function(MetadataCollection metadata)? onMetadata, void Function(MetadataCollection metadata)? onMetadata,
void Function()? onLocalAIInitializing,
}) { }) {
_onData = onData; _onData = onData;
_onStart = onStart; _onStart = onStart;
@ -99,10 +131,11 @@ class AnswerStream {
_onError = onError; _onError = onError;
_onAIResponseLimit = onAIResponseLimit; _onAIResponseLimit = onAIResponseLimit;
_onAIImageResponseLimit = onAIImageResponseLimit; _onAIImageResponseLimit = onAIImageResponseLimit;
_onMetadata = onMetadata;
_onAIMaxRequired = onAIMaxRequired; _onAIMaxRequired = onAIMaxRequired;
_onMetadata = onMetadata;
_onLocalAIInitializing = onLocalAIInitializing;
// Flush any buffered AI_MAX_REQUIRED events. // Flush pending AI_MAX_REQUIRED events.
if (_onAIMaxRequired != null && _pendingAIMaxRequiredEvents.isNotEmpty) { if (_onAIMaxRequired != null && _pendingAIMaxRequiredEvents.isNotEmpty) {
for (final msg in _pendingAIMaxRequiredEvents) { for (final msg in _pendingAIMaxRequiredEvents) {
_onAIMaxRequired!(msg); _onAIMaxRequired!(msg);
@ -110,6 +143,12 @@ class AnswerStream {
_pendingAIMaxRequiredEvents.clear(); _pendingAIMaxRequiredEvents.clear();
} }
// Flush pending LOCAL_AI_NOT_READY event.
if (_pendingLocalAINotReady && _onLocalAIInitializing != null) {
_onLocalAIInitializing!();
_pendingLocalAINotReady = false;
}
_onStart?.call(); _onStart?.call();
} }
} }

View File

@ -247,6 +247,9 @@ class _ChatContentPage extends StatelessWidget {
onChangeFormat: (format) => context onChangeFormat: (format) => context
.read<ChatBloc>() .read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, format)), .add(ChatEvent.regenerateAnswer(message.id, format)),
onStopStream: () => context.read<ChatBloc>().add(
const ChatEvent.stopStream(),
),
); );
}, },
); );

View File

@ -32,6 +32,7 @@ class ChatAIMessageWidget extends StatelessWidget {
required this.questionId, required this.questionId,
required this.chatId, required this.chatId,
required this.refSourceJsonString, required this.refSourceJsonString,
required this.onStopStream,
this.onSelectedMetadata, this.onSelectedMetadata,
this.onRegenerate, this.onRegenerate,
this.onChangeFormat, this.onChangeFormat,
@ -50,6 +51,7 @@ class ChatAIMessageWidget extends StatelessWidget {
final String? refSourceJsonString; final String? refSourceJsonString;
final void Function(ChatMessageRefSource metadata)? onSelectedMetadata; final void Function(ChatMessageRefSource metadata)? onSelectedMetadata;
final void Function()? onRegenerate; final void Function()? onRegenerate;
final void Function() onStopStream;
final void Function(PredefinedFormat)? onChangeFormat; final void Function(PredefinedFormat)? onChangeFormat;
final bool isStreaming; final bool isStreaming;
final bool isLastMessage; final bool isLastMessage;
@ -126,26 +128,39 @@ class ChatAIMessageWidget extends StatelessWidget {
); );
}, },
onError: (error) { onError: (error) {
onStopStream();
return ChatErrorMessageWidget( return ChatErrorMessageWidget(
errorMessage: LocaleKeys.chat_aiServerUnavailable.tr(), errorMessage: LocaleKeys.chat_aiServerUnavailable.tr(),
); );
}, },
onAIResponseLimit: () { onAIResponseLimit: () {
onStopStream();
return ChatErrorMessageWidget( return ChatErrorMessageWidget(
errorMessage: errorMessage:
LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(), LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(),
); );
}, },
onAIImageResponseLimit: () { onAIImageResponseLimit: () {
onStopStream();
return ChatErrorMessageWidget( return ChatErrorMessageWidget(
errorMessage: LocaleKeys.sideBar_purchaseAIMax.tr(), errorMessage: LocaleKeys.sideBar_purchaseAIMax.tr(),
); );
}, },
onAIMaxRequired: (message) { onAIMaxRequired: (message) {
onStopStream();
return ChatErrorMessageWidget( return ChatErrorMessageWidget(
errorMessage: message, errorMessage: message,
); );
}, },
onInitializingLocalAI: () {
onStopStream();
return ChatErrorMessageWidget(
errorMessage: LocaleKeys
.settings_aiPage_keys_localAIInitializing
.tr(),
);
},
), ),
), ),
); );

View File

@ -13,7 +13,7 @@ part 'local_ai_setting_panel_bloc.freezed.dart';
class LocalAISettingPanelBloc class LocalAISettingPanelBloc
extends Bloc<LocalAISettingPanelEvent, LocalAISettingPanelState> { extends Bloc<LocalAISettingPanelEvent, LocalAISettingPanelState> {
LocalAISettingPanelBloc() LocalAISettingPanelBloc()
: listener = LocalLLMListener(), : listener = LocalAIStateListener(),
super(const LocalAISettingPanelState()) { super(const LocalAISettingPanelState()) {
on<LocalAISettingPanelEvent>(_handleEvent); on<LocalAISettingPanelEvent>(_handleEvent);
@ -35,7 +35,7 @@ class LocalAISettingPanelBloc
); );
} }
final LocalLLMListener listener; final LocalAIStateListener listener;
/// Handles incoming events and dispatches them to the appropriate handler. /// Handles incoming events and dispatches them to the appropriate handler.
Future<void> _handleEvent( Future<void> _handleEvent(

View File

@ -11,8 +11,8 @@ import 'package:appflowy_result/appflowy_result.dart';
typedef PluginStateCallback = void Function(LocalAIPB state); typedef PluginStateCallback = void Function(LocalAIPB state);
typedef PluginResourceCallback = void Function(LackOfAIResourcePB data); typedef PluginResourceCallback = void Function(LackOfAIResourcePB data);
class LocalLLMListener { class LocalAIStateListener {
LocalLLMListener() { LocalAIStateListener() {
_parser = _parser =
ChatNotificationParser(id: "appflowy_ai_plugin", callback: _callback); ChatNotificationParser(id: "appflowy_ai_plugin", callback: _callback);
_subscription = RustStreamReceiver.listen( _subscription = RustStreamReceiver.listen(

View File

@ -11,7 +11,7 @@ part 'plugin_state_bloc.freezed.dart';
class PluginStateBloc extends Bloc<PluginStateEvent, PluginStateState> { class PluginStateBloc extends Bloc<PluginStateEvent, PluginStateState> {
PluginStateBloc() PluginStateBloc()
: listener = LocalLLMListener(), : listener = LocalAIStateListener(),
super( super(
const PluginStateState( const PluginStateState(
action: PluginStateAction.unknown(), action: PluginStateAction.unknown(),
@ -33,7 +33,7 @@ class PluginStateBloc extends Bloc<PluginStateEvent, PluginStateState> {
on<PluginStateEvent>(_handleEvent); on<PluginStateEvent>(_handleEvent);
} }
final LocalLLMListener listener; final LocalAIStateListener listener;
@override @override
Future<void> close() async { Future<void> close() async {

View File

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/settings/ai/local_ai_bloc.dart'; import 'package:appflowy/workspace/application/settings/ai/local_ai_bloc.dart';
import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart';
import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting_panel.dart'; import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/local_ai_setting_panel.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
@ -17,12 +16,6 @@ class LocalAISetting extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<SettingsAIBloc, SettingsAIState>(
builder: (context, state) {
if (state.aiSettings == null) {
return const SizedBox.shrink();
}
return BlocProvider( return BlocProvider(
create: (context) => create: (context) =>
LocalAIToggleBloc()..add(const LocalAIToggleEvent.started()), LocalAIToggleBloc()..add(const LocalAIToggleEvent.started()),
@ -55,11 +48,9 @@ class LocalAISetting extends StatelessWidget {
const VSpace(6), const VSpace(6),
DecoratedBox( DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context) color:
.colorScheme Theme.of(context).colorScheme.surfaceContainerHighest,
.surfaceContainerHighest, borderRadius: const BorderRadius.all(Radius.circular(4)),
borderRadius:
const BorderRadius.all(Radius.circular(4)),
), ),
child: const Padding( child: const Padding(
padding: padding:
@ -74,8 +65,6 @@ class LocalAISetting extends StatelessWidget {
), ),
), ),
); );
},
);
} }
} }
@ -87,12 +76,8 @@ class LocalAISettingHeader extends StatelessWidget {
return BlocBuilder<LocalAIToggleBloc, LocalAIToggleState>( return BlocBuilder<LocalAIToggleBloc, LocalAIToggleState>(
builder: (context, state) { builder: (context, state) {
return state.pageIndicator.when( return state.pageIndicator.when(
error: (error) { error: (error) => SizedBox.shrink(),
return const SizedBox.shrink(); loading: () => const SizedBox.shrink(),
},
loading: () {
return const SizedBox.shrink();
},
isEnabled: (isEnabled) { isEnabled: (isEnabled) {
return Row( return Row(
children: [ children: [

View File

@ -55,13 +55,7 @@ class SettingsAIView extends StatelessWidget {
]; ];
children.add(const _AISearchToggle(value: false)); children.add(const _AISearchToggle(value: false));
children.add( children.add(const LocalAISetting());
_LocalAIOnBoarding(
userProfile: userProfile,
currentWorkspaceMemberRole: state.currentWorkspaceMemberRole!,
workspaceId: workspaceId,
),
);
return SettingsBody( return SettingsBody(
title: LocaleKeys.settings_aiPage_title.tr(), title: LocaleKeys.settings_aiPage_title.tr(),

View File

@ -144,34 +144,34 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
SPEC CHECKSUMS: SPEC CHECKSUMS:
app_links: 9028728e32c83a0831d9db8cf91c526d16cc5468 app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a
appflowy_backend: 464aeb3e5c6966a41641a2111e5ead72ce2695f7 appflowy_backend: 865496343de667fc8c600e04b9fd05234e130cf9
auto_updater_macos: 3a42f1a06be6981f1a18be37e6e7bf86aa732118 auto_updater_macos: 3e3462c418fe4e731917eacd8d28eef7af84086d
bitsdojo_window_macos: 7959fb0ca65a3ccda30095c181ecb856fae48ea9 bitsdojo_window_macos: 44e3b8fe3dd463820e0321f6256c5b1c16bb6a00
connectivity_plus: e74b9f74717d2d99d45751750e266e55912baeb5 connectivity_plus: 18d3c32514c886e046de60e9c13895109866c747
desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43 desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898
device_info_plus: a56e6e74dbbd2bb92f2da12c64ddd4f67a749041 device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720
file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31 file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d
flowy_infra_ui: 8760ff42a789de40bf5007a5f176b454722a341e flowy_infra_ui: 03301a39ad118771adbf051a664265c61c507f38
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277 HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
hotkey_manager: b443f35f4d772162937aa73fd8995e579f8ac4e2 hotkey_manager: c32bf0bfe8f934b7bc17ab4ad5c4c142960b023c
irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478
local_notifier: ebf072651e35ae5e47280ad52e2707375cb2ae4e local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff
package_info_plus: f0052d280d17aa382b932f399edf32507174e870 package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161
Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1 Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1
sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90 sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc share_plus: 1fa619de8392a4398bfaf176d441853922614e89
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
Sparkle: 5f8960a7a119aa7d45dacc0d5837017170bc5675 Sparkle: 5f8960a7a119aa7d45dacc0d5837017170bc5675
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189 super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3
url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4
window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8
PODFILE CHECKSUM: 0532f3f001ca3110b8be345d6491fff690e95823 PODFILE CHECKSUM: 0532f3f001ca3110b8be345d6491fff690e95823

View File

@ -198,7 +198,7 @@ dependencies = [
[[package]] [[package]]
name = "appflowy-local-ai" name = "appflowy-local-ai"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=8fea7ed2375eb54c8dfb8af6db6e32a61854fb2e#8fea7ed2375eb54c8dfb8af6db6e32a61854fb2e" source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=f1b5167e9569e8a61ef50a1afb140306a5287e57#f1b5167e9569e8a61ef50a1afb140306a5287e57"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"appflowy-plugin", "appflowy-plugin",
@ -218,7 +218,7 @@ dependencies = [
[[package]] [[package]]
name = "appflowy-plugin" name = "appflowy-plugin"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=8fea7ed2375eb54c8dfb8af6db6e32a61854fb2e#8fea7ed2375eb54c8dfb8af6db6e32a61854fb2e" source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=f1b5167e9569e8a61ef50a1afb140306a5287e57#f1b5167e9569e8a61ef50a1afb140306a5287e57"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"cfg-if", "cfg-if",

View File

@ -152,5 +152,5 @@ collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFl
# To update the commit ID, run: # To update the commit ID, run:
# scripts/tool/update_local_ai_rev.sh new_rev_id # scripts/tool/update_local_ai_rev.sh new_rev_id
# ⚠️⚠️⚠️️ # ⚠️⚠️⚠️️
appflowy-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "8fea7ed2375eb54c8dfb8af6db6e32a61854fb2e" } appflowy-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "f1b5167e9569e8a61ef50a1afb140306a5287e57" }
appflowy-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "8fea7ed2375eb54c8dfb8af6db6e32a61854fb2e" } appflowy-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "f1b5167e9569e8a61ef50a1afb140306a5287e57" }

View File

@ -263,6 +263,10 @@ impl Chat {
let _ = answer_sink let _ = answer_sink
.send(format!("AI_MAX_REQUIRED:{}", err.msg)) .send(format!("AI_MAX_REQUIRED:{}", err.msg))
.await; .await;
} else if err.is_local_ai_not_ready() {
let _ = answer_sink
.send(format!("LOCAL_AI_NOT_READY:{}", err.msg))
.await;
} else { } else {
let _ = answer_sink.send(format!("error:{}", err)).await; let _ = answer_sink.send(format!("error:{}", err)).await;
} }

View File

@ -72,6 +72,10 @@ impl LocalAIController {
user_service: Arc<dyn AIUserService>, user_service: Arc<dyn AIUserService>,
cloud_service: Arc<dyn ChatCloudService>, cloud_service: Arc<dyn ChatCloudService>,
) -> Self { ) -> Self {
debug!(
"[AI Plugin] init local ai controller, thread: {:?}",
std::thread::current().id()
);
let local_ai = Arc::new(OllamaAIPlugin::new(plugin_manager)); let local_ai = Arc::new(OllamaAIPlugin::new(plugin_manager));
let res_impl = LLMResourceServiceImpl { let res_impl = LLMResourceServiceImpl {
user_service: user_service.clone(), user_service: user_service.clone(),
@ -176,7 +180,7 @@ impl LocalAIController {
if !self.is_enabled() { if !self.is_enabled() {
return false; return false;
} }
self.ai_plugin.get_plugin_running_state().is_ready() self.ai_plugin.get_plugin_running_state().is_running()
} }
/// Indicate whether the local AI is enabled. /// Indicate whether the local AI is enabled.

View File

@ -158,6 +158,7 @@ impl ChatCloudService for AICloudServiceMiddleware {
question_id: i64, question_id: i64,
format: ResponseFormat, format: ResponseFormat,
) -> Result<StreamAnswer, FlowyError> { ) -> Result<StreamAnswer, FlowyError> {
if self.local_ai.is_enabled() {
if self.local_ai.is_running() { if self.local_ai.is_running() {
let row = self.get_message_record(question_id)?; let row = self.get_message_record(question_id)?;
match self match self
@ -171,6 +172,9 @@ impl ChatCloudService for AICloudServiceMiddleware {
Ok(stream::once(async { Err(FlowyError::local_ai_unavailable()) }).boxed()) Ok(stream::once(async { Err(FlowyError::local_ai_unavailable()) }).boxed())
}, },
} }
} else {
Err(FlowyError::local_ai_not_ready())
}
} else { } else {
self self
.cloud_service .cloud_service

View File

@ -371,6 +371,9 @@ pub enum ErrorCode {
#[error("Request timeout")] #[error("Request timeout")]
RequestTimeout = 127, RequestTimeout = 127,
#[error("Local AI is not ready")]
LocalAINotReady = 128,
} }
impl ErrorCode { impl ErrorCode {

View File

@ -95,6 +95,10 @@ impl FlowyError {
self.code == ErrorCode::AIImageResponseLimitExceeded self.code == ErrorCode::AIImageResponseLimitExceeded
} }
pub fn is_local_ai_not_ready(&self) -> bool {
self.code == ErrorCode::LocalAINotReady
}
pub fn is_ai_max_required(&self) -> bool { pub fn is_ai_max_required(&self) -> bool {
self.code == ErrorCode::AIMaxRequired self.code == ErrorCode::AIMaxRequired
} }
@ -151,6 +155,7 @@ impl FlowyError {
static_flowy_error!(file_storage_limit, ErrorCode::FileStorageLimitExceeded); static_flowy_error!(file_storage_limit, ErrorCode::FileStorageLimitExceeded);
static_flowy_error!(view_is_locked, ErrorCode::ViewIsLocked); static_flowy_error!(view_is_locked, ErrorCode::ViewIsLocked);
static_flowy_error!(local_ai_not_ready, ErrorCode::LocalAINotReady);
} }
impl std::convert::From<ErrorCode> for FlowyError { impl std::convert::From<ErrorCode> for FlowyError {