Merge pull request #7650 from AppFlowy-IO/display_plugin_version

chore: show plugin version
This commit is contained in:
Nathan.fooo 2025-03-30 12:05:25 +08:00 committed by GitHub
commit 3c74208ab9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 136 additions and 212 deletions

View File

@ -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<DownloadOfflineAIEvent, DownloadOfflineAIState> {
DownloadOfflineAIBloc() : super(const DownloadOfflineAIState()) {
on<DownloadOfflineAIEvent>(_handleEvent);
}
Future<void> _handleEvent(
DownloadOfflineAIEvent event,
Emitter<DownloadOfflineAIState> 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;
}

View File

@ -44,7 +44,7 @@ class LocalAISettingPanelBloc
) async {
event.when(
updateAIState: (LocalAIPB pluginState) {
if (pluginState.isPluginExecutableReady) {
if (pluginState.pluginDownloaded) {
emit(
state.copyWith(
runningState: pluginState.state,

View File

@ -91,7 +91,11 @@ class PluginStateBloc extends Bloc<PluginStateEvent, PluginStateState> {
);
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;
}

View File

@ -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<DownloadOfflineAIBloc, DownloadOfflineAIState>(
builder: (context, state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
maxLines: 3,
textAlign: TextAlign.left,
text: TextSpan(
children: <TextSpan>[
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<DownloadOfflineAIBloc>().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});

View File

@ -3217,4 +3217,4 @@
"rewrite": "إعادة كتابة",
"insertBelow": "أدخل أدناه"
}
}
}

View File

@ -3202,4 +3202,4 @@
"rewrite": "Rewrite",
"insertBelow": "Insert below"
}
}
}

View File

@ -3185,4 +3185,4 @@
"rewrite": "다시 작성",
"insertBelow": "아래에 삽입"
}
}
}

View File

@ -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",

View File

@ -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" }

View File

@ -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<String>,
#[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<String>,
#[pb(index = 5)]
pub plugin_downloaded: bool,
}
#[derive(Default, ProtoBuf, Validate, Clone, Debug)]

View File

@ -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<Weak<AIManager>>,
) -> DataResult<LocalAIAppLinkPB, FlowyError> {
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<CreateChatContextPB>,

View File

@ -34,7 +34,6 @@ pub fn init(ai_manager: Weak<AIManager>) -> 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,

View File

@ -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 cant 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 isnt 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();