diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_offline_ai_app_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_offline_ai_app_bloc.dart deleted file mode 100644 index 185a8c049f..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/download_offline_ai_app_bloc.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'dart:async'; - -import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:bloc/bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:url_launcher/url_launcher.dart' show launchUrl; -part 'download_offline_ai_app_bloc.freezed.dart'; - -class DownloadOfflineAIBloc - extends Bloc { - DownloadOfflineAIBloc() : super(const DownloadOfflineAIState()) { - on(_handleEvent); - } - - Future _handleEvent( - DownloadOfflineAIEvent event, - Emitter emit, - ) async { - await event.when( - started: () async { - final result = await AIEventGetLocalAIDownloadLink().send(); - await result.fold( - (app) async { - await launchUrl(Uri.parse(app.link)); - }, - (err) {}, - ); - }, - ); - } -} - -@freezed -class DownloadOfflineAIEvent with _$DownloadOfflineAIEvent { - const factory DownloadOfflineAIEvent.started() = _Started; -} - -@freezed -class DownloadOfflineAIState with _$DownloadOfflineAIState { - const factory DownloadOfflineAIState() = _DownloadOfflineAIState; -} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart index f6d5ef949d..60c68b70c6 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/local_ai_setting_panel_bloc.dart @@ -44,7 +44,7 @@ class LocalAISettingPanelBloc ) async { event.when( updateAIState: (LocalAIPB pluginState) { - if (pluginState.isPluginExecutableReady) { + if (pluginState.pluginDownloaded) { emit( state.copyWith( runningState: pluginState.state, diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart index 4c3130ea00..d91d7151ab 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart @@ -91,7 +91,11 @@ class PluginStateBloc extends Bloc { ); break; case RunningStatePB.Running: - emit(const PluginStateState(action: PluginStateAction.running())); + emit( + PluginStateState( + action: PluginStateAction.running(aiState.pluginVersion), + ), + ); break; case RunningStatePB.Stopped: emit( @@ -140,7 +144,7 @@ class PluginStateAction with _$PluginStateAction { const factory PluginStateAction.unknown() = _Unknown; const factory PluginStateAction.readToRun() = _ReadyToRun; const factory PluginStateAction.initializingPlugin() = _InitializingPlugin; - const factory PluginStateAction.running() = _PluginRunning; + const factory PluginStateAction.running(String version) = _PluginRunning; const factory PluginStateAction.restartPlugin() = _RestartPlugin; const factory PluginStateAction.lackOfResource(String desc) = _LackOfResource; } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart index ab9303b429..12ce391a29 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/plugin_state.dart @@ -1,15 +1,11 @@ -import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/application/settings/ai/download_offline_ai_app_bloc.dart'; import 'package:appflowy/workspace/application/settings/ai/plugin_state_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/pages/setting_ai_view/init_local_ai.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/style_widget/button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/spacing.dart'; -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -27,7 +23,10 @@ class PluginStateIndicator extends StatelessWidget { unknown: () => const SizedBox.shrink(), readToRun: () => const _PrepareRunning(), initializingPlugin: () => const InitLocalAIIndicator(), - running: () => const _LocalAIRunning(), + running: (version) => _LocalAIRunning( + key: ValueKey(version), + version: version, + ), restartPlugin: () => const _RestartPluginButton(), lackOfResource: (desc) => _LackOfResource(desc: desc), ); @@ -88,7 +87,9 @@ class _RestartPluginButton extends StatelessWidget { } class _LocalAIRunning extends StatelessWidget { - const _LocalAIRunning(); + const _LocalAIRunning({required this.version, super.key}); + + final String version; @override Widget build(BuildContext context) { @@ -113,6 +114,14 @@ class _LocalAIRunning extends StatelessWidget { color: Color(0xFF2E7D32), ), const HSpace(6), + if (version.isNotEmpty) + Flexible( + child: FlowyText( + "($version) ", + fontSize: 11, + color: const Color(0xFF1E4620), + ), + ), Flexible( child: FlowyText( LocaleKeys.settings_aiPage_keys_localAIRunning.tr(), @@ -131,95 +140,6 @@ class _LocalAIRunning extends StatelessWidget { } } -class OpenOrDownloadOfflineAIApp extends StatelessWidget { - const OpenOrDownloadOfflineAIApp({required this.onRetry, super.key}); - - final VoidCallback onRetry; - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => DownloadOfflineAIBloc(), - child: BlocBuilder( - builder: (context, state) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - RichText( - maxLines: 3, - textAlign: TextAlign.left, - text: TextSpan( - children: [ - TextSpan( - text: - "${LocaleKeys.settings_aiPage_keys_offlineAIInstruction1.tr()} ", - style: Theme.of(context) - .textTheme - .bodySmall! - .copyWith(height: 1.5), - ), - TextSpan( - text: - " ${LocaleKeys.settings_aiPage_keys_offlineAIInstruction2.tr()} ", - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: FontSizes.s14, - color: Theme.of(context).colorScheme.primary, - height: 1.5, - ), - recognizer: TapGestureRecognizer() - ..onTap = () => afLaunchUrlString( - "https://docs.appflowy.io/docs/appflowy/product/appflowy-ai-offline", - ), - ), - TextSpan( - text: - " ${LocaleKeys.settings_aiPage_keys_offlineAIInstruction3.tr()} ", - style: Theme.of(context) - .textTheme - .bodySmall! - .copyWith(height: 1.5), - ), - TextSpan( - text: - "${LocaleKeys.settings_aiPage_keys_offlineAIDownload1.tr()} ", - style: Theme.of(context) - .textTheme - .bodySmall! - .copyWith(height: 1.5), - ), - TextSpan( - text: - " ${LocaleKeys.settings_aiPage_keys_offlineAIDownload2.tr()} ", - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: FontSizes.s14, - color: Theme.of(context).colorScheme.primary, - height: 1.5, - ), - recognizer: TapGestureRecognizer() - ..onTap = - () => context.read().add( - const DownloadOfflineAIEvent.started(), - ), - ), - TextSpan( - text: - " ${LocaleKeys.settings_aiPage_keys_offlineAIDownload3.tr()} ", - style: Theme.of(context) - .textTheme - .bodySmall! - .copyWith(height: 1.5), - ), - ], - ), - ), - ], - ); - }, - ), - ); - } -} - class _LackOfResource extends StatelessWidget { const _LackOfResource({required this.desc}); diff --git a/frontend/resources/translations/ar-SA.json b/frontend/resources/translations/ar-SA.json index 62708e664f..e6a8be240c 100644 --- a/frontend/resources/translations/ar-SA.json +++ b/frontend/resources/translations/ar-SA.json @@ -3217,4 +3217,4 @@ "rewrite": "إعادة كتابة", "insertBelow": "أدخل أدناه" } -} +} \ No newline at end of file diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 2b22da596c..59877db7a6 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -3202,4 +3202,4 @@ "rewrite": "Rewrite", "insertBelow": "Insert below" } -} +} \ No newline at end of file diff --git a/frontend/resources/translations/ko-KR.json b/frontend/resources/translations/ko-KR.json index f956327073..635efe92de 100644 --- a/frontend/resources/translations/ko-KR.json +++ b/frontend/resources/translations/ko-KR.json @@ -3185,4 +3185,4 @@ "rewrite": "다시 작성", "insertBelow": "아래에 삽입" } -} +} \ No newline at end of file diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index 01b9251e23..69d6220409 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -345,7 +345,7 @@ dependencies = [ [[package]] name = "af-local-ai" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=a7d4ca96ec30cad941b67acb1e0e426d689c1270#a7d4ca96ec30cad941b67acb1e0e426d689c1270" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=4b3d50cbec2f58be2ac385231b8f585f1555e282#4b3d50cbec2f58be2ac385231b8f585f1555e282" dependencies = [ "af-plugin", "anyhow", @@ -365,7 +365,7 @@ dependencies = [ [[package]] name = "af-mcp" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=a7d4ca96ec30cad941b67acb1e0e426d689c1270#a7d4ca96ec30cad941b67acb1e0e426d689c1270" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=4b3d50cbec2f58be2ac385231b8f585f1555e282#4b3d50cbec2f58be2ac385231b8f585f1555e282" dependencies = [ "anyhow", "futures-util", @@ -379,7 +379,7 @@ dependencies = [ [[package]] name = "af-plugin" version = "0.1.0" -source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=a7d4ca96ec30cad941b67acb1e0e426d689c1270#a7d4ca96ec30cad941b67acb1e0e426d689c1270" +source = "git+https://github.com/AppFlowy-IO/AppFlowy-LocalAI?rev=4b3d50cbec2f58be2ac385231b8f585f1555e282#4b3d50cbec2f58be2ac385231b8f585f1555e282" dependencies = [ "anyhow", "cfg-if", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index b4a0a34b14..9245232f29 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -152,6 +152,6 @@ collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFl # To update the commit ID, run: # scripts/tool/update_local_ai_rev.sh new_rev_id # ⚠️⚠️⚠️️ -af-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "a7d4ca96ec30cad941b67acb1e0e426d689c1270" } -af-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "a7d4ca96ec30cad941b67acb1e0e426d689c1270" } -af-mcp = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "a7d4ca96ec30cad941b67acb1e0e426d689c1270" } +af-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "4b3d50cbec2f58be2ac385231b8f585f1555e282" } +af-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "4b3d50cbec2f58be2ac385231b8f585f1555e282" } +af-mcp = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "4b3d50cbec2f58be2ac385231b8f585f1555e282" } diff --git a/frontend/rust-lib/flowy-ai/src/entities.rs b/frontend/rust-lib/flowy-ai/src/entities.rs index b24d0cb13e..30a3e5dd28 100644 --- a/frontend/rust-lib/flowy-ai/src/entities.rs +++ b/frontend/rust-lib/flowy-ai/src/entities.rs @@ -585,20 +585,17 @@ pub struct LocalAIPB { #[pb(index = 1)] pub enabled: bool, - #[pb(index = 2)] - pub is_plugin_executable_ready: bool, - - #[pb(index = 3, one_of)] + #[pb(index = 2, one_of)] pub lack_of_resource: Option, - #[pb(index = 4)] + #[pb(index = 3)] pub state: RunningStatePB, -} -#[derive(Default, ProtoBuf, Clone, Debug)] -pub struct LocalAIAppLinkPB { - #[pb(index = 1)] - pub link: String, + #[pb(index = 4, one_of)] + pub plugin_version: Option, + + #[pb(index = 5)] + pub plugin_downloaded: bool, } #[derive(Default, ProtoBuf, Validate, Clone, Debug)] diff --git a/frontend/rust-lib/flowy-ai/src/event_handler.rs b/frontend/rust-lib/flowy-ai/src/event_handler.rs index ec8b7b4964..3150d3e378 100644 --- a/frontend/rust-lib/flowy-ai/src/event_handler.rs +++ b/frontend/rust-lib/flowy-ai/src/event_handler.rs @@ -301,15 +301,6 @@ pub(crate) async fn get_local_ai_state_handler( data_result_ok(state) } -#[tracing::instrument(level = "debug", skip_all, err)] -pub(crate) async fn get_offline_app_handler( - ai_manager: AFPluginState>, -) -> DataResult { - let ai_manager = upgrade_ai_manager(ai_manager)?; - let link = ai_manager.local_ai.get_plugin_download_link().await?; - data_result_ok(LocalAIAppLinkPB { link }) -} - #[tracing::instrument(level = "debug", skip_all, err)] pub(crate) async fn create_chat_context_handler( data: AFPluginData, diff --git a/frontend/rust-lib/flowy-ai/src/event_map.rs b/frontend/rust-lib/flowy-ai/src/event_map.rs index 0e24ca6a21..51c49eaabb 100644 --- a/frontend/rust-lib/flowy-ai/src/event_map.rs +++ b/frontend/rust-lib/flowy-ai/src/event_map.rs @@ -34,7 +34,6 @@ pub fn init(ai_manager: Weak) -> AFPlugin { .event(AIEvent::RestartLocalAI, restart_local_ai_handler) .event(AIEvent::ToggleLocalAI, toggle_local_ai_handler) .event(AIEvent::GetLocalAIState, get_local_ai_state_handler) - .event(AIEvent::GetLocalAIDownloadLink, get_offline_app_handler) .event(AIEvent::GetLocalAISetting, get_local_ai_setting_handler) .event( AIEvent::UpdateLocalAISetting, @@ -97,9 +96,6 @@ pub enum AIEvent { #[event(output = "LocalAIPB")] GetLocalAIState = 19, - #[event(output = "LocalAIAppLinkPB")] - GetLocalAIDownloadLink = 22, - #[event(input = "CreateChatContextPB")] CreateChatContext = 23, diff --git a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs index c3f57e6fac..03b014c89d 100644 --- a/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs +++ b/frontend/rust-lib/flowy-ai/src/local_ai/controller.rs @@ -77,61 +77,88 @@ impl LocalAIController { "[AI Plugin] init local ai controller, thread: {:?}", std::thread::current().id() ); + + // Create the core plugin and resource controller let local_ai = Arc::new(OllamaAIPlugin::new(plugin_manager)); let res_impl = LLMResourceServiceImpl { user_service: user_service.clone(), cloud_service: cloud_service.clone(), store_preferences: store_preferences.clone(), }; - let local_ai_resource = Arc::new(LocalAIResourceController::new( user_service.clone(), res_impl, )); - let current_chat_id = ArcSwapOption::default(); + // Subscribe to state changes let mut running_state_rx = local_ai.subscribe_running_state(); - let cloned_llm_res = local_ai_resource.clone(); - let cloned_store_preferences = store_preferences.clone(); - let cloned_user_service = user_service.clone(); + + let cloned_llm_res = Arc::clone(&local_ai_resource); + let cloned_store_preferences = Arc::clone(&store_preferences); + let cloned_local_ai = Arc::clone(&local_ai); + let cloned_user_service = Arc::clone(&user_service); + + // Spawn a background task to listen for plugin state changes tokio::spawn(async move { while let Some(state) = running_state_rx.next().await { - if let Ok(workspace_id) = cloned_user_service.workspace_id() { - let key = local_ai_enabled_key(&workspace_id); - info!("[AI Plugin] state: {:?}", state); - let mut ready = false; - let mut lack_of_resource = None; - let enabled = cloned_store_preferences.get_bool(&key).unwrap_or(true); - if !matches!(state, RunningState::UnexpectedStop { .. }) && enabled { - ready = is_plugin_ready(); - lack_of_resource = cloned_llm_res.get_lack_of_resource().await; - } + // Skip if we can’t get workspace_id + let Ok(workspace_id) = cloned_user_service.workspace_id() else { + continue; + }; - let new_state = RunningStatePB::from(state); - chat_notification_builder( - APPFLOWY_AI_NOTIFICATION_KEY, - ChatNotification::UpdateLocalAIState, - ) - .payload(LocalAIPB { - enabled, - is_plugin_executable_ready: ready, - lack_of_resource, - state: new_state, - }) - .send(); - } + let key = local_ai_enabled_key(&workspace_id); + info!("[AI Plugin] state: {:?}", state); + + // Read whether plugin is enabled from store; default to true + let enabled = cloned_store_preferences.get_bool(&key).unwrap_or(true); + + // Only check resource status if the plugin isn’t in "UnexpectedStop" and is enabled + let (plugin_downloaded, lack_of_resource) = + if !matches!(state, RunningState::UnexpectedStop { .. }) && enabled { + // Possibly check plugin readiness and resource concurrency in parallel, + // but here we do it sequentially for clarity. + let downloaded = is_plugin_ready(); + let resource_lack = cloned_llm_res.get_lack_of_resource().await; + (downloaded, resource_lack) + } else { + (false, None) + }; + + // If plugin is running, retrieve version + let plugin_version = if matches!(state, RunningState::Running { .. }) { + match cloned_local_ai.plugin_info().await { + Ok(info) => Some(info.version), + Err(_) => None, + } + } else { + None + }; + + // Broadcast the new local AI state + let new_state = RunningStatePB::from(state); + chat_notification_builder( + APPFLOWY_AI_NOTIFICATION_KEY, + ChatNotification::UpdateLocalAIState, + ) + .payload(LocalAIPB { + enabled, + plugin_downloaded, + lack_of_resource, + state: new_state, + plugin_version, + }) + .send(); } }); Self { ai_plugin: local_ai, resource: local_ai_resource, - current_chat_id, + current_chat_id: ArcSwapOption::default(), store_preferences, user_service, cloud_service, } } - #[instrument(level = "debug", skip_all)] pub async fn observe_plugin_resource(&self) { debug!( @@ -274,28 +301,56 @@ impl LocalAIController { pub async fn get_local_ai_state(&self) -> LocalAIPB { let start = std::time::Instant::now(); let enabled = self.is_enabled(); - let mut is_plugin_executable_ready = false; - let mut state = RunningState::ReadyToConnect; - let mut lack_of_resource = None; - if enabled { - is_plugin_executable_ready = is_plugin_ready(); - state = self.ai_plugin.get_plugin_running_state(); - lack_of_resource = self.resource.get_lack_of_resource().await; + + // If not enabled, return immediately. + if !enabled { + debug!( + "[AI Plugin] get local ai state, elapsed: {:?}, thread: {:?}", + start.elapsed(), + std::thread::current().id() + ); + return LocalAIPB { + enabled: false, + plugin_downloaded: false, + state: RunningStatePB::from(RunningState::ReadyToConnect), + lack_of_resource: None, + plugin_version: None, + }; } + + let plugin_downloaded = is_plugin_ready(); + let state = self.ai_plugin.get_plugin_running_state(); + + // If the plugin is running, run both requests in parallel. + // Otherwise, only fetch the resource info. + let (plugin_version, lack_of_resource) = if matches!(state, RunningState::Running { .. }) { + // Launch both futures at once + let plugin_info_fut = self.ai_plugin.plugin_info(); + let resource_fut = self.resource.get_lack_of_resource(); + + let (plugin_info_res, resource_res) = tokio::join!(plugin_info_fut, resource_fut); + let plugin_version = plugin_info_res.ok().map(|info| info.version); + (plugin_version, resource_res) + } else { + let resource_res = self.resource.get_lack_of_resource().await; + (None, resource_res) + }; + let elapsed = start.elapsed(); debug!( "[AI Plugin] get local ai state, elapsed: {:?}, thread: {:?}", elapsed, std::thread::current().id() ); + LocalAIPB { enabled, - is_plugin_executable_ready, + plugin_downloaded, state: RunningStatePB::from(state), lack_of_resource, + plugin_version, } } - #[instrument(level = "debug", skip_all)] pub async fn restart_plugin(&self) { if let Err(err) = initialize_ai_plugin(&self.ai_plugin, &self.resource, None).await { @@ -442,9 +497,10 @@ impl LocalAIController { ) .payload(LocalAIPB { enabled, - is_plugin_executable_ready: true, + plugin_downloaded: true, state: RunningStatePB::Stopped, lack_of_resource: None, + plugin_version: None, }) .send(); } @@ -466,9 +522,10 @@ async fn initialize_ai_plugin( ) .payload(LocalAIPB { enabled: true, - is_plugin_executable_ready: true, + plugin_downloaded: true, state: RunningStatePB::ReadyToRun, lack_of_resource: lack_of_resource.clone(), + plugin_version: None, }) .send();