diff --git a/backend/.sqlx/query-975099ff6b07718ea94bcb5f84a4414c59199964cee53ef2ee6b35a78cf0c49a.json b/backend/.sqlx/query-975099ff6b07718ea94bcb5f84a4414c59199964cee53ef2ee6b35a78cf0c49a.json new file mode 100644 index 0000000000..012316fd4a --- /dev/null +++ b/backend/.sqlx/query-975099ff6b07718ea94bcb5f84a4414c59199964cee53ef2ee6b35a78cf0c49a.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value FROM global_settings WHERE name = 'ai_config'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "975099ff6b07718ea94bcb5f84a4414c59199964cee53ef2ee6b35a78cf0c49a" +} diff --git a/backend/src/main.rs b/backend/src/main.rs index 17567167a3..bba51ada6b 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -36,9 +36,10 @@ use windmill_common::ee_oss::{ use windmill_common::{ agent_workers::AgentConfig, + ai_cache::bump_instance_ai_config_revision, global_settings::{ - APP_WORKSPACED_ROUTE_SETTING, AUDIT_LOG_RETENTION_DAYS_SETTING, BASE_URL_SETTING, - BUNFIG_INSTALL_SCOPES_SETTING, CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING, + AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUDIT_LOG_RETENTION_DAYS_SETTING, + BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING, CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING, CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, @@ -1813,6 +1814,10 @@ async fn process_notify_event( tracing::error!(error = %e, "Could not reload app workspaced route setting"); } } + AI_CONFIG_SETTING => { + tracing::info!("AI config setting changed, bumping instance AI cache revision"); + bump_instance_ai_config_revision(); + } OTEL_SETTING => { tracing::info!("OTEL setting changed, restarting"); send_delayed_killpill(tx, 4, "OTEL setting change").await; diff --git a/backend/windmill-api-integration-tests/tests/workspaces.rs b/backend/windmill-api-integration-tests/tests/workspaces.rs index 58c07965dc..55e00eb05d 100644 --- a/backend/windmill-api-integration-tests/tests/workspaces.rs +++ b/backend/windmill-api-integration-tests/tests/workspaces.rs @@ -82,12 +82,10 @@ async fn test_workspace_endpoints(db: Pool) -> anyhow::Result<()> { assert_eq!(resp.status(), 200); // --- allowed_domain_auto_invite --- - let resp = authed(client().get(format!( - "{global_base}/allowed_domain_auto_invite" - ))) - .send() - .await - .unwrap(); + let resp = authed(client().get(format!("{global_base}/allowed_domain_auto_invite"))) + .send() + .await + .unwrap(); assert_eq!(resp.status(), 200); resp.json::().await?; @@ -213,12 +211,10 @@ async fn test_workspace_endpoints(db: Pool) -> anyhow::Result<()> { resp.json::>().await?; // --- get_dependents (empty, no dependencies exist) --- - let resp = authed(client().get(format!( - "{base}/get_dependents/u/test-user/nonexistent" - ))) - .send() - .await - .unwrap(); + let resp = authed(client().get(format!("{base}/get_dependents/u/test-user/nonexistent"))) + .send() + .await + .unwrap(); assert_eq!(resp.status(), 200); let dependents = resp.json::>().await?; assert!(dependents.is_empty()); @@ -425,13 +421,11 @@ async fn test_workspace_endpoints(db: Pool) -> anyhow::Result<()> { ); // --- edit_large_file_storage_config --- - let resp = authed(client().post(format!( - "{base}/edit_large_file_storage_config" - ))) - .json(&json!({"large_file_storage": null})) - .send() - .await - .unwrap(); + let resp = authed(client().post(format!("{base}/edit_large_file_storage_config"))) + .json(&json!({"large_file_storage": null})) + .send() + .await + .unwrap(); assert_eq!( resp.status(), 200, @@ -532,9 +526,7 @@ async fn test_workspace_endpoints(db: Pool) -> anyhow::Result<()> { .unwrap(); let invites = resp.json::>().await?; assert!( - invites - .iter() - .any(|i| i["email"] == "invited@example.com"), + invites.iter().any(|i| i["email"] == "invited@example.com"), "invite not found: {:?}", invites ); @@ -549,12 +541,7 @@ async fn test_workspace_endpoints(db: Pool) -> anyhow::Result<()> { .send() .await .unwrap(); - assert_eq!( - resp.status(), - 201, - "delete_invite: {}", - resp.text().await? - ); + assert_eq!(resp.status(), 201, "delete_invite: {}", resp.text().await?); // ===== Critical alerts (EE-gated, returns 404 in OSS) ===== @@ -624,12 +611,7 @@ async fn test_workspace_endpoints(db: Pool) -> anyhow::Result<()> { .send() .await .unwrap(); - assert_eq!( - resp.status(), - 200, - "create_fork: {}", - resp.text().await? - ); + assert_eq!(resp.status(), 200, "create_fork: {}", resp.text().await?); // verify fork exists let resp = authed(client().post(format!("{global_base}/exists"))) @@ -702,13 +684,122 @@ async fn test_workspace_endpoints(db: Pool) -> anyhow::Result<()> { assert_eq!(resp.json::().await?, false); // --- create_workspace_require_superadmin --- - let resp = authed(client().get(format!( - "{global_base}/create_workspace_require_superadmin" - ))) - .send() - .await - .unwrap(); + let resp = authed(client().get(format!("{global_base}/create_workspace_require_superadmin"))) + .send() + .await + .unwrap(); assert_eq!(resp.status(), 200); Ok(()) } + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_get_copilot_settings_state_reports_instance_ai_fallback_flags( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces"); + + let instance_ai_config = json!({ + "providers": { + "openai": { + "resource_path": "u/test-user/openai_instance", + "models": ["gpt-4o-mini"] + } + } + }); + let workspace_ai_config = json!({ + "providers": { + "anthropic": { + "resource_path": "u/test-user/anthropic_workspace", + "models": ["claude-3-5-haiku-latest"] + } + } + }); + + sqlx::query("UPDATE workspace_settings SET ai_config = NULL WHERE workspace_id = $1") + .bind("test-workspace") + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO global_settings (name, value) VALUES ($1, $2) \ + ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value", + ) + .bind("ai_config") + .bind(instance_ai_config) + .execute(&db) + .await?; + + let resp = authed(client().get(format!("{base}/get_copilot_settings_state"))) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let settings = resp.json::().await?; + assert_eq!(settings["has_instance_ai_config"], true); + assert_eq!(settings["uses_instance_ai_config"], true); + assert_eq!( + settings["instance_ai_summary"]["providers"][0]["provider"], + "openai" + ); + assert_eq!( + settings["instance_ai_summary"]["providers"][0]["models"][0], + "gpt-4o-mini" + ); + + sqlx::query("UPDATE workspace_settings SET ai_config = $1 WHERE workspace_id = $2") + .bind(workspace_ai_config) + .bind("test-workspace") + .execute(&db) + .await?; + + let resp = authed(client().get(format!("{base}/get_copilot_settings_state"))) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let settings = resp.json::().await?; + assert_eq!(settings["has_instance_ai_config"], true); + assert_eq!(settings["uses_instance_ai_config"], false); + assert_eq!( + settings["instance_ai_summary"]["providers"][0]["provider"], + "openai" + ); + + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_get_copilot_info_ignores_empty_instance_ai_row( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces"); + + sqlx::query("UPDATE workspace_settings SET ai_config = NULL WHERE workspace_id = $1") + .bind("test-workspace") + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO global_settings (name, value) VALUES ($1, $2) \ + ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value", + ) + .bind("ai_config") + .bind(json!({})) + .execute(&db) + .await?; + + let resp = authed(client().get(format!("{base}/get_copilot_info"))) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let settings = resp.json::().await?; + assert!(settings["providers"].is_null()); + + Ok(()) +} diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index e64ca86ea8..dbe80aca94 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -38,11 +38,12 @@ use windmill_common::ee_oss::{send_critical_alert, CriticalAlertKind, CriticalEr #[cfg(all(feature = "private", feature = "enterprise"))] use windmill_common::secret_backend::{SecretMigrationReport, VaultSettings}; use windmill_common::{ + ai_cache::bump_instance_ai_config_revision, email_oss::send_email_plain_text, error::{self, JsonResult, Result}, get_database_url, global_settings::{ - APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING, + AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, WS_BASE_URL_SETTING, @@ -284,6 +285,7 @@ pub async fn set_global_setting_internal( key: String, value: serde_json::Value, ) -> error::Result<()> { + let should_bump_instance_ai_revision = key == AI_CONFIG_SETTING; let value = if key == "retention_period_secs" { instance_config::clamp_retention_period(value) } else { @@ -325,6 +327,10 @@ pub async fn set_global_setting_internal( } }; + if should_bump_instance_ai_revision { + bump_instance_ai_config_revision(); + } + Ok(()) } @@ -471,6 +477,10 @@ async fn set_instance_config( let current_map = current.global_settings.to_settings_map(); let settings_diff = instance_config::diff_global_settings(¤t_map, &desired_map, ApplyMode::Merge); + let ai_config_changed = settings_diff + .upserts + .iter() + .any(|(key, _)| key == AI_CONFIG_SETTING); for (key, value) in &settings_diff.upserts { run_setting_pre_write_hook(&db, key, value).await?; @@ -479,6 +489,10 @@ async fn set_instance_config( instance_config::apply_settings_diff(&db, &settings_diff) .await .map_err(|e| error::Error::internal_err(e.to_string()))?; + + if ai_config_changed { + bump_instance_ai_config_revision(); + } } if !desired.worker_configs.is_empty() { diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 35e1e83b21..8cd8f27d9b 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -82,6 +82,10 @@ pub fn workspaced_service() -> Router { .route("/get_dependents/*imported_path", get(get_dependents)) .route("/get_dependents_amounts", post(get_dependents_amounts)) .route("/get_settings", get(get_settings)) + .route( + "/get_copilot_settings_state", + get(get_copilot_settings_state), + ) .route("/get_deploy_to", get(get_deploy_to)) .route("/edit_slack_command", post(edit_slack_command)) .route( @@ -257,6 +261,35 @@ pub struct WorkspaceSettings { pub public_app_execution_limit_per_minute: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct CopilotSettingsState { + pub has_instance_ai_config: bool, + pub uses_instance_ai_config: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub instance_ai_summary: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct InstanceAIProviderSummary { + pub provider: String, + pub models: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct InstanceAIModelSummary { + pub provider: String, + pub model: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct InstanceAISummary { + pub providers: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub code_completion_model: Option, +} + /// #[derive(sqlx::Type, Serialize, Deserialize, Debug)] // #[sqlx(type_name = "WORKSPACE_KEY_KIND", rename_all = "lowercase")] // pub enum WorkspaceKeyKind { @@ -608,15 +641,106 @@ async fn get_settings( .await .map_err(|e| Error::internal_err(format!("getting settings: {e:#}")))?; + let mut settings = not_found_if_none(settings, "workspace settings", &w_id)?; tx.commit().await?; - let mut settings = not_found_if_none(settings, "workspace settings", &w_id)?; if !authed.is_admin { settings.slack_oauth_client_secret = None; } Ok(Json(settings)) } +async fn get_copilot_settings_state( + authed: ApiAuthed, + Path(w_id): Path, + Extension(user_db): Extension, +) -> JsonResult { + let mut tx = user_db.begin(&authed).await?; + let workspace_ai_config = sqlx::query_scalar!( + "SELECT ai_config FROM workspace_settings WHERE workspace_id = $1", + &w_id + ) + .fetch_optional(&mut *tx) + .await + .map_err(|e| Error::internal_err(format!("getting workspace ai settings: {e:#}")))?; + let workspace_ai_config = not_found_if_none(workspace_ai_config, "workspace settings", &w_id)?; + let instance_ai_config: Option = + sqlx::query_scalar("SELECT value FROM global_settings WHERE name = 'ai_config'") + .fetch_optional(&mut *tx) + .await + .map_err(|e| Error::internal_err(format!("getting instance ai settings: {e:#}")))?; + tx.commit().await?; + + Ok(Json(build_copilot_settings_state( + has_ai_providers(workspace_ai_config.as_ref()), + instance_ai_config.as_ref(), + ))) +} + +pub fn has_ai_providers(config: Option<&serde_json::Value>) -> bool { + config + .and_then(|value| value.get("providers")) + .and_then(|providers| providers.as_object()) + .map(|providers| !providers.is_empty()) + .unwrap_or(false) +} + +pub fn build_copilot_settings_state( + has_workspace_ai_config: bool, + instance_ai_config: Option<&serde_json::Value>, +) -> CopilotSettingsState { + let has_instance_ai_config = has_ai_providers(instance_ai_config); + CopilotSettingsState { + has_instance_ai_config, + uses_instance_ai_config: !has_workspace_ai_config && has_instance_ai_config, + instance_ai_summary: build_instance_ai_summary(instance_ai_config), + } +} + +pub fn build_instance_ai_summary(config: Option<&serde_json::Value>) -> Option { + let config = config?; + if !has_ai_providers(Some(config)) { + return None; + } + let providers = config.get("providers")?.as_object()?; + + let mut provider_summaries = providers + .iter() + .map(|(provider, provider_config)| InstanceAIProviderSummary { + provider: provider.clone(), + models: provider_config + .get("models") + .and_then(|models| models.as_array()) + .map(|models| { + models + .iter() + .filter_map(|model| model.as_str().map(ToOwned::to_owned)) + .collect::>() + }) + .unwrap_or_default(), + }) + .collect::>(); + + provider_summaries.sort_by(|left, right| left.provider.cmp(&right.provider)); + + Some(InstanceAISummary { + providers: provider_summaries, + default_model: extract_instance_ai_model_summary(config, "default_model"), + code_completion_model: extract_instance_ai_model_summary(config, "code_completion_model"), + }) +} + +fn extract_instance_ai_model_summary( + config: &serde_json::Value, + key: &str, +) -> Option { + let model_config = config.get(key)?.as_object()?; + Some(InstanceAIModelSummary { + provider: model_config.get("provider")?.as_str()?.to_owned(), + model: model_config.get("model")?.as_str()?.to_owned(), + }) +} + #[derive(Serialize)] struct DeployTo { deploy_to: Option, diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 8ef9caf936..d55596bc72 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -3191,9 +3191,49 @@ paths: "200": description: status content: - text/plain: + application/json: schema: - type: string + type: object + properties: + effective_ai_config: + $ref: "#/components/schemas/AIConfig" + has_instance_ai_config: + type: boolean + uses_instance_ai_config: + type: boolean + instance_ai_summary: + $ref: "#/components/schemas/InstanceAISummary" + required: + - effective_ai_config + - has_instance_ai_config + - uses_instance_ai_config + + /w/{workspace}/workspaces/get_copilot_settings_state: + get: + summary: get copilot settings state + operationId: getCopilotSettingsState + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + + responses: + "200": + description: status + content: + application/json: + schema: + type: object + properties: + has_instance_ai_config: + type: boolean + uses_instance_ai_config: + type: boolean + instance_ai_summary: + $ref: "#/components/schemas/InstanceAISummary" + required: + - has_instance_ai_config + - uses_instance_ai_config /w/{workspace}/workspaces/get_copilot_info: get: @@ -18792,6 +18832,33 @@ components: minimum: 1 maximum: 2000000 + InstanceAIProviderSummary: + type: object + properties: + provider: + $ref: "#/components/schemas/AIProvider" + models: + type: array + items: + type: string + required: + - provider + - models + + InstanceAISummary: + type: object + properties: + providers: + type: array + items: + $ref: "#/components/schemas/InstanceAIProviderSummary" + default_model: + $ref: "#/components/schemas/AIProviderModel" + code_completion_model: + $ref: "#/components/schemas/AIProviderModel" + required: + - providers + Alert: type: object properties: diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index ed9cdb366c..d77a0fa8cc 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -16,6 +16,7 @@ use serde_json::{json, value::RawValue}; use std::collections::HashMap; use std::time::Duration; use windmill_audit::{audit_oss::audit_log, ActionKind}; +use windmill_common::ai_cache::current_instance_ai_config_revision; use windmill_common::ai_providers::{ empty_string_as_none, AIPlatform, AIProvider, ProviderConfig, ProviderModel, }; @@ -127,6 +128,10 @@ lazy_static::lazy_static! { }; } +pub(crate) fn invalidate_ai_request_cache_for_workspace(workspace_id: &str) { + AI_REQUEST_CACHE.retain(|(cached_workspace_id, _), _| cached_workspace_id != workspace_id); +} + #[derive(Deserialize, Debug)] struct AIOAuthResource { client_id: String, @@ -373,8 +378,7 @@ impl AIRequestConfig { let is_azure = provider.is_azure_openai(base_url); let is_anthropic = matches!(provider, AIProvider::Anthropic); - let is_anthropic_vertex = - is_anthropic && self.platform == AIPlatform::GoogleVertexAi; + let is_anthropic_vertex = is_anthropic && self.platform == AIPlatform::GoogleVertexAi; let is_anthropic_sdk = headers.get("X-Anthropic-SDK").is_some(); let is_google_ai = matches!(provider, AIProvider::GoogleAI); @@ -483,18 +487,27 @@ impl AIRequestConfig { pub struct ExpiringAIRequestConfig { config: AIRequestConfig, expires_at: std::time::Instant, + instance_ai_config_revision: Option, } impl ExpiringAIRequestConfig { - fn new(config: AIRequestConfig) -> Self { - Self { config, expires_at: std::time::Instant::now() + std::time::Duration::from_secs(60) } + fn new(config: AIRequestConfig, instance_ai_config_revision: Option) -> Self { + Self { + config, + expires_at: std::time::Instant::now() + std::time::Duration::from_secs(60), + instance_ai_config_revision, + } } + fn is_expired(&self) -> bool { self.expires_at < std::time::Instant::now() + || self + .instance_ai_config_revision + .is_some_and(|revision| revision != current_instance_ai_config_revision()) } } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Default)] pub struct AIConfig { #[serde(skip_serializing_if = "Option::is_none")] pub providers: Option>, @@ -508,6 +521,14 @@ pub struct AIConfig { pub max_tokens_per_model: Option>, } +impl AIConfig { + pub fn has_providers(&self) -> bool { + self.providers + .as_ref() + .is_some_and(|providers| !providers.is_empty()) + } +} + /// Anthropic API version for Google Vertex AI const ANTHROPIC_VERSION_VERTEX: &str = "vertex-2023-10-16"; @@ -762,47 +783,76 @@ async fn proxy( request_cache.config } _ => { - let (resource_path, save_to_cache) = if let Some(resource_path) = forced_resource_path { - // forced resource path - (resource_path, false) - } else { - let ai_config = sqlx::query_scalar!( - "SELECT ai_config FROM workspace_settings WHERE workspace_id = $1", - &w_id - ) - .fetch_one(&db) - .await?; + let (resource_path, save_to_cache, resource_workspace, instance_ai_config_revision) = + if let Some(resource_path) = forced_resource_path { + // forced resource path + (resource_path, false, w_id.clone(), None) + } else { + let workspace_ai_config = sqlx::query_scalar!( + "SELECT ai_config FROM workspace_settings WHERE workspace_id = $1", + &w_id + ) + .fetch_one(&db) + .await?; - if ai_config.is_none() { - return Err(Error::internal_err( - "AI resource not configured".to_string(), - )); - } + let (ai_config_value, resource_workspace, instance_ai_config_revision) = { + let ws_has_config = workspace_ai_config + .as_ref() + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + .is_some_and(|config| config.has_providers()); - let mut ai_config = serde_json::from_value::(ai_config.unwrap()) - .map_err(|e| Error::BadRequest(e.to_string()))?; + if ws_has_config { + (workspace_ai_config.unwrap(), w_id.clone(), None) + } else { + let instance_config = sqlx::query_scalar!( + "SELECT value FROM global_settings WHERE name = 'ai_config'" + ) + .fetch_optional(&db) + .await?; - let provider_config = ai_config - .providers - .as_mut() - .map(|providers| providers.remove(&provider)) - .flatten() - .ok_or_else(|| { - Error::BadRequest(format!("Provider {:?} not configured", provider)) - })?; + match instance_config { + Some(config) => ( + config, + "admins".to_string(), + Some(current_instance_ai_config_revision()), + ), + None => { + return Err(Error::internal_err( + "AI resource not configured".to_string(), + )); + } + } + } + }; - if provider_config.resource_path.is_empty() { - return Err(Error::BadRequest("Resource path is empty".to_string())); - } + let mut ai_config = serde_json::from_value::(ai_config_value) + .map_err(|e| Error::BadRequest(e.to_string()))?; - (provider_config.resource_path, true) - }; + let provider_config = ai_config + .providers + .as_mut() + .and_then(|providers| providers.remove(&provider)) + .ok_or_else(|| { + Error::BadRequest(format!("Provider {:?} not configured", provider)) + })?; - let resource= sqlx::query_scalar!( - "SELECT value as \"value: sqlx::types::Json>\" FROM resource WHERE path = $1 AND workspace_id = $2", - &resource_path, - &w_id + if provider_config.resource_path.is_empty() { + return Err(Error::BadRequest("Resource path is empty".to_string())); + } + + ( + provider_config.resource_path, + true, + resource_workspace, + instance_ai_config_revision, + ) + }; + + let resource = sqlx::query_scalar::<_, Option>>>( + "SELECT value FROM resource WHERE path = $1 AND workspace_id = $2", ) + .bind(&resource_path) + .bind(&resource_workspace) .fetch_optional(&db) .await? .ok_or_else(|| Error::NotFound(format!("Could not find the resource {}, update the resource path in the workspace settings", resource_path)))? @@ -811,11 +861,15 @@ async fn proxy( let resource = serde_json::from_str::(resource.0.get()) .map_err(|e| Error::BadRequest(e.to_string()))?; - let request_config = AIRequestConfig::new(&provider, &db, &w_id, resource).await?; + let request_config = + AIRequestConfig::new(&provider, &db, &resource_workspace, resource).await?; if save_to_cache { AI_REQUEST_CACHE.insert( (w_id.clone(), provider.clone()), - ExpiringAIRequestConfig::new(request_config.clone()), + ExpiringAIRequestConfig::new( + request_config.clone(), + instance_ai_config_revision, + ), ); } request_config @@ -858,9 +912,7 @@ async fn proxy( "chat/completions" => { crate::google::handle_google_ai_chat(&body, api_key, base_url, is_vertex).await } - "models" => { - crate::google::handle_google_ai_models(api_key, base_url, is_vertex).await - } + "models" => crate::google::handle_google_ai_models(api_key, base_url, is_vertex).await, _ => Err(Error::BadRequest(format!( "Unsupported Google AI path: {}", ai_path @@ -1005,3 +1057,76 @@ async fn proxy( }; Ok((status_code, headers, body)) } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{LazyLock, Mutex}; + use windmill_common::ai_cache::bump_instance_ai_config_revision; + use windmill_common::ai_providers::AIPlatform; + + static TEST_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); + + fn sample_request_config() -> AIRequestConfig { + AIRequestConfig { + base_url: "https://example.com".to_string(), + api_key: None, + access_token: None, + organization_id: None, + user: None, + region: None, + aws_access_key_id: None, + aws_secret_access_key: None, + aws_session_token: None, + platform: AIPlatform::Standard, + enable_1m_context: false, + custom_headers: HashMap::new(), + } + } + + #[test] + fn invalidates_all_cached_providers_for_workspace() { + let _guard = TEST_LOCK.lock().unwrap(); + AI_REQUEST_CACHE.clear(); + AI_REQUEST_CACHE.insert( + ("workspace-a".to_string(), AIProvider::OpenAI), + ExpiringAIRequestConfig::new(sample_request_config(), None), + ); + AI_REQUEST_CACHE.insert( + ("workspace-a".to_string(), AIProvider::Anthropic), + ExpiringAIRequestConfig::new(sample_request_config(), None), + ); + AI_REQUEST_CACHE.insert( + ("workspace-b".to_string(), AIProvider::OpenAI), + ExpiringAIRequestConfig::new(sample_request_config(), None), + ); + + invalidate_ai_request_cache_for_workspace("workspace-a"); + + assert!(AI_REQUEST_CACHE + .get(&("workspace-a".to_string(), AIProvider::OpenAI)) + .is_none()); + assert!(AI_REQUEST_CACHE + .get(&("workspace-a".to_string(), AIProvider::Anthropic)) + .is_none()); + assert!(AI_REQUEST_CACHE + .get(&("workspace-b".to_string(), AIProvider::OpenAI)) + .is_some()); + } + + #[test] + fn instance_backed_cache_entries_expire_when_revision_changes() { + let _guard = TEST_LOCK.lock().unwrap(); + AI_REQUEST_CACHE.clear(); + + let cached = ExpiringAIRequestConfig::new( + sample_request_config(), + Some(current_instance_ai_config_revision()), + ); + assert!(!cached.is_expired()); + + bump_instance_ai_config_revision(); + + assert!(cached.is_expired()); + } +} diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index 50c3913b47..3438ad97af 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -8,8 +8,9 @@ // Re-export everything from windmill-api-workspaces pub use windmill_api_workspaces::workspaces::*; +use windmill_api_workspaces::workspaces::{build_copilot_settings_state, InstanceAISummary}; -use crate::ai::{AIConfig, AI_REQUEST_CACHE}; +use crate::ai::{invalidate_ai_request_cache_for_workspace, AIConfig}; use crate::db::ApiAuthed; use crate::teams_oss::{ connect_teams, edit_teams_command, run_teams_message_test_job, @@ -24,7 +25,7 @@ use axum::{ use windmill_audit::audit_oss::audit_log; use windmill_audit::ActionKind; use windmill_common::{ - error::{Error, JsonResult, Result}, + error::{Error, JsonResult}, utils::require_admin, DB, }; @@ -34,6 +35,9 @@ use windmill_git_sync::{handle_deployment_metadata, DeployedObject}; use axum::extract::Query; #[cfg(feature = "enterprise")] use serde::Deserialize; +use serde::Serialize; +#[cfg(feature = "enterprise")] +use windmill_common::error::Result; #[cfg(feature = "enterprise")] use windmill_common::utils::require_admin_or_devops; @@ -83,7 +87,7 @@ async fn edit_copilot_config( Path(w_id): Path, ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(ai_config): Json, -) -> Result { +) -> JsonResult { require_admin(is_admin, &username)?; if let Some(ref custom_prompts) = ai_config.custom_prompts { @@ -109,11 +113,7 @@ async fn edit_copilot_config( .execute(&mut *tx) .await?; - if let Some(ref providers) = ai_config.providers { - for provider in providers.keys() { - AI_REQUEST_CACHE.remove(&(w_id.clone(), provider.clone())); - } - } + invalidate_ai_request_cache_for_workspace(&w_id); audit_log( &mut *tx, @@ -139,37 +139,66 @@ async fn edit_copilot_config( ) .await?; - Ok(format!("Edit copilot config for workspace {}", &w_id)) + let workspace_has_config = ai_config.has_providers(); + let instance_ai_config = + sqlx::query_scalar!("SELECT value FROM global_settings WHERE name = 'ai_config'") + .fetch_optional(&db) + .await?; + let settings_state = + build_copilot_settings_state(workspace_has_config, instance_ai_config.as_ref()); + let effective_ai_config = if workspace_has_config { + ai_config + } else if let Some(instance_ai_config) = instance_ai_config { + serde_json::from_value::(instance_ai_config).unwrap_or_default() + } else { + AIConfig::default() + }; + + Ok(Json(EditCopilotConfigResponse { + effective_ai_config, + has_instance_ai_config: settings_state.has_instance_ai_config, + uses_instance_ai_config: settings_state.uses_instance_ai_config, + instance_ai_summary: settings_state.instance_ai_summary, + })) +} + +#[derive(Serialize)] +struct EditCopilotConfigResponse { + effective_ai_config: AIConfig, + has_instance_ai_config: bool, + uses_instance_ai_config: bool, + #[serde(skip_serializing_if = "Option::is_none")] + instance_ai_summary: Option, } async fn get_copilot_info( Extension(db): Extension, Path(w_id): Path, ) -> JsonResult { - let mut tx = db.begin().await?; - let copilot_info = sqlx::query_scalar!( + let workspace_ai_config = sqlx::query_scalar!( "SELECT ai_config as \"ai_config: sqlx::types::Json\" FROM workspace_settings WHERE workspace_id = $1", &w_id ) - .fetch_one(&mut *tx) + .fetch_one(&db) .await .map_err(|e| { Error::internal_err(format!( "getting ai config: {e:#}" )) })?; - tx.commit().await?; - if let Some(sqlx::types::Json(copilot_info)) = copilot_info { - Ok(Json(copilot_info)) + if let Some(workspace_ai_config) = workspace_ai_config.filter(|c| c.0.has_providers()) { + Ok(Json(workspace_ai_config.0)) + } else if let Some(instance_config) = + sqlx::query_scalar!("SELECT value FROM global_settings WHERE name = 'ai_config'") + .fetch_optional(&db) + .await? + { + Ok(Json( + serde_json::from_value::(instance_config).unwrap_or_default(), + )) } else { - Ok(Json(AIConfig { - providers: None, - default_model: None, - code_completion_model: None, - custom_prompts: None, - max_tokens_per_model: None, - })) + Ok(Json(AIConfig::default())) } } diff --git a/backend/windmill-common/src/ai_cache.rs b/backend/windmill-common/src/ai_cache.rs new file mode 100644 index 0000000000..11bcd42254 --- /dev/null +++ b/backend/windmill-common/src/ai_cache.rs @@ -0,0 +1,11 @@ +use std::sync::atomic::{AtomicU64, Ordering}; + +static INSTANCE_AI_CONFIG_REVISION: AtomicU64 = AtomicU64::new(0); + +pub fn current_instance_ai_config_revision() -> u64 { + INSTANCE_AI_CONFIG_REVISION.load(Ordering::SeqCst) +} + +pub fn bump_instance_ai_config_revision() -> u64 { + INSTANCE_AI_CONFIG_REVISION.fetch_add(1, Ordering::SeqCst) + 1 +} diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 937bebf09c..a473c06ebc 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -4,6 +4,7 @@ pub const DEFAULT_TAGS_WORKSPACES_SETTING: &str = "default_tags_workspaces"; pub const BASE_URL_SETTING: &str = "base_url"; pub const WS_BASE_URL_SETTING: &str = "ws_base_url"; pub const OAUTH_SETTING: &str = "oauths"; +pub const AI_CONFIG_SETTING: &str = "ai_config"; pub const RETENTION_PERIOD_SECS_SETTING: &str = "retention_period_secs"; pub const AUDIT_LOG_RETENTION_DAYS_SETTING: &str = "audit_log_retention_days"; pub const MONITOR_LOGS_ON_OBJECT_STORE_SETTING: &str = "monitor_logs_on_s3"; diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index 6e791906f9..63dde895fa 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -351,6 +351,13 @@ pub struct GlobalSettings { std::collections::HashMap>, >, + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr( + feature = "instance_config_schema", + schemars(schema_with = "opaque_json_schema") + )] + pub ai_config: Option, + /// Catch-all for settings not yet covered by typed fields. #[serde(flatten)] pub extra: BTreeMap, diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 7f32eb27d4..a31b665868 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -29,6 +29,7 @@ use sqlx::{Acquire, Postgres}; pub mod agent_workers; #[cfg(feature = "bedrock")] pub mod ai_bedrock; +pub mod ai_cache; pub mod ai_google; pub mod ai_providers; pub mod ai_types; diff --git a/frontend/src/lib/components/AppConnectDrawer.svelte b/frontend/src/lib/components/AppConnectDrawer.svelte index 8156b2c1f4..39df15721e 100644 --- a/frontend/src/lib/components/AppConnectDrawer.svelte +++ b/frontend/src/lib/components/AppConnectDrawer.svelte @@ -10,9 +10,11 @@ interface Props { expressOAuthSetup?: boolean + workspace?: string + disableChatOffset?: boolean } - let { expressOAuthSetup = false }: Props = $props() + let { expressOAuthSetup = false, workspace = undefined, disableChatOffset = false }: Props = $props() let drawer: Drawer | undefined = $state() let resourceType = $state('') @@ -50,6 +52,7 @@ dispatch('close') }} size="800px" + {disableChatOffset} > {#snippet actions()}
diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index d7b37fcdd9..5ae6fbd6fb 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -42,6 +42,7 @@ disabled?: boolean manual?: boolean express?: boolean + workspace?: string } let { @@ -50,9 +51,12 @@ isGoogleSignin = $bindable(false), disabled = $bindable(false), manual = $bindable(true), - express = false + express = false, + workspace = undefined }: Props = $props() + let effectiveWorkspace = $derived(workspace ?? $workspaceStore!) + let isValid = $state(true) const nativeLanguagesCategory = [ @@ -214,7 +218,7 @@ return } const availableRts = await ResourceService.listResourceTypeNames({ - workspace: $workspaceStore! + workspace: effectiveWorkspace }) connectsManual = availableRts @@ -316,7 +320,7 @@ async function getResourceTypeInfo() { resourceTypeInfo = await ResourceService.getResourceType({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, path: resourceType }) const props: Record = resourceTypeInfo?.schema?.['properties'] ?? {} @@ -419,7 +423,7 @@ // Check if variable paths already exist if (!manual || linkedSecrets.length <= 1) { const exists = await VariableService.existsVariable({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, path }) if (exists) { @@ -429,7 +433,7 @@ for (const secretField of linkedSecrets) { const varPath = `${path}_${secretField}` const exists = await VariableService.existsVariable({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, path: varPath }) if (exists) { @@ -440,7 +444,7 @@ } } let exists = await ResourceService.existsResource({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, path }) @@ -478,7 +482,7 @@ account = Number( await OauthService.createAccount({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, requestBody: accountData }) ) @@ -492,7 +496,7 @@ if (typeof value == 'string' && value != '' && !value.startsWith('$var:')) { savedVariableCount++ await VariableService.createVariable({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, requestBody: { path, value: value, @@ -513,7 +517,7 @@ if (typeof v == 'string' && v != '' && !v.startsWith('$var:')) { savedVariableCount++ await VariableService.createVariable({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, requestBody: { path, value: v, @@ -532,7 +536,7 @@ const varPath = `${path}_${secretField}` savedVariableCount++ await VariableService.createVariable({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, requestBody: { path: varPath, value: v, @@ -549,7 +553,7 @@ } await ResourceService.createResource({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, requestBody: { resource_type: resourceType, path, diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 70aaf6a2ee..3956cea7eb 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -33,6 +33,7 @@ hidePath?: boolean onChange?: (args: { path: string; args: Record; description: string }) => void defaultValues?: Record | undefined + workspace?: string | undefined } let { @@ -41,9 +42,12 @@ path = $bindable(''), hidePath = false, onChange, - defaultValues = undefined + defaultValues = undefined, + workspace = undefined }: Props = $props() + let effectiveWorkspace = $derived(workspace ?? $workspaceStore!) + let isValid = $state(true) let jsonError = $state('') let can_write = $state(true) @@ -68,13 +72,13 @@ let rawCode: string | undefined = $state(undefined) async function initEdit() { - resourceToEdit = await ResourceService.getResource({ workspace: $workspaceStore!, path }) + resourceToEdit = await ResourceService.getResource({ workspace: effectiveWorkspace, path }) description = resourceToEdit!.description ?? '' resource_type = resourceToEdit!.resource_type args = resourceToEdit?.value ?? ({} as any) loadResourceType() can_write = - resourceToEdit.workspace_id == $workspaceStore && + resourceToEdit.workspace_id == effectiveWorkspace && canWrite(path, resourceToEdit.extra_perms ?? {}, $userStore) linkedVars = Object.entries(args) .filter(([_, v]) => typeof v == 'string' && v == `$var:${initialPath}`) @@ -92,12 +96,12 @@ export async function editResource(): Promise { if (resourceToEdit) { await ResourceService.updateResource({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, path: resourceToEdit.path, requestBody: { path, value: args, description } }) if (resourceToEdit.resource_type === 'json_schema') { - clearJsonSchemaResourceCache(resourceToEdit.path, $workspaceStore!) + clearJsonSchemaResourceCache(resourceToEdit.path, effectiveWorkspace) } sendUserToast(`Updated resource at ${path}`) dispatch('refresh', path) @@ -108,7 +112,7 @@ export async function createResource(): Promise { await ResourceService.createResource({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, requestBody: { path, value: args, description, resource_type: resource_type! } }) sendUserToast(`Updated resource at ${path}`) @@ -119,7 +123,7 @@ if (resource_type) { try { const resourceType = await ResourceService.getResourceType({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, path: resource_type }) diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index dae6943868..54348e81c1 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -5,6 +5,11 @@ import { Loader2, Save } from 'lucide-svelte' + let { + workspace = undefined, + disableChatOffset = false + }: { workspace?: string; disableChatOffset?: boolean } = $props() + let drawer: Drawer | undefined = $state() let canSave = $state(true) let resource_type: string | undefined = $state(undefined) @@ -34,7 +39,7 @@ let mode: 'edit' | 'new' = $derived(!path ? 'new' : 'edit') - + void excludedValues?: string[] datatableAsPgResource?: boolean + workspace?: string | undefined + disableChatOffset?: boolean } let { @@ -47,9 +49,13 @@ class: className = '', onClear = undefined, excludedValues = undefined, - datatableAsPgResource = false + datatableAsPgResource = false, + workspace = undefined, + disableChatOffset = false }: Props = $props() + let effectiveWorkspace = $derived(workspace ?? $workspaceStore!) + if (initialValue && value == undefined) { value = initialValue } @@ -104,7 +110,7 @@ const resources = await Promise.all( resourceTypesToQuery.map((rt) => ResourceService.listResource({ - workspace: $workspaceStore!, + workspace: effectiveWorkspace, resourceType: rt }) ) @@ -122,7 +128,7 @@ if (datatableAsPgResource && resourceType === 'postgresql') { try { const datatables = await WorkspaceService.listDataTables({ - workspace: $workspaceStore! + workspace: effectiveWorkspace }) for (const dt of datatables) { nc.push({ @@ -155,7 +161,7 @@ let previousResourceType = untrack(() => resourceType) $effect(() => { - $workspaceStore && resourceType + effectiveWorkspace && resourceType untrack(() => { if (previousResourceType != resourceType) { previousResourceType = resourceType @@ -167,7 +173,7 @@ $effect(() => { excludedValues - if ($workspaceStore && resourceType && !disabled) { + if (effectiveWorkspace && resourceType && !disabled) { untrack(() => loadResources(resourceType)) } }) @@ -186,9 +192,13 @@ }} bind:this={appConnect} {expressOAuthSetup} + {workspace} + {disableChatOffset} /> { await loadResources(resourceType) if (e.detail) { diff --git a/frontend/src/lib/components/SuperadminSettings.svelte b/frontend/src/lib/components/SuperadminSettings.svelte index b9dbb7c6d6..316a6237cd 100644 --- a/frontend/src/lib/components/SuperadminSettings.svelte +++ b/frontend/src/lib/components/SuperadminSettings.svelte @@ -146,6 +146,7 @@ bind:this={innerComponent} closeDrawer={handleClose} showHeaderInfo={false} + {disableChatOffset} bind:yamlMode bind:hasUnsavedChanges bind:hasAnyInvalid diff --git a/frontend/src/lib/components/SuperadminSettingsInner.svelte b/frontend/src/lib/components/SuperadminSettingsInner.svelte index 964414cafa..4a1d376f8c 100644 --- a/frontend/src/lib/components/SuperadminSettingsInner.svelte +++ b/frontend/src/lib/components/SuperadminSettingsInner.svelte @@ -39,12 +39,14 @@ import TextInput from './text_input/TextInput.svelte' import SettingsPageHeader from './settings/SettingsPageHeader.svelte' import SettingsSearchInput from './instanceSettings/SettingsSearchInput.svelte' + import InstanceAISettings from './instanceSettings/InstanceAISettings.svelte' let filter = $state('') let { closeDrawer, showHeaderInfo = true, + disableChatOffset = false, yamlMode = $bindable(false), hasUnsavedChanges = $bindable(false), hasAnyInvalid = $bindable(false) @@ -234,7 +236,9 @@
- {#if tab === 'users' && !yamlMode} + {#if tab === 'ai' && !yamlMode} + + {:else if tab === 'users' && !yamlMode}
{#if !automateUsernameCreation && !isCloudHosted()}
diff --git a/frontend/src/lib/components/copilot/TestAIKey.svelte b/frontend/src/lib/components/copilot/TestAIKey.svelte index 575159aef8..1f0f97c4ef 100644 --- a/frontend/src/lib/components/copilot/TestAIKey.svelte +++ b/frontend/src/lib/components/copilot/TestAIKey.svelte @@ -7,6 +7,7 @@ interface Props { disabled?: boolean apiKey?: string | undefined + workspace?: string | undefined resourcePath?: string | undefined aiProvider: AIProvider model: string @@ -15,6 +16,7 @@ let { disabled = false, apiKey = undefined, + workspace = undefined, resourcePath = undefined, aiProvider, model @@ -38,6 +40,7 @@ await testKey({ apiKey, + workspace, resourcePath, messages: [ { diff --git a/frontend/src/lib/components/copilot/chat/openai-responses.ts b/frontend/src/lib/components/copilot/chat/openai-responses.ts index d7e5ad5c96..5003f48099 100644 --- a/frontend/src/lib/components/copilot/chat/openai-responses.ts +++ b/frontend/src/lib/components/copilot/chat/openai-responses.ts @@ -5,10 +5,14 @@ import type { ChatCompletionCreateParams } from 'openai/resources/index.mjs' import type { ResponseErrorEvent } from 'openai/resources/responses/responses.mjs' -import { getProviderAndCompletionConfig, workspaceAIClients } from '../lib' +import { + createOpenAIProxyClient, + getAiProxyBaseURL, + getProviderAndCompletionConfig, + workspaceAIClients +} from '../lib' import { processToolCall, type Tool, type ToolCallbacks } from './shared' import type { ResponseStream } from 'openai/lib/responses/ResponseStream.mjs' -import { OpenAPI } from '$lib/gen' import type { AIProviderModel } from '$lib/gen' // Conversion utilities for Responses API @@ -354,6 +358,7 @@ export async function getNonStreamingOpenAIResponsesCompletion( abortController: AbortController, testOptions?: { apiKey?: string + workspace?: string resourcePath?: string forceModelProvider: AIProviderModel } @@ -390,15 +395,10 @@ export async function getNonStreamingOpenAIResponsesCompletion( } const openaiClient = testOptions?.apiKey - ? new OpenAI({ - baseURL: `${location.origin}${OpenAPI.BASE}/ai/proxy`, - apiKey: 'fake-key', - defaultHeaders: { - Authorization: '' // a non empty string will be unable to access Windmill backend proxy - }, - dangerouslyAllowBrowser: true - }) - : workspaceAIClients.getOpenaiClient() + ? createOpenAIProxyClient(getAiProxyBaseURL()) + : testOptions?.workspace + ? workspaceAIClients.createOpenaiClient(testOptions.workspace) + : workspaceAIClients.getOpenaiClient() const response = await openaiClient.responses.create( { diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index f9afd9bb2c..dc05e3a247 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -67,7 +67,14 @@ export const AI_PROVIDERS: Record = { }, googleai: { label: 'Google AI', - defaultModels: ['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-2.5-flash-lite', 'gemini-3-flash', 'gemini-3.1-pro', 'gemini-3.1-flash-lite'] + defaultModels: [ + 'gemini-2.5-flash', + 'gemini-2.5-pro', + 'gemini-2.5-flash-lite', + 'gemini-3-flash', + 'gemini-3.1-pro', + 'gemini-3.1-flash-lite' + ] }, groq: { label: 'Groq', @@ -364,38 +371,46 @@ export const PROVIDER_COMPLETION_CONFIG_MAP: Record = { general: 'Core', + ai: 'AI', sso: 'Auth/OAuth/SAML', oauth: 'Auth/OAuth/SAML', scim_saml: 'Auth/OAuth/SAML', @@ -897,6 +909,7 @@ export const setupNavigationGroups = instanceSettingsNavigationGroups export const categoryToTabMap: Record = { Core: 'general', + AI: 'ai', SMTP: 'smtp', 'Auth/OAuth/SAML': 'sso', Registries: 'registries', diff --git a/frontend/src/lib/components/instanceSettings/InstanceAISettings.svelte b/frontend/src/lib/components/instanceSettings/InstanceAISettings.svelte new file mode 100644 index 0000000000..91b08eb46e --- /dev/null +++ b/frontend/src/lib/components/instanceSettings/InstanceAISettings.svelte @@ -0,0 +1,149 @@ + + +{#if loaded} + {#if showHubSync} +
+
+

Resource types

+

+ AI providers require their resource types. Sync from the Hub if they are missing. +

+
+ +
+ {#if hubSyncStatus === 'success'} +
+ + {hubSyncMessage} + +
+ {:else if hubSyncStatus === 'error'} +
+ + {hubSyncMessage} + +
+ {/if} + {/if} + + +{/if} diff --git a/frontend/src/lib/components/settings/AIPromptsModal.svelte b/frontend/src/lib/components/settings/AIPromptsModal.svelte index a0ea762c8d..b9c126d19c 100644 --- a/frontend/src/lib/components/settings/AIPromptsModal.svelte +++ b/frontend/src/lib/components/settings/AIPromptsModal.svelte @@ -13,7 +13,7 @@ onSave?: () => void onReset: () => void hasChanges: boolean - isWorkspaceSettings?: boolean + scope?: 'user' | 'workspace' | 'instance' } let { @@ -22,7 +22,7 @@ onSave, onReset, hasChanges, - isWorkspaceSettings = false + scope = 'user' }: Props = $props() const placeholders: Record = { @@ -63,9 +63,12 @@
- {#if isWorkspaceSettings} + {#if scope === 'workspace'} Customize the system prompts for each AI mode. These prompts apply to all workspace members. + {:else if scope === 'instance'} + Customize the system prompts for each AI mode. These prompts apply to workspaces using + instance AI defaults. {:else} Customize the system prompts for each AI mode. These prompts are stored locally in your browser and apply in addition to workspace-level prompts. diff --git a/frontend/src/lib/components/workspaceSettings/AISettings.svelte b/frontend/src/lib/components/workspaceSettings/AISettings.svelte index 2ed3432a3c..dd3cc6e0d0 100644 --- a/frontend/src/lib/components/workspaceSettings/AISettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/AISettings.svelte @@ -1,5 +1,12 @@ - +
- -
- {#each Object.entries(AI_PROVIDERS) as [provider, details]} -
-
- { - if (e.detail) { - aiProviders = { - ...aiProviders, - [provider]: { - resource_path: '', - models: - availableAiModels[provider].length > 0 - ? [availableAiModels[provider][0]] - : [] - } - } - - if (availableAiModels[provider].length > 0 && !defaultModel) { - defaultModel = availableAiModels[provider][0] - } - } else { - aiProviders = Object.fromEntries( - Object.entries(aiProviders).filter(([key]) => key !== provider) - ) - if (defaultModel) { - const currentDefaultModel = Object.values(aiProviders).find( - (p) => defaultModel && p.models.includes(defaultModel) - ) - if (!currentDefaultModel) { - defaultModel = undefined - } - } - if (codeCompletionModel) { - const currentCodeCompletionModel = Object.values(aiProviders).find( - (p) => codeCompletionModel && p.models.includes(codeCompletionModel) - ) - if (!currentCodeCompletionModel) { - codeCompletionModel = undefined - } - } - } - }} - /> - {#if provider === 'anthropic'} - - Recommended - - Anthropic models handle tool calls better than other providers, which makes them a - better choice for AI chat. - - - {/if} -
- - {#if aiProviders[provider]} -
-
- {/if} -
+ + {#key Object.keys(aiProviders).length} + + +
{/if}
- + + + + +
+ + {#if promptCount > 0} + ({promptCount} configured) + {/if} + {#if hasPromptsChanges} + Unsaved changes + {/if} +
+
+ {/if}
- onDiscard?.()} - saveLabel="Save AI settings" - disabled={!Object.values(aiProviders).every((p) => p.resource_path) || - (codeCompletionModel != undefined && codeCompletionModel.length === 0) || - (Object.keys(aiProviders).length > 0 && !defaultModel)} -/> +{#if showWorkspaceOverrideEditor} + +{/if} diff --git a/frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte b/frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte index 7f4b4e0f67..e02facb38e 100644 --- a/frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte +++ b/frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte @@ -11,7 +11,8 @@ VariableService, WorkspaceService, type AIProvider, - type CompletedJob + type CompletedJob, + type GetCopilotInfoResponse } from '$lib/gen' import { validateUsername } from '$lib/utils' import { logoutWithRedirect } from '$lib/logoutKit' @@ -52,6 +53,10 @@ let aiKey = $state('') let codeCompletionEnabled = $state(true) let checking = $state(false) + let createLoading = $state(false) + let aiSetupLoading = $state(false) + let creationStep = $state<'details' | 'ai'>('details') + let createdWorkspaceId: string | undefined = $state(undefined) let workspaceColor: string | undefined = $state(undefined) let colorEnabled = $state(false) @@ -85,6 +90,64 @@ let errorMsgs: string[] = $state([]) let failedSyncJobs: string[] = $state([]) + function getErrorMessage(error: any): string { + return ( + error?.body?.error?.message || + error?.body?.message || + (typeof error?.body === 'string' ? error.body : null) || + error?.message || + 'Unknown error' + ) + } + + function hasEffectiveAi(copilotInfo: GetCopilotInfoResponse): boolean { + return Object.keys(copilotInfo.providers ?? {}).length > 0 + } + + async function finishWorkspaceSetup(workspaceId: string): Promise { + usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces()) + switchWorkspace(workspaceId) + goto(rd ?? '/') + } + + async function getWorkspaceUsername(workspaceId: string): Promise { + if (!automateUsernameCreation) { + return username + } + + const user = await UserService.whoami({ + workspace: workspaceId + }) + return user.username + } + + async function maybeShowAiSetupStep(workspaceId: string): Promise { + try { + const copilotInfo = await WorkspaceService.getCopilotInfo({ + workspace: workspaceId + }) + + if (hasEffectiveAi(copilotInfo)) { + await finishWorkspaceSetup(workspaceId) + return + } + } catch (error) { + console.error('Failed to check effective AI configuration for new workspace', error) + sendUserToast( + 'Workspace created, but Windmill AI availability could not be verified. You can configure it later in Workspace settings.', + true + ) + await finishWorkspaceSetup(workspaceId) + return + } + + createdWorkspaceId = workspaceId + creationStep = 'ai' + aiKey = '' + codeCompletionEnabled = true + selected = 'openai' + } + async function fetchFailedSyncJobs(jobs: string[]): Promise { let ret: CompletedJob[] = [] for (const job of jobs) { @@ -188,20 +251,22 @@ forkCreationLoading = false sendUserToast(`Successfully forked workspace ${$workspaceStore} as: wm-fork-${id}`) + await finishWorkspaceSetup(prefixed_id) } else { sendUserToast('No workspace selected, cannot fork non-existent workspace', true) } } else { - await createWorkspace() + createLoading = true + try { + const workspaceId = await createWorkspace() + await maybeShowAiSetupStep(workspaceId) + } finally { + createLoading = false + } } - - usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces()) - switchWorkspace(isFork ? prefixed_id : id) - - goto(rd ?? '/') } - async function createWorkspace(): Promise { + async function createWorkspace(): Promise { await WorkspaceService.createWorkspace({ requestBody: { id, @@ -216,17 +281,23 @@ requestBody: { operator: operatorOnly, invite_all: !isCloudHosted(), auto_add: autoAdd } }) } - if (aiKey != '') { - let actualUsername = username - if (automateUsernameCreation) { - const user = await UserService.whoami({ - workspace: id - }) - actualUsername = user.username - } - let path = `u/${actualUsername}/${selected}_windmill_codegen` + + sendUserToast(`Created workspace id: ${id}`) + return id + } + + async function saveWorkspaceAiSetup(): Promise { + if (!createdWorkspaceId || !aiKey) { + return + } + + aiSetupLoading = true + try { + const actualUsername = await getWorkspaceUsername(createdWorkspaceId) + const path = `u/${actualUsername}/${selected}_windmill_codegen` + await VariableService.createVariable({ - workspace: id, + workspace: createdWorkspaceId, requestBody: { path, value: aiKey, @@ -235,7 +306,7 @@ } }) await ResourceService.createResource({ - workspace: id, + workspace: createdWorkspaceId, requestBody: { path, value: { @@ -245,40 +316,46 @@ } }) await WorkspaceService.editCopilotConfig({ - workspace: id, - requestBody: aiKey - ? { - providers: { - [selected]: { - resource_path: path, - models: [AI_PROVIDERS[selected].defaultModels[0]] - } - }, - default_model: { - model: AI_PROVIDERS[selected].defaultModels[0], - provider: selected - }, - code_completion_model: codeCompletionEnabled - ? { model: AI_PROVIDERS[selected].defaultModels[0], provider: selected } - : undefined + workspace: createdWorkspaceId, + requestBody: { + providers: { + [selected]: { + resource_path: path, + models: [AI_PROVIDERS[selected].defaultModels[0]] } - : {} + }, + default_model: { + model: AI_PROVIDERS[selected].defaultModels[0], + provider: selected + }, + code_completion_model: codeCompletionEnabled + ? { model: AI_PROVIDERS[selected].defaultModels[0], provider: selected } + : undefined + } }) + + sendUserToast('Windmill AI configured') + await finishWorkspaceSetup(createdWorkspaceId) + } catch (error) { + sendUserToast(`Failed to configure Windmill AI: ${getErrorMessage(error)}`, true) + } finally { + aiSetupLoading = false } - - sendUserToast(`Created workspace id: ${id}`) - - usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces()) - switchWorkspace(id) - - goto(rd ?? '/') } - function handleKeyUp(event: KeyboardEvent) { + function handleCreateKeyUp(event: KeyboardEvent) { const key = event.key if (key === 'Enter') { event.preventDefault() - createWorkspace() + createOrForkWorkspace() + } + } + + function handleAiKeyUp(event: KeyboardEvent) { + const key = event.key + if (key === 'Enter' && aiKey) { + event.preventDefault() + saveWorkspaceAiSetup() } } @@ -329,6 +406,9 @@ let operatorOnly = $state(false) let autoAdd = $state(true) let selected: Exclude = $state('openai') + let modalTitle = $derived( + isFork ? 'Fork Workspace' : creationStep === 'ai' ? 'Set up Windmill AI' : 'New Workspace' + ) run(() => { id = name.toLowerCase().replace(/\s/gi, '-') }) @@ -344,7 +424,7 @@ let domain = $derived($usersWorkspaceStore?.email.split('@')[1]) - +
{#if isFork}
@@ -410,88 +490,184 @@ {/if} {/if} - - - - {#if !automateUsernameCreation} + {#if isFork || creationStep === 'details'} + - {/if} - {#if !isFork} -
+ + {#if !automateUsernameCreation} + + {/if} + {#if !isFork} +
+ + + {#if isCloudHosted() && isDomainAllowed == false} +
{domain} domain not allowed for auto-invite
+ {/if} + + {#if auto_invite} +
+ + {#if isCloudHosted()} + + {/if} + + +
+ {/if} +
+ {/if} + +
+ + {#if !forkCreationLoading} + + {:else} + + {/if} +
+ {:else} +
+ + Windmill AI powers the chat, code generation, flow creation, and code completion. Set + it up now or configure it later in Workspace settings. + + Learn more + + - + {#snippet children({ item })} @@ -517,7 +704,7 @@ type="password" autocomplete="new-password" bind:value={aiKey} - onkeyup={handleKeyUp} + onkeyup={handleAiKeyUp} /> {#if aiKey} -
+
{/if}
-
- +
- {/if} -
- - {#if !forkCreationLoading} + Skip for now + - {:else} - - {/if} -
+
+ {/if}
diff --git a/frontend/src/lib/components/workspaceSettings/InstanceFallbackSettings.svelte b/frontend/src/lib/components/workspaceSettings/InstanceFallbackSettings.svelte new file mode 100644 index 0000000000..a44981ccbd --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/InstanceFallbackSettings.svelte @@ -0,0 +1,93 @@ + + +{#if instanceAiSummary} + +
+

+ This workspace is currently using the instance AI defaults shown below. +

+ +
+ {#each sortedInstanceProviders as providerSummary} +
+
+ + {getProviderLabel(providerSummary.provider)} + + Instance +
+
+ {#each providerSummary.models as model} + {model} + {/each} +
+
+ {/each} +
+ + {#if instanceAiSummary.default_model} +
+ Default chat model: + {instanceAiSummary.default_model.model} + + ({getProviderLabel(instanceAiSummary.default_model.provider)}) + +
+ {/if} + + {#if instanceAiSummary.code_completion_model} +
+ Code completion model: + + {instanceAiSummary.code_completion_model.model} + + + ({getProviderLabel(instanceAiSummary.code_completion_model.provider)}) + +
+ {/if} +
+
+{/if} + + +
+

+ Create workspace-specific AI settings only if this workspace needs to override the active + instance defaults. +

+
+ +
+
+
diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte index 66d9568c09..36f9be8faa 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/instance_settings/+page.svelte @@ -25,13 +25,20 @@ import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte' import SettingCard from '$lib/components/instanceSettings/SettingCard.svelte' import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' + import InstanceAISettings from '$lib/components/instanceSettings/InstanceAISettings.svelte' const settingsSteps = [ { id: 'Core', label: 'Core' }, { id: 'Auth/OAuth/SAML', label: 'Authentication' } ] as const - const wizardStepLabels = [...settingsSteps.map((s) => s.label), 'Root login & Resource Types'] + const AI_STEP_INDEX = settingsSteps.length + + const wizardStepLabels = [ + ...settingsSteps.map((s) => s.label), + 'AI', + 'Root login & Resource Types' + ] const fullStepLabels = ['Settings', 'Root login & Resource Types'] @@ -67,6 +74,7 @@ }) let instanceSettings: InstanceSettings | undefined = $state() + let instanceAiSettings: InstanceAISettings | undefined = $state() function isSettingsStep(step: number): boolean { return step < settingsSteps.length @@ -148,6 +156,9 @@ let passwordValid = $derived(newPassword.length >= 2) let accountFormValid = $derived(emailValid && passwordValid) + // --- AI step state --- + let aiHasUnsavedChanges = $state(false) + // --- EE license key warning --- let showLicenseKeyWarning = $state(false) let pendingNextCallback: (() => void) | undefined = $state(undefined) @@ -168,9 +179,20 @@ let authSubTab: 'sso' | 'oauth' | 'scim' = $derived(tabToAuthSubTab[fullTab] ?? 'sso') let yamlMode = $state(false) - function handleNavigate(newTab: string) { - if (newTab === fullTab) return + function isAiStepActive(): boolean { + return ( + (mode === 'wizard' && wizardStep === AI_STEP_INDEX) || + (mode === 'full' && fullStep === 0 && fullTab === 'ai' && !yamlMode) + ) + } + + async function handleNavigate(newTab: string): Promise { + if (newTab === fullTab) return true + if (isAiStepActive() && !((await instanceAiSettings?.persistBeforeExit()) ?? true)) { + return false + } fullTab = newTab + return true } // --- Settings search (full mode) --- @@ -180,7 +202,10 @@ let highlightTimeout: ReturnType | undefined async function handleSearchSelect(item: SearchableSettingItem) { - handleNavigate(item.tabId) + const didNavigate = await handleNavigate(item.tabId) + if (!didNavigate) { + return + } if (item.settingKey) { clearTimeout(scrollTimeout) clearTimeout(highlightTimeout) @@ -202,7 +227,7 @@ }) /** Check if we need to warn about missing EE license key before proceeding */ - function proceedFromCore(callback: () => void) { + async function proceedFromCore(callback: () => void) { const leavingSettings = (mode === 'wizard' && wizardStep === 0) || (mode === 'full' && fullStep === 0) if (leavingSettings && isEeImage() && isLicenseKeyEmpty()) { @@ -210,12 +235,16 @@ showLicenseKeyWarning = true return } - saveAndProceed(callback) + await saveAndProceed(callback) } /** Auto-save dirty settings, then run the callback */ async function saveAndProceed(callback: () => void) { - if (yamlMode) { + if (isAiStepActive()) { + if (!((await instanceAiSettings?.persistBeforeExit()) ?? true)) { + return + } + } else if (yamlMode) { // In YAML mode, sync editor → form, then bulk-save everything if (!instanceSettings?.syncBeforeDiff()) return await instanceSettings.saveSettings() @@ -231,11 +260,14 @@ callback() } - function switchToFullMode() { + async function switchToFullMode() { mode = 'full' } - function switchToWizardMode() { + async function switchToWizardMode() { + if (isAiStepActive() && !((await instanceAiSettings?.persistBeforeExit()) ?? true)) { + return + } yamlMode = false fullStep = 0 mode = 'wizard' @@ -461,6 +493,13 @@ tab={settingsSteps[wizardStep].id} /> {/key} + {:else if wizardStep === AI_STEP_INDEX} + {:else} {@render accountSetupContent()} {/if} @@ -505,19 +544,28 @@ {/if}
- { - const targetTab = categoryToTabMap[category] - if (targetTab) { - handleNavigate(targetTab) - } - }} - /> + {#if fullTab === 'ai' && !yamlMode} + + {:else} + { + const targetTab = categoryToTabMap[category] + if (targetTab) { + handleNavigate(targetTab) + } + }} + /> + {/if}
{:else} diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index d481346af2..db3a140c0b 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -19,10 +19,12 @@ import { OauthService, WorkspaceService, - ResourceService, SettingService, type AIConfig, - type ErrorHandler + type ErrorHandler, + type GetCopilotSettingsStateResponse, + type InstanceAISummary, + type GetSettingsResponse } from '$lib/gen' import { enterpriseLicense, @@ -60,7 +62,6 @@ convertDucklakeSettingsFromBackend, type DucklakeSettingsType } from '$lib/components/workspaceSettings/DucklakeSettings.svelte' - import { AIMode } from '$lib/components/copilot/chat/AIChatManager.svelte' import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' import CollapseLink from '$lib/components/CollapseLink.svelte' @@ -112,19 +113,12 @@ let publicAppRateLimitPerMinute: number | undefined = $state(undefined) let initialPublicAppRateLimitPerMinute: number | undefined = $state(undefined) - let aiProviders: Exclude = $state({}) - let codeCompletionModel: string | undefined = $state(undefined) - let defaultModel: string | undefined = $state(undefined) - let customPrompts: Record = $state({}) - let maxTokensPerModel: Record = $state({}) - - // Track initial AI config for unsaved changes detection - let initialAiProviders: Exclude = $state({}) - let initialCodeCompletionModel: string | undefined = $state(undefined) - let initialDefaultModel: string | undefined = $state(undefined) - let initialCustomPrompts: Record = $state({}) - let initialMaxTokensPerModel: Record = $state({}) - + let hasInstanceAiConfig = $state(false) + let usesInstanceAiConfig = $state(false) + let instanceAiSummary: InstanceAISummary | undefined = $state(undefined) + let aiInitialConfig: AIConfig | undefined = $state(undefined) + let aiSettingsComponent: AISettings | undefined = $state(undefined) + let hasAiSettingsChanges = $state(false) // Track initial deploy settings for unsaved changes detection let initialWorkspaceToDeployTo: string | undefined = $state(undefined) let initialDeployUiSettings: { @@ -227,14 +221,6 @@ return currentValue !== initialValue }) - // Derived state for checking unsaved changes in AI settings - let hasAiSettingsChanges = $derived.by(() => { - if (tab !== 'ai') return false - const changes = getAiSettingsInitialAndModifiedValues() - if (!changes.savedValue || !changes.modifiedValue) return false - return hasUnsavedChanges(changes.savedValue, changes.modifiedValue) - }) - // Derived state for checking unsaved changes in deployment settings let hasDeploySettingsChanges = $derived.by(() => { if (tab !== 'deploy_to') return false @@ -320,8 +306,6 @@ $page.url.searchParams.get('tab') === 'teams' ? 'teams_commands' : 'slack_commands' ) - let usingOpenaiClientCredentialsOauth = $state(false) - let loadedSettings = $state(false) let oauths: Record = $state({}) @@ -489,7 +473,17 @@ } async function loadSettings(): Promise { - const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! }) + const [settings, copilotSettingsState]: [ + GetSettingsResponse, + GetCopilotSettingsStateResponse + ] = await Promise.all([ + WorkspaceService.getSettings({ + workspace: $workspaceStore! + }), + WorkspaceService.getCopilotSettingsState({ + workspace: $workspaceStore! + }) + ]) slack_team_name = settings.slack_name teams_team_id = settings.teams_team_id teams_team_name = settings.teams_team_name @@ -508,23 +502,10 @@ workspaceToDeployTo = settings.deploy_to webhook = settings.webhook - aiProviders = settings.ai_config?.providers ?? {} - defaultModel = settings.ai_config?.default_model?.model - codeCompletionModel = settings.ai_config?.code_completion_model?.model - customPrompts = settings.ai_config?.custom_prompts ?? {} - maxTokensPerModel = settings.ai_config?.max_tokens_per_model ?? {} - for (const mode of Object.values(AIMode)) { - if (!(mode in customPrompts)) { - customPrompts[mode] = '' - } - } - - // Store initial AI config state for unsaved changes detection - initialAiProviders = clone(aiProviders) - initialDefaultModel = defaultModel - initialCodeCompletionModel = codeCompletionModel - initialCustomPrompts = clone(customPrompts) - initialMaxTokensPerModel = clone(maxTokensPerModel) + aiInitialConfig = settings.ai_config ?? {} + hasInstanceAiConfig = copilotSettingsState.has_instance_ai_config + usesInstanceAiConfig = copilotSettingsState.uses_instance_ai_config + instanceAiSummary = copilotSettingsState.instance_ai_summary const errorHandler = settings.error_handler as | { path?: string; extra_args?: any; muted_on_cancel?: boolean; muted_on_user_path?: boolean } | undefined @@ -600,12 +581,6 @@ // Store initial success handler state for unsaved changes detection initialSuccessHandlerScriptPath = successHandlerScriptPath - // check openai_client_credentials_oauth - usingOpenaiClientCredentialsOauth = await ResourceService.existsResourceType({ - workspace: $workspaceStore!, - path: 'openai_client_credentials_oauth' - }) - loadedSettings = true } @@ -816,36 +791,6 @@ ) } - // Function to check if there are unsaved changes in AI settings - function getAiSettingsInitialAndModifiedValues() { - const savedValue = { - aiProviders: initialAiProviders, - defaultModel: initialDefaultModel, - codeCompletionModel: initialCodeCompletionModel, - customPrompts: initialCustomPrompts, - maxTokensPerModel: initialMaxTokensPerModel - } - - const modifiedValue = { - aiProviders: aiProviders, - defaultModel: defaultModel, - codeCompletionModel: codeCompletionModel, - customPrompts: customPrompts, - maxTokensPerModel: maxTokensPerModel - } - - return { savedValue, modifiedValue } - } - - // Function to discard unsaved AI settings changes - function discardAiSettingsChanges() { - aiProviders = clone(initialAiProviders) - defaultModel = initialDefaultModel - codeCompletionModel = initialCodeCompletionModel - customPrompts = clone(initialCustomPrompts) - maxTokensPerModel = clone(initialMaxTokensPerModel) - } - // Function to check if there are unsaved changes in storage settings function getStorageSettingsInitialAndModifiedValues() { return { @@ -1017,7 +962,9 @@ case 'windmill_data_tables': return dataTableSettingsComponent?.unsavedChanges() ?? { savedValue: {}, modifiedValue: {} } case 'ai': - return getAiSettingsInitialAndModifiedValues() + return hasAiSettingsChanges + ? { savedValue: { changed: false }, modifiedValue: { changed: true } } + : { savedValue: {}, modifiedValue: {} } case 'windmill_lfs': return getStorageSettingsInitialAndModifiedValues() case 'volume_storage': @@ -1059,7 +1006,7 @@ function discardAllChanges() { switch (tab) { case 'ai': - discardAiSettingsChanges() + aiSettingsComponent?.discard() break case 'windmill_lfs': discardStorageSettingsChanges() @@ -1830,21 +1777,19 @@ export async function main( /> {:else if tab == 'ai'} { - // Update initial state after successful save - initialAiProviders = clone(aiProviders) - initialDefaultModel = defaultModel - initialCodeCompletionModel = codeCompletionModel - initialCustomPrompts = clone(customPrompts) - initialMaxTokensPerModel = clone(maxTokensPerModel) + bind:this={aiSettingsComponent} + initialConfig={aiInitialConfig} + bind:hasUnsavedChanges={hasAiSettingsChanges} + {hasInstanceAiConfig} + {usesInstanceAiConfig} + {instanceAiSummary} + onSave={(copilotSettingsState) => { + if (!copilotSettingsState) { + return + } + hasInstanceAiConfig = copilotSettingsState.has_instance_ai_config + usesInstanceAiConfig = copilotSettingsState.uses_instance_ai_config + instanceAiSummary = copilotSettingsState.instance_ai_summary }} /> {:else if tab == 'windmill_data_tables'}