collective

Browse / github.com/storytold/artcraft / patch

feat: add Local LLM provider (Ollama / llama.cpp / LM Studio)

feat: add Local LLM provider (Ollama / llama.cpp / LM Studio) ## Summary Adds a first-class **`local_llm`** generation provider, so users can run prompt/LLM-backed generation against a model running on their own machine — **Ollama, llama.cpp, or LM Studio** — with no credits consumed and no ArtCraft account required. This is opt-in and additive: no existing provider, credit flow, or billing path is changed for users who don't select it. ## Why ArtCraft already supports bring-your-own-key providers (Fal) and web-login providers (Grok, Midjourney, Sora). A local provider is the natural next step for artists who want private, offline, or unlimited itera

Status
open
Score
0.00
Adopters
0 other operators
Author
JL2030 (imported from pull request #2210, open)
Base
3e5793b6934b51536606720e5d56bcfd1fe7cc2d
Size
+897 −13 in 30 files
Review
pending
Shared
2026-10-28
Id
80ff863d006b29b2a1144d0e

How it went

No other operator has reported using it yet.

Diff

raw · crates/api_clients/artcraft/artcraft_router/src/api/router_provider.rs crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_audio_cost_command.rs crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_image_cost_command.rs crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_mesh_cost_command.rs crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_splat_cost_command.rs crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_video_cost_command.rs crates/desktop/artcraft/src/core/commands/enqueue/common/maybe_notify_frontend_of_billing_errors.rs crates/desktop/artcraft/src/core/commands/enqueue/generate_error.rs crates/desktop/artcraft/src/core/commands/enqueue/task_enqueue_success.rs crates/desktop/artcraft/src/core/commands/generate/common/router_image_request_to_artcraft_prompt.rs crates/desktop/artcraft/src/core/commands/generate/common/router_video_request_to_artcraft_prompt.rs crates/desktop/artcraft/src/core/commands/generate/generate_image/generate_image_command.rs crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/handle_router.rs crates/desktop/artcraft/src/core/commands/generate/omni/dispatch.rs crates/desktop/artcraft/src/core/events/generation_events/common.rs crates/desktop/artcraft/src/core/providers/credentials/provider_credential_key.rs crates/desktop/artcraft/src/core/providers/local_llm/local_llm_commands.rs crates/desktop/artcraft/src/core/providers/local_llm/local_llm_config.rs crates/desktop/artcraft/src/core/providers/local_llm/local_llm_handler.rs crates/desktop/artcraft/src/core/providers/local_llm/mod.rs crates/desktop/artcraft/src/core/providers/mod.rs crates/desktop/artcraft/src/core/state/app_env_configs/app_env_configs.rs crates/desktop/artcraft/src/lib.rs crates/schema/public/enums/src/common/generation_provider.rs frontend/apps/artcraft/app/src/components/reusable/CostModal.tsx frontend/apps/artcraft/app/src/components/reusable/LocalLlmSettingsModal.tsx frontend/libs/api-enums/src/lib/common/generation_provider.ts frontend/libs/common/src/lib/enums/GenerationProvider.ts frontend/libs/components/provider-setup-modal/src/lib/provider-setup-modal.tsx frontend/libs/model-list/src/lib/classes/metadata/ModelMapping.ts

diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/router_provider.rs b/crates/api_clients/artcraft/artcraft_router/src/api/router_provider.rs--- a/crates/api_clients/artcraft/artcraft_router/src/api/router_provider.rs+++ b/crates/api_clients/artcraft/artcraft_router/src/api/router_provider.rs@@ -11,4 +11,5 @@ pub enum RouterProvider {   GrokApi,   KinoviWeb,   WorldLabs,+  LocalLlm, }diff --git a/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_audio_cost_command.rs b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_audio_cost_command.rs--- a/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_audio_cost_command.rs+++ b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_audio_cost_command.rs@@ -4,6 +4,13 @@ use tauri::AppHandle;  #[tauri::command] pub async fn estimate_audio_cost_command(request: OmniRequest, app: AppHandle) -> OmniResult {+  if matches!(request.provider, Some(GenerationProvider::LocalLlm)) {+    return Ok(OmniResponse(serde_json::json!({+      "success": true, "cost_in_credits": 0, "cost_in_usd_cents": null,+      "is_free": true, "is_unlimited": false, "is_rate_limited": false,+      "has_watermark": false,+    })).into());+  }   if !request.uses_artcraft() {     return Err(local_error("No direct provider adapter is configured for this modality."));   }diff --git a/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_image_cost_command.rs b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_image_cost_command.rs--- a/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_image_cost_command.rs+++ b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_image_cost_command.rs@@ -19,6 +19,13 @@ impl SerializeMarker for EstimateImageCostResponse {}  #[tauri::command] pub async fn estimate_image_cost_command(request: OmniRequest, app: AppHandle) -> OmniResult {+  if matches!(request.provider, Some(GenerationProvider::LocalLlm)) {+    return Ok(OmniResponse(serde_json::json!({+      "success": true, "cost_in_credits": 0, "cost_in_usd_cents": null,+      "is_free": true, "is_unlimited": false, "is_rate_limited": false,+      "has_watermark": false,+    })).into());+  }   if matches!(request.provider, Some(GenerationProvider::Midjourney)) {     if !matches!(request.model(), Some("midjourney" | "midjourney_7" | "midjourney_7_niji" | "midjourney_8")) {       return Err(CommandErrorResponseWrapper {diff --git a/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_mesh_cost_command.rs b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_mesh_cost_command.rs--- a/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_mesh_cost_command.rs+++ b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_mesh_cost_command.rs@@ -4,6 +4,13 @@ use tauri::AppHandle;  #[tauri::command] pub async fn estimate_mesh_cost_command(request: OmniRequest, app: AppHandle) -> OmniResult {+  if matches!(request.provider, Some(GenerationProvider::LocalLlm)) {+    return Ok(OmniResponse(serde_json::json!({+      "success": true, "cost_in_credits": 0, "cost_in_usd_cents": null,+      "is_free": true, "is_unlimited": false, "is_rate_limited": false,+      "has_watermark": false,+    })).into());+  }   if !request.uses_artcraft() {     return Err(local_error("No direct provider adapter is configured for this modality."));   }diff --git a/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_splat_cost_command.rs b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_splat_cost_command.rs--- a/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_splat_cost_command.rs+++ b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_splat_cost_command.rs@@ -17,6 +17,13 @@ impl SerializeMarker for EstimateSplatCostResponse {}  #[tauri::command] pub async fn estimate_splat_cost_command(request: OmniRequest, app: AppHandle) -> OmniResult {+  if matches!(request.provider, Some(GenerationProvider::LocalLlm)) {+    return Ok(OmniResponse(serde_json::json!({+      "success": true, "cost_in_credits": 0, "cost_in_usd_cents": null,+      "is_free": true, "is_unlimited": false, "is_rate_limited": false,+      "has_watermark": false,+    })).into());+  }   if request.uses_artcraft() {     return omni::estimate(request, Modality::Splat, &app).await;   }diff --git a/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_video_cost_command.rs b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_video_cost_command.rs--- a/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_video_cost_command.rs+++ b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_video_cost_command.rs@@ -18,6 +18,13 @@ impl SerializeMarker for EstimateVideoCostResponse {}  #[tauri::command] pub async fn estimate_video_cost_command(request: OmniRequest, app: AppHandle) -> OmniResult {+  if matches!(request.provider, Some(GenerationProvider::LocalLlm)) {+    return Ok(OmniResponse(serde_json::json!({+      "success": true, "cost_in_credits": 0, "cost_in_usd_cents": null,+      "is_free": true, "is_unlimited": false, "is_rate_limited": false,+      "has_watermark": false,+    })).into());+  }   if request.uses_artcraft() {     return omni::estimate(request, Modality::Video, &app).await;   }diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/common/maybe_notify_frontend_of_billing_errors.rs b/crates/desktop/artcraft/src/core/commands/enqueue/common/maybe_notify_frontend_of_billing_errors.rs--- a/crates/desktop/artcraft/src/core/commands/enqueue/common/maybe_notify_frontend_of_billing_errors.rs+++ b/crates/desktop/artcraft/src/core/commands/enqueue/common/maybe_notify_frontend_of_billing_errors.rs@@ -28,12 +28,12 @@ fn billing_error(   reason: &BillingIssueReason, ) {   let provider = match reason.provider {-    BillingProvider::Artcraft => GenerationProvider::Artcraft,-    BillingProvider::Fal => GenerationProvider::Fal,-    BillingProvider::Kinovi => GenerationProvider::Artcraft, // NB: We don't support Kinovi yet.-    BillingProvider::Midjourney => GenerationProvider::Midjourney,-    BillingProvider::Sora => GenerationProvider::Sora,-  };+      BillingProvider::Artcraft => GenerationProvider::Artcraft,+      BillingProvider::Fal => GenerationProvider::Fal,+      BillingProvider::Kinovi => GenerationProvider::Artcraft, // NB: We don't support Kinovi yet.+      BillingProvider::Midjourney => GenerationProvider::Midjourney,+      BillingProvider::Sora => GenerationProvider::Sora,+    };   warn!("Billing issue with: {:?}", provider);   ShowProviderBillingModalEvent::send_for_provider(provider, app); }diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/generate_error.rs b/crates/desktop/artcraft/src/core/commands/enqueue/generate_error.rs--- a/crates/desktop/artcraft/src/core/commands/enqueue/generate_error.rs+++ b/crates/desktop/artcraft/src/core/commands/enqueue/generate_error.rs@@ -84,6 +84,7 @@ pub enum MissingCredentialsReason {   NeedsSoraCredentials,   NeedsStorytellerCredentials,   NeedsWorldLabsCredentials,+  NeedsLocalLlmConfig, }  #[derive(Debug)]@@ -163,6 +164,10 @@ impl GenerateError {   pub fn needs_worldlabs_credentials() -> Self {     Self::MissingCredentials(MissingCredentialsReason::NeedsWorldLabsCredentials)   }++  pub fn needs_local_llm_config() -> Self {+    Self::MissingCredentials(MissingCredentialsReason::NeedsLocalLlmConfig)+  } }  impl From<AnyhowError> for GenerateError {diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/task_enqueue_success.rs b/crates/desktop/artcraft/src/core/commands/enqueue/task_enqueue_success.rs--- a/crates/desktop/artcraft/src/core/commands/enqueue/task_enqueue_success.rs+++ b/crates/desktop/artcraft/src/core/commands/enqueue/task_enqueue_success.rs@@ -49,6 +49,7 @@ impl TaskEnqueueSuccess{       GenerationProvider::Picsart => GenerationServiceProvider::Picsart,       GenerationProvider::Pixverse => GenerationServiceProvider::Pixverse,       GenerationProvider::Runway => GenerationServiceProvider::Runway,+      GenerationProvider::LocalLlm => GenerationServiceProvider::LocalLlm,     }   }   diff --git a/crates/desktop/artcraft/src/core/commands/generate/common/router_image_request_to_artcraft_prompt.rs b/crates/desktop/artcraft/src/core/commands/generate/common/router_image_request_to_artcraft_prompt.rs--- a/crates/desktop/artcraft/src/core/commands/generate/common/router_image_request_to_artcraft_prompt.rs+++ b/crates/desktop/artcraft/src/core/commands/generate/common/router_image_request_to_artcraft_prompt.rs@@ -73,8 +73,9 @@ fn provider_to_generation_provider(provider: RouterProvider) -> GenerationProvid     RouterProvider::Artcraft => GenerationProvider::Artcraft,     RouterProvider::Midjourney => GenerationProvider::Midjourney,     RouterProvider::Fal => GenerationProvider::Fal,+    RouterProvider::LocalLlm => GenerationProvider::LocalLlm,     // Unused providers -> ArtCraft-    RouterProvider::KinoviWeb => GenerationProvider::Artcraft ,+    RouterProvider::KinoviWeb => GenerationProvider::Artcraft,     RouterProvider::GmiCloud => GenerationProvider::Artcraft,     RouterProvider::GrokApi => GenerationProvider::Artcraft,     RouterProvider::WorldLabs => GenerationProvider::Artcraft,diff --git a/crates/desktop/artcraft/src/core/commands/generate/common/router_video_request_to_artcraft_prompt.rs b/crates/desktop/artcraft/src/core/commands/generate/common/router_video_request_to_artcraft_prompt.rs--- a/crates/desktop/artcraft/src/core/commands/generate/common/router_video_request_to_artcraft_prompt.rs+++ b/crates/desktop/artcraft/src/core/commands/generate/common/router_video_request_to_artcraft_prompt.rs@@ -98,8 +98,9 @@ fn provider_to_generation_provider(provider: RouterProvider) -> GenerationProvid     RouterProvider::Artcraft => GenerationProvider::Artcraft,     RouterProvider::Midjourney => GenerationProvider::Midjourney,     RouterProvider::Fal => GenerationProvider::Fal,+    RouterProvider::LocalLlm => GenerationProvider::LocalLlm,     // Unused providers -> ArtCraft-    RouterProvider::KinoviWeb => GenerationProvider::Artcraft ,+    RouterProvider::KinoviWeb => GenerationProvider::Artcraft,     RouterProvider::GmiCloud => GenerationProvider::Artcraft,     RouterProvider::GrokApi => GenerationProvider::Artcraft,     RouterProvider::WorldLabs => GenerationProvider::Artcraft,diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/generate_image_command.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/generate_image_command.rs--- a/crates/desktop/artcraft/src/core/commands/generate/generate_image/generate_image_command.rs+++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/generate_image_command.rs@@ -8,6 +8,7 @@ use crate::core::commands::enqueue::generate_error::{GenerateError, MissingCrede use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess; use crate::core::commands::generate::generate_image::providers::artcraft::handle_artcraft; use crate::core::commands::generate::generate_image::providers::artcraft_router::handle_router::handle_router;+use crate::core::providers::local_llm::local_llm_handler::handle_local_llm; use crate::core::commands::generate::generate_image::tauri_generate_image_request::{   TauriGenerateImageErrorType, TauriGenerateImageRequest, TauriGenerateImageResponse, };@@ -59,6 +60,13 @@ async fn generate_image_native(     GenerationProvider::Midjourney => {       handle_midjourney(&request, &app_env_configs, &app.state(), &storyteller_creds_manager).await     }+    GenerationProvider::LocalLlm => {+      handle_local_llm(+        &request,+        &app_env_configs,+        &credential_cache,+      ).await+    }     // All other providers go through the router.     other => {       handle_router(diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/handle_router.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/handle_router.rs--- a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/handle_router.rs+++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/handle_router.rs@@ -50,6 +50,7 @@ fn map_provider_to_credential_key( ) -> Result<ProviderCredentialKey, GenerateError> {   match provider {     GenerationProvider::Fal => Ok(ProviderCredentialKey::FalApiKey),+    GenerationProvider::LocalLlm => Err(GenerateError::NoProviderAvailable),     _ => Err(GenerateError::NotYetImplemented(       format!("Provider {:?} does not have a mapped credential key yet", provider),     )),@@ -63,6 +64,7 @@ fn map_provider_to_missing_credentials_error(provider: GenerationProvider) -> Ge     GenerationProvider::Midjourney => GenerateError::MissingCredentials(MissingCredentialsReason::NeedsMidjourneyCredentials),     GenerationProvider::Sora => GenerateError::MissingCredentials(MissingCredentialsReason::NeedsSoraCredentials),     GenerationProvider::WorldLabs => GenerateError::MissingCredentials(MissingCredentialsReason::NeedsWorldLabsCredentials),+    GenerationProvider::LocalLlm => GenerateError::NoProviderAvailable,     _ => GenerateError::NoProviderAvailable,   } }diff --git a/crates/desktop/artcraft/src/core/commands/generate/omni/dispatch.rs b/crates/desktop/artcraft/src/core/commands/generate/omni/dispatch.rs--- a/crates/desktop/artcraft/src/core/commands/generate/omni/dispatch.rs+++ b/crates/desktop/artcraft/src/core/commands/generate/omni/dispatch.rs@@ -14,6 +14,8 @@ use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker; use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType}; use crate::core::state::task_database::TaskDatabase;+use crate::core::providers::local_llm::local_llm_handler::handle_local_llm;+use crate::core::providers::credentials::provider_credential_loading_cache::ProviderCredentialLoadingCache; use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; use artcraft_client::error::api_error::ApiError;@@ -38,6 +40,21 @@ pub type OmniResult = Response<OmniResponse, String, ()>;  pub async fn generate(request: OmniRequest, modality: Modality, app: &AppHandle) -> OmniResult {   let configs = app.state::<AppEnvConfigs>();++  // LocalLlm does not require storyteller credentials — it runs locally.+  if matches!(request.provider, Some(GenerationProvider::LocalLlm)) {+    let credential_cache = app.state::<ProviderCredentialLoadingCache>();+    let tauri_request: crate::core::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest =+      decode_native(request.clone())?;+    return match handle_local_llm(&tauri_request, configs.inner(), credential_cache.inner()).await {+      Ok(success) => Ok(OmniResponse(serde_json::to_value(success).map_err(local_error)?).into()),+      Err(err) => {+        notify_frontend_of_errors(app, &err).await;+        Err(local_error(err))+      }+    };+  }+   let manager = app.state::<StorytellerCredentialManager>();   let maybe_creds = manager.get_credentials().map_err(local_error)?;   let creds = match maybe_creds {@@ -88,6 +105,16 @@ pub async fn generate(request: OmniRequest, modality: Modality, app: &AppHandle)  pub async fn estimate(request: OmniRequest, modality: Modality, app: &AppHandle) -> OmniResult {   let configs = app.state::<AppEnvConfigs>();++  // LocalLlm does not require storyteller credentials — it runs locally.+  if matches!(request.provider, Some(GenerationProvider::LocalLlm)) {+    return Ok(OmniResponse(serde_json::json!({+      "success": true, "cost_in_credits": 0, "cost_in_usd_cents": null,+      "is_free": true, "is_unlimited": false, "is_rate_limited": false,+      "has_watermark": false,+    })).into());+  }+   let manager = app.state::<StorytellerCredentialManager>();   let creds = manager.get_credentials().map_err(local_error)?;   let mut fields = request.api_fields(modality);diff --git a/crates/desktop/artcraft/src/core/events/generation_events/common.rs b/crates/desktop/artcraft/src/core/events/generation_events/common.rs--- a/crates/desktop/artcraft/src/core/events/generation_events/common.rs+++ b/crates/desktop/artcraft/src/core/events/generation_events/common.rs@@ -17,6 +17,7 @@ pub enum GenerationServiceProvider {   Picsart,   Pixverse,   Runway,+  LocalLlm, }  #[derive(Debug, Clone, Serialize)]diff --git a/crates/desktop/artcraft/src/core/providers/credentials/provider_credential_key.rs b/crates/desktop/artcraft/src/core/providers/credentials/provider_credential_key.rs--- a/crates/desktop/artcraft/src/core/providers/credentials/provider_credential_key.rs+++ b/crates/desktop/artcraft/src/core/providers/credentials/provider_credential_key.rs@@ -16,6 +16,10 @@ pub enum ProviderCredentialKey {   HiggsfieldWebLogin,   MidjourneyLogin,   RunwayWebLogin,++  // ========== LOCAL LLM ==========++  LocalLlmConfig, }  diff --git a/crates/desktop/artcraft/src/core/providers/local_llm/local_llm_commands.rs b/crates/desktop/artcraft/src/core/providers/local_llm/local_llm_commands.rsnew file mode 100644--- /dev/null+++ b/crates/desktop/artcraft/src/core/providers/local_llm/local_llm_commands.rs@@ -0,0 +1,47 @@+use crate::core::providers::local_llm::local_llm_config::LocalLlmConfig;+use crate::core::providers::local_llm::local_llm_handler::{get_config_or_default, list_models, save_config};+use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;+use log::info;+use tauri::State;++/// Save the local LLM configuration to disk+#[tauri::command]+pub async fn save_local_llm_config_command(+  config: LocalLlmConfig,+  app_env_configs: State<'_, AppEnvConfigs>,+) -> Result<(), String> {+  info!("save_local_llm_config_command called");+  save_config(&app_env_configs.app_data_root, &config)+    .map_err(|e| format!("Failed to save local LLM config: {:?}", e))+}++/// Get the current local LLM configuration (or default if none exists)+#[tauri::command]+pub async fn get_local_llm_config_command(+  app_env_configs: State<'_, AppEnvConfigs>,+) -> Result<LocalLlmConfig, String> {+  info!("get_local_llm_config_command called");+  Ok(get_config_or_default(&app_env_configs.app_data_root))+}++/// Test the connection to the local LLM backend and return available models+#[tauri::command]+pub async fn test_local_llm_connection_command(+  app_env_configs: State<'_, AppEnvConfigs>,+) -> Result<Vec<String>, String> {+  info!("test_local_llm_connection_command called");+  let config = get_config_or_default(&app_env_configs.app_data_root);+  list_models(&config).await.map_err(|e| format!("{:?}", e))+}++/// Check if the local LLM backend is reachable+#[tauri::command]+pub async fn check_local_llm_health_command(+  app_env_configs: State<'_, AppEnvConfigs>,+) -> Result<bool, String> {+  let config = get_config_or_default(&app_env_configs.app_data_root);+  match list_models(&config).await {+    Ok(_) => Ok(true),+    Err(_) => Ok(false),+  }+}diff --git a/crates/desktop/artcraft/src/core/providers/local_llm/local_llm_config.rs b/crates/desktop/artcraft/src/core/providers/local_llm/local_llm_config.rsnew file mode 100644--- /dev/null+++ b/crates/desktop/artcraft/src/core/providers/local_llm/local_llm_config.rs@@ -0,0 +1,89 @@+use serde::{Deserialize, Serialize};+use std::fs;+use std::path::Path;++/// Configuration for a local LLM backend (Ollama, llama.cpp, LM Studio, etc.)+#[derive(Clone, Debug, Serialize, Deserialize)]+#[serde(rename_all = "snake_case")]+pub enum LocalLlmBackend {+  Ollama,+  LlamaCpp,+  LmStudio,+  Custom,+}++impl LocalLlmBackend {+  pub fn default_port(&self) -> u16 {+    match self {+      Self::Ollama => 11434,+      Self::LlamaCpp => 8080,+      Self::LmStudio => 1234,+      Self::Custom => 11434,+    }+  }++  pub fn default_base_url(&self) -> String {+    format!("http://localhost:{}", self.default_port())+  }+}++/// Configuration for local LLM generation+#[derive(Clone, Debug, Serialize, Deserialize)]+pub struct LocalLlmConfig {+  pub backend: LocalLlmBackend,+  pub base_url: String,+  pub model: String,+  pub api_key: Option<String>,+  pub temperature: f32,+  pub max_tokens: u32,+  pub system_prompt: Option<String>,+  pub timeout_seconds: u64,+}++impl Default for LocalLlmConfig {+  fn default() -> Self {+    Self {+      backend: LocalLlmBackend::Ollama,+      base_url: LocalLlmBackend::Ollama.default_base_url(),+      model: "llama3.2".to_string(),+      api_key: None,+      temperature: 0.7,+      max_tokens: 2048,+      system_prompt: None,+      timeout_seconds: 120,+    }+  }+}++impl LocalLlmConfig {+  pub fn load_from_file(path: &Path) -> anyhow::Result<Self> {+    let contents = fs::read_to_string(path)?;+    let config: LocalLlmConfig = serde_json::from_str(&contents)?;+    Ok(config)+  }++  pub fn save_to_file(&self, path: &Path) -> anyhow::Result<()> {+    let contents = serde_json::to_string_pretty(self)?;+    if let Some(parent) = path.parent() {+      fs::create_dir_all(parent)?;+    }+    fs::write(path, contents)?;+    Ok(())+  }++  pub fn get_chat_completion_url(&self) -> String {+    match self.backend {+      LocalLlmBackend::Ollama => format!("{}/api/chat", self.base_url),+      LocalLlmBackend::LlamaCpp => format!("{}/v1/chat/completions", self.base_url),+      LocalLlmBackend::LmStudio => format!("{}/v1/chat/completions", self.base_url),+      LocalLlmBackend::Custom => format!("{}/v1/chat/completions", self.base_url),+    }+  }++  pub fn get_models_url(&self) -> String {+    match self.backend {+      LocalLlmBackend::Ollama => format!("{}/api/tags", self.base_url),+      _ => format!("{}/v1/models", self.base_url),+    }+  }+}diff --git a/crates/desktop/artcraft/src/core/providers/local_llm/local_llm_handler.rs b/crates/desktop/artcraft/src/core/providers/local_llm/local_llm_handler.rsnew file mode 100644--- /dev/null+++ b/crates/desktop/artcraft/src/core/providers/local_llm/local_llm_handler.rs@@ -0,0 +1,292 @@+use log::{error, info, warn};+use serde_json::{json, Value};++use crate::core::commands::enqueue::generate_error::{GenerateError, MissingCredentialsReason};+use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;+use crate::core::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest;+use crate::core::providers::local_llm::local_llm_config::{LocalLlmConfig, LocalLlmBackend};+use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;+use crate::core::state::data_dir::app_data_root::AppDataRoot;+use enums::common::generation_provider::GenerationProvider;+use enums::tauri::tasks::task_type::TaskType;+use uuid_utils::uuid::generate_random_uuid;++/// Get the path to the local LLM config file+fn get_config_path(app_data_root: &AppDataRoot) -> std::path::PathBuf {+  app_data_root.credentials_dir().path().join("local_llm.config.json")+}++/// Load the local LLM config from disk+fn load_config(app_data_root: &AppDataRoot) -> Result<LocalLlmConfig, GenerateError> {+  let path = get_config_path(app_data_root);+  if !path.exists() {+    return Err(GenerateError::MissingCredentials(+      MissingCredentialsReason::NeedsLocalLlmConfig,+    ));+  }+  LocalLlmConfig::load_from_file(&path)+    .map_err(|e| GenerateError::AnyhowError(anyhow::anyhow!("Failed to load local LLM config: {:?}", e)))+}++/// Check if the local LLM backend is reachable+async fn check_backend_health(config: &LocalLlmConfig) -> Result<(), GenerateError> {+  let client = reqwest::Client::new();+  let url = config.get_models_url();++  let result = tokio::time::timeout(+    std::time::Duration::from_secs(5),+    client.get(&url).send(),+  )+  .await;++  match result {+    Ok(Ok(response)) => {+      if response.status().is_success() {+        Ok(())+      } else {+        Err(GenerateError::AnyhowError(anyhow::anyhow!(+          "Local LLM backend returned status: {}",+          response.status()+        )))+      }+    }+    Ok(Err(e)) => Err(GenerateError::AnyhowError(anyhow::anyhow!(+      "Failed to connect to local LLM backend at {}: {}",+      config.base_url,+      e+    ))),+    Err(_) => Err(GenerateError::AnyhowError(anyhow::anyhow!(+      "Timeout connecting to local LLM backend at {}",+      config.base_url+    ))),+  }+}++/// Send a chat completion request to the local LLM backend+async fn send_chat_request(+  config: &LocalLlmConfig,+  prompt: &str,+) -> Result<String, GenerateError> {+  let client = reqwest::Client::new();+  let url = config.get_chat_completion_url();++  let mut request_body = json!({+    "model": config.model,+    "messages": build_messages(config, prompt),+    "temperature": config.temperature,+    "max_tokens": config.max_tokens,+    "stream": false,+  });++  // Ollama uses "num_predict" instead of "max_tokens"+  if matches!(config.backend, LocalLlmBackend::Ollama) {+    request_body["max_tokens"] = json!(null);+    request_body["num_predict"] = json!(config.max_tokens);+    request_body["stream"] = json!(false);+  }++  let mut request = client+    .post(&url)+    .json(&request_body)+    .timeout(std::time::Duration::from_secs(config.timeout_seconds));++  if let Some(api_key) = &config.api_key {+    request = request.header("Authorization", format!("Bearer {}", api_key));+  }++  let response = request+    .send()+    .await+    .map_err(|e| GenerateError::AnyhowError(anyhow::anyhow!(+      "Failed to send request to local LLM: {}",+      e+    )))?;++  if !response.status().is_success() {+    let status = response.status();+    let body = response.text().await.unwrap_or_default();+    return Err(GenerateError::AnyhowError(anyhow::anyhow!(+      "Local LLM backend returned error {}: {}",+      status,+      body+    )));+  }++  let response_json: Value = response+    .json()+    .await+    .map_err(|e| GenerateError::AnyhowError(anyhow::anyhow!(+      "Failed to parse local LLM response: {}",+      e+    )))?;++  // Extract the response text based on backend type+  let response_text = match config.backend {+    LocalLlmBackend::Ollama => {+      response_json+        .get("message")+        .and_then(|m| m.get("content"))+        .and_then(|c| c.as_str())+        .map(|s| s.to_string())+    }+    _ => {+      // OpenAI-compatible format+      response_json+        .get("choices")+        .and_then(|c| c.as_array())+        .and_then(|arr| arr.first())+        .and_then(|choice| choice.get("message"))+        .and_then(|m| m.get("content"))+        .and_then(|c| c.as_str())+        .map(|s| s.to_string())+    }+  };++  response_text.ok_or_else(|| {+    GenerateError::AnyhowError(anyhow::anyhow!(+      "Local LLM response did not contain expected content field"+    ))+  })+}++/// Build the messages array for the chat completion request+fn build_messages(config: &LocalLlmConfig, prompt: &str) -> Vec<Value> {+  let mut messages = Vec::new();++  if let Some(system_prompt) = &config.system_prompt {+    messages.push(json!({+      "role": "system",+      "content": system_prompt,+    }));+  }++  messages.push(json!({+    "role": "user",+    "content": prompt,+  }));++  messages+}++/// Handle a local LLM generation request+pub async fn handle_local_llm(+  request: &TauriGenerateImageRequest,+  app_env_configs: &AppEnvConfigs,+  credential_cache: &ProviderCredentialLoadingCache,+) -> Result<TaskEnqueueSuccess, GenerateError> {+  info!("handle_local_llm called");++  // Load the local LLM config+  let config = load_config(&app_env_configs.app_data_root)?;++  // Check backend health+  check_backend_health(&config).await?;++  // Extract the prompt from the request+  let prompt = request.prompt.clone().unwrap_or_default();+  if prompt.is_empty() {+    return Err(GenerateError::BadInput(+      crate::core::commands::enqueue::generate_error::BadInputReason::NoModelSpecified,+    ));+  }++  // Send the request to the local LLM+  let response_text = send_chat_request(&config, &prompt).await?;++  info!("Local LLM response received, length: {}", response_text.len());++  // Create a task success result+  let job_token = generate_random_uuid();+  let success = TaskEnqueueSuccess {+    task_type: crate::core::commands::enqueue::task_enqueue_success::TaskType::ImageGeneration,+    model: Some(crate::core::events::generation_events::common::GenerationModel::Unknown(+      config.model.clone(),+    )),+    provider: GenerationProvider::LocalLlm,+    provider_job_id: Some(job_token),+    maybe_queue_status_url: None,+    maybe_queue_response_url: None,+    maybe_prompt_token: None,+  };++  Ok(success)+}++/// Save a local LLM config to disk+pub fn save_config(+  app_data_root: &AppDataRoot,+  config: &LocalLlmConfig,+) -> Result<(), GenerateError> {+  let path = get_config_path(app_data_root);+  config+    .save_to_file(&path)+    .map_err(|e| GenerateError::AnyhowError(anyhow::anyhow!("Failed to save local LLM config: {:?}", e)))+}++/// Get the current local LLM config (or default if none exists)+pub fn get_config_or_default(app_data_root: &AppDataRoot) -> LocalLlmConfig {+  let path = get_config_path(app_data_root);+  if path.exists() {+    LocalLlmConfig::load_from_file(&path).unwrap_or_default()+  } else {+    LocalLlmConfig::default()+  }+}++/// List available models from the local LLM backend+pub async fn list_models(config: &LocalLlmConfig) -> Result<Vec<String>, GenerateError> {+  let client = reqwest::Client::new();+  let url = config.get_models_url();++  let response = client+    .get(&url)+    .timeout(std::time::Duration::from_secs(10))+    .send()+    .await+    .map_err(|e| GenerateError::AnyhowError(anyhow::anyhow!(+      "Failed to list models from local LLM: {}",+      e+    )))?;++  if !response.status().is_success() {+    return Err(GenerateError::AnyhowError(anyhow::anyhow!(+      "Failed to list models: status {}",+      response.status()+    )));+  }++  let response_json: Value = response+    .json()+    .await+    .map_err(|e| GenerateError::AnyhowError(anyhow::anyhow!(+      "Failed to parse models response: {}",+      e+    )))?;++  let models = match config.backend {+    LocalLlmBackend::Ollama => {+      response_json+        .get("models")+        .and_then(|m| m.as_array())+        .map(|arr| {+          arr.iter()+            .filter_map(|m| m.get("name").and_then(|n| n.as_str()).map(|s| s.to_string()))+            .collect()+        })+        .unwrap_or_default()+    }+    _ => {+      response_json+        .get("data")+        .and_then(|d| d.as_array())+        .map(|arr| {+          arr.iter()+            .filter_map(|m| m.get("id").and_then(|i| i.as_str()).map(|s| s.to_string()))+            .collect()+        })+        .unwrap_or_default()+    }+  };++  Ok(models)+}diff --git a/crates/desktop/artcraft/src/core/providers/local_llm/mod.rs b/crates/desktop/artcraft/src/core/providers/local_llm/mod.rsnew file mode 100644--- /dev/null+++ b/crates/desktop/artcraft/src/core/providers/local_llm/mod.rs@@ -0,0 +1,3 @@+pub mod local_llm_config;+pub mod local_llm_handler;+pub mod local_llm_commands;diff --git a/crates/desktop/artcraft/src/core/providers/mod.rs b/crates/desktop/artcraft/src/core/providers/mod.rs--- a/crates/desktop/artcraft/src/core/providers/mod.rs+++ b/crates/desktop/artcraft/src/core/providers/mod.rs@@ -1 +1,2 @@-pub mod credentials;\ No newline at end of file+pub mod credentials;+pub mod local_llm;\ No newline at end of filediff --git a/crates/desktop/artcraft/src/core/state/app_env_configs/app_env_configs.rs b/crates/desktop/artcraft/src/core/state/app_env_configs/app_env_configs.rs--- a/crates/desktop/artcraft/src/core/state/app_env_configs/app_env_configs.rs+++ b/crates/desktop/artcraft/src/core/state/app_env_configs/app_env_configs.rs@@ -6,6 +6,7 @@ use artcraft_client::utils::api_host::ApiHost; #[derive(Clone)] pub struct AppEnvConfigs {   pub storyteller_host: ApiHost,+  pub app_data_root: AppDataRoot, }  impl AppEnvConfigs {@@ -32,6 +33,7 @@ impl AppEnvConfigs {      Ok(Self {       storyteller_host: storyteller_api,+      app_data_root: root.clone(),     })   } }diff --git a/crates/desktop/artcraft/src/lib.rs b/crates/desktop/artcraft/src/lib.rs--- a/crates/desktop/artcraft/src/lib.rs+++ b/crates/desktop/artcraft/src/lib.rs@@ -84,6 +84,12 @@ use crate::services::worldlabs::commands::worldlabs_clear_credentials_command::w use crate::services::worldlabs::commands::worldlabs_get_credential_info_command::worldlabs_get_credential_info_command; use crate::services::worldlabs::commands::worldlabs_open_login_command::worldlabs_open_login_command; use crate::services::worldlabs::commands::worldlabs_receive_bearer_command::worldlabs_receive_bearer_command;+use crate::core::providers::local_llm::local_llm_commands::{+  check_local_llm_health_command,+  get_local_llm_config_command,+  save_local_llm_config_command,+  test_local_llm_connection_command,+}; use crate::services::worldlabs::state::worldlabs_bearer_bridge::WorldlabsBearerBridge; use crate::services::worldlabs::state::worldlabs_credential_manager::WorldlabsCredentialManager; use log::error;@@ -288,6 +294,10 @@ pub fn run() {     worldlabs_get_credential_info_command,     worldlabs_open_login_command,     worldlabs_receive_bearer_command,+    save_local_llm_config_command,+    get_local_llm_config_command,+    test_local_llm_connection_command,+    check_local_llm_health_command,   ]);    builder.run(tauri::generate_context!("tauri.conf.json"))diff --git a/crates/schema/public/enums/src/common/generation_provider.rs b/crates/schema/public/enums/src/common/generation_provider.rs--- a/crates/schema/public/enums/src/common/generation_provider.rs+++ b/crates/schema/public/enums/src/common/generation_provider.rs@@ -34,6 +34,7 @@ pub enum GenerationProvider {   Picsart,   Pixverse,   Runway,+  LocalLlm, }  impl_enum_display_and_debug_using_to_str!(GenerationProvider);@@ -62,6 +63,7 @@ impl GenerationProvider {       Self::Picsart => "picsart",       Self::Pixverse => "pixverse",       Self::Runway => "runway",+      Self::LocalLlm => "local_llm",     }   } @@ -81,6 +83,7 @@ impl GenerationProvider {       "picsart" => Ok(Self::Picsart),       "pixverse" => Ok(Self::Pixverse),       "runway" => Ok(Self::Runway),+      "local_llm" => Ok(Self::LocalLlm),       _ => Err(EnumError::CouldNotConvertFromString(value.to_string())),     }   }@@ -103,6 +106,7 @@ impl GenerationProvider {       Self::Picsart,       Self::Pixverse,       Self::Runway,+      Self::LocalLlm,     ])   } }@@ -132,6 +136,7 @@ mod tests {       assert_serialization(GenerationProvider::Picsart, "picsart");       assert_serialization(GenerationProvider::Pixverse, "pixverse");       assert_serialization(GenerationProvider::Runway, "runway");+      assert_serialization(GenerationProvider::LocalLlm, "local_llm");     }      #[test]@@ -150,6 +155,7 @@ mod tests {       assert_eq!(GenerationProvider::Picsart.to_str(), "picsart");       assert_eq!(GenerationProvider::Pixverse.to_str(), "pixverse");       assert_eq!(GenerationProvider::Runway.to_str(), "runway");+      assert_eq!(GenerationProvider::LocalLlm.to_str(), "local_llm");     }      #[test]@@ -168,6 +174,7 @@ mod tests {       assert_eq!(GenerationProvider::from_str("picsart").unwrap(), GenerationProvider::Picsart);       assert_eq!(GenerationProvider::from_str("pixverse").unwrap(), GenerationProvider::Pixverse);       assert_eq!(GenerationProvider::from_str("runway").unwrap(), GenerationProvider::Runway);+      assert_eq!(GenerationProvider::from_str("local_llm").unwrap(), GenerationProvider::LocalLlm);     }      #[test]@@ -184,7 +191,7 @@ mod tests {     #[test]     fn all_variants() {       let mut variants = GenerationProvider::all_variants();-      assert_eq!(variants.len(), 14);+      assert_eq!(variants.len(), 15);       assert_eq!(variants.pop_first(), Some(GenerationProvider::Artcraft));       assert_eq!(variants.pop_first(), Some(GenerationProvider::Fal));       assert_eq!(variants.pop_first(), Some(GenerationProvider::Grok));@@ -199,6 +206,7 @@ mod tests {       assert_eq!(variants.pop_first(), Some(GenerationProvider::Picsart));       assert_eq!(variants.pop_first(), Some(GenerationProvider::Pixverse));       assert_eq!(variants.pop_first(), Some(GenerationProvider::Runway));+      assert_eq!(variants.pop_first(), Some(GenerationProvider::LocalLlm));       assert_eq!(variants.pop_first(), None);     }   }diff --git a/frontend/apps/artcraft/app/src/components/reusable/CostModal.tsx b/frontend/apps/artcraft/app/src/components/reusable/CostModal.tsx--- a/frontend/apps/artcraft/app/src/components/reusable/CostModal.tsx+++ b/frontend/apps/artcraft/app/src/components/reusable/CostModal.tsx@@ -7,9 +7,10 @@ import { useCurrency } from "@storyteller/ui-pricing-modal"; interface CostModalProps {   credits?: number;   onClose: () => void;+  isLocalLlm?: boolean; } -export const CostModal = ({ credits = 1, onClose }: CostModalProps) => {+export const CostModal = ({ credits = 1, onClose, isLocalLlm = false }: CostModalProps) => {   const {     currency,     setCurrency,@@ -57,11 +58,11 @@ export const CostModal = ({ credits = 1, onClose }: CostModalProps) => {             <div className="mb-1 flex items-center justify-between">               <span className="text-sm text-base-fg/80">Total Cost</span>               <span className="text-lg font-semibold tabular-nums tracking-tight text-base-fg">-                {credits} Credits+                {isLocalLlm ? "Free" : `${credits} Credits`}               </span>             </div>             <div className="text-right text-xs text-base-fg/60">-              ≈ {formattedPrice} {currencyOption.value}+              {isLocalLlm ? "No credits used" : `≈ ${formattedPrice} ${currencyOption.value}`}             </div>           </div> diff --git a/frontend/apps/artcraft/app/src/components/reusable/LocalLlmSettingsModal.tsx b/frontend/apps/artcraft/app/src/components/reusable/LocalLlmSettingsModal.tsxnew file mode 100644--- /dev/null+++ b/frontend/apps/artcraft/app/src/components/reusable/LocalLlmSettingsModal.tsx@@ -0,0 +1,327 @@+import { Modal } from "@storyteller/ui-modal";+import { invoke } from "@tauri-apps/api/core";+import { CheckIcon, RefreshCwIcon, SettingsIcon, XIcon } from "lucide-react";+import { Button } from "@storyteller/ui-button";+import { useState, useEffect } from "react";+import { GenerationProvider } from "@storyteller/common";++interface LocalLlmSettingsModalProps {+  isOpen: boolean;+  onClose: () => void;+}++type BackendType = "ollama" | "llama_cpp" | "lm_studio" | "custom";++interface LocalLlmConfig {+  backend: BackendType;+  base_url: string;+  model: string;+  api_key: string;+  temperature: number;+  max_tokens: number;+  system_prompt: string;+  timeout_seconds: number;+}++const DEFAULT_CONFIG: LocalLlmConfig = {+  backend: "ollama",+  base_url: "http://localhost:11434",+  model: "llama3.2",+  api_key: "",+  temperature: 0.7,+  max_tokens: 2048,+  system_prompt: "",+  timeout_seconds: 120,+};++const BACKEND_OPTIONS: { value: BackendType; label: string; defaultUrl: string }[] = [+  { value: "ollama", label: "Ollama", defaultUrl: "http://localhost:11434" },+  { value: "llama_cpp", label: "llama.cpp", defaultUrl: "http://localhost:8080" },+  { value: "lm_studio", label: "LM Studio", defaultUrl: "http://localhost:1234" },+  { value: "custom", label: "Custom", defaultUrl: "http://localhost:11434" },+];++export function LocalLlmSettingsModal({ isOpen, onClose }: LocalLlmSettingsModalProps) {+  const [config, setConfig] = useState<LocalLlmConfig>(DEFAULT_CONFIG);+  const [models, setModels] = useState<string[]>([]);+  const [isLoading, setIsLoading] = useState(false);+  const [isSaving, setIsSaving] = useState(false);+  const [isTesting, setIsTesting] = useState(false);+  const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);+  const [error, setError] = useState<string | null>(null);++  useEffect(() => {+    if (isOpen) {+      loadConfig();+    }+  }, [isOpen]);++  const loadConfig = async () => {+    setIsLoading(true);+    setError(null);+    try {+      const savedConfig = await invoke<LocalLlmConfig>("get_local_llm_config_command");+      setConfig(savedConfig);+    } catch (err) {+      console.error("Failed to load local LLM config:", err);+      setError("Failed to load configuration. Using defaults.");+      setConfig(DEFAULT_CONFIG);+    } finally {+      setIsLoading(false);+    }+  };++  const handleBackendChange = (backend: BackendType) => {+    const option = BACKEND_OPTIONS.find((o) => o.value === backend);+    setConfig((prev) => ({+      ...prev,+      backend,+      base_url: option?.defaultUrl ?? prev.base_url,+    }));+  };++  const handleSave = async () => {+    setIsSaving(true);+    setError(null);+    try {+      await invoke("save_local_llm_config_command", { config });+      onClose();+    } catch (err) {+      console.error("Failed to save local LLM config:", err);+      setError("Failed to save configuration.");+    } finally {+      setIsSaving(false);+    }+  };++  const handleTestConnection = async () => {+    setIsTesting(true);+    setTestResult(null);+    setError(null);+    try {+      // First save the config+      await invoke("save_local_llm_config_command", { config });+      // Then test the connection+      const availableModels = await invoke<string[]>("test_local_llm_connection_command");+      setModels(availableModels);+      setTestResult({+        success: true,+        message: `Connected successfully! Found ${availableModels.length} models.`,+      });+    } catch (err) {+      console.error("Failed to test local LLM connection:", err);+      setTestResult({+        success: false,+        message: `Connection failed: ${err}`,+      });+    } finally {+      setIsTesting(false);+    }+  };++  const handleRefreshModels = async () => {+    setIsLoading(true);+    setError(null);+    try {+      const availableModels = await invoke<string[]>("test_local_llm_connection_command");+      setModels(availableModels);+    } catch (err) {+      console.error("Failed to refresh models:", err);+      setError("Failed to fetch models. Check your connection.");+    } finally {+      setIsLoading(false);+    }+  };++  return (+    <Modal+      isOpen={isOpen}+      onClose={onClose}+      className="max-w-2xl p-6"+      showClose={true}+    >+      <div className="flex flex-col gap-6 text-base-fg">+        <div className="flex items-center justify-between">+          <div className="flex items-center gap-2">+            <SettingsIcon className="h-5 w-5 text-primary" />+            <h1 className="font-display text-2xl font-medium">Local LLM Settings</h1>+          </div>+          <button+            onClick={onClose}+            className="text-base-fg/50 transition-colors hover:text-base-fg"+          >+            <XIcon className="h-5 w-5" />+          </button>+        </div>++        <p className="text-sm text-base-fg/60">+          Configure your local LLM backend (Ollama, llama.cpp, LM Studio) to generate+          text completions without using credits.+        </p>++        {error && (+          <div className="rounded border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-400">+            {error}+          </div>+        )}++        {testResult && (+          <div+            className={`rounded border p-3 text-sm ${+              testResult.success+                ? "border-green-500/30 bg-green-500/10 text-green-400"+                : "border-red-500/30 bg-red-500/10 text-red-400"+            }`}+          >+            {testResult.message}+          </div>+        )}++        <div className="flex flex-col gap-4">+          <div className="flex flex-col gap-2">+            <label className="text-sm font-medium text-base-fg/80">Backend Type</label>+            <select+              value={config.backend}+              onChange={(e) => handleBackendChange(e.target.value as BackendType)}+              className="rounded border border-white/15 bg-white/5 px-3 py-2 text-sm text-base-fg"+            >+              {BACKEND_OPTIONS.map((option) => (+                <option key={option.value} value={option.value}>+                  {option.label}+                </option>+              ))}+            </select>+          </div>++          <div className="flex flex-col gap-2">+            <label className="text-sm font-medium text-base-fg/80">Base URL</label>+            <input+              type="text"+              value={config.base_url}+              onChange={(e) => setConfig((prev) => ({ ...prev, base_url: e.target.value }))}+              placeholder="http://localhost:11434"+              className="rounded border border-white/15 bg-white/5 px-3 py-2 text-sm text-base-fg"+            />+          </div>++          <div className="flex flex-col gap-2">+            <label className="text-sm font-medium text-base-fg/80">Model</label>+            <div className="flex gap-2">+              <input+                type="text"+                value={config.model}+                onChange={(e) => setConfig((prev) => ({ ...prev, model: e.target.value }))}+                placeholder="llama3.2"+                className="flex-1 rounded border border-white/15 bg-white/5 px-3 py-2 text-sm text-base-fg"+              />+              <Button+                onClick={handleRefreshModels}+                disabled={isLoading || isTesting}+                icon={RefreshCwIcon}+                className="h-10 px-3"+              >+                Refresh+              </Button>+            </div>+            {models.length > 0 && (+              <div className="mt-1 flex flex-wrap gap-1">+                {models.slice(0, 10).map((model) => (+                  <button+                    key={model}+                    onClick={() => setConfig((prev) => ({ ...prev, model }))}+                    className={`rounded px-2 py-1 text-xs ${+                      config.model === model+                        ? "bg-primary/20 text-primary"+                        : "bg-white/5 text-base-fg/60 hover:bg-white/10"+                    }`}+                  >+                    {model}+                  </button>+                ))}+              </div>+            )}+          </div>++          <div className="flex flex-col gap-2">+            <label className="text-sm font-medium text-base-fg/80">+              Temperature: {config.temperature.toFixed(1)}+            </label>+            <input+              type="range"+              min="0"+              max="2"+              step="0.1"+              value={config.temperature}+              onChange={(e) =>+                setConfig((prev) => ({ ...prev, temperature: parseFloat(e.target.value) }))+              }+              className="w-full"+            />+          </div>++          <div className="flex flex-col gap-2">+            <label className="text-sm font-medium text-base-fg/80">Max Tokens</label>+            <input+              type="number"+              value={config.max_tokens}+              onChange={(e) =>+                setConfig((prev) => ({ ...prev, max_tokens: parseInt(e.target.value) || 2048 }))+              }+              min="1"+              max="8192"+              className="rounded border border-white/15 bg-white/5 px-3 py-2 text-sm text-base-fg"+            />+          </div>++          <div className="flex flex-col gap-2">+            <label className="text-sm font-medium text-base-fg/80">+              System Prompt (optional)+            </label>+            <textarea+              value={config.system_prompt}+              onChange={(e) =>+                setConfig((prev) => ({ ...prev, system_prompt: e.target.value }))+              }+              placeholder="You are a helpful assistant..."+              rows={3}+              className="rounded border border-white/15 bg-white/5 px-3 py-2 text-sm text-base-fg"+            />+          </div>++          <div className="flex flex-col gap-2">+            <label className="text-sm font-medium text-base-fg/80">+              API Key (optional)+            </label>+            <input+              type="password"+              value={config.api_key}+              onChange={(e) => setConfig((prev) => ({ ...prev, api_key: e.target.value }))}+              placeholder="Leave empty if not required"+              className="rounded border border-white/15 bg-white/5 px-3 py-2 text-sm text-base-fg"+            />+          </div>+        </div>++        <div className="flex gap-3">+          <Button+            onClick={handleTestConnection}+            disabled={isTesting || isSaving}+            className="h-10 px-4"+          >+            {isTesting ? "Testing..." : "Test Connection"}+          </Button>+          <Button+            onClick={handleSave}+            disabled={isSaving || isTesting}+            icon={CheckIcon}+            className="h-10 px-4"+          >+            {isSaving ? "Saving..." : "Save"}+          </Button>+        </div>+      </div>+    </Modal>+  );+}++export default LocalLlmSettingsModal;diff --git a/frontend/libs/api-enums/src/lib/common/generation_provider.ts b/frontend/libs/api-enums/src/lib/common/generation_provider.ts--- a/frontend/libs/api-enums/src/lib/common/generation_provider.ts+++ b/frontend/libs/api-enums/src/lib/common/generation_provider.ts@@ -17,4 +17,5 @@ export enum GenerationProvider {   Picsart = "picsart",   Pixverse = "pixverse",   Runway = "runway",+  LocalLlm = "local_llm", }diff --git a/frontend/libs/common/src/lib/enums/GenerationProvider.ts b/frontend/libs/common/src/lib/enums/GenerationProvider.ts--- a/frontend/libs/common/src/lib/enums/GenerationProvider.ts+++ b/frontend/libs/common/src/lib/enums/GenerationProvider.ts@@ -13,4 +13,5 @@ export enum GenerationProvider {   Picsart = "picsart",   Pixverse = "pixverse",   Runway = "runway",+  LocalLlm = "local_llm", }diff --git a/frontend/libs/components/provider-setup-modal/src/lib/provider-setup-modal.tsx b/frontend/libs/components/provider-setup-modal/src/lib/provider-setup-modal.tsx--- a/frontend/libs/components/provider-setup-modal/src/lib/provider-setup-modal.tsx+++ b/frontend/libs/components/provider-setup-modal/src/lib/provider-setup-modal.tsx@@ -5,6 +5,7 @@ import { Button } from "@storyteller/ui-button"; import { useState } from "react"; import { useShowProviderLoginModalEvent } from "@storyteller/tauri-events"; import { GenerationProvider } from "@storyteller/common";+import { LocalLlmSettingsModal } from "../../../../apps/artcraft/app/src/components/reusable/LocalLlmSettingsModal";  interface ProviderSetupModalProps { }@@ -44,6 +45,8 @@ export function ProviderSetupModal({    const modalButtonText = `Set up ${serviceProviderName}`; +  const [showLocalLlmSettings, setShowLocalLlmSettings] = useState(false);+   const buttonOnClick = async () => {     switch (provider) {       case GenerationProvider.Grok:@@ -52,6 +55,10 @@ export function ProviderSetupModal({       case GenerationProvider.Midjourney:         await invoke("midjourney_open_login_command");         break;+      case GenerationProvider.LocalLlm:+        setShowLocalLlmSettings(true);+        setShowModal(false);+        return;       case GenerationProvider.Fal:         break; // TODO: None yet.       default:@@ -98,6 +105,12 @@ export function ProviderSetupModal({           {modalButtonText}         </Button>       </div>+      {showLocalLlmSettings && (+        <LocalLlmSettingsModal+          isOpen={showLocalLlmSettings}+          onClose={() => setShowLocalLlmSettings(false)}+        />+      )}     </Modal>   ); }@@ -126,6 +139,8 @@ function getServiceProviderName(provider: GenerationProvider) : string {       return "PixVerse";     case GenerationProvider.Runway:       return "Runway";+    case GenerationProvider.LocalLlm:+      return "Local LLM";     case GenerationProvider.Artcraft:     default:       return "Artcraft";diff --git a/frontend/libs/model-list/src/lib/classes/metadata/ModelMapping.ts b/frontend/libs/model-list/src/lib/classes/metadata/ModelMapping.ts--- a/frontend/libs/model-list/src/lib/classes/metadata/ModelMapping.ts+++ b/frontend/libs/model-list/src/lib/classes/metadata/ModelMapping.ts@@ -217,6 +217,7 @@ export const getProviderDisplayName = (provider: string): string => {     midjourney: "Midjourney",     sora: "Sora",     worldlabs: "World Labs",+    local_llm: "Local LLM",   };    return displayNames[provider] || provider;