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
@@ -11,4 +11,5 @@ pub enum RouterProvider { GrokApi, KinoviWeb, WorldLabs,+ LocalLlm, }@@ -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.")); }@@ -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 {@@ -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.")); }@@ -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; }@@ -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; }@@ -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); }@@ -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 {@@ -49,6 +49,7 @@ impl TaskEnqueueSuccess{ GenerationProvider::Picsart => GenerationServiceProvider::Picsart, GenerationProvider::Pixverse => GenerationServiceProvider::Pixverse, GenerationProvider::Runway => GenerationServiceProvider::Runway,+ GenerationProvider::LocalLlm => GenerationServiceProvider::LocalLlm, } } @@ -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,@@ -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,@@ -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(@@ -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, } }@@ -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);@@ -17,6 +17,7 @@ pub enum GenerationServiceProvider { Picsart, Pixverse, Runway,+ LocalLlm, } #[derive(Debug, Clone, Serialize)]@@ -16,6 +16,10 @@ pub enum ProviderCredentialKey { HiggsfieldWebLogin, MidjourneyLogin, RunwayWebLogin,++ // ========== LOCAL LLM ==========++ LocalLlmConfig, } new file mode 100644@@ -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),+ }+}new file mode 100644@@ -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),+ }+ }+}new file mode 100644@@ -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)+}new file mode 100644@@ -0,0 +1,3 @@+pub mod local_llm_config;+pub mod local_llm_handler;+pub mod local_llm_commands;@@ -1 +1,2 @@-pub mod credentials;\ No newline at end of file+pub mod credentials;+pub mod local_llm;\ No newline at end of file@@ -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(), }) } }@@ -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"))@@ -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); } }@@ -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> new file mode 100644@@ -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;@@ -17,4 +17,5 @@ export enum GenerationProvider { Picsart = "picsart", Pixverse = "pixverse", Runway = "runway",+ LocalLlm = "local_llm", }@@ -13,4 +13,5 @@ export enum GenerationProvider { Picsart = "picsart", Pixverse = "pixverse", Runway = "runway",+ LocalLlm = "local_llm", }@@ -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";@@ -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;