mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
feat: add instance-level AI settings (#8453)
* feat: add instance-level AI settings with workspace fallback Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add AI step to onboarding setup wizard Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: thread workspace prop through resource editor and disable chat offset Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Revert "fix: thread workspace prop through resource editor and disable chat offset" This reverts commit 9fea9cc0c239f6432d1fef1487c45e74ab752e21. * fix: set workspace store and disable chat offset during AI setup step Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: thread workspace and disableChatOffset props through resource editors Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: populate workspace and user stores for AI step path component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: initialize AI clients for test key during onboarding Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: extract AI config state into InstanceAISettings component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: move AI config state ownership into AISettings component Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Persist instance AI settings before navigation * Reload effective workspace AI state after save * Scope AI key tests to the rendered workspace * Add post-create AI onboarding for new workspaces * Unify instance AI settings header * Fix instance AI drawer offset on workspace selection * Add instance AI fallback settings behavior * Update sqlx metadata * Update sqlx metadata * Clarify active instance AI in workspace settings * Refresh workspace AI state after instance AI save * Declare instance AI summary in API schema * Normalize empty instance AI config handling * Clean up workspace AI settings UI * Unify AI config provider checks * Split AI settings metadata from effective config * Propagate instance AI cache invalidation across servers * Fix AI settings dirty state tracking * Update sqlx metadata --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+20
@@ -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"
|
||||
}
|
||||
+7
-2
@@ -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;
|
||||
|
||||
@@ -82,12 +82,10 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> 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::<bool>().await?;
|
||||
|
||||
@@ -213,12 +211,10 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
resp.json::<Vec<String>>().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::<Vec<serde_json::Value>>().await?;
|
||||
assert!(dependents.is_empty());
|
||||
@@ -425,13 +421,11 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> 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<Postgres>) -> anyhow::Result<()> {
|
||||
.unwrap();
|
||||
let invites = resp.json::<Vec<serde_json::Value>>().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<Postgres>) -> 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<Postgres>) -> 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<Postgres>) -> anyhow::Result<()> {
|
||||
assert_eq!(resp.json::<bool>().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<Postgres>,
|
||||
) -> 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::<serde_json::Value>().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::<serde_json::Value>().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<Postgres>,
|
||||
) -> 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::<serde_json::Value>().await?;
|
||||
assert!(settings["providers"].is_null());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<i32>,
|
||||
}
|
||||
|
||||
#[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<InstanceAISummary>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct InstanceAIProviderSummary {
|
||||
pub provider: String,
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
#[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<InstanceAIProviderSummary>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_model: Option<InstanceAIModelSummary>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code_completion_model: Option<InstanceAIModelSummary>,
|
||||
}
|
||||
|
||||
/// #[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<String>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
) -> JsonResult<CopilotSettingsState> {
|
||||
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<serde_json::Value> =
|
||||
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<InstanceAISummary> {
|
||||
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::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
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<InstanceAIModelSummary> {
|
||||
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<String>,
|
||||
|
||||
@@ -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:
|
||||
|
||||
+169
-44
@@ -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<u64>,
|
||||
}
|
||||
|
||||
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<u64>) -> 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<HashMap<AIProvider, ProviderConfig>>,
|
||||
@@ -508,6 +521,14 @@ pub struct AIConfig {
|
||||
pub max_tokens_per_model: Option<HashMap<String, i32>>,
|
||||
}
|
||||
|
||||
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::<AIConfig>(v.clone()).ok())
|
||||
.is_some_and(|config| config.has_providers());
|
||||
|
||||
let mut ai_config = serde_json::from_value::<AIConfig>(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::<AIConfig>(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<Box<RawValue>>\" 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<sqlx::types::Json<Box<RawValue>>>>(
|
||||
"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::<AIResource>(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<Mutex<()>> = 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
ApiAuthed { is_admin, username, .. }: ApiAuthed,
|
||||
Json(ai_config): Json<AIConfig>,
|
||||
) -> Result<String> {
|
||||
) -> JsonResult<EditCopilotConfigResponse> {
|
||||
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::<AIConfig>(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<InstanceAISummary>,
|
||||
}
|
||||
|
||||
async fn get_copilot_info(
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<AIConfig> {
|
||||
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<AIConfig>\" 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::<AIConfig>(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()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -351,6 +351,13 @@ pub struct GlobalSettings {
|
||||
std::collections::HashMap<String, std::collections::HashMap<String, serde_json::Value>>,
|
||||
>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(
|
||||
feature = "instance_config_schema",
|
||||
schemars(schema_with = "opaque_json_schema")
|
||||
)]
|
||||
pub ai_config: Option<serde_json::Value>,
|
||||
|
||||
/// Catch-all for settings not yet covered by typed fields.
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, serde_json::Value>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<DrawerContent
|
||||
title="Add a resource"
|
||||
@@ -68,6 +71,7 @@
|
||||
on:close={drawer?.closeDrawer}
|
||||
on:refresh
|
||||
express={expressOAuthSetup}
|
||||
{workspace}
|
||||
/>
|
||||
{#snippet actions()}
|
||||
<div class="flex gap-1">
|
||||
|
||||
@@ -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<string, SchemaProperty> = 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,
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
hidePath?: boolean
|
||||
onChange?: (args: { path: string; args: Record<string, any>; description: string }) => void
|
||||
defaultValues?: Record<string, any> | 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<void> {
|
||||
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<void> {
|
||||
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
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={drawer} size="800px">
|
||||
<Drawer bind:this={drawer} size="800px" {disableChatOffset}>
|
||||
<DrawerContent
|
||||
title={mode == 'edit' ? 'Edit ' + path : 'Add a resource'}
|
||||
on:close={drawer?.closeDrawer}
|
||||
@@ -46,6 +51,7 @@
|
||||
{path}
|
||||
{resource_type}
|
||||
{defaultValues}
|
||||
{workspace}
|
||||
on:refresh
|
||||
bind:this={resourceEditor}
|
||||
bind:canSave
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
onClear?: () => 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}
|
||||
/>
|
||||
<ResourceEditorDrawer
|
||||
bind:this={resourceEditor}
|
||||
{workspace}
|
||||
{disableChatOffset}
|
||||
on:refresh={async (e) => {
|
||||
await loadResources(resourceType)
|
||||
if (e.detail) {
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
bind:this={innerComponent}
|
||||
closeDrawer={handleClose}
|
||||
showHeaderInfo={false}
|
||||
{disableChatOffset}
|
||||
bind:yamlMode
|
||||
bind:hasUnsavedChanges
|
||||
bind:hasAnyInvalid
|
||||
|
||||
@@ -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 @@
|
||||
<div class="flex-1 min-w-0 h-full">
|
||||
<div class="h-full overflow-auto bg-surface">
|
||||
<div class="h-fit px-8 py-4">
|
||||
{#if tab === 'users' && !yamlMode}
|
||||
{#if tab === 'ai' && !yamlMode}
|
||||
<InstanceAISettings {disableChatOffset} />
|
||||
{:else if tab === 'users' && !yamlMode}
|
||||
<div class="h-full">
|
||||
{#if !automateUsernameCreation && !isCloudHosted()}
|
||||
<div class="mb-4">
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -67,7 +67,14 @@ export const AI_PROVIDERS: Record<AIProvider, AIProviderDetails> = {
|
||||
},
|
||||
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<AIProvider, ChatCompletionCr
|
||||
aws_bedrock: DEFAULT_COMPLETION_CONFIG
|
||||
} as const
|
||||
|
||||
export function getAiProxyBaseURL(workspace?: string): string {
|
||||
return workspace
|
||||
? `${location.origin}${OpenAPI.BASE}/w/${workspace}/ai/proxy`
|
||||
: `${location.origin}${OpenAPI.BASE}/ai/proxy`
|
||||
}
|
||||
|
||||
export function createOpenAIProxyClient(baseURL: string): OpenAI {
|
||||
return new OpenAI({
|
||||
baseURL,
|
||||
apiKey: 'fake-key',
|
||||
defaultHeaders: {
|
||||
Authorization: '' // a non empty string will be unable to access Windmill backend proxy
|
||||
},
|
||||
dangerouslyAllowBrowser: true
|
||||
})
|
||||
}
|
||||
|
||||
export function createAnthropicProxyClient(baseURL: string): Anthropic {
|
||||
return new Anthropic({
|
||||
baseURL,
|
||||
apiKey: 'fake-key',
|
||||
dangerouslyAllowBrowser: true
|
||||
})
|
||||
}
|
||||
|
||||
class WorkspacedAIClients {
|
||||
private openaiClient: OpenAI | undefined
|
||||
private anthropicClient: Anthropic | undefined
|
||||
|
||||
init(workspace: string) {
|
||||
this.initOpenai(workspace)
|
||||
this.initAnthropic(workspace)
|
||||
this.openaiClient = this.createOpenaiClient(workspace)
|
||||
this.anthropicClient = this.createAnthropicClient(workspace)
|
||||
}
|
||||
|
||||
private getBaseURL(workspace: string) {
|
||||
return `${location.origin}${OpenAPI.BASE}/w/${workspace}/ai/proxy`
|
||||
createOpenaiClient(workspace: string): OpenAI {
|
||||
return createOpenAIProxyClient(getAiProxyBaseURL(workspace))
|
||||
}
|
||||
|
||||
private initOpenai(workspace: string) {
|
||||
const baseURL = this.getBaseURL(workspace)
|
||||
this.openaiClient = new OpenAI({
|
||||
baseURL,
|
||||
apiKey: 'fake-key',
|
||||
defaultHeaders: {
|
||||
Authorization: '' // a non empty string will be unable to access Windmill backend proxy
|
||||
},
|
||||
dangerouslyAllowBrowser: true
|
||||
})
|
||||
}
|
||||
|
||||
private initAnthropic(workspace: string) {
|
||||
const baseURL = this.getBaseURL(workspace)
|
||||
this.anthropicClient = new Anthropic({
|
||||
baseURL,
|
||||
apiKey: 'fake-key',
|
||||
dangerouslyAllowBrowser: true
|
||||
})
|
||||
createAnthropicClient(workspace: string): Anthropic {
|
||||
return createAnthropicProxyClient(getAiProxyBaseURL(workspace))
|
||||
}
|
||||
|
||||
getOpenaiClient() {
|
||||
@@ -417,6 +432,7 @@ export const workspaceAIClients = new WorkspacedAIClients()
|
||||
|
||||
export async function testKey({
|
||||
apiKey,
|
||||
workspace,
|
||||
resourcePath,
|
||||
model,
|
||||
abortController,
|
||||
@@ -424,6 +440,7 @@ export async function testKey({
|
||||
aiProvider
|
||||
}: {
|
||||
apiKey?: string
|
||||
workspace?: string
|
||||
resourcePath?: string
|
||||
model: string | undefined
|
||||
messages: ChatCompletionMessageParam[]
|
||||
@@ -443,6 +460,7 @@ export async function testKey({
|
||||
if (aiProvider === 'anthropic') {
|
||||
await testAnthropicKey({
|
||||
apiKey,
|
||||
workspace,
|
||||
resourcePath,
|
||||
model: modelToTest,
|
||||
abortController,
|
||||
@@ -453,6 +471,7 @@ export async function testKey({
|
||||
|
||||
await getNonStreamingCompletion(messages, abortController, {
|
||||
apiKey,
|
||||
workspace,
|
||||
resourcePath,
|
||||
forceModelProvider: {
|
||||
model: modelToTest,
|
||||
@@ -463,12 +482,14 @@ export async function testKey({
|
||||
|
||||
async function testAnthropicKey({
|
||||
apiKey,
|
||||
workspace,
|
||||
resourcePath,
|
||||
model,
|
||||
abortController,
|
||||
messages
|
||||
}: {
|
||||
apiKey?: string
|
||||
workspace?: string
|
||||
resourcePath?: string
|
||||
model: string
|
||||
abortController: AbortController
|
||||
@@ -489,12 +510,10 @@ async function testAnthropicKey({
|
||||
}
|
||||
|
||||
const anthropicClient = apiKey
|
||||
? new Anthropic({
|
||||
baseURL: `${location.origin}${OpenAPI.BASE}/ai/proxy`,
|
||||
apiKey: 'fake-key',
|
||||
dangerouslyAllowBrowser: true
|
||||
})
|
||||
: workspaceAIClients.getAnthropicClient()
|
||||
? createAnthropicProxyClient(getAiProxyBaseURL())
|
||||
: workspace
|
||||
? workspaceAIClients.createAnthropicClient(workspace)
|
||||
: workspaceAIClients.getAnthropicClient()
|
||||
|
||||
await anthropicClient.messages.create(
|
||||
{
|
||||
@@ -719,6 +738,7 @@ export async function getNonStreamingCompletion(
|
||||
testOptions?: {
|
||||
apiKey?: string // testing API KEY using the global ai proxy
|
||||
resourcePath?: string // testing resource path passed as a header to the backend proxy
|
||||
workspace?: string // use a specific workspace proxy when testing a workspace resource
|
||||
forceModelProvider: AIProviderModel
|
||||
}
|
||||
) {
|
||||
@@ -768,15 +788,10 @@ export async function getNonStreamingCompletion(
|
||||
}
|
||||
}
|
||||
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 completion = await openaiClient.chat.completions.create(config, fetchOptions)
|
||||
response = completion.choices?.[0]?.message.content || ''
|
||||
|
||||
@@ -822,6 +822,17 @@ export const instanceSettingsNavigationGroups = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'AI',
|
||||
items: [
|
||||
{
|
||||
id: 'ai',
|
||||
label: 'AI',
|
||||
aiId: 'instance-settings-ai',
|
||||
aiDescription: 'Instance AI settings (providers, models, prompts)'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Advanced',
|
||||
items: [
|
||||
@@ -863,6 +874,7 @@ export const instanceSettingsNavigationGroups = [
|
||||
|
||||
export const tabToCategoryMap: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
Core: 'general',
|
||||
AI: 'ai',
|
||||
SMTP: 'smtp',
|
||||
'Auth/OAuth/SAML': 'sso',
|
||||
Registries: 'registries',
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<script lang="ts">
|
||||
import { JobService, SettingService, WorkspaceService, type AIConfig } from '$lib/gen'
|
||||
import { setCopilotInfo } from '$lib/aiStore'
|
||||
import { workspaceStore, userStore } from '$lib/stores'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { workspaceAIClients } from '../copilot/lib'
|
||||
import AISettings from '../workspaceSettings/AISettings.svelte'
|
||||
import { Alert, Button } from '../common'
|
||||
|
||||
interface Props {
|
||||
hasUnsavedChanges?: boolean
|
||||
disableChatOffset?: boolean
|
||||
showHubSync?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
hasUnsavedChanges = $bindable(false),
|
||||
disableChatOffset = false,
|
||||
showHubSync = false
|
||||
}: Props = $props()
|
||||
|
||||
let initialConfig: AIConfig | undefined = $state(undefined)
|
||||
let loaded = $state(false)
|
||||
let aiSettings: AISettings | undefined = $state(undefined)
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
initialConfig =
|
||||
((await SettingService.getGlobal({ key: 'ai_config' })) as AIConfig | undefined) ?? {}
|
||||
loaded = true
|
||||
} catch (e) {
|
||||
console.error('Failed to load instance AI config', e)
|
||||
sendUserToast('Failed to load instance AI config', true)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCustomSave(config: AIConfig) {
|
||||
const hasProviders = Object.keys(config.providers ?? {}).length > 0
|
||||
await SettingService.setGlobal({
|
||||
key: 'ai_config',
|
||||
requestBody: { value: hasProviders ? config : null }
|
||||
})
|
||||
if ($workspaceStore) {
|
||||
try {
|
||||
const effectiveConfig = await WorkspaceService.getCopilotInfo({
|
||||
workspace: $workspaceStore
|
||||
})
|
||||
setCopilotInfo(effectiveConfig)
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh workspace AI state after instance save', e)
|
||||
}
|
||||
}
|
||||
sendUserToast('Instance AI settings saved')
|
||||
}
|
||||
|
||||
export async function persistBeforeExit(): Promise<boolean> {
|
||||
return (await aiSettings?.saveIfDirtyAndValid()) ?? true
|
||||
}
|
||||
|
||||
// Ensure stores are set (this page may bypass the (logged) layout)
|
||||
async function ensureStores() {
|
||||
if (!$workspaceStore) {
|
||||
$workspaceStore = 'admins'
|
||||
}
|
||||
if (!$userStore) {
|
||||
$userStore = await getUserExt($workspaceStore)
|
||||
}
|
||||
workspaceAIClients.init($workspaceStore)
|
||||
}
|
||||
|
||||
ensureStores()
|
||||
loadConfig()
|
||||
|
||||
// --- Hub sync ---
|
||||
let hubSyncStatus: 'idle' | 'loading' | 'success' | 'error' = $state('idle')
|
||||
let hubSyncMessage = $state('')
|
||||
|
||||
async function syncFromHub() {
|
||||
hubSyncStatus = 'loading'
|
||||
hubSyncMessage = ''
|
||||
try {
|
||||
await JobService.runWaitResultScriptByPath({
|
||||
workspace: 'admins',
|
||||
path: 'u/admin/hub_sync',
|
||||
requestBody: {}
|
||||
})
|
||||
hubSyncStatus = 'success'
|
||||
hubSyncMessage = 'Resource types synced from hub successfully'
|
||||
} catch (e: any) {
|
||||
hubSyncMessage =
|
||||
e?.body?.error?.message ||
|
||||
e?.body?.message ||
|
||||
(typeof e?.body === 'string' ? e.body : null) ||
|
||||
e?.message ||
|
||||
'Failed to sync from hub'
|
||||
hubSyncStatus = 'error'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loaded}
|
||||
{#if showHubSync}
|
||||
<div
|
||||
class="p-3 border rounded-md bg-surface-secondary mb-4 mt-4 flex items-center justify-between gap-4"
|
||||
>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-secondary">Resource types</p>
|
||||
<p class="text-2xs text-tertiary mt-0.5">
|
||||
AI providers require their resource types. Sync from the Hub if they are missing.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
loading={hubSyncStatus === 'loading'}
|
||||
onClick={syncFromHub}
|
||||
>
|
||||
Sync from hub
|
||||
</Button>
|
||||
</div>
|
||||
{#if hubSyncStatus === 'success'}
|
||||
<div class="mb-4">
|
||||
<Alert type="success" title="Resource types synced">
|
||||
{hubSyncMessage}
|
||||
</Alert>
|
||||
</div>
|
||||
{:else if hubSyncStatus === 'error'}
|
||||
<div class="mb-4">
|
||||
<Alert type="error" title="Sync failed">
|
||||
{hubSyncMessage}
|
||||
</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<AISettings
|
||||
bind:this={aiSettings}
|
||||
bind:hasUnsavedChanges
|
||||
{initialConfig}
|
||||
workspace="admins"
|
||||
{disableChatOffset}
|
||||
title="Windmill AI"
|
||||
description="Windmill AI integrates with your favorite AI providers and models. Set your AI settings at the instance level to be able to use them on all your workspaces. Workspace-level settings can override these."
|
||||
link="https://www.windmill.dev/docs/core_concepts/ai_generation"
|
||||
promptScope="instance"
|
||||
customSave={handleCustomSave}
|
||||
/>
|
||||
{/if}
|
||||
@@ -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<AIMode, string> = {
|
||||
@@ -63,9 +63,12 @@
|
||||
<div class="flex flex-col gap-6 h-full px-1 w-full">
|
||||
<div class="grow min-h-0 overflow-y-auto" style="scrollbar-gutter: stable;">
|
||||
<div class="text-xs text-secondary mb-6">
|
||||
{#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.
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { WorkspaceService, type AIConfig, type AIProvider } from '$lib/gen'
|
||||
import {
|
||||
ResourceService,
|
||||
WorkspaceService,
|
||||
type AIConfig,
|
||||
type AIProvider,
|
||||
type GetCopilotSettingsStateResponse,
|
||||
type InstanceAISummary
|
||||
} from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { AI_PROVIDERS, fetchAvailableModels } from '../copilot/lib'
|
||||
@@ -19,32 +26,139 @@
|
||||
import { setCopilotInfo } from '$lib/aiStore'
|
||||
import AIPromptsModal from '../settings/AIPromptsModal.svelte'
|
||||
import { Settings } from 'lucide-svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { slide } from 'svelte/transition'
|
||||
import SettingsFooter from './SettingsFooter.svelte'
|
||||
import InstanceFallbackSettings from './InstanceFallbackSettings.svelte'
|
||||
import SettingCard from '../instanceSettings/SettingCard.svelte'
|
||||
|
||||
let {
|
||||
aiProviders = $bindable(),
|
||||
codeCompletionModel = $bindable(),
|
||||
defaultModel = $bindable(),
|
||||
customPrompts = $bindable(),
|
||||
maxTokensPerModel = $bindable(),
|
||||
usingOpenaiClientCredentialsOauth = $bindable(),
|
||||
onSave,
|
||||
onDiscard,
|
||||
hasUnsavedChanges = false
|
||||
initialConfig = undefined,
|
||||
hasUnsavedChanges = $bindable(false),
|
||||
workspace = undefined,
|
||||
disableChatOffset = false,
|
||||
hasInstanceAiConfig = false,
|
||||
usesInstanceAiConfig = false,
|
||||
instanceAiSummary = undefined,
|
||||
customSave = undefined,
|
||||
onSave = undefined,
|
||||
title = 'Windmill AI',
|
||||
description = 'Windmill AI integrates with your favorite AI providers and models.',
|
||||
link = 'https://www.windmill.dev/docs/core_concepts/ai_generation',
|
||||
promptScope = 'workspace'
|
||||
}: {
|
||||
aiProviders: Exclude<AIConfig['providers'], undefined>
|
||||
codeCompletionModel: string | undefined
|
||||
defaultModel: string | undefined
|
||||
customPrompts: Record<string, string>
|
||||
maxTokensPerModel: Record<string, number>
|
||||
usingOpenaiClientCredentialsOauth: boolean
|
||||
onSave?: () => void
|
||||
onDiscard?: () => void
|
||||
initialConfig?: AIConfig | undefined
|
||||
hasUnsavedChanges?: boolean
|
||||
workspace?: string | undefined
|
||||
disableChatOffset?: boolean
|
||||
hasInstanceAiConfig?: boolean
|
||||
usesInstanceAiConfig?: boolean
|
||||
instanceAiSummary?: InstanceAISummary
|
||||
customSave?: (config: AIConfig) => Promise<void>
|
||||
onSave?: (info?: GetCopilotSettingsStateResponse) => void | Promise<void>
|
||||
title?: string
|
||||
description?: string
|
||||
link?: string
|
||||
promptScope?: 'workspace' | 'instance'
|
||||
} = $props()
|
||||
|
||||
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
|
||||
|
||||
// --- Internal state ---
|
||||
let aiProviders: Exclude<AIConfig['providers'], undefined> = $state({})
|
||||
let codeCompletionModel: string | undefined = $state(undefined)
|
||||
let defaultModel: string | undefined = $state(undefined)
|
||||
let customPrompts: Record<string, string> = $state({})
|
||||
let maxTokensPerModel: Record<string, number> = $state({})
|
||||
let usingOpenaiClientCredentialsOauth = $state(false)
|
||||
let workspaceOverrideEditorOpened = $state(false)
|
||||
|
||||
// --- Initial state for dirty tracking ---
|
||||
let initialAiProviders: Exclude<AIConfig['providers'], undefined> = $state({})
|
||||
let initialCodeCompletionModel: string | undefined = $state(undefined)
|
||||
let initialDefaultModel: string | undefined = $state(undefined)
|
||||
let initialCustomPrompts: Record<string, string> = $state({})
|
||||
let initialMaxTokensPerModel: Record<string, number> = $state({})
|
||||
let initialPrompts: Record<string, string> = $state({})
|
||||
let lastLoadedConfigKey = $state<string | undefined>(undefined)
|
||||
|
||||
function clone<T>(v: T): T {
|
||||
return JSON.parse(JSON.stringify(v))
|
||||
}
|
||||
|
||||
function applyConfig(config: AIConfig | undefined) {
|
||||
aiProviders = clone(config?.providers ?? {})
|
||||
defaultModel = config?.default_model?.model
|
||||
codeCompletionModel = config?.code_completion_model?.model
|
||||
customPrompts = clone(config?.custom_prompts ?? {})
|
||||
maxTokensPerModel = clone(config?.max_tokens_per_model ?? {})
|
||||
for (const mode of ['edit', 'fix', 'gen']) {
|
||||
if (!(mode in customPrompts)) {
|
||||
customPrompts[mode] = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function storeInitialState() {
|
||||
initialAiProviders = clone(aiProviders)
|
||||
initialDefaultModel = defaultModel
|
||||
initialCodeCompletionModel = codeCompletionModel
|
||||
initialCustomPrompts = clone(customPrompts)
|
||||
initialMaxTokensPerModel = clone(maxTokensPerModel)
|
||||
initialPrompts = clone(customPrompts)
|
||||
}
|
||||
|
||||
export function loadFromConfig(config: AIConfig | undefined) {
|
||||
applyConfig(config)
|
||||
storeInitialState()
|
||||
}
|
||||
|
||||
export function discard() {
|
||||
aiProviders = clone(initialAiProviders)
|
||||
defaultModel = initialDefaultModel
|
||||
codeCompletionModel = initialCodeCompletionModel
|
||||
customPrompts = clone(initialCustomPrompts)
|
||||
maxTokensPerModel = clone(initialMaxTokensPerModel)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const configKey = JSON.stringify(initialConfig ?? {})
|
||||
if (configKey === lastLoadedConfigKey) {
|
||||
return
|
||||
}
|
||||
lastLoadedConfigKey = configKey
|
||||
untrack(() => {
|
||||
loadFromConfig(initialConfig)
|
||||
})
|
||||
})
|
||||
|
||||
// Check if openai_client_credentials_oauth resource type exists
|
||||
async function loadOpenaiOauthFlag() {
|
||||
try {
|
||||
usingOpenaiClientCredentialsOauth = await ResourceService.existsResourceType({
|
||||
workspace: effectiveWorkspace,
|
||||
path: 'openai_client_credentials_oauth'
|
||||
})
|
||||
} catch {
|
||||
usingOpenaiClientCredentialsOauth = false
|
||||
}
|
||||
}
|
||||
loadOpenaiOauthFlag()
|
||||
|
||||
// --- Dirty tracking ---
|
||||
let dirty = $derived(
|
||||
JSON.stringify(aiProviders) !== JSON.stringify(initialAiProviders) ||
|
||||
defaultModel !== initialDefaultModel ||
|
||||
codeCompletionModel !== initialCodeCompletionModel ||
|
||||
JSON.stringify(customPrompts) !== JSON.stringify(initialCustomPrompts) ||
|
||||
JSON.stringify(maxTokensPerModel) !== JSON.stringify(initialMaxTokensPerModel)
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
hasUnsavedChanges = dirty
|
||||
})
|
||||
|
||||
// --- Model fetching ---
|
||||
let fetchedAiModels = $state(false)
|
||||
let availableAiModels = $state(
|
||||
Object.fromEntries(
|
||||
@@ -53,7 +167,6 @@
|
||||
)
|
||||
|
||||
let modalOpen = $state(false)
|
||||
let initialPrompts = $state($state.snapshot(customPrompts))
|
||||
let hasPromptsChanges = $derived(
|
||||
Array.from(new Set([...Object.keys(customPrompts), ...Object.keys(initialPrompts)])).some(
|
||||
(key) => {
|
||||
@@ -66,6 +179,14 @@
|
||||
let promptCount = $derived(
|
||||
Object.values(customPrompts).filter((p) => p?.trim().length > 0).length
|
||||
)
|
||||
let promptDescription = $derived(
|
||||
promptScope === 'instance'
|
||||
? 'Customize AI behavior with instance-level system prompts. These apply when a workspace uses instance AI defaults.'
|
||||
: 'Customize AI behavior with workspace-level system prompts. These apply to all workspace members.'
|
||||
)
|
||||
let showWorkspaceOverrideEditor = $derived(
|
||||
!usesInstanceAiConfig || Object.keys(aiProviders).length > 0 || workspaceOverrideEditorOpened
|
||||
)
|
||||
|
||||
let selectedAiModels = $derived(Object.values(aiProviders).flatMap((p) => p.models))
|
||||
let modelProviderMap = $derived(
|
||||
@@ -91,7 +212,7 @@
|
||||
try {
|
||||
const models = await fetchAvailableModels(
|
||||
aiProviders[provider].resource_path,
|
||||
$workspaceStore!,
|
||||
effectiveWorkspace,
|
||||
provider as AIProvider
|
||||
)
|
||||
availableAiModels[provider] = models
|
||||
@@ -109,44 +230,74 @@
|
||||
sendUserToast('Reset to last saved state')
|
||||
}
|
||||
|
||||
async function editCopilotConfig(): Promise<void> {
|
||||
if (Object.keys(aiProviders ?? {}).length > 0) {
|
||||
const code_completion_model =
|
||||
codeCompletionModel && modelProviderMap[codeCompletionModel]
|
||||
? { model: codeCompletionModel, provider: modelProviderMap[codeCompletionModel] }
|
||||
: undefined
|
||||
const default_model =
|
||||
defaultModel && modelProviderMap[defaultModel]
|
||||
? { model: defaultModel, provider: modelProviderMap[defaultModel] }
|
||||
: undefined
|
||||
// Convert customPrompts to include only non-empty prompts
|
||||
const custom_prompts: Record<string, string> = Object.entries(customPrompts)
|
||||
.filter(([_, prompt]) => prompt.trim().length > 0)
|
||||
.reduce((acc, [mode, prompt]) => ({ ...acc, [mode]: prompt }), {})
|
||||
function buildConfig(): AIConfig {
|
||||
const code_completion_model =
|
||||
codeCompletionModel && modelProviderMap[codeCompletionModel]
|
||||
? { model: codeCompletionModel, provider: modelProviderMap[codeCompletionModel] }
|
||||
: undefined
|
||||
const default_model =
|
||||
defaultModel && modelProviderMap[defaultModel]
|
||||
? { model: defaultModel, provider: modelProviderMap[defaultModel] }
|
||||
: undefined
|
||||
const custom_prompts: Record<string, string> = Object.entries(customPrompts)
|
||||
.filter(([_, prompt]) => prompt.trim().length > 0)
|
||||
.reduce((acc, [mode, prompt]) => ({ ...acc, [mode]: prompt }), {})
|
||||
|
||||
const config: AIConfig = {
|
||||
providers: aiProviders,
|
||||
code_completion_model,
|
||||
default_model,
|
||||
custom_prompts: Object.keys(custom_prompts).length > 0 ? custom_prompts : undefined,
|
||||
max_tokens_per_model:
|
||||
Object.keys(maxTokensPerModel).length > 0 ? maxTokensPerModel : undefined
|
||||
}
|
||||
await WorkspaceService.editCopilotConfig({
|
||||
workspace: $workspaceStore!,
|
||||
return Object.keys(aiProviders ?? {}).length > 0
|
||||
? {
|
||||
providers: aiProviders,
|
||||
code_completion_model,
|
||||
default_model,
|
||||
custom_prompts: Object.keys(custom_prompts).length > 0 ? custom_prompts : undefined,
|
||||
max_tokens_per_model:
|
||||
Object.keys(maxTokensPerModel).length > 0 ? maxTokensPerModel : undefined
|
||||
}
|
||||
: {}
|
||||
}
|
||||
|
||||
function isSaveDisabled(): boolean {
|
||||
return (
|
||||
!Object.values(aiProviders).every((p) => p.resource_path) ||
|
||||
(codeCompletionModel != undefined && codeCompletionModel.length === 0) ||
|
||||
(Object.keys(aiProviders).length > 0 && !defaultModel)
|
||||
)
|
||||
}
|
||||
|
||||
export async function saveIfDirtyAndValid(): Promise<boolean> {
|
||||
if (!dirty) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (isSaveDisabled()) {
|
||||
sendUserToast('Complete AI settings before leaving this page', true)
|
||||
return false
|
||||
}
|
||||
|
||||
await editCopilotConfig()
|
||||
return true
|
||||
}
|
||||
|
||||
async function editCopilotConfig(): Promise<void> {
|
||||
const config = buildConfig()
|
||||
let settingsState: GetCopilotSettingsStateResponse | undefined
|
||||
|
||||
if (customSave) {
|
||||
await customSave(config)
|
||||
} else {
|
||||
const response = await WorkspaceService.editCopilotConfig({
|
||||
workspace: effectiveWorkspace,
|
||||
requestBody: config
|
||||
})
|
||||
setCopilotInfo(config)
|
||||
} else {
|
||||
await WorkspaceService.editCopilotConfig({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {}
|
||||
})
|
||||
setCopilotInfo({})
|
||||
setCopilotInfo(response.effective_ai_config)
|
||||
settingsState = {
|
||||
has_instance_ai_config: response.has_instance_ai_config,
|
||||
uses_instance_ai_config: response.uses_instance_ai_config,
|
||||
instance_ai_summary: response.instance_ai_summary
|
||||
}
|
||||
sendUserToast('AI settings updated')
|
||||
}
|
||||
sendUserToast(`AI settings updated`)
|
||||
initialPrompts = { ...customPrompts } // Update initial prompts after successful save
|
||||
onSave?.()
|
||||
storeInitialState()
|
||||
await onSave?.(settingsState)
|
||||
}
|
||||
|
||||
async function onAiProviderChange(provider: AIProvider) {
|
||||
@@ -154,7 +305,7 @@
|
||||
try {
|
||||
const models = await fetchAvailableModels(
|
||||
aiProviders[provider].resource_path,
|
||||
$workspaceStore!,
|
||||
effectiveWorkspace,
|
||||
provider as AIProvider
|
||||
)
|
||||
availableAiModels[provider] = models
|
||||
@@ -176,197 +327,214 @@
|
||||
const autocompleteModels = $derived(selectedAiModels.filter(supportsAutocomplete))
|
||||
</script>
|
||||
|
||||
<SettingsPageHeader
|
||||
title="Windmill AI"
|
||||
description="Windmill AI integrates with your favorite AI providers and models."
|
||||
link="https://www.windmill.dev/docs/core_concepts/ai_generation"
|
||||
/>
|
||||
<SettingsPageHeader {title} {description} {link} />
|
||||
|
||||
<div class="flex flex-col gap-6 mt-4 pb-8">
|
||||
<SettingCard label="AI Providers">
|
||||
<div class="flex flex-col gap-4 p-4 rounded-md border bg-surface-tertiary">
|
||||
{#each Object.entries(AI_PROVIDERS) as [provider, details]}
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-row gap-2">
|
||||
<Toggle
|
||||
options={{
|
||||
right: details.label
|
||||
}}
|
||||
checked={!!aiProviders[provider]}
|
||||
on:change={(e) => {
|
||||
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'}
|
||||
<Badge color="blue">
|
||||
Recommended
|
||||
<Tooltip>
|
||||
Anthropic models handle tool calls better than other providers, which makes them a
|
||||
better choice for AI chat.
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if aiProviders[provider]}
|
||||
<div
|
||||
class="mb-4 flex flex-col gap-6 border p-4 rounded-md mt-2"
|
||||
transition:slide|local={{ duration: 150 }}
|
||||
>
|
||||
<Label label="Resource">
|
||||
<div class="flex flex-row gap-1">
|
||||
<ResourcePicker
|
||||
selectFirst
|
||||
resourceType={provider === 'openai' && usingOpenaiClientCredentialsOauth
|
||||
? 'openai_client_credentials_oauth'
|
||||
: provider}
|
||||
initialValue={aiProviders[provider].resource_path}
|
||||
bind:value={
|
||||
() => aiProviders[provider].resource_path || undefined,
|
||||
(v) => {
|
||||
aiProviders[provider].resource_path = v ?? ''
|
||||
onAiProviderChange(provider as AIProvider)
|
||||
{#if usesInstanceAiConfig}
|
||||
<div
|
||||
class="p-3 border border-blue-200 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/20 rounded-md text-xs text-secondary"
|
||||
>
|
||||
Instance-level AI settings are currently active. Configure workspace-specific settings below
|
||||
to override them.
|
||||
</div>
|
||||
<InstanceFallbackSettings
|
||||
{instanceAiSummary}
|
||||
{showWorkspaceOverrideEditor}
|
||||
onToggleOverride={() => (workspaceOverrideEditorOpened = !workspaceOverrideEditorOpened)}
|
||||
/>
|
||||
{:else if hasInstanceAiConfig && Object.keys(aiProviders).length > 0}
|
||||
<div
|
||||
class="p-3 border border-surface-hover bg-surface-secondary rounded-md text-xs text-secondary"
|
||||
>
|
||||
Workspace AI settings override instance defaults. Remove workspace settings to use instance
|
||||
defaults.
|
||||
</div>
|
||||
{/if}
|
||||
{#if showWorkspaceOverrideEditor}
|
||||
<SettingCard label="AI Providers">
|
||||
<div class="flex flex-col gap-4 p-4 rounded-md border bg-surface-tertiary">
|
||||
{#each Object.entries(AI_PROVIDERS) as [provider, details]}
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-row gap-2">
|
||||
<Toggle
|
||||
options={{
|
||||
right: details.label
|
||||
}}
|
||||
checked={!!aiProviders[provider]}
|
||||
on:change={(e) => {
|
||||
if (e.detail) {
|
||||
aiProviders = {
|
||||
...aiProviders,
|
||||
[provider]: {
|
||||
resource_path: '',
|
||||
models:
|
||||
availableAiModels[provider].length > 0
|
||||
? [availableAiModels[provider][0]]
|
||||
: []
|
||||
}
|
||||
}
|
||||
/>
|
||||
<TestAiKey
|
||||
aiProvider={provider as AIProvider}
|
||||
resourcePath={aiProviders[provider].resource_path}
|
||||
model={aiProviders[provider].models[0]}
|
||||
/>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<Label label="Enabled models">
|
||||
<MultiSelect
|
||||
items={safeSelectItems([
|
||||
...availableAiModels[provider],
|
||||
...aiProviders[provider].models
|
||||
])}
|
||||
bind:value={aiProviders[provider].models}
|
||||
placeholder="Select models"
|
||||
onCreateItem={(item) =>
|
||||
(aiProviders[provider].models = [...aiProviders[provider].models, item])}
|
||||
/>
|
||||
<p class="text-2xs text-hint">
|
||||
If you don't see the model you want, you can type it manually in the selector.
|
||||
</p>
|
||||
</Label>
|
||||
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'}
|
||||
<Badge color="blue">
|
||||
Recommended
|
||||
<Tooltip>
|
||||
Anthropic models handle tool calls better than other providers, which makes them
|
||||
a better choice for AI chat.
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<SettingCard label="Default chat model">
|
||||
{#key Object.keys(aiProviders).length}
|
||||
<Select
|
||||
items={safeSelectItems(selectedAiModels)}
|
||||
bind:value={defaultModel}
|
||||
disabled={false}
|
||||
placeholder="Select a default model"
|
||||
size="sm"
|
||||
class="max-w-lg"
|
||||
/>
|
||||
{/key}
|
||||
</SettingCard>
|
||||
{#if aiProviders[provider]}
|
||||
<div
|
||||
class="mb-4 flex flex-col gap-6 border p-4 rounded-md mt-2"
|
||||
transition:slide|local={{ duration: 150 }}
|
||||
>
|
||||
<Label label="Resource">
|
||||
<div class="flex flex-row gap-1">
|
||||
<ResourcePicker
|
||||
selectFirst
|
||||
{workspace}
|
||||
{disableChatOffset}
|
||||
resourceType={provider === 'openai' && usingOpenaiClientCredentialsOauth
|
||||
? 'openai_client_credentials_oauth'
|
||||
: provider}
|
||||
initialValue={aiProviders[provider].resource_path}
|
||||
bind:value={
|
||||
() => aiProviders[provider].resource_path || undefined,
|
||||
(v) => {
|
||||
aiProviders[provider].resource_path = v ?? ''
|
||||
onAiProviderChange(provider as AIProvider)
|
||||
}
|
||||
}
|
||||
/>
|
||||
<TestAiKey
|
||||
aiProvider={provider as AIProvider}
|
||||
workspace={effectiveWorkspace}
|
||||
resourcePath={aiProviders[provider].resource_path}
|
||||
model={aiProviders[provider].models[0]}
|
||||
/>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<!-- Code completion group for animation purposes -->
|
||||
<div>
|
||||
<SettingCard label="Code completion">
|
||||
<Toggle
|
||||
on:change={(e) => {
|
||||
if (e.detail) {
|
||||
codeCompletionModel = autocompleteModels[0] ?? ''
|
||||
} else {
|
||||
codeCompletionModel = undefined
|
||||
}
|
||||
}}
|
||||
checked={codeCompletionModel != undefined}
|
||||
disabled={autocompleteModels.length == 0}
|
||||
options={{
|
||||
right: 'Enable code completion',
|
||||
rightTooltip: 'We currently only support Mistral Codestral models for code completion.'
|
||||
}}
|
||||
/>
|
||||
<Label label="Enabled models">
|
||||
<MultiSelect
|
||||
items={safeSelectItems([
|
||||
...availableAiModels[provider],
|
||||
...aiProviders[provider].models
|
||||
])}
|
||||
bind:value={aiProviders[provider].models}
|
||||
placeholder="Select models"
|
||||
onCreateItem={(item) =>
|
||||
(aiProviders[provider].models = [...aiProviders[provider].models, item])}
|
||||
/>
|
||||
<p class="text-2xs text-hint">
|
||||
If you don't see the model you want, you can type it manually in the selector.
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
{#if codeCompletionModel != undefined}
|
||||
<div transition:slide|local={{ duration: 150 }} class="mt-6">
|
||||
<SettingCard label="Code completion model">
|
||||
<Select
|
||||
items={safeSelectItems(autocompleteModels)}
|
||||
bind:value={codeCompletionModel}
|
||||
disabled={false}
|
||||
placeholder="Select a code completion model"
|
||||
size="sm"
|
||||
/>
|
||||
</SettingCard>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<SettingCard label="Default chat model">
|
||||
{#key Object.keys(aiProviders).length}
|
||||
<Select
|
||||
items={safeSelectItems(selectedAiModels)}
|
||||
bind:value={defaultModel}
|
||||
disabled={false}
|
||||
placeholder="Select a default model"
|
||||
size="sm"
|
||||
class="max-w-lg"
|
||||
/>
|
||||
{/key}
|
||||
</SettingCard>
|
||||
|
||||
<ModelTokenLimits {aiProviders} bind:maxTokensPerModel />
|
||||
<!-- Code completion group for animation purposes -->
|
||||
<div>
|
||||
<SettingCard label="Code completion">
|
||||
<Toggle
|
||||
on:change={(e) => {
|
||||
if (e.detail) {
|
||||
codeCompletionModel = autocompleteModels[0] ?? ''
|
||||
} else {
|
||||
codeCompletionModel = undefined
|
||||
}
|
||||
}}
|
||||
checked={codeCompletionModel != undefined}
|
||||
disabled={autocompleteModels.length == 0}
|
||||
options={{
|
||||
right: 'Enable code completion',
|
||||
rightTooltip: 'We currently only support Mistral Codestral models for code completion.'
|
||||
}}
|
||||
/>
|
||||
</SettingCard>
|
||||
|
||||
<SettingCard
|
||||
label="Custom system prompts"
|
||||
description="Customize AI behavior with workspace-level system prompts. These apply to all workspace
|
||||
members."
|
||||
>
|
||||
<div class="flex items-center gap-2 pt-1">
|
||||
<Button
|
||||
onclick={() => (modalOpen = true)}
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: Settings }}
|
||||
disabled={Object.keys(aiProviders ?? {}).length === 0}
|
||||
>
|
||||
Configure AI prompts
|
||||
</Button>
|
||||
{#if promptCount > 0}
|
||||
<span class="text-xs text-secondary">({promptCount} configured)</span>
|
||||
{/if}
|
||||
{#if hasPromptsChanges}
|
||||
<Badge color="yellow">Unsaved changes</Badge>
|
||||
{#if codeCompletionModel != undefined}
|
||||
<div transition:slide|local={{ duration: 150 }} class="mt-6">
|
||||
<SettingCard label="Code completion model">
|
||||
<Select
|
||||
items={safeSelectItems(autocompleteModels)}
|
||||
bind:value={codeCompletionModel}
|
||||
disabled={false}
|
||||
placeholder="Select a code completion model"
|
||||
size="sm"
|
||||
/>
|
||||
</SettingCard>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<ModelTokenLimits {aiProviders} bind:maxTokensPerModel />
|
||||
|
||||
<SettingCard label="Custom system prompts" description={promptDescription}>
|
||||
<div class="flex items-center gap-2 pt-1">
|
||||
<Button
|
||||
onclick={() => (modalOpen = true)}
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: Settings }}
|
||||
disabled={Object.keys(aiProviders ?? {}).length === 0}
|
||||
>
|
||||
Configure AI prompts
|
||||
</Button>
|
||||
{#if promptCount > 0}
|
||||
<span class="text-xs text-secondary">({promptCount} configured)</span>
|
||||
{/if}
|
||||
{#if hasPromptsChanges}
|
||||
<Badge color="yellow">Unsaved changes</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingCard>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<AIPromptsModal
|
||||
@@ -374,15 +542,15 @@
|
||||
bind:customPrompts
|
||||
onReset={resetPrompts}
|
||||
hasChanges={hasPromptsChanges}
|
||||
isWorkspaceSettings={true}
|
||||
scope={promptScope}
|
||||
/>
|
||||
|
||||
<SettingsFooter
|
||||
{hasUnsavedChanges}
|
||||
onSave={editCopilotConfig}
|
||||
onDiscard={() => 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}
|
||||
<SettingsFooter
|
||||
hasUnsavedChanges={dirty}
|
||||
onSave={editCopilotConfig}
|
||||
onDiscard={discard}
|
||||
saveLabel="Save AI settings"
|
||||
disabled={isSaveDisabled()}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -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<void> {
|
||||
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
|
||||
switchWorkspace(workspaceId)
|
||||
goto(rd ?? '/')
|
||||
}
|
||||
|
||||
async function getWorkspaceUsername(workspaceId: string): Promise<string> {
|
||||
if (!automateUsernameCreation) {
|
||||
return username
|
||||
}
|
||||
|
||||
const user = await UserService.whoami({
|
||||
workspace: workspaceId
|
||||
})
|
||||
return user.username
|
||||
}
|
||||
|
||||
async function maybeShowAiSetupStep(workspaceId: string): Promise<void> {
|
||||
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<CompletedJob[]> {
|
||||
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<void> {
|
||||
async function createWorkspace(): Promise<string> {
|
||||
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<void> {
|
||||
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<AIProvider, 'customai'> = $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])
|
||||
</script>
|
||||
|
||||
<CenteredModal title="{isFork ? 'Forking' : 'New'} Workspace" centerVertically={false}>
|
||||
<CenteredModal title={modalTitle} centerVertically={false}>
|
||||
<div class="flex flex-col gap-8">
|
||||
{#if isFork}
|
||||
<div class="flex flex-block gap-2">
|
||||
@@ -410,88 +490,184 @@
|
||||
{/if}
|
||||
</Alert>
|
||||
{/if}
|
||||
<label class="flex flex-col gap-1">
|
||||
{#if isFork}
|
||||
<span class="text-xs font-semibold text-emphasis">Fork name</span>
|
||||
<span class="text-xs text-secondary">Displayable name of the forked workspace</span>
|
||||
{:else}
|
||||
<span class="text-xs font-semibold text-emphasis">Workspace name</span>
|
||||
<span class="text-xs text-secondary">Displayable name</span>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<TextInput inputProps={{ autofocus: true }} bind:value={name} />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Workspace ID</span>
|
||||
{#if isFork}
|
||||
<span class="text-xs text-secondary"
|
||||
>Slug to uniquely identify your fork (this will also set the branch name)</span
|
||||
>
|
||||
{:else}
|
||||
<span class="text-xs text-secondary">Slug to uniquely identify your workspace</span>
|
||||
{/if}
|
||||
|
||||
{#if isFork}
|
||||
<PrefixedInput
|
||||
prefix={WM_FORK_PREFIX}
|
||||
type="text"
|
||||
bind:value={id}
|
||||
placeholder="example.com"
|
||||
class={errorId != '' ? 'input-error' : ''}
|
||||
/>
|
||||
{:else}
|
||||
<TextInput bind:value={id} error={errorId} />
|
||||
{/if}
|
||||
{#if errorId}
|
||||
<span class="text-red-500 text-2xs font-normal">{errorId}</span>
|
||||
{/if}
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Workspace color</span>
|
||||
<span class="text-xs text-secondary"
|
||||
>Color to identify the current workspace in the list of workspaces</span
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<Toggle bind:checked={colorEnabled} options={{ right: 'Enable' }} />
|
||||
{#if colorEnabled}
|
||||
<div class="flex items-center gap-1 grow">
|
||||
<input
|
||||
class="grow min-w-10"
|
||||
type="color"
|
||||
bind:value={workspaceColor}
|
||||
disabled={!colorEnabled}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
class="w-24"
|
||||
bind:value={workspaceColor}
|
||||
inputProps={{ disabled: !colorEnabled }}
|
||||
/>
|
||||
<Button
|
||||
on:click={generateRandomColor}
|
||||
size="xs"
|
||||
variant="default"
|
||||
disabled={!colorEnabled}>Random</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
{#if !automateUsernameCreation}
|
||||
{#if isFork || creationStep === 'details'}
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Your username in that workspace</span>
|
||||
<TextInput bind:value={username} inputProps={{ onkeyup: handleKeyUp }} error={errorUser} />
|
||||
{#if errorUser}
|
||||
<span class="text-red-500 text-2xs">{errorUser}</span>
|
||||
{#if isFork}
|
||||
<span class="text-xs font-semibold text-emphasis">Fork name</span>
|
||||
<span class="text-xs text-secondary">Displayable name of the forked workspace</span>
|
||||
{:else}
|
||||
<span class="text-xs font-semibold text-emphasis">Workspace name</span>
|
||||
<span class="text-xs text-secondary">Displayable name</span>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<TextInput inputProps={{ autofocus: true }} bind:value={name} />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Workspace ID</span>
|
||||
{#if isFork}
|
||||
<span class="text-xs text-secondary"
|
||||
>Slug to uniquely identify your fork (this will also set the branch name)</span
|
||||
>
|
||||
{:else}
|
||||
<span class="text-xs text-secondary">Slug to uniquely identify your workspace</span>
|
||||
{/if}
|
||||
|
||||
{#if isFork}
|
||||
<PrefixedInput
|
||||
prefix={WM_FORK_PREFIX}
|
||||
type="text"
|
||||
bind:value={id}
|
||||
placeholder="example.com"
|
||||
class={errorId != '' ? 'input-error' : ''}
|
||||
/>
|
||||
{:else}
|
||||
<TextInput bind:value={id} error={errorId} />
|
||||
{/if}
|
||||
{#if errorId}
|
||||
<span class="text-red-500 text-2xs font-normal">{errorId}</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/if}
|
||||
{#if !isFork}
|
||||
<div class="block">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Workspace color</span>
|
||||
<span class="text-xs text-secondary"
|
||||
>Color to identify the current workspace in the list of workspaces</span
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<Toggle bind:checked={colorEnabled} options={{ right: 'Enable' }} />
|
||||
{#if colorEnabled}
|
||||
<div class="flex items-center gap-1 grow">
|
||||
<input
|
||||
class="grow min-w-10"
|
||||
type="color"
|
||||
bind:value={workspaceColor}
|
||||
disabled={!colorEnabled}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
class="w-24"
|
||||
bind:value={workspaceColor}
|
||||
inputProps={{ disabled: !colorEnabled }}
|
||||
/>
|
||||
<Button
|
||||
on:click={generateRandomColor}
|
||||
size="xs"
|
||||
variant="default"
|
||||
disabled={!colorEnabled}>Random</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
{#if !automateUsernameCreation}
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Your username in that workspace</span>
|
||||
<TextInput
|
||||
bind:value={username}
|
||||
inputProps={{ onkeyup: handleCreateKeyUp }}
|
||||
error={errorUser}
|
||||
/>
|
||||
{#if errorUser}
|
||||
<span class="text-red-500 text-2xs">{errorUser}</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/if}
|
||||
{#if !isFork}
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="auto-invite" class="text-xs font-semibold text-emphasis"
|
||||
>{isCloudHosted()
|
||||
? `Auto-${autoAdd ? 'add' : 'invite'} anyone from ${domain}`
|
||||
: `Auto-${autoAdd ? 'add' : 'invite'} anyone joining the instance`}</label
|
||||
>
|
||||
<Toggle
|
||||
id="auto-invite"
|
||||
disabled={isCloudHosted() && !isDomainAllowed}
|
||||
bind:checked={auto_invite}
|
||||
/>
|
||||
{#if isCloudHosted() && isDomainAllowed == false}
|
||||
<div class="text-secondary text-2xs">{domain} domain not allowed for auto-invite</div>
|
||||
{/if}
|
||||
|
||||
{#if auto_invite}
|
||||
<div class="bg-surface-tertiary p-4 rounded-md flex flex-col gap-8">
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
{#if isCloudHosted()}
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Mode</span>
|
||||
<span class="text-xs text-secondary font-normal"
|
||||
>Whether to invite or add users directly to the workspace.</span
|
||||
>
|
||||
<ToggleButtonGroup
|
||||
selected={autoAdd ? 'add' : 'invite'}
|
||||
on:selected={async (e) => {
|
||||
autoAdd = e.detail === 'add'
|
||||
}}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="invite" label="Auto-invite" {item} />
|
||||
<ToggleButton value="add" label="Auto-add" {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<label class="font-semibold flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Role</span>
|
||||
<span class="text-xs text-secondary font-normal">Role of the auto-invited users</span>
|
||||
<ToggleButtonGroup
|
||||
selected={operatorOnly ? 'operator' : 'developer'}
|
||||
on:selected={(e) => {
|
||||
operatorOnly = e.detail == 'operator'
|
||||
}}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="operator" label="Operator" {item} />
|
||||
<ToggleButton value="developer" label="Developer" {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap flex-row justify-between gap-4 pt-4">
|
||||
<Button
|
||||
disabled={forkCreationLoading || createLoading}
|
||||
variant="default"
|
||||
size="sm"
|
||||
href="{base}/user/workspaces">← Back to workspaces</Button
|
||||
>
|
||||
{#if !forkCreationLoading}
|
||||
<Button
|
||||
variant="accent"
|
||||
loading={createLoading}
|
||||
disabled={createLoading ||
|
||||
checking ||
|
||||
errorId != '' ||
|
||||
!name ||
|
||||
(!automateUsernameCreation && (errorUser != '' || !username)) ||
|
||||
!id}
|
||||
on:click={createOrForkWorkspace}
|
||||
>
|
||||
{#if isFork}
|
||||
Fork workspace
|
||||
{:else}
|
||||
Create workspace
|
||||
{/if}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="accent" disabled={true}>
|
||||
<LoaderCircle class="animate-spin" /> Creating branch
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="ai-key" class="flex flex-row gap-2">
|
||||
<span class="text-xs font-semibold text-emphasis">
|
||||
AI key for Windmill AI
|
||||
Set up Windmill AI
|
||||
<Tooltip>
|
||||
Find out how it can help you <a
|
||||
href="https://www.windmill.dev/docs/core_concepts/ai_generation"
|
||||
@@ -500,10 +676,21 @@
|
||||
>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<span class="text-2xs text-secondary">(optional but recommended)</span>
|
||||
</label>
|
||||
<span class="text-xs text-secondary">
|
||||
Windmill AI powers the chat, code generation, flow creation, and code completion. Set
|
||||
it up now or configure it later in Workspace settings.
|
||||
<a
|
||||
href="https://www.windmill.dev/docs/core_concepts/ai_generation"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</span>
|
||||
|
||||
<ToggleButtonGroup bind:selected>
|
||||
<ToggleButtonGroup bind:selected class="mt-4">
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="openai" label="OpenAI" {item} />
|
||||
<ToggleButton value="anthropic" label="Anthropic" {item} />
|
||||
@@ -517,7 +704,7 @@
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
bind:value={aiKey}
|
||||
onkeyup={handleKeyUp}
|
||||
onkeyup={handleAiKeyUp}
|
||||
/>
|
||||
<TestAIKey
|
||||
apiKey={aiKey}
|
||||
@@ -529,7 +716,7 @@
|
||||
</div>
|
||||
|
||||
{#if aiKey}
|
||||
<div class="flex flex-col gap-2 mt-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Toggle
|
||||
disabled={!aiKey}
|
||||
bind:checked={codeCompletionEnabled}
|
||||
@@ -538,91 +725,25 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="auto-invite" class="text-xs font-semibold text-emphasis"
|
||||
>{isCloudHosted()
|
||||
? `Auto-${autoAdd ? 'add' : 'invite'} anyone from ${domain}`
|
||||
: `Auto-${autoAdd ? 'add' : 'invite'} anyone joining the instance`}</label
|
||||
|
||||
<div class="flex flex-wrap flex-row justify-between gap-4 pt-4">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={aiSetupLoading || !createdWorkspaceId}
|
||||
on:click={() => createdWorkspaceId && finishWorkspaceSetup(createdWorkspaceId)}
|
||||
>
|
||||
<Toggle
|
||||
id="auto-invite"
|
||||
disabled={isCloudHosted() && !isDomainAllowed}
|
||||
bind:checked={auto_invite}
|
||||
/>
|
||||
{#if isCloudHosted() && isDomainAllowed == false}
|
||||
<div class="text-secondary text-2xs">{domain} domain not allowed for auto-invite</div>
|
||||
{/if}
|
||||
|
||||
{#if auto_invite}
|
||||
<div class="bg-surface-tertiary p-4 rounded-md flex flex-col gap-8">
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
{#if isCloudHosted()}
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Mode</span>
|
||||
<span class="text-xs text-secondary font-normal"
|
||||
>Whether to invite or add users directly to the workspace.</span
|
||||
>
|
||||
<ToggleButtonGroup
|
||||
selected={autoAdd ? 'add' : 'invite'}
|
||||
on:selected={async (e) => {
|
||||
autoAdd = e.detail === 'add'
|
||||
}}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="invite" label="Auto-invite" {item} />
|
||||
<ToggleButton value="add" label="Auto-add" {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<label class="font-semibold flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-emphasis">Role</span>
|
||||
<span class="text-xs text-secondary font-normal">Role of the auto-invited users</span>
|
||||
<ToggleButtonGroup
|
||||
selected={operatorOnly ? 'operator' : 'developer'}
|
||||
on:selected={(e) => {
|
||||
operatorOnly = e.detail == 'operator'
|
||||
}}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="operator" label="Operator" {item} />
|
||||
<ToggleButton value="developer" label="Developer" {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-wrap flex-row justify-between gap-4 pt-4">
|
||||
<Button
|
||||
disabled={forkCreationLoading}
|
||||
variant="default"
|
||||
size="sm"
|
||||
href="{base}/user/workspaces">← Back to workspaces</Button
|
||||
>
|
||||
{#if !forkCreationLoading}
|
||||
Skip for now
|
||||
</Button>
|
||||
<Button
|
||||
variant="accent"
|
||||
disabled={checking ||
|
||||
errorId != '' ||
|
||||
!name ||
|
||||
(!automateUsernameCreation && (errorUser != '' || !username)) ||
|
||||
!id}
|
||||
on:click={createOrForkWorkspace}
|
||||
loading={aiSetupLoading}
|
||||
disabled={aiSetupLoading || !aiKey}
|
||||
on:click={saveWorkspaceAiSetup}
|
||||
>
|
||||
{#if isFork}
|
||||
Fork workspace
|
||||
{:else}
|
||||
Create workspace
|
||||
{/if}
|
||||
Set up AI
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="accent" disabled={true}>
|
||||
<LoaderCircle class="animate-spin" /> Creating branch
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</CenteredModal>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import { type AIProvider, type InstanceAISummary } from '$lib/gen'
|
||||
import { AI_PROVIDERS } from '../copilot/lib'
|
||||
import Badge from '../common/badge/Badge.svelte'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import SettingCard from '../instanceSettings/SettingCard.svelte'
|
||||
|
||||
interface Props {
|
||||
instanceAiSummary?: InstanceAISummary
|
||||
showWorkspaceOverrideEditor?: boolean
|
||||
onToggleOverride: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
instanceAiSummary = undefined,
|
||||
showWorkspaceOverrideEditor = false,
|
||||
onToggleOverride
|
||||
}: Props = $props()
|
||||
|
||||
let sortedInstanceProviders = $derived(
|
||||
[...(instanceAiSummary?.providers ?? [])].sort((left, right) =>
|
||||
left.provider.localeCompare(right.provider)
|
||||
)
|
||||
)
|
||||
|
||||
function getProviderLabel(provider: AIProvider): string {
|
||||
return AI_PROVIDERS[provider]?.label ?? provider
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if instanceAiSummary}
|
||||
<SettingCard label="Active instance AI">
|
||||
<div class="flex flex-col gap-4 p-4 rounded-md border bg-surface-tertiary">
|
||||
<p class="text-xs text-secondary">
|
||||
This workspace is currently using the instance AI defaults shown below.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each sortedInstanceProviders as providerSummary}
|
||||
<div class="rounded-md border bg-surface p-3 flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-medium">
|
||||
{getProviderLabel(providerSummary.provider)}
|
||||
</span>
|
||||
<Badge color="blue">Instance</Badge>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each providerSummary.models as model}
|
||||
<Badge color="gray">{model}</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if instanceAiSummary.default_model}
|
||||
<div class="text-xs text-secondary">
|
||||
Default chat model:
|
||||
<span class="text-primary font-medium">{instanceAiSummary.default_model.model}</span>
|
||||
<span class="text-tertiary">
|
||||
({getProviderLabel(instanceAiSummary.default_model.provider)})
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if instanceAiSummary.code_completion_model}
|
||||
<div class="text-xs text-secondary">
|
||||
Code completion model:
|
||||
<span class="text-primary font-medium">
|
||||
{instanceAiSummary.code_completion_model.model}
|
||||
</span>
|
||||
<span class="text-tertiary">
|
||||
({getProviderLabel(instanceAiSummary.code_completion_model.provider)})
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingCard>
|
||||
{/if}
|
||||
|
||||
<SettingCard label="Workspace override">
|
||||
<div class="flex flex-col gap-3 p-4 rounded-md border bg-surface-tertiary">
|
||||
<p class="text-xs text-secondary">
|
||||
Create workspace-specific AI settings only if this workspace needs to override the active
|
||||
instance defaults.
|
||||
</p>
|
||||
<div>
|
||||
<Button onclick={onToggleOverride} variant="default" unifiedSize="sm">
|
||||
{showWorkspaceOverrideEditor ? 'Hide override form' : 'Override for this workspace'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingCard>
|
||||
@@ -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<boolean> {
|
||||
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<typeof setTimeout> | 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}
|
||||
<InstanceAISettings
|
||||
bind:this={instanceAiSettings}
|
||||
bind:hasUnsavedChanges={aiHasUnsavedChanges}
|
||||
disableChatOffset
|
||||
showHubSync
|
||||
/>
|
||||
{:else}
|
||||
{@render accountSetupContent()}
|
||||
{/if}
|
||||
@@ -505,19 +544,28 @@
|
||||
{/if}
|
||||
|
||||
<div class="flex-1 min-w-0 h-full overflow-auto px-4">
|
||||
<InstanceSettings
|
||||
bind:this={instanceSettings}
|
||||
hideTabs
|
||||
tab={instanceSettingsCategory}
|
||||
{authSubTab}
|
||||
bind:yamlMode
|
||||
onNavigateToTab={(category) => {
|
||||
const targetTab = categoryToTabMap[category]
|
||||
if (targetTab) {
|
||||
handleNavigate(targetTab)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if fullTab === 'ai' && !yamlMode}
|
||||
<InstanceAISettings
|
||||
bind:this={instanceAiSettings}
|
||||
bind:hasUnsavedChanges={aiHasUnsavedChanges}
|
||||
disableChatOffset
|
||||
showHubSync
|
||||
/>
|
||||
{:else}
|
||||
<InstanceSettings
|
||||
bind:this={instanceSettings}
|
||||
hideTabs
|
||||
tab={instanceSettingsCategory}
|
||||
{authSubTab}
|
||||
bind:yamlMode
|
||||
onNavigateToTab={(category) => {
|
||||
const targetTab = categoryToTabMap[category]
|
||||
if (targetTab) {
|
||||
handleNavigate(targetTab)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -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<AIConfig['providers'], undefined> = $state({})
|
||||
let codeCompletionModel: string | undefined = $state(undefined)
|
||||
let defaultModel: string | undefined = $state(undefined)
|
||||
let customPrompts: Record<string, string> = $state({})
|
||||
let maxTokensPerModel: Record<string, number> = $state({})
|
||||
|
||||
// Track initial AI config for unsaved changes detection
|
||||
let initialAiProviders: Exclude<AIConfig['providers'], undefined> = $state({})
|
||||
let initialCodeCompletionModel: string | undefined = $state(undefined)
|
||||
let initialDefaultModel: string | undefined = $state(undefined)
|
||||
let initialCustomPrompts: Record<string, string> = $state({})
|
||||
let initialMaxTokensPerModel: Record<string, number> = $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<string, any> = $state({})
|
||||
|
||||
@@ -489,7 +473,17 @@
|
||||
}
|
||||
|
||||
async function loadSettings(): Promise<void> {
|
||||
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'}
|
||||
<AISettings
|
||||
bind:aiProviders
|
||||
bind:codeCompletionModel
|
||||
bind:defaultModel
|
||||
bind:customPrompts
|
||||
bind:maxTokensPerModel
|
||||
bind:usingOpenaiClientCredentialsOauth
|
||||
hasUnsavedChanges={hasAiSettingsChanges}
|
||||
onDiscard={discardAiSettingsChanges}
|
||||
onSave={() => {
|
||||
// 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'}
|
||||
|
||||
Reference in New Issue
Block a user