Skip to content

Support switch local models#7834

Merged
appflowy merged 7 commits intomainfrom
support_switch_local_models
Apr 26, 2025
Merged

Support switch local models#7834
appflowy merged 7 commits intomainfrom
support_switch_local_models

Conversation

@appflowy
Copy link
Contributor

@appflowy appflowy commented Apr 25, 2025

Feature Preview


PR Checklist

  • My code adheres to AppFlowy's Conventions
  • I've listed at least one issue that this PR fixes in the description above.
  • I've added a test(s) to validate changes in this PR, or this PR only contains semantic changes.
  • All existing tests are passing.

Summary by Sourcery

Introduce support for selecting a default local AI model (Ollama) from a list of available models discovered on the user's machine.

New Features:

  • Allow users to select a default chat model from available local Ollama models in AI settings.
  • Display available local models fetched from the configured Ollama instance in settings.
  • Enable switching between available local and cloud models within the chat interface contextually.
  • Persist the user's selected default local model preference.
  • Fetch and display available models from the configured local AI provider (Ollama).

Enhancements:

  • Integrate the Ollama API client (ollama-rs) to interact with the local instance for model discovery.
  • Refactor AI model management logic in both frontend and backend to handle lists of local models and a user-selected default.
  • Improve the UI for selecting models in the chat input and AI settings pages, including animations.
  • Determine and cache whether a discovered local model is for chat or embedding.
  • Rename internal fields like selected_model to global_model and chat_model_name to global_chat_model for better clarity.
  • Update the AI model selection dropdown in settings to list available local models.
  • Fetch local models and settings concurrently on settings page load.
  • Restart local AI provider connection only when the server URL changes.
  • Add animations to the model selection button in the chat input action bar.
  • Make the model selection list in the chat input scrollable.
  • Add a dropdown in Ollama settings to select the default local chat model.
  • Refactor Ollama settings BLoC for better state management and event handling.
  • Refactor backend AI Manager to handle model selection logic more robustly, considering local vs cloud modes and model availability.
  • Refactor local AI controller to use Ollama client for fetching models and checking types.
  • Adapt mobile AI settings UI to reflect model selection changes.
  • Adapt AI model state notifier to use the renamed global_model field.
  • Adapt workspace AI model selection UI to use the renamed global_model field.
  • Add error handling for Ollama API errors.
  • Add SQLite persistence layer for caching local AI model types.
  • Add database schema and migrations for the new local AI model table.
  • Update event handlers and mappings for new local model events.
  • Update default local model names to include ':latest' tag.
  • Add SQLite persistence for local AI model type caching.
  • Add database migration for the local_ai_model_table.

@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Apr 25, 2025

Reviewer's Guide by Sourcery

This pull request adds support for switching between available local AI models in the Ollama settings page and the chat prompt interface. It involves modifications to the frontend UI and Bloc logic to fetch and display local models, and backend changes to interact with the Ollama service, manage model selection persistence, and update AI settings accordingly. A new database table is introduced to track local model types.

No diagrams generated as the changes look simple and do not need a visual representation.

File-Level Changes

Change Details Files
Implement UI for selecting local AI models in Ollama settings.
  • Add LocalAIModelSelection widget to display and handle local model selection.
  • Use BlocBuilder to react to changes in available local models.
  • Use SettingsDropdown to present the list of available local models.
  • Dispatch OllamaSettingEvent.setDefaultModel when a model is selected.
frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/ollama_setting.dart
Modify Ollama setting Bloc to handle local model data and selection.
  • Add new event _DidLoadLocalModels and _SetDefaultModel to handle loading available local models and setting the default model.
  • Modify _handleStarted to fetch both local AI models and settings concurrently on initialization.
  • Add _onLoadLocalModels handler to update the state with fetched local models.
  • Add _onSetDefaultModel handler to update the state with the selected model and mark as edited.
  • Update _onSubmit to use the selectedModel from the state when saving the chat model setting.
  • Add localModels, selectedModel, and originalMap fields to OllamaSettingState.
  • Add extension methods toInputItems and toSubmittedItems to LocalAISettingPB for better data mapping.
  • Update SubmittedItem with a copyWith method.
frontend/appflowy_flutter/lib/workspace/application/settings/ai/ollama_setting_bloc.dart
Update chat prompt UI to display and allow switching of selected AI models.
  • Modify SelectModelPopoverContent to display local models separately from cloud models.
  • Update _CurrentModelButton to animate the size of the model name text.
  • Update AIModelSelection to filter available models into local and cloud lists.
  • Use globalModel instead of selectedModel from AvailableModelsPB.
frontend/appflowy_flutter/lib/ai/widgets/prompt_input/select_model_menu.dart
frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/setting_ai_view/model_selection.dart
Refactor AI Manager to handle local model listing and selection logic.
  • Modify stream_regenerate_response to use get_active_model if no specific model is provided.
  • Refactor update_local_ai_setting to only restart the plugin if the server URL changes and the plugin is not running, and handle model changes separately.
  • Update update_selected_model to use ai_available_models_key consistently and send notification with the correct key.
  • Modify toggle_local_ai to use GLOBAL_ACTIVE_MODEL_KEY.to_string() for consistency.
  • Refactor get_active_model to check available local models if the stored model is not found.
  • Revamp get_available_models to handle local and cloud models, determine the default/active model based on user preference and availability, and update the stored preference if necessary.
  • Add get_local_available_models method to specifically fetch local models.
frontend/rust-lib/flowy-ai/src/ai_manager.rs
Add functionality to Local AI Controller to list local models and check model types.
  • Initialize Ollama client in the controller constructor if the system is desktop.
  • Add ollama field to LocalAIController using ArcSwapOption.
  • Add get_all_chat_local_models and get_all_embedded_local_models methods to filter local models.
  • Add get_filtered_local_models helper function.
  • Add check_model_type method to determine if a local model is for chat or embedding and store the type in the database.
  • Remove plugin toggle logic from update_local_ai_setting.
frontend/rust-lib/flowy-ai/src/local_ai/controller.rs
Add new database table to store local AI model types.
  • Define local_ai_model_table schema in schema.rs.
  • Create new module local_model_sql.rs with LocalAIModelTable struct and functions for selecting and upserting local AI model types.
  • Add ModelType enum to represent chat and embedding models.
  • Include local_model_sql in flowy-ai-pub/src/persistence/mod.rs.
  • Add database migration files for creating and dropping the local_ai_model_table.
frontend/rust-lib/flowy-sqlite/src/schema.rs
frontend/rust-lib/flowy-ai-pub/src/persistence/mod.rs
frontend/rust-lib/flowy-ai-pub/src/persistence/local_model_sql.rs
frontend/rust-lib/flowy-sqlite/migrations/2025-04-25-071459_local_ai_model/up.sql
frontend/rust-lib/flowy-sqlite/migrations/2025-04-25-071459_local_ai_model/down.sql
Update Protocol Buffer definitions and mappings.
  • Rename selected_model to global_model in AvailableModelsPB.
  • Add RepeatedAIModelPB struct.
  • Rename chat_model_name to global_chat_model in LocalAISettingPB.
  • Update From implementations for LocalAISetting and LocalAISettingPB to reflect the field name change.
frontend/rust-lib/flowy-ai/src/entities.rs
Add event handler for fetching local AI models.
  • Add get_local_ai_models_handler function.
  • Add GetLocalAIModels event to the AIEvent enum.
  • Map the GetLocalAIModels event to its handler.
frontend/rust-lib/flowy-ai/src/event_handler.rs
frontend/rust-lib/flowy-ai/src/event_map.rs
Update AI Model State Notifier to use globalModel.
  • Update references from selectedModel to globalModel.
frontend/appflowy_flutter/lib/ai/service/ai_model_state_notifier.dart
Update mobile AI settings UI to use globalModel.
  • Update references from selectedModel to globalModel.
frontend/appflowy_flutter/lib/mobile/presentation/setting/ai/ai_settings_group.dart
Add ollama-rs dependency to flowy-error crate.
  • Add ollama-rs dependency under the cfg for desktop operating systems.
  • Implement From<ollama_rs::error::OllamaError> for FlowyError.
frontend/rust-lib/flowy-error/Cargo.toml
Add ollama-rs and af-mcp dependencies to flowy-ai crate.
  • Add ollama-rs and af-mcp dependencies under the cfg for desktop operating systems.
  • Add local_ai feature flag.
frontend/rust-lib/flowy-ai/Cargo.toml

Possibly linked issues

  • docs: s/APGL/AGPL/ #123: The PR adds support for selecting and managing local AI models, implementing the local inference feature requested.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@github-actions
Copy link

github-actions bot commented Apr 25, 2025

🥷 Ninja i18n – 🛎️ Translations need to be updated

Project /project.inlang

lint rule new reports level link
Missing translation 29 warning contribute (via Fink 🐦)

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @appflowy - I've reviewed your changes - here's some feedback:

Overall Comments:

  • The logic for determining the active model in ai_manager.rs::get_available_models appears complex; consider opportunities for simplification.
  • Review the naming consistency between backend (global_model) and frontend (selectedModel) concepts for clarity.
  • The local_ai feature flag added in flowy-ai/Cargo.toml does not seem to conditionally compile code; clarify its purpose or remove it.
Here's what I looked at during the review
  • 🟢 General issues: all looks good
  • 🟢 Security: all looks good
  • 🟢 Testing: all looks good
  • 🟡 Complexity: 2 issues found
  • 🟢 Documentation: all looks good

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

})
}

pub async fn get_available_models(&self, source: String) -> FlowyResult<AvailableModelsPB> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider extracting the active model selection logic and preference updating into helper functions to reduce nesting and improve code clarity.

You can reduce nesting by extracting distinct concerns into helper methods. For example, you could extract two helper functions: one to determine the active (server/default) model from the available models, and another to update stored preferences if needed. This isolates the decision logic and minimizes the nested conditions in get_available_models.

Step 1: Extract active model selection

Create a helper that selects the active model from a given list. For instance:

fn select_active_model(
    &self,
    all_models: &[AIModel],
    server_active_model: &AIModel,
    user_selected_model: Option<AIModel>,
) -> AIModel {
    // Use user-selected model if available and valid, otherwise default to server_active_model
    if let Some(user_model) = user_selected_model {
        if all_models.iter().any(|m| m.name == user_model.name) {
            return user_model;
        }
    }
    // Fallback: use server active model if it's available in the list
    if all_models.iter().any(|m| m.name == server_active_model.name) {
        return server_active_model.clone();
    }
    // Otherwise, return default
    AIModel::default()
}

Step 2: Refactor get_available_models using early returns and helpers

Update your method to call the above helper instead of inline nested conditions:

pub async fn get_available_models(&self, source: String) -> FlowyResult<AvailableModelsPB> {
    if self.user_service.is_local_model().await? {
        return self.get_local_available_models().await;
    }

    // Fetch server models and add local model if enabled
    let mut all_models: Vec<AIModel> = self
        .get_server_available_models()
        .await?
        .into_iter()
        .map(AIModel::from)
        .collect();

    if self.local_ai.is_enabled() {
        let setting = self.local_ai.get_local_ai_setting();
        all_models.push(AIModel::local(setting.chat_model_name, "".to_string()));
    }

    if all_models.is_empty() {
        return Ok(AvailableModelsPB {
            models: vec![],
            global_model: AIModelPB::default(),
        });
    }

    // Get server active model
    let server_active_model = self
        .get_workspace_select_model()
        .await
        .map(|m| AIModel::server(m, "".to_string()))
        .unwrap_or_else(|_| AIModel::default());

    let user_selected_model = self.get_active_model(&source).await;
    let active_model = self.select_active_model(&all_models, &server_active_model, user_selected_model);

    // Update stored preference if it has changed
    if active_model.name != self.get_active_model(&source).await.map(|m| m.name).unwrap_or_default() {
        if let Err(err) = self.update_selected_model(source.clone(), active_model.clone()).await {
            error!("[Model Selection] failed to update selected model: {}", err);
        }
    }

    Ok(AvailableModelsPB {
        models: all_models.into_iter().map(AIModelPB::from).collect(),
        global_model: AIModelPB::from(active_model),
    })
}

Actionable Steps:

  1. Extract the active model selection logic into a helper (as shown above).
  2. Use early returns to handle empty or local-only cases.
  3. Delegate update of stored preferences to keep the main flow clear.

These changes isolate different concerns, reduce nesting, and improve maintainability while keeping the functionality intact.

let cloned_local_ai = Arc::clone(&local_ai);
let cloned_user_service = Arc::clone(&user_service);
let ollama = ArcSwapOption::default();
let sys = get_operating_system();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider extracting the nested logic inside the tokio::spawn closures into helper functions to reduce nesting and code duplication.

Consider extracting the nested logic inside the `tokio::spawn` closures into one or more helper functions. This would reduce the deep nesting and duplicate code in the state change handling.

For example, you could create a helper like:

```rust
async fn process_state_change(
    state: RunningState,
    cloned_user_service: Arc<dyn AIUserService>,
    cloned_store_preferences: Weak<KVStorePreferences>,
    cloned_llm_res: Arc<LocalAIResourceController>,
    cloned_local_ai: Arc<OllamaAIPlugin>,
) {
    if let Ok(workspace_id) = cloned_user_service.workspace_id() {
        let key = local_ai_enabled_key(&workspace_id.to_string());
        let enabled = if let Some(sp) = cloned_store_preferences.upgrade() {
            sp.get_bool(&key).unwrap_or(true)
        } else {
            warn!("[AI Plugin] store preferences is dropped");
            return;
        };

        let (plugin_downloaded, lack_of_resource) =
            if !matches!(state, RunningState::UnexpectedStop { .. }) && enabled {
                let downloaded = is_plugin_ready();
                let resource_lack = cloned_llm_res.get_lack_of_resource().await;
                (downloaded, resource_lack)
            } else {
                (false, None)
            };

        let plugin_version = if matches!(state, RunningState::Running { .. }) {
            match cloned_local_ai.plugin_info().await {
                Ok(info) => Some(info.version),
                Err(_) => None,
            }
        } else {
            None
        };

        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();
    }
}

Then simplify your spawn block to:

tokio::spawn(async move {
    while let Some(state) = running_state_rx.next().await {
        process_state_change(
            state,
            Arc::clone(&cloned_user_service),
            cloned_store_preferences.clone(),
            Arc::clone(&cloned_llm_res),
            Arc::clone(&cloned_local_ai),
        ).await;
    }
});

This refactoring centralizes the state change processing logic and reduces nesting, preserving functionality and simplifying the control flow.

@appflowy appflowy merged commit ec5eb4e into main Apr 26, 2025
8 checks passed
@appflowy appflowy deleted the support_switch_local_models branch April 26, 2025 02:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant