mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-21 08:02:26 +00:00
Merge origin/main into scim-handle-active-false
This commit is contained in:
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## [1.664.0](https://github.com/windmill-labs/windmill/compare/v1.663.0...v1.664.0) (2026-03-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add instance-level AI settings ([#8453](https://github.com/windmill-labs/windmill/issues/8453)) ([db5e036](https://github.com/windmill-labs/windmill/commit/db5e03610da325288d53afdbca94b9cbfc7ceace))
|
||||
* add selfApproval option to WAC + inline approval buttons ([#8440](https://github.com/windmill-labs/windmill/issues/8440)) ([d578e40](https://github.com/windmill-labs/windmill/commit/d578e40101a838d3dffda14157cf72ee4d5a93c0))
|
||||
* flow group nodes with collapsible groups ([#8075](https://github.com/windmill-labs/windmill/issues/8075)) ([81eb446](https://github.com/windmill-labs/windmill/commit/81eb446eee359f44374b81320690e5345fd08c15))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add GIT_SSL_CAINFO to tracing proxy env vars ([#8502](https://github.com/windmill-labs/windmill/issues/8502)) ([bdfd5d5](https://github.com/windmill-labs/windmill/commit/bdfd5d57261a4bb760fc57ad41ee56aff9b9c0af))
|
||||
* create parent dirs and accept 'python' alias in script bootstrap ([#8497](https://github.com/windmill-labs/windmill/issues/8497)) ([7f27d99](https://github.com/windmill-labs/windmill/commit/7f27d996accb3c3b471d1c50df397867d89c738a))
|
||||
|
||||
## [1.663.0](https://github.com/windmill-labs/windmill/compare/v1.662.0...v1.663.0) (2026-03-24)
|
||||
|
||||
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT ws.default_app AS default_app_path, av.raw_app AS \"default_app_raw: Option<bool>\"\n FROM workspace_settings ws\n LEFT JOIN app ON app.path = ws.default_app AND app.workspace_id = ws.workspace_id\n LEFT JOIN app_version av ON av.id = app.versions[array_upper(app.versions, 1)]\n WHERE ws.workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "default_app_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "default_app_raw: Option<bool>",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1bc77ad29b9c68b1d339b85158bc3592deb61d1111d1430ddd2879b72e6424ef"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af"
|
||||
}
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
|
||||
|
||||
+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"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT default_app FROM workspace_settings WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "default_app",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ed1a053c7b22d9cb69767be40d33f3be67b6160cd258c86b8e8f22a6d601afd0"
|
||||
}
|
||||
Generated
+216
-216
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.663.0"
|
||||
version = "1.664.0"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -82,7 +82,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.663.0"
|
||||
version = "1.664.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -373,7 +373,7 @@ tower = "^0"
|
||||
tower-http = { version = "^0.6", features = ["trace", "cors", "catch-panic"] }
|
||||
tower-cookies = "^0.10"
|
||||
#stuck because of swc for now
|
||||
serde = "=1.0.219"
|
||||
serde = "=1.0.220"
|
||||
serde_json = { version = "^1", features = ["preserve_order", "raw_value"] }
|
||||
serde_yml = "0.0.12"
|
||||
uuid = { version = "^1", features = ["serde", "v4", "js"] }
|
||||
@@ -510,7 +510,7 @@ native-tls = ">=0.2, <0.2.17"
|
||||
# samael will break compilation on MacOS. Use this fork instead to make it work
|
||||
# samael = { git="https://github.com/njaremko/samael", rev="464d015e3ae393e4b5dd00b4d6baa1b617de0dd6", features = ["xmlsec"] }
|
||||
libxml = { version = "=0.3.3" }
|
||||
samael = { version="0.0.14", features = ["xmlsec"] }
|
||||
samael = { git="https://github.com/njaremko/samael", rev="f879f1942ec1b34b6d3027ce7e4724ad95d15dfa", features = ["xmlsec"] }
|
||||
gcp_auth = "0.9.0"
|
||||
rust_decimal = { version = "^1", features = ["db-postgres", "serde-float"]}
|
||||
jsonwebtoken = "8.3.0"
|
||||
@@ -587,7 +587,7 @@ tikv-jemalloc-ctl = { version = "^0.5" }
|
||||
triomphe = "^0"
|
||||
pin-project-lite = "^0"
|
||||
|
||||
tantivy = { git="https://github.com/windmill-labs/tantivy", rev="6a24621231202ccd77bec90d8787e2281fb94e4e" }
|
||||
tantivy = { git="https://github.com/windmill-labs/tantivy", rev="6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" }
|
||||
|
||||
backon = "1.3.0"
|
||||
|
||||
|
||||
+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() {
|
||||
|
||||
@@ -14,9 +14,11 @@ enterprise = ["windmill-common/enterprise"]
|
||||
private = ["windmill-common/private"]
|
||||
cloud = ["windmill-common/cloud"]
|
||||
no_auth = ["windmill-api-auth/no_auth"]
|
||||
parquet = ["windmill-object-store/parquet"]
|
||||
|
||||
[dependencies]
|
||||
windmill-common = { workspace = true, default-features = false }
|
||||
windmill-object-store = { workspace = true, optional = true }
|
||||
windmill-types.workspace = true
|
||||
windmill-api-auth.workspace = true
|
||||
windmill-api-users.workspace = true
|
||||
|
||||
@@ -35,7 +35,6 @@ use windmill_common::variables::{
|
||||
build_crypt, decrypt, encrypt, SECRET_SALT, WORKSPACE_CRYPT_CACHE,
|
||||
};
|
||||
use windmill_common::worker::{to_raw_value, CLOUD_HOSTED};
|
||||
#[cfg(feature = "enterprise")]
|
||||
use windmill_common::workspaces::GitRepositorySettings;
|
||||
#[cfg(feature = "enterprise")]
|
||||
use windmill_common::workspaces::WorkspaceDeploymentUISettings;
|
||||
@@ -82,6 +81,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(
|
||||
@@ -111,6 +114,7 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/list_datatables", get(list_datatables))
|
||||
.route("/list_datatable_schemas", get(list_datatable_schemas))
|
||||
.route("/edit_datatable_config", post(edit_datatable_config))
|
||||
.route("/git_sync_enabled", get(get_git_sync_enabled))
|
||||
.route("/edit_git_sync_config", post(edit_git_sync_config))
|
||||
.route("/edit_git_sync_repository", post(edit_git_sync_repository))
|
||||
.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>,
|
||||
@@ -1471,24 +1595,20 @@ async fn edit_datatable_config(
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct EditGitSyncConfig {
|
||||
#[cfg(feature = "enterprise")]
|
||||
pub git_sync_settings: Option<WorkspaceGitSyncSettings>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct EditGitSyncRepository {
|
||||
pub git_repo_resource_path: String,
|
||||
pub repository: GitRepositorySettings,
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct DeleteGitSyncRepositoryRequest {
|
||||
pub git_repo_resource_path: String,
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
fn validate_git_repo_resource_path(path: &str) -> Result<()> {
|
||||
// Resource paths should follow the pattern: $res:f/<folder>/<name> or $res:u/<username>/<name>
|
||||
if path.is_empty() {
|
||||
@@ -1537,7 +1657,6 @@ fn validate_git_repo_resource_path(path: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
fn cleanup_legacy_git_sync_settings_in_memory(
|
||||
git_sync_settings: &mut windmill_common::workspaces::WorkspaceGitSyncSettings,
|
||||
workspace_id: &str,
|
||||
@@ -1564,18 +1683,72 @@ fn cleanup_legacy_git_sync_settings_in_memory(
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
async fn edit_git_sync_config(
|
||||
_authed: ApiAuthed,
|
||||
Extension(_db): Extension<DB>,
|
||||
Path(_w_id): Path<String>,
|
||||
Json(_new_config): Json<EditGitSyncConfig>,
|
||||
) -> Result<String> {
|
||||
return Err(Error::BadRequest(
|
||||
"Git sync is only available on Windmill Enterprise Edition".to_string(),
|
||||
));
|
||||
const CE_GIT_SYNC_MAX_USERS: i64 = 2;
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
async fn check_git_sync_access(_db: &DB, _w_id: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
async fn check_git_sync_access(db: &DB, w_id: &str) -> Result<()> {
|
||||
let user_count: i64 = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false",
|
||||
w_id
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
|
||||
if user_count > CE_GIT_SYNC_MAX_USERS {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Git sync is available for workspaces with up to {} members. \
|
||||
Upgrade to Windmill Enterprise Edition for unlimited workspace members.",
|
||||
CE_GIT_SYNC_MAX_USERS
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
async fn get_git_sync_enabled(
|
||||
_authed: ApiAuthed,
|
||||
Extension(_db): Extension<DB>,
|
||||
Path(_w_id): Path<String>,
|
||||
) -> JsonResult<serde_json::Value> {
|
||||
Ok(Json(serde_json::json!({
|
||||
"enabled": true,
|
||||
"reason": "enterprise",
|
||||
"max_repos": null,
|
||||
"user_count": null,
|
||||
"max_users": null,
|
||||
})))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
async fn get_git_sync_enabled(
|
||||
_authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<serde_json::Value> {
|
||||
let user_count: i64 = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false",
|
||||
&w_id
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
|
||||
let enabled = user_count <= CE_GIT_SYNC_MAX_USERS;
|
||||
Ok(Json(serde_json::json!({
|
||||
"enabled": enabled,
|
||||
"reason": if enabled { Some("free_tier") } else { None::<&str> },
|
||||
"max_repos": if enabled { Some(1) } else { None::<i32> },
|
||||
"user_count": user_count,
|
||||
"max_users": CE_GIT_SYNC_MAX_USERS,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn edit_git_sync_config(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -1584,6 +1757,7 @@ async fn edit_git_sync_config(
|
||||
Json(new_config): Json<EditGitSyncConfig>,
|
||||
) -> Result<String> {
|
||||
require_admin(is_admin, &username)?;
|
||||
check_git_sync_access(&db, &w_id).await?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
@@ -1640,19 +1814,6 @@ async fn edit_git_sync_config(
|
||||
Ok(format!("Edit git sync config for workspace {}", &w_id))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
async fn edit_git_sync_repository(
|
||||
_authed: ApiAuthed,
|
||||
Extension(_db): Extension<DB>,
|
||||
Path(_w_id): Path<String>,
|
||||
Json(_new_config): Json<serde_json::Value>,
|
||||
) -> Result<String> {
|
||||
return Err(Error::BadRequest(
|
||||
"Git sync is only available on Windmill Enterprise Edition".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
async fn edit_git_sync_repository(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -1661,10 +1822,19 @@ async fn edit_git_sync_repository(
|
||||
Json(new_config): Json<EditGitSyncRepository>,
|
||||
) -> Result<String> {
|
||||
require_admin(is_admin, &username)?;
|
||||
check_git_sync_access(&db, &w_id).await?;
|
||||
|
||||
// Validate the resource path format
|
||||
validate_git_repo_resource_path(&new_config.git_repo_resource_path)?;
|
||||
|
||||
// Promotion mode: EE only
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
if new_config.repository.use_individual_branch.unwrap_or(false) {
|
||||
return Err(Error::BadRequest(
|
||||
"Promotion mode is an Enterprise Edition feature".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
// First, get the current git sync settings
|
||||
@@ -1686,6 +1856,20 @@ async fn edit_git_sync_repository(
|
||||
WorkspaceGitSyncSettings::default()
|
||||
};
|
||||
|
||||
// Multi-repo: EE only
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
{
|
||||
let is_new = !git_sync_settings
|
||||
.repositories
|
||||
.iter()
|
||||
.any(|r| r.git_repo_resource_path == new_config.git_repo_resource_path);
|
||||
if is_new && !git_sync_settings.repositories.is_empty() {
|
||||
return Err(Error::BadRequest(
|
||||
"Multiple git sync repositories is an Enterprise Edition feature".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Audit log before we move the repository
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
@@ -1769,19 +1953,6 @@ async fn edit_git_sync_repository(
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
async fn delete_git_sync_repository(
|
||||
_authed: ApiAuthed,
|
||||
Extension(_db): Extension<DB>,
|
||||
Path(_w_id): Path<String>,
|
||||
Json(_request): Json<serde_json::Value>,
|
||||
) -> Result<String> {
|
||||
return Err(Error::BadRequest(
|
||||
"Git sync is only available on Windmill Enterprise Edition".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
async fn delete_git_sync_repository(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -1791,7 +1962,7 @@ async fn delete_git_sync_repository(
|
||||
) -> Result<String> {
|
||||
require_admin(is_admin, &username)?;
|
||||
|
||||
// For deletion, only validate that path is not empty to allow cleanup of malformed entries
|
||||
// No check_git_sync_access here — admins should always be able to delete/clean up repos
|
||||
if request.git_repo_resource_path.is_empty() {
|
||||
return Err(Error::BadRequest(
|
||||
"Resource path cannot be empty".to_string(),
|
||||
@@ -2109,22 +2280,29 @@ async fn edit_default_app(
|
||||
#[derive(Serialize)]
|
||||
struct WorkspaceDefaultApp {
|
||||
pub default_app_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_app_raw: Option<bool>,
|
||||
}
|
||||
async fn get_default_app(
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<WorkspaceDefaultApp> {
|
||||
let mut tx = db.begin().await?;
|
||||
let default_app_path = sqlx::query_scalar!(
|
||||
"SELECT default_app FROM workspace_settings WHERE workspace_id = $1",
|
||||
let row = sqlx::query!(
|
||||
"SELECT ws.default_app AS default_app_path, av.raw_app AS \"default_app_raw: Option<bool>\"
|
||||
FROM workspace_settings ws
|
||||
LEFT JOIN app ON app.path = ws.default_app AND app.workspace_id = ws.workspace_id
|
||||
LEFT JOIN app_version av ON av.id = app.versions[array_upper(app.versions, 1)]
|
||||
WHERE ws.workspace_id = $1",
|
||||
&w_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.map_err(|err| Error::internal_err(format!("getting default_app: {err}")))?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(WorkspaceDefaultApp { default_app_path }))
|
||||
Ok(Json(WorkspaceDefaultApp {
|
||||
default_app_path: row.default_app_path,
|
||||
default_app_raw: row.default_app_raw,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn edit_error_handler(
|
||||
@@ -3296,6 +3474,11 @@ async fn clone_apps(
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
|
||||
let mut cloned_from_db: std::collections::HashSet<(i64, String)> = HashSet::new();
|
||||
for bundle in &bundles {
|
||||
cloned_from_db.insert((bundle.app_version_id, bundle.file_type.clone()));
|
||||
}
|
||||
|
||||
for bundle in bundles {
|
||||
if let Some(&new_version_id) = version_id_mapping.get(&bundle.app_version_id) {
|
||||
sqlx::query!(
|
||||
@@ -3310,6 +3493,66 @@ async fn clone_apps(
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Clone bundles from S3 for versions not found in DB
|
||||
#[cfg(all(feature = "enterprise", feature = "parquet"))]
|
||||
{
|
||||
let object_store = windmill_object_store::get_object_store().await;
|
||||
if let Some(os) = object_store {
|
||||
for (&old_version_id, &new_version_id) in &version_id_mapping {
|
||||
for file_type in &["js", "css"] {
|
||||
if cloned_from_db.contains(&(old_version_id, file_type.to_string())) {
|
||||
continue;
|
||||
}
|
||||
let src_path = format!(
|
||||
"/app_bundles/{}/{}.{}",
|
||||
source_workspace_id, old_version_id, file_type
|
||||
);
|
||||
let get_result = os
|
||||
.get(&windmill_object_store::object_store_reexports::Path::from(
|
||||
src_path,
|
||||
))
|
||||
.await;
|
||||
match get_result {
|
||||
Ok(result) => {
|
||||
let data = result.bytes().await.map_err(
|
||||
windmill_object_store::object_store_error_to_error,
|
||||
)?;
|
||||
let dst_path = format!(
|
||||
"/app_bundles/{}/{}.{}",
|
||||
target_workspace_id, new_version_id, file_type
|
||||
);
|
||||
os.put(
|
||||
&windmill_object_store::object_store_reexports::Path::from(
|
||||
dst_path.clone(),
|
||||
),
|
||||
data.into(),
|
||||
)
|
||||
.await
|
||||
.map_err(
|
||||
windmill_object_store::object_store_error_to_error,
|
||||
)?;
|
||||
tracing::info!(
|
||||
"Cloned app bundle from S3: {}.{} -> {}.{}",
|
||||
old_version_id,
|
||||
file_type,
|
||||
new_version_id,
|
||||
file_type
|
||||
);
|
||||
}
|
||||
Err(windmill_object_store::object_store_reexports::ObjectStoreError::NotFound { .. }) => {
|
||||
// No bundle in S3 for this version/type, skip
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(
|
||||
windmill_object_store::object_store_error_to_error(e),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update app versions arrays
|
||||
|
||||
@@ -18,7 +18,7 @@ agent_worker_server = ["dep:windmill-worker", "dep:windmill-api-agent-workers"]
|
||||
enterprise_saml = ["dep:samael", "dep:libxml"]
|
||||
benchmark = []
|
||||
embedding = ["windmill-api-embeddings/embedding"]
|
||||
parquet = ["dep:datafusion", "windmill-common/parquet", "windmill-object-store/parquet", "windmill-worker?/parquet", "windmill-api-users/parquet", "windmill-api-settings/parquet", "dep:aws-sigv4", "dep:aws-sdk-config"]
|
||||
parquet = ["dep:datafusion", "windmill-common/parquet", "windmill-object-store/parquet", "windmill-worker?/parquet", "windmill-api-users/parquet", "windmill-api-settings/parquet", "windmill-api-workspaces/parquet", "dep:aws-sigv4", "dep:aws-sdk-config"]
|
||||
prometheus = ["windmill-common/prometheus", "windmill-queue/prometheus", "dep:prometheus", "windmill-worker?/prometheus"]
|
||||
openidconnect = ["dep:openidconnect", "windmill-common/openidconnect", "windmill-store/openidconnect"]
|
||||
tantivy = ["dep:windmill-indexer"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.663.0
|
||||
version: 1.664.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -3193,9 +3193,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:
|
||||
@@ -3392,6 +3432,37 @@ paths:
|
||||
application/json:
|
||||
schema: {}
|
||||
|
||||
/w/{workspace}/workspaces/git_sync_enabled:
|
||||
get:
|
||||
summary: Check if git sync is available for this workspace
|
||||
operationId: getGitSyncEnabled
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: Git sync availability status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
reason:
|
||||
type: string
|
||||
nullable: true
|
||||
max_repos:
|
||||
type: integer
|
||||
nullable: true
|
||||
user_count:
|
||||
type: integer
|
||||
nullable: true
|
||||
max_users:
|
||||
type: integer
|
||||
nullable: true
|
||||
|
||||
/w/{workspace}/workspaces/edit_git_sync_config:
|
||||
post:
|
||||
summary: edit workspace git sync settings
|
||||
@@ -3664,6 +3735,8 @@ paths:
|
||||
properties:
|
||||
default_app_path:
|
||||
type: string
|
||||
default_app_raw:
|
||||
type: boolean
|
||||
|
||||
/w/{workspace}/workspaces/usage:
|
||||
get:
|
||||
@@ -11050,6 +11123,129 @@ paths:
|
||||
"200":
|
||||
description: Interactive slack approval message sent successfully
|
||||
|
||||
/w/{workspace}/jobs_u/flow/resume_suspended/{job_id}:
|
||||
post:
|
||||
summary: resume or cancel a suspended flow/WAC job
|
||||
description: >
|
||||
Resume or cancel a suspended flow/WAC job. Uses approval rules to
|
||||
determine authorization. Either a valid approval_token or an
|
||||
authenticated session is required.
|
||||
operationId: resumeSuspended
|
||||
tags:
|
||||
- job
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: job_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
payload:
|
||||
description: payload to send to the resumed job
|
||||
approval_token:
|
||||
type: string
|
||||
description: approval token for unauthenticated access
|
||||
approved:
|
||||
type: boolean
|
||||
description: whether to approve (true) or cancel (false) the job
|
||||
default: true
|
||||
responses:
|
||||
"201":
|
||||
description: job resumed
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/jobs_u/flow/approval_info/{job_id}:
|
||||
get:
|
||||
summary: get approval info for a suspended flow/WAC job
|
||||
description: >
|
||||
Get approval info for a suspended flow/WAC job. Returns form schema,
|
||||
approval rules, and whether the current user can approve. Either a
|
||||
valid token query parameter or an authenticated session is required.
|
||||
operationId: getApprovalInfo
|
||||
tags:
|
||||
- job
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: job_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- name: token
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: approval token for unauthenticated access
|
||||
responses:
|
||||
"200":
|
||||
description: approval info
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- flow_id
|
||||
- can_approve
|
||||
- user_auth_required
|
||||
- approvers
|
||||
properties:
|
||||
flow_id:
|
||||
type: string
|
||||
format: uuid
|
||||
form_schema:
|
||||
description: form schema for the approval step
|
||||
description:
|
||||
description: description of the approval step
|
||||
approval_conditions:
|
||||
type: object
|
||||
properties:
|
||||
user_auth_required:
|
||||
type: boolean
|
||||
user_groups_required:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
self_approval_disabled:
|
||||
type: boolean
|
||||
required:
|
||||
- user_auth_required
|
||||
- user_groups_required
|
||||
- self_approval_disabled
|
||||
can_approve:
|
||||
type: boolean
|
||||
description: whether the current user/token holder can approve
|
||||
user_auth_required:
|
||||
type: boolean
|
||||
description: whether user authentication is required to approve
|
||||
hide_cancel:
|
||||
type: boolean
|
||||
description: whether to hide the cancel button in the UI
|
||||
approvers:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- resume_id
|
||||
- approver
|
||||
properties:
|
||||
resume_id:
|
||||
type: integer
|
||||
approver:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/jobs_u/resume/{id}/{resume_id}/{signature}:
|
||||
get:
|
||||
summary: resume a job for a suspended flow
|
||||
@@ -17266,6 +17462,27 @@ paths:
|
||||
description: count of log lines that matched the query per hostname
|
||||
type: object
|
||||
|
||||
/srch/index/storage/disk:
|
||||
get:
|
||||
summary: Get index disk storage sizes from the indexer.
|
||||
operationId: getIndexDiskStorageSizes
|
||||
tags:
|
||||
- indexSearch
|
||||
responses:
|
||||
"200":
|
||||
description: disk storage sizes for each index
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
job_index_disk_size_bytes:
|
||||
type: integer
|
||||
nullable: true
|
||||
log_index_disk_size_bytes:
|
||||
type: integer
|
||||
nullable: true
|
||||
|
||||
/indexer/delete/{idx_name}:
|
||||
delete:
|
||||
summary: Clear an index and restart the indexer.
|
||||
@@ -18794,6 +19011,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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,17 +440,29 @@ async fn get_raw_app_data(
|
||||
#[cfg(all(feature = "enterprise", feature = "parquet"))]
|
||||
if let Some(os) = object_store {
|
||||
let path = format!("/app_bundles/{}/{}.{}", w_id, id, file_type);
|
||||
let stream = os
|
||||
match os
|
||||
.get(&windmill_object_store::object_store_reexports::Path::from(
|
||||
path,
|
||||
))
|
||||
.await
|
||||
.map_err(windmill_object_store::object_store_error_to_error)?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(windmill_object_store::object_store_error_to_error)?;
|
||||
tracing::info!("stream: {}", stream.len());
|
||||
body = Some(Body::from(stream));
|
||||
{
|
||||
Ok(result) => {
|
||||
let stream = result
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(windmill_object_store::object_store_error_to_error)?;
|
||||
tracing::info!("stream: {}", stream.len());
|
||||
body = Some(Body::from(stream));
|
||||
}
|
||||
Err(windmill_object_store::object_store_reexports::ObjectStoreError::NotFound {
|
||||
..
|
||||
}) => {
|
||||
// S3 key not found, fall through to DB lookup below
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(windmill_object_store::object_store_error_to_error(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if body.is_none() {
|
||||
|
||||
@@ -103,7 +103,7 @@ use windmill_common::{
|
||||
cache,
|
||||
db::UserDB,
|
||||
error::{self, to_anyhow, Error},
|
||||
flow_status::{Approval, FlowStatus, FlowStatusModule},
|
||||
flow_status::{Approval, ApprovalConditions, FlowStatus, FlowStatusModule},
|
||||
flows::{add_virtual_items_if_necessary, resolve_maybe_value, FlowValue},
|
||||
jobs::{script_path_to_payload, CompletedJob, JobKind, JobPayload, QueuedJob, RawCode},
|
||||
oauth2::HmacSha256,
|
||||
@@ -401,6 +401,8 @@ pub fn workspace_unauthed_service() -> Router {
|
||||
post(cancel_persistent_script_api),
|
||||
)
|
||||
.route("/queue/force_cancel/:id", post(force_cancel))
|
||||
.route("/flow/resume_suspended/:job_id", post(resume_suspended))
|
||||
.route("/flow/approval_info/:job_id", get(get_approval_info))
|
||||
}
|
||||
|
||||
pub fn global_root_service() -> Router {
|
||||
@@ -1058,6 +1060,7 @@ impl<'a> GetQuery<'a> {
|
||||
Self { with_code: false, ..self }
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn without_flow(self) -> Self {
|
||||
Self { with_flow: false, ..self }
|
||||
}
|
||||
@@ -2181,7 +2184,7 @@ pub async fn resume_suspended_flow_as_owner(
|
||||
) -> error::Result<StatusCode> {
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
let (flow, job_id) = get_suspended_flow_info(flow_id, &mut tx).await?;
|
||||
let (flow, job_id, is_wac) = get_suspended_flow_info(flow_id, &mut tx).await?;
|
||||
|
||||
let flow_path = flow.script_path.as_deref().unwrap_or_else(|| "");
|
||||
require_owner_of_path(&authed, flow_path)?;
|
||||
@@ -2189,10 +2192,17 @@ pub async fn resume_suspended_flow_as_owner(
|
||||
|
||||
// Check approval conditions (self-approval, required groups, etc.)
|
||||
if let Some(ref flow_status_value) = flow.flow_status {
|
||||
if let Ok(flow_status) = serde_json::from_value::<FlowStatus>(flow_status_value.clone()) {
|
||||
let trigger_email = flow.email.as_deref().unwrap_or("");
|
||||
conditionally_require_authed_user(Some(authed.clone()), flow_status, trigger_email)?;
|
||||
}
|
||||
let trigger_email = flow.email.as_deref().unwrap_or("");
|
||||
let ac = serde_json::from_value::<FlowStatus>(flow_status_value.clone())
|
||||
.ok()
|
||||
.and_then(|fs| fs.approval_conditions)
|
||||
.or_else(|| {
|
||||
// WAC flows store approval_conditions directly in flow_status JSONB
|
||||
flow_status_value
|
||||
.get("approval_conditions")
|
||||
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok())
|
||||
});
|
||||
conditionally_require_authed_user(Some(authed.clone()), ac, trigger_email)?;
|
||||
}
|
||||
|
||||
let value = value.unwrap_or(serde_json::Value::Null);
|
||||
@@ -2208,12 +2218,426 @@ pub async fn resume_suspended_flow_as_owner(
|
||||
)
|
||||
.await?;
|
||||
|
||||
resume_immediately_if_relevant(flow, job_id, &mut tx).await?;
|
||||
if is_wac {
|
||||
// WAC: directly decrement suspend counter
|
||||
if flow.suspend > 0 {
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1",
|
||||
flow.id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
} else {
|
||||
resume_immediately_if_relevant(flow, job_id, &mut tx).await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
// --- New approval system endpoints ---
|
||||
|
||||
use windmill_common::variables::generate_approval_token;
|
||||
|
||||
/// Verify an approval token against the workspace key + job_id.
|
||||
async fn validate_approval_token(
|
||||
db: &DB,
|
||||
token: &str,
|
||||
job_id: Uuid,
|
||||
workspace_id: &str,
|
||||
) -> error::Result<()> {
|
||||
let expected = generate_approval_token(workspace_id, job_id, db).await?;
|
||||
if token != expected {
|
||||
return Err(Error::NotAuthorized("Invalid approval token".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ResumeSuspendedBody {
|
||||
payload: Option<serde_json::Value>,
|
||||
approval_token: Option<String>,
|
||||
approved: Option<bool>,
|
||||
}
|
||||
|
||||
async fn resume_suspended(
|
||||
OptAuthed(opt_authed): OptAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, job_id)): Path<(String, Uuid)>,
|
||||
Json(body): Json<ResumeSuspendedBody>,
|
||||
) -> error::Result<StatusCode> {
|
||||
let approved = body.approved.unwrap_or(true);
|
||||
let value = body.payload.unwrap_or(serde_json::Value::Null);
|
||||
|
||||
// Determine if we have a valid authed user or token
|
||||
let has_token = if let Some(ref token) = body.approval_token {
|
||||
validate_approval_token(&db, token, job_id, &w_id)
|
||||
.await
|
||||
.is_ok()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if opt_authed.is_none() && !has_token {
|
||||
return Err(Error::NotAuthorized(
|
||||
"Must be logged in or provide a valid approval token".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
// Resolve the suspended flow (works for both WAC and classic flows)
|
||||
let (flow, resume_job_id, is_wac) = get_suspended_flow_info(job_id, &mut tx).await?;
|
||||
|
||||
// Verify the job belongs to this workspace
|
||||
let job_workspace: Option<String> =
|
||||
sqlx::query_scalar("SELECT workspace_id FROM v2_job WHERE id = $1")
|
||||
.bind(&flow.id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if job_workspace.as_deref() != Some(w_id.as_str()) {
|
||||
return Err(Error::NotFound(
|
||||
"Job not found in this workspace".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Check approval conditions
|
||||
let approval_conditions = if is_wac {
|
||||
flow.flow_status
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("approval_conditions"))
|
||||
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok())
|
||||
} else {
|
||||
flow.flow_status
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok())
|
||||
.and_then(|fs| fs.approval_conditions)
|
||||
};
|
||||
|
||||
if let Some(ref ac) = approval_conditions {
|
||||
if ac.user_auth_required && opt_authed.is_none() {
|
||||
return Err(Error::NotAuthorized(
|
||||
"This approval requires a logged-in user. Please sign in.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// If logged in, check authorization rules
|
||||
if let Some(ref authed) = opt_authed {
|
||||
let is_admin = authed.is_admin;
|
||||
let is_owner = flow
|
||||
.script_path
|
||||
.as_deref()
|
||||
.map(|p| require_owner_of_path(authed, p).is_ok())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_admin && !is_owner {
|
||||
let trigger_email = flow.email.as_deref().unwrap_or("");
|
||||
conditionally_require_authed_user(
|
||||
Some(authed.clone()),
|
||||
approval_conditions.clone(),
|
||||
trigger_email,
|
||||
)?;
|
||||
}
|
||||
} else if !has_token {
|
||||
return Err(Error::NotAuthorized(
|
||||
"Must be logged in or provide a valid approval token".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Generate a unique resume_id
|
||||
let resume_id: u32 = rand::random();
|
||||
|
||||
// Check for duplicate
|
||||
let exists: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM resume_job WHERE id = $1)")
|
||||
.bind(Uuid::from_u128(resume_job_id.as_u128() ^ resume_id as u128))
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if exists {
|
||||
return Err(Error::BadRequest("Resume request already sent".to_string()));
|
||||
}
|
||||
|
||||
let approver_value = opt_authed.as_ref().map(|a| a.username.clone());
|
||||
|
||||
insert_resume_job(
|
||||
resume_id,
|
||||
resume_job_id,
|
||||
&flow,
|
||||
value,
|
||||
approver_value.clone(),
|
||||
approved,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !approved {
|
||||
sqlx::query("UPDATE v2_job_queue SET suspend = 0 WHERE id = $1")
|
||||
.bind(&flow.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
} else if is_wac {
|
||||
if flow.suspend > 0 {
|
||||
sqlx::query("UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1")
|
||||
.bind(&flow.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
} else {
|
||||
resume_immediately_if_relevant(flow, resume_job_id, &mut tx).await?;
|
||||
}
|
||||
|
||||
let approver = approver_value.unwrap_or_else(|| "anonymous".to_string());
|
||||
let audit_author = if let Some(ref authed) = opt_authed {
|
||||
AuditAuthor::from(authed)
|
||||
} else {
|
||||
AuditAuthor {
|
||||
email: approver.clone(),
|
||||
username: approver.clone(),
|
||||
username_override: None,
|
||||
token_prefix: None,
|
||||
}
|
||||
};
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&audit_author,
|
||||
"jobs.suspend_resume",
|
||||
ActionKind::Update,
|
||||
&w_id,
|
||||
Some(
|
||||
&serde_json::json!({
|
||||
"approved": approved,
|
||||
"job_id": job_id,
|
||||
"details": if approved {
|
||||
format!("Approved by {}", &approver)
|
||||
} else {
|
||||
format!("Cancelled by {}", &approver)
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ApprovalInfoQuery {
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ApprovalInfo {
|
||||
flow_id: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
form_schema: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
approval_conditions: Option<ApprovalConditions>,
|
||||
can_approve: bool,
|
||||
user_auth_required: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
hide_cancel: Option<bool>,
|
||||
approvers: Vec<Approval>,
|
||||
}
|
||||
|
||||
async fn get_approval_info(
|
||||
OptAuthed(opt_authed): OptAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, job_id)): Path<(String, Uuid)>,
|
||||
Query(query): Query<ApprovalInfoQuery>,
|
||||
) -> error::Result<Json<ApprovalInfo>> {
|
||||
// Validate access: either logged in or valid token
|
||||
let has_token = if let Some(ref token) = query.token {
|
||||
validate_approval_token(&db, token, job_id, &w_id)
|
||||
.await
|
||||
.is_ok()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if opt_authed.is_none() && !has_token {
|
||||
return Err(Error::NotAuthorized(
|
||||
"Must be logged in or provide a valid approval token".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Fetch job info
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ApprovalJobRow {
|
||||
id: Uuid,
|
||||
script_path: Option<String>,
|
||||
email: String,
|
||||
flow_status: Option<serde_json::Value>,
|
||||
workflow_as_code_status: Option<serde_json::Value>,
|
||||
}
|
||||
let row = sqlx::query_as::<_, ApprovalJobRow>(
|
||||
"SELECT j.id, j.runnable_path as script_path, j.permissioned_as_email as email,
|
||||
s.flow_status, s.workflow_as_code_status
|
||||
FROM v2_job j
|
||||
LEFT JOIN v2_job_status s ON s.id = j.id
|
||||
WHERE j.id = $1 AND j.workspace_id = $2",
|
||||
)
|
||||
.bind(&job_id)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound(format!("Job {job_id} not found")))?;
|
||||
|
||||
let is_wac = row.workflow_as_code_status.is_some();
|
||||
|
||||
// Extract approval info based on WAC vs classic flow
|
||||
let (form_schema, description, approval_conditions, hide_cancel) = if is_wac {
|
||||
let approval_meta = row
|
||||
.workflow_as_code_status
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("_approval"));
|
||||
let form = approval_meta.and_then(|m| m.get("form").cloned());
|
||||
let ac = row
|
||||
.flow_status
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("approval_conditions"))
|
||||
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok());
|
||||
(form, None, ac, None)
|
||||
} else {
|
||||
let fs = row
|
||||
.flow_status
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok());
|
||||
let ac = fs.as_ref().and_then(|s| s.approval_conditions.clone());
|
||||
|
||||
// For classic flows, form/description come from the flow definition and step result
|
||||
let approval_step = fs.as_ref().map(|s| (s.step as usize).saturating_sub(1));
|
||||
|
||||
// Fetch flow definition to get suspend settings (form schema, hide_cancel).
|
||||
// Try raw_flow on the job first, fall back to flow_version for deployed flows.
|
||||
let raw_flow: Option<FlowValue> = {
|
||||
let from_job: Option<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT raw_flow FROM v2_job WHERE id = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(&job_id)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
if let Some(v) = from_job {
|
||||
serde_json::from_value(v).ok()
|
||||
} else {
|
||||
// Deployed flow: fetch from flow_version using runnable_id
|
||||
let from_version: Option<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT fv.value FROM v2_job j JOIN flow_version fv ON fv.id = j.runnable_id \
|
||||
WHERE j.id = $1 AND j.workspace_id = $2",
|
||||
)
|
||||
.bind(&job_id)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten();
|
||||
from_version.and_then(|v| serde_json::from_value(v).ok())
|
||||
}
|
||||
};
|
||||
|
||||
let suspend_module = raw_flow
|
||||
.as_ref()
|
||||
.and_then(|rf| approval_step.and_then(|s| rf.modules.get(s)));
|
||||
let suspend_settings = suspend_module.and_then(|m| m.suspend.as_ref());
|
||||
|
||||
let form = suspend_settings
|
||||
.and_then(|s| s.resume_form.as_ref())
|
||||
.map(|rf| serde_json::json!(rf));
|
||||
let hc = suspend_settings.map(|s| s.hide_cancel.unwrap_or(false));
|
||||
|
||||
// Fetch description and default_args from the step's completed job result
|
||||
let step_job_id = fs
|
||||
.as_ref()
|
||||
.and_then(|s| approval_step.and_then(|step| s.modules.get(step)))
|
||||
.and_then(|m| m.job());
|
||||
let (desc, _default_args) = if let Some(sjid) = step_job_id {
|
||||
let result: Option<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT result FROM v2_job_completed WHERE id = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(sjid)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten();
|
||||
let desc = result.as_ref().and_then(|r| r.get("description").cloned());
|
||||
let da = result.as_ref().and_then(|r| r.get("default_args").cloned());
|
||||
(desc, da)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
(form, desc, ac, hc)
|
||||
};
|
||||
|
||||
let user_auth_required = approval_conditions
|
||||
.as_ref()
|
||||
.map(|ac| ac.user_auth_required)
|
||||
.unwrap_or(false);
|
||||
|
||||
// Determine if current user can approve
|
||||
let can_approve = if let Some(ref authed) = opt_authed {
|
||||
if authed.is_admin {
|
||||
true
|
||||
} else {
|
||||
let is_owner = row
|
||||
.script_path
|
||||
.as_deref()
|
||||
.map(|p| require_owner_of_path(authed, p).is_ok())
|
||||
.unwrap_or(false);
|
||||
if is_owner {
|
||||
true
|
||||
} else {
|
||||
let trigger_email = row.email.as_str();
|
||||
conditionally_require_authed_user(
|
||||
Some(authed.clone()),
|
||||
approval_conditions.clone(),
|
||||
trigger_email,
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Not logged in — can approve only if no auth required
|
||||
!user_auth_required
|
||||
};
|
||||
|
||||
// Get existing approvers
|
||||
let approvers: Vec<Approval> = sqlx::query_as::<_, (i32, Option<String>)>(
|
||||
"SELECT resume_id, approver FROM resume_job WHERE flow = $1",
|
||||
)
|
||||
.bind(&job_id)
|
||||
.fetch_all(&db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(rid, approver)| Approval {
|
||||
resume_id: rid as u16,
|
||||
approver: approver.unwrap_or_else(|| "anonymous".to_string()),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(ApprovalInfo {
|
||||
flow_id: row.id,
|
||||
form_schema,
|
||||
description,
|
||||
approval_conditions,
|
||||
can_approve,
|
||||
user_auth_required,
|
||||
hide_cancel,
|
||||
approvers,
|
||||
}))
|
||||
}
|
||||
|
||||
// --- End new approval system endpoints ---
|
||||
|
||||
pub async fn resume_suspended_job(
|
||||
authed: Option<ApiAuthed>,
|
||||
opt_tokened: OptTokened,
|
||||
@@ -2255,26 +2679,8 @@ async fn resume_suspended_job_internal(
|
||||
// Get flow info - works for step-level, flow-level, and WAC approval
|
||||
let (flow_info, is_flow_level, is_wac) = get_flow_info_for_resume(job_id, &db).await?;
|
||||
|
||||
// For step-level resumes, verify user auth and flow status
|
||||
// For flow-level resumes (pre-approvals), the flow might not be at a suspended step yet
|
||||
// For WAC approvals, skip flow status checks (there is no flow)
|
||||
if !is_flow_level && !is_wac {
|
||||
let parent_flow = GetQuery::new()
|
||||
.without_logs()
|
||||
.without_code()
|
||||
.without_flow()
|
||||
.fetch(&db, &flow_info.id, &w_id)
|
||||
.await?;
|
||||
let flow_status = parent_flow
|
||||
.flow_status()
|
||||
.ok_or_else(|| anyhow::anyhow!("unable to find the flow status in the flow job"))?;
|
||||
|
||||
let trigger_email = match &parent_flow {
|
||||
Job::CompletedJob(job) => &job.email,
|
||||
Job::QueuedJob(job) => &job.email,
|
||||
};
|
||||
conditionally_require_authed_user(authed.clone(), flow_status, trigger_email)?;
|
||||
}
|
||||
// HMAC secret = full capability. Skip approval_conditions checks.
|
||||
// Authorization rules are enforced by the new resume_suspended endpoint instead.
|
||||
|
||||
let exists = sqlx::query_scalar!(
|
||||
r#"
|
||||
@@ -2540,7 +2946,7 @@ async fn get_flow_info_for_resume(job_id: Uuid, db: &DB) -> error::Result<(FlowI
|
||||
async fn get_suspended_flow_info<'c>(
|
||||
job_id: Uuid,
|
||||
tx: &mut Transaction<'c, Postgres>,
|
||||
) -> error::Result<(FlowInfo, Uuid)> {
|
||||
) -> error::Result<(FlowInfo, Uuid, bool)> {
|
||||
let flow = sqlx::query_as!(
|
||||
FlowInfo,
|
||||
r#"
|
||||
@@ -2553,7 +2959,9 @@ async fn get_suspended_flow_info<'c>(
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("parent flow job not found"))?;
|
||||
let job_id = flow
|
||||
|
||||
// Try to extract step job_id from FlowStatus modules (classic flow path)
|
||||
let step_job_id = flow
|
||||
.flow_status
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok())
|
||||
@@ -2562,8 +2970,31 @@ async fn get_suspended_flow_info<'c>(
|
||||
_ => None,
|
||||
});
|
||||
|
||||
if let Some(job_id) = job_id {
|
||||
Ok((flow, job_id))
|
||||
if let Some(step_job_id) = step_job_id {
|
||||
// Classic flow
|
||||
Ok((flow, step_job_id, false))
|
||||
} else if flow.suspend > 0 {
|
||||
// WAC approval: no FlowStatus modules, but the job is suspended
|
||||
// The flow_status here comes from COALESCE(flow_status, workflow_as_code_status),
|
||||
// so for WAC it may contain approval_conditions from flow_status column
|
||||
// or the WAC checkpoint from workflow_as_code_status column.
|
||||
// We need the approval_conditions which are in flow_status column.
|
||||
// Re-fetch just flow_status (without COALESCE fallback) for the auth check.
|
||||
let flow_status_only: Option<serde_json::Value> =
|
||||
sqlx::query_scalar("SELECT flow_status FROM v2_job_status WHERE id = $1")
|
||||
.bind(&job_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
let flow = FlowInfo {
|
||||
id: flow.id,
|
||||
flow_status: flow_status_only,
|
||||
suspend: flow.suspend,
|
||||
script_path: flow.script_path,
|
||||
email: flow.email,
|
||||
};
|
||||
Ok((flow, job_id, true))
|
||||
} else {
|
||||
Err(anyhow::anyhow!("the flow is not in a suspended state anymore").into())
|
||||
}
|
||||
@@ -2640,7 +3071,11 @@ pub async fn get_suspended_job_flow(
|
||||
Job::CompletedJob(job) => &job.email,
|
||||
Job::QueuedJob(job) => &job.email,
|
||||
};
|
||||
conditionally_require_authed_user(authed.clone(), flow_status.clone(), trigger_email)?;
|
||||
conditionally_require_authed_user(
|
||||
authed.clone(),
|
||||
flow_status.approval_conditions.clone(),
|
||||
trigger_email,
|
||||
)?;
|
||||
|
||||
let approvers_from_status = match flow_module_status {
|
||||
FlowStatusModule::Success { approvers, .. } => approvers.to_owned(),
|
||||
@@ -2681,16 +3116,25 @@ pub async fn get_suspended_job_flow(
|
||||
|
||||
fn conditionally_require_authed_user(
|
||||
_authed: Option<ApiAuthed>,
|
||||
flow_status: FlowStatus,
|
||||
approval_conditions_opt: Option<ApprovalConditions>,
|
||||
_trigger_email: &str,
|
||||
) -> error::Result<()> {
|
||||
let approval_conditions_opt = flow_status.approval_conditions;
|
||||
|
||||
if approval_conditions_opt.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let approval_conditions = approval_conditions_opt.unwrap();
|
||||
|
||||
// Check self-approval independently of user_auth_required
|
||||
if approval_conditions.self_approval_disabled {
|
||||
if let Some(ref authed) = _authed {
|
||||
if !authed.is_admin && authed.email.eq(_trigger_email) {
|
||||
return Err(Error::PermissionDenied(
|
||||
"Self-approval is disabled for this flow step".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if approval_conditions.user_auth_required {
|
||||
{
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
@@ -2708,13 +3152,6 @@ fn conditionally_require_authed_user(
|
||||
|
||||
let authed = _authed.unwrap();
|
||||
if !authed.is_admin {
|
||||
if approval_conditions.self_approval_disabled && authed.email.eq(_trigger_email)
|
||||
{
|
||||
return Err(Error::PermissionDenied(
|
||||
"Self-approval is disabled for this flow step".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if !approval_conditions.user_groups_required.is_empty() {
|
||||
#[cfg(feature = "enterprise")]
|
||||
{
|
||||
@@ -2860,11 +3297,18 @@ pub async fn get_resume_urls_internal(
|
||||
.map(|x| format!("?approver={}", encode(x)))
|
||||
.unwrap_or_else(String::new);
|
||||
|
||||
// Generate approval token for the new approval page URL.
|
||||
// The token targets the parent flow/WAC job for proper resolution.
|
||||
let approval_target_id = get_flow_id_for_job(&db, job_id)
|
||||
.await
|
||||
.unwrap_or(target_job_id);
|
||||
let approval_token = generate_approval_token(&w_id, approval_target_id, &db).await?;
|
||||
|
||||
let base_url_str = BASE_URL.read().await.clone();
|
||||
let base_url = base_url_str.as_str();
|
||||
let res = ResumeUrls {
|
||||
approvalPage: format!(
|
||||
"{base_url}/approve/{w_id}/{target_job_id}/{resume_id}/{signature}{approver_query}"
|
||||
"{base_url}/approve/{w_id}/{approval_target_id}?token={approval_token}"
|
||||
),
|
||||
cancel: build_resume_url(
|
||||
"cancel",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -23,6 +23,7 @@ lazy_static::lazy_static! {
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
#[derive(PartialEq, Eq)]
|
||||
pub enum LicensePlan {
|
||||
Community,
|
||||
Pro,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -140,6 +140,24 @@ pub async fn get_workspace_key(w_id: &str, db: &DB) -> crate::error::Result<Stri
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Generate a stateless approval token from workspace key + job_id.
|
||||
/// This token grants access to view approval info and attempt to resume,
|
||||
/// but cannot be reversed to obtain the HMAC resume secret.
|
||||
pub async fn generate_approval_token(
|
||||
w_id: &str,
|
||||
job_id: uuid::Uuid,
|
||||
db: &DB,
|
||||
) -> crate::error::Result<String> {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
let key = get_workspace_key(w_id, db).await?;
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(key.as_bytes())
|
||||
.map_err(|e| crate::Error::internal_err(format!("HMAC key error: {e}")))?;
|
||||
mac.update(job_id.as_bytes());
|
||||
mac.update(b"approval_token");
|
||||
Ok(hex::encode(mac.finalize().into_bytes()))
|
||||
}
|
||||
|
||||
pub async fn get_secret_value_as_admin(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
|
||||
@@ -9,7 +9,7 @@ name = "windmill_git_sync"
|
||||
path = "./src/lib.rs"
|
||||
|
||||
[features]
|
||||
private = []
|
||||
private = ["windmill-common/private"]
|
||||
enterprise = ["windmill-queue/enterprise", "windmill-common/enterprise"]
|
||||
all_sqlx_features = ["enterprise"]
|
||||
default = []
|
||||
|
||||
@@ -272,7 +272,10 @@ mod tests {
|
||||
path: "f/folder/script".to_string(),
|
||||
parent_path: Some("f/folder/old_script".to_string()),
|
||||
};
|
||||
assert_eq!(obj.get_parent_path(), Some("f/folder/old_script".to_string()));
|
||||
assert_eq!(
|
||||
obj.get_parent_path(),
|
||||
Some("f/folder/old_script".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -313,21 +316,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_get_kind_flow() {
|
||||
let obj = DeployedObject::Flow {
|
||||
path: "test".to_string(),
|
||||
parent_path: None,
|
||||
version: 1,
|
||||
};
|
||||
let obj = DeployedObject::Flow { path: "test".to_string(), parent_path: None, version: 1 };
|
||||
assert_eq!(obj.get_kind(), "flow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_kind_app() {
|
||||
let obj = DeployedObject::App {
|
||||
path: "test".to_string(),
|
||||
version: 1,
|
||||
parent_path: None,
|
||||
};
|
||||
let obj = DeployedObject::App { path: "test".to_string(), version: 1, parent_path: None };
|
||||
assert_eq!(obj.get_kind(), "app");
|
||||
}
|
||||
|
||||
@@ -346,7 +341,8 @@ mod tests {
|
||||
"http_trigger"
|
||||
);
|
||||
assert_eq!(
|
||||
DeployedObject::WebsocketTrigger { path: "t".to_string(), parent_path: None }.get_kind(),
|
||||
DeployedObject::WebsocketTrigger { path: "t".to_string(), parent_path: None }
|
||||
.get_kind(),
|
||||
"websocket_trigger"
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
@@ -110,6 +110,7 @@ gcp_auth = { workspace = true, optional = true }
|
||||
rust_decimal.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
sha2.workspace = true
|
||||
hmac.workspace = true
|
||||
pem = { workspace = true, optional = true }
|
||||
urlencoding.workspace = true
|
||||
nix.workspace = true
|
||||
|
||||
@@ -1504,7 +1504,7 @@ async function run() {{
|
||||
return {{ type: "inline_checkpoint", key: dispatch.key, result: dispatch.result ?? null, started_at: dispatch.started_at, duration_ms: dispatch.duration_ms }};
|
||||
}}
|
||||
if (dispatch.mode === "approval") {{
|
||||
return {{ type: "approval", key: dispatch.key, timeout: dispatch.timeout, form: dispatch.form }};
|
||||
return {{ type: "approval", key: dispatch.key, timeout: dispatch.timeout, form: dispatch.form, self_approval_disabled: dispatch.self_approval_disabled }};
|
||||
}}
|
||||
if (dispatch.mode === "sleep") {{
|
||||
return {{ type: "sleep", key: dispatch.key, seconds: dispatch.seconds }};
|
||||
@@ -2634,7 +2634,7 @@ pub async fn handle_wac_v2_output(
|
||||
job.id, num_steps
|
||||
)))
|
||||
}
|
||||
WacOutput::Approval { key, timeout, form } => {
|
||||
WacOutput::Approval { key, timeout, form, self_approval_disabled } => {
|
||||
let db = match conn {
|
||||
Connection::Sql(db) => db,
|
||||
_ => {
|
||||
@@ -2676,11 +2676,91 @@ pub async fn handle_wac_v2_output(
|
||||
.await
|
||||
.map_err(|e| error::Error::internal_err(format!("Failed to save checkpoint: {e}")))?;
|
||||
|
||||
// Store approval_conditions in flow_status for resume endpoint auth checks
|
||||
let sad = self_approval_disabled.unwrap_or(false);
|
||||
if sad {
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
return Err(error::Error::ExecutionErr(
|
||||
"Disabling self-approval is an enterprise only feature".to_string(),
|
||||
));
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
{
|
||||
use windmill_common::flow_status::ApprovalConditions;
|
||||
let approval_conditions = ApprovalConditions {
|
||||
user_auth_required: true,
|
||||
user_groups_required: vec![],
|
||||
self_approval_disabled: true,
|
||||
};
|
||||
sqlx::query(
|
||||
"UPDATE v2_job_status SET flow_status = JSONB_SET(
|
||||
COALESCE(flow_status, '{}'::jsonb),
|
||||
'{approval_conditions}',
|
||||
$2::jsonb
|
||||
) WHERE id = $1",
|
||||
)
|
||||
.bind(&job.id)
|
||||
.bind(&serde_json::json!(approval_conditions))
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error::Error::internal_err(format!(
|
||||
"Failed to save approval conditions: {e}"
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate resume URLs for the inline approval buttons.
|
||||
// Use a hash of the step key as resume_id so each waitForApproval()
|
||||
// in the same workflow gets a unique resume_job record.
|
||||
let resume_id: u32 = {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
key.hash(&mut hasher);
|
||||
(hasher.finish() & 0xFFFF_FFFF) as u32
|
||||
};
|
||||
// Generate stateless approval token using shared utility
|
||||
let approval_token =
|
||||
windmill_common::variables::generate_approval_token(&job.workspace_id, job.id, db)
|
||||
.await?;
|
||||
|
||||
let (resume_url, cancel_url, approval_page_url) = {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use windmill_common::variables::get_workspace_key;
|
||||
|
||||
let wkey = get_workspace_key(&job.workspace_id, db).await?;
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(wkey.as_bytes())
|
||||
.map_err(|e| error::Error::internal_err(format!("HMAC key error: {e}")))?;
|
||||
mac.update(job.id.as_bytes());
|
||||
mac.update(resume_id.to_be_bytes().as_ref());
|
||||
let signature = hex::encode(mac.finalize().into_bytes());
|
||||
|
||||
let base_url = windmill_common::BASE_URL.read().await.clone();
|
||||
let w_id = &job.workspace_id;
|
||||
let job_id = &job.id;
|
||||
|
||||
let resume = format!(
|
||||
"{base_url}/api/w/{w_id}/jobs_u/resume/{job_id}/{resume_id}/{signature}"
|
||||
);
|
||||
let cancel = format!(
|
||||
"{base_url}/api/w/{w_id}/jobs_u/cancel/{job_id}/{resume_id}/{signature}"
|
||||
);
|
||||
let approval_page =
|
||||
format!("{base_url}/approve/{w_id}/{job_id}?token={approval_token}");
|
||||
(resume, cancel, approval_page)
|
||||
};
|
||||
|
||||
// Store approval form metadata for the approval page endpoint
|
||||
let approval_meta = serde_json::json!({
|
||||
"key": key,
|
||||
"form": form,
|
||||
"timeout": timeout_secs as u32,
|
||||
"self_approval_disabled": sad,
|
||||
"resume": resume_url,
|
||||
"cancel": cancel_url,
|
||||
"approvalPage": approval_page_url,
|
||||
});
|
||||
sqlx::query(
|
||||
"UPDATE v2_job_status SET workflow_as_code_status = jsonb_set(
|
||||
@@ -2705,6 +2785,11 @@ pub async fn handle_wac_v2_output(
|
||||
"started_at": &now_str,
|
||||
"name": key,
|
||||
"approval": true,
|
||||
"self_approval_disabled": sad,
|
||||
"form": form,
|
||||
"resume": &resume_url,
|
||||
"cancel": &cancel_url,
|
||||
"approvalPage": &approval_page_url,
|
||||
});
|
||||
let step_timeline_key = format!("_step/{}", key);
|
||||
sqlx::query(
|
||||
|
||||
@@ -59,7 +59,13 @@ pub enum WacOutput {
|
||||
/// No child job is dispatched — the parent suspends directly and resumes
|
||||
/// when a user hits the resume/cancel endpoint.
|
||||
#[serde(rename = "approval")]
|
||||
Approval { key: String, timeout: Option<u32>, form: Option<Value> },
|
||||
Approval {
|
||||
key: String,
|
||||
timeout: Option<u32>,
|
||||
form: Option<Value>,
|
||||
#[serde(default)]
|
||||
self_approval_disabled: Option<bool>,
|
||||
},
|
||||
/// Server-side sleep — suspend the workflow for a duration without holding a worker.
|
||||
#[serde(rename = "sleep")]
|
||||
Sleep { key: String, seconds: u32 },
|
||||
@@ -306,15 +312,13 @@ pub async fn prepare_checkpoint_for_resume(
|
||||
}
|
||||
|
||||
/// Detect WAC v2 patterns in TypeScript/Bun code.
|
||||
/// Checks for `import ... from "windmill-client"` containing workflow/task,
|
||||
/// Checks for `import ... from "windmill-client"` containing workflow,
|
||||
/// skipping comment lines. Handles both single-line and multi-line imports.
|
||||
pub fn is_wac_v2_ts(code: &str) -> bool {
|
||||
let mut has_wac_import = false;
|
||||
let mut has_workflow = false;
|
||||
let mut has_task = false;
|
||||
let mut in_import_block = false;
|
||||
let mut import_block_has_workflow = false;
|
||||
let mut import_block_has_task = false;
|
||||
for line in code.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("//") {
|
||||
@@ -328,34 +332,24 @@ pub fn is_wac_v2_ts(code: &str) -> bool {
|
||||
if trimmed.contains("workflow") {
|
||||
has_workflow = true;
|
||||
}
|
||||
if trimmed.contains("task") {
|
||||
has_task = true;
|
||||
}
|
||||
in_import_block = false;
|
||||
}
|
||||
// Start of multi-line import: import {
|
||||
else if trimmed.starts_with("import") && trimmed.contains("{") && !trimmed.contains("}") {
|
||||
in_import_block = true;
|
||||
import_block_has_workflow = trimmed.contains("workflow");
|
||||
import_block_has_task = trimmed.contains("task");
|
||||
}
|
||||
// Inside multi-line import block
|
||||
else if in_import_block {
|
||||
if trimmed.contains("workflow") {
|
||||
import_block_has_workflow = true;
|
||||
}
|
||||
if trimmed.contains("task") {
|
||||
import_block_has_task = true;
|
||||
}
|
||||
// End of multi-line import: } from "windmill-client"
|
||||
if trimmed.contains("windmill-client") {
|
||||
has_wac_import = true;
|
||||
if import_block_has_workflow {
|
||||
has_workflow = true;
|
||||
}
|
||||
if import_block_has_task {
|
||||
has_task = true;
|
||||
}
|
||||
in_import_block = false;
|
||||
}
|
||||
// End of import block but not windmill-client
|
||||
@@ -367,7 +361,7 @@ pub fn is_wac_v2_ts(code: &str) -> bool {
|
||||
has_workflow = true;
|
||||
}
|
||||
}
|
||||
has_wac_import && has_workflow && has_task
|
||||
has_wac_import && has_workflow
|
||||
}
|
||||
|
||||
/// Detect WAC v2 patterns in Python code.
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
|
||||
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
|
||||
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
|
||||
|
||||
export const VERSION = "v1.663.0";
|
||||
export const VERSION = "v1.664.0";
|
||||
|
||||
export async function login(email: string, password: string): Promise<string> {
|
||||
return await windmill.UserService.login({
|
||||
|
||||
Generated
+10
-4
@@ -25,10 +25,11 @@
|
||||
"windmill-parser-wasm-nu": "*",
|
||||
"windmill-parser-wasm-php": "*",
|
||||
"windmill-parser-wasm-py": "*",
|
||||
"windmill-parser-wasm-py-imports": "*",
|
||||
"windmill-parser-wasm-regex": "*",
|
||||
"windmill-parser-wasm-ruby": "*",
|
||||
"windmill-parser-wasm-rust": "*",
|
||||
"windmill-parser-wasm-ts": "*",
|
||||
"windmill-parser-wasm-ts": "^1.659.1",
|
||||
"windmill-parser-wasm-yaml": "*",
|
||||
"windmill-yaml-validator": "1.1.1",
|
||||
"ws": "8.18.0",
|
||||
@@ -1414,6 +1415,11 @@
|
||||
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.628.3.tgz",
|
||||
"integrity": "sha512-TlluqknZpg8cZ+A3m6JFLPseY2PpKtDsxdj26fAnCUzKPtse8TxQR+n0dwC80rfW5TwdWSulvNGRDgcNuf7CTw=="
|
||||
},
|
||||
"node_modules/windmill-parser-wasm-py-imports": {
|
||||
"version": "1.659.1",
|
||||
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-py-imports/-/windmill-parser-wasm-py-imports-1.659.1.tgz",
|
||||
"integrity": "sha512-nfnf04WBRf8f/mNIwdvggYOgz3erxrFGjKqULYBH+bKFMlKA6V7eB19m6CXOBkq9rjTp0ZFG+rgsR+Us7JEkyQ=="
|
||||
},
|
||||
"node_modules/windmill-parser-wasm-regex": {
|
||||
"version": "1.639.0",
|
||||
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-regex/-/windmill-parser-wasm-regex-1.639.0.tgz",
|
||||
@@ -1430,9 +1436,9 @@
|
||||
"integrity": "sha512-9yGLYZX2Hn9TdTqGY/5Fp50ftzgUsrfBkSK9vJkKJd5Amyg+yXLBGzd8pz6Org+4uxMenz/16wpsgijvo6uhhQ=="
|
||||
},
|
||||
"node_modules/windmill-parser-wasm-ts": {
|
||||
"version": "1.647.1",
|
||||
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.647.1.tgz",
|
||||
"integrity": "sha512-64iSAUMU5W/WtePqE1vtDvglDqtkiZVndyieYBVDX0nl7UuovS+wPgH/P3TEoKbR+FwAPacki0CX3DsEzZ/Yxw=="
|
||||
"version": "1.659.1",
|
||||
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.659.1.tgz",
|
||||
"integrity": "sha512-EmXMzOmazC5r29UZh+1TVF9g/N2X51pqK11qDL6xWGeWTIIonhfOZ5nWdGvKQMDUR650fGxehImZzW2v9hNy+w=="
|
||||
},
|
||||
"node_modules/windmill-parser-wasm-yaml": {
|
||||
"version": "1.593.0",
|
||||
|
||||
@@ -740,7 +740,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
|
||||
* await step("notify", () => sendEmail(urls.approvalPage));
|
||||
* const { value, approver } = await waitForApproval({ timeout: 3600 });
|
||||
*/
|
||||
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
|
||||
/**
|
||||
* Process items in parallel with optional concurrency control.
|
||||
@@ -1403,7 +1403,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
|
||||
* await step("notify", () => sendEmail(urls.approvalPage));
|
||||
* const { value, approver } = await waitForApproval({ timeout: 3600 });
|
||||
*/
|
||||
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
|
||||
/**
|
||||
* Process items in parallel with optional concurrency control.
|
||||
@@ -2129,7 +2129,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
|
||||
* await step("notify", () => sendEmail(urls.approvalPage));
|
||||
* const { value, approver } = await waitForApproval({ timeout: 3600 });
|
||||
*/
|
||||
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
|
||||
/**
|
||||
* Process items in parallel with optional concurrency control.
|
||||
@@ -3069,7 +3069,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
|
||||
* await step("notify", () => sendEmail(urls.approvalPage));
|
||||
* const { value, approver } = await waitForApproval({ timeout: 3600 });
|
||||
*/
|
||||
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
|
||||
/**
|
||||
* Process items in parallel with optional concurrency control.
|
||||
@@ -4078,12 +4078,17 @@ async def sleep(seconds: int)
|
||||
#
|
||||
# Returns a dict with \`\`value\`\` (form data), \`\`approver\`\`, and \`\`approved\`\`.
|
||||
#
|
||||
# Args:
|
||||
# timeout: Approval timeout in seconds (default 1800).
|
||||
# form: Optional form schema for the approval page.
|
||||
# self_approval: Whether the user who triggered the flow can approve it (default True).
|
||||
#
|
||||
# Example::
|
||||
#
|
||||
# urls = await step("urls", lambda: get_resume_urls())
|
||||
# await step("notify", lambda: send_email(urls["approvalPage"]))
|
||||
# result = await wait_for_approval(timeout=3600)
|
||||
async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict
|
||||
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict
|
||||
|
||||
# Process items in parallel with optional concurrency control.
|
||||
#
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ export {
|
||||
workspaceAdd,
|
||||
};
|
||||
|
||||
export const VERSION = "1.663.0";
|
||||
export const VERSION = "1.664.0";
|
||||
|
||||
// Re-exported from constants.ts to maintain backwards compatibility
|
||||
export { WM_FORK_PREFIX } from "./core/constants.ts";
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "windmill-components",
|
||||
"version": "1.663.0",
|
||||
"version": "1.664.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "windmill-components",
|
||||
"version": "1.663.0",
|
||||
"version": "1.664.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "windmill-components",
|
||||
"version": "1.663.0",
|
||||
"version": "1.664.0",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2084,6 +2084,7 @@
|
||||
stepResults={getStepResults(node.workflow_as_code_status)}
|
||||
result={node.result}
|
||||
success={node.type === 'Success'}
|
||||
jobId={node.job_id}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -18,11 +18,9 @@
|
||||
light?: boolean
|
||||
}
|
||||
|
||||
let { isOwner, workspaceId, job, light = false }: Props = $props()
|
||||
let { isOwner: _isOwner, workspaceId, job, light = false }: Props = $props()
|
||||
|
||||
let default_payload: object = $state({})
|
||||
let resumeUrl: string | undefined = $state(undefined)
|
||||
let cancelUrl: string | undefined = $state(undefined)
|
||||
let description: any = $state(undefined)
|
||||
let hide_cancel = $state(false)
|
||||
|
||||
@@ -49,8 +47,6 @@
|
||||
defaultValues = JSON.parse(JSON.stringify(args))
|
||||
default_payload = args
|
||||
|
||||
resumeUrl = job_result?.['resume']
|
||||
cancelUrl = job_result?.['cancel']
|
||||
hide_cancel = job?.raw_flow?.modules?.[approvalStep]?.suspend?.hide_cancel ?? false
|
||||
schema = mergeSchema(
|
||||
job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema ?? {},
|
||||
@@ -61,61 +57,19 @@
|
||||
let loading = $state(false)
|
||||
async function continu(approve: boolean) {
|
||||
loading = true
|
||||
if ((resumeUrl && approve) || (cancelUrl && !approve)) {
|
||||
let split = (approve ? resumeUrl : cancelUrl)!.split('/')
|
||||
let signatureUrl = split.pop() ?? ''
|
||||
const regex = /([^?]+)(?:\?[^=]+=(\w+))?/
|
||||
|
||||
const matches = signatureUrl.match(regex)
|
||||
|
||||
const signature = matches?.[1]
|
||||
if (!signature) {
|
||||
sendUserToast(`Could not parse signature: ${signatureUrl}`, true)
|
||||
return
|
||||
}
|
||||
const approver = matches?.[2] || undefined
|
||||
|
||||
let resumeId = -1
|
||||
let parsedResumeId = split.pop() ?? ''
|
||||
try {
|
||||
resumeId = new Number(parsedResumeId).valueOf()
|
||||
} catch (e) {
|
||||
console.error(`Could not parse resume id: ${parsedResumeId}`)
|
||||
}
|
||||
let jobId = split.pop() ?? ''
|
||||
if (approve) {
|
||||
await JobService.resumeSuspendedJobPost({
|
||||
workspace: workspaceId ?? $workspaceStore ?? '',
|
||||
id: jobId,
|
||||
requestBody: default_payload as any,
|
||||
resumeId,
|
||||
signature,
|
||||
approver
|
||||
})
|
||||
} else {
|
||||
await JobService.cancelSuspendedJobPost({
|
||||
workspace: workspaceId ?? $workspaceStore ?? '',
|
||||
id: jobId,
|
||||
resumeId,
|
||||
signature,
|
||||
approver,
|
||||
requestBody: {}
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if (approve) {
|
||||
await JobService.resumeSuspendedFlowAsOwner({
|
||||
workspace: workspaceId ?? $workspaceStore ?? '',
|
||||
id: job?.id ?? '',
|
||||
requestBody: default_payload as any
|
||||
})
|
||||
} else {
|
||||
await JobService.cancelQueuedJob({
|
||||
workspace: workspaceId ?? $workspaceStore ?? '',
|
||||
id: job?.id ?? '',
|
||||
requestBody: {}
|
||||
})
|
||||
}
|
||||
try {
|
||||
await JobService.resumeSuspended({
|
||||
workspace: workspaceId ?? $workspaceStore ?? '',
|
||||
jobId: job?.id ?? '',
|
||||
requestBody: {
|
||||
payload: approve ? (default_payload as any) : undefined,
|
||||
approved: approve
|
||||
}
|
||||
})
|
||||
} catch (e: any) {
|
||||
sendUserToast(e?.body ?? e?.message ?? 'Failed', true)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
let approvalStep = $derived((job?.flow_status?.step ?? 1) - 1)
|
||||
@@ -130,51 +84,41 @@
|
||||
<div class="mt-2"></div>
|
||||
{/if}
|
||||
<div>
|
||||
{#if isOwner || resumeUrl}
|
||||
<div class={twMerge('flex gap-2', light ? 'flex-col' : 'flex-row ')}>
|
||||
{#if !hide_cancel}
|
||||
<div>
|
||||
<Button
|
||||
title="Cancel the step"
|
||||
{loading}
|
||||
iconOnly
|
||||
startIcon={{ icon: X }}
|
||||
variant="default"
|
||||
disabled={!cancelUrl}
|
||||
destructive
|
||||
unifiedSize="md"
|
||||
on:click={() => continu(false)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class={twMerge('flex gap-2', light ? 'flex-col' : 'flex-row ')}>
|
||||
{#if !hide_cancel}
|
||||
<div>
|
||||
<Button variant="accent" onClick={() => continu(true)} {loading} unifiedSize="md">
|
||||
Resume
|
||||
<Tooltip class="text-white">
|
||||
Since you are an owner of this flow, you can send resume events without necessarily
|
||||
knowing the resume id sent by the approval step
|
||||
</Tooltip>
|
||||
</Button>
|
||||
<Button
|
||||
title="Cancel the step"
|
||||
iconOnly
|
||||
startIcon={{ icon: X }}
|
||||
variant="default"
|
||||
disabled={loading}
|
||||
destructive
|
||||
unifiedSize="md"
|
||||
on:click={() => continu(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema}
|
||||
<div
|
||||
class={twMerge(
|
||||
'w-full border rounded-lg p-2',
|
||||
light ? 'min-w-96 max-h-svh overflow-y-auto' : ''
|
||||
)}
|
||||
>
|
||||
<SchemaForm onlyMaskPassword bind:args={default_payload} {defaultValues} {schema} />
|
||||
</div>
|
||||
<Tooltip>
|
||||
The payload is optional, it is passed to the following step through the `resume`
|
||||
variable
|
||||
</Tooltip>
|
||||
{/if}
|
||||
{/if}
|
||||
<div>
|
||||
<Button variant="accent" onClick={() => continu(true)} disabled={loading} unifiedSize="md">
|
||||
Resume
|
||||
<Tooltip class="text-white">Resume or approve this suspended step</Tooltip>
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
You cannot resume the flow yourself without receiving the resume secret since you are not an
|
||||
owner of {job.script_path} and the approval step did not contain the resume url at key `resume`
|
||||
{/if}
|
||||
|
||||
{#if job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema}
|
||||
<div
|
||||
class={twMerge(
|
||||
'w-full border rounded-lg p-2',
|
||||
light ? 'min-w-96 max-h-svh overflow-y-auto' : ''
|
||||
)}
|
||||
>
|
||||
<SchemaForm onlyMaskPassword bind:args={default_payload} {defaultValues} {schema} />
|
||||
</div>
|
||||
<Tooltip>
|
||||
The payload is optional, it is passed to the following step through the `resume` variable
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -164,7 +164,8 @@
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
if (!emptyString(defaultApp.default_app_path)) {
|
||||
goto(`/apps/get/${defaultApp.default_app_path}`)
|
||||
const prefix = defaultApp.default_app_raw ? '/apps_raw/get' : '/apps/get'
|
||||
goto(`${prefix}/${defaultApp.default_app_path}`)
|
||||
} else {
|
||||
goto(rd ?? '/')
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -236,7 +238,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">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$lib/base'
|
||||
import { displayDate, msToSec } from '$lib/utils'
|
||||
import { displayDate, msToSec, emptyString } from '$lib/utils'
|
||||
import { onDestroy } from 'svelte'
|
||||
import { getDbClockNow } from '$lib/forLater'
|
||||
import { ChevronDown, ChevronRight, Loader2, Moon, ShieldCheck } from 'lucide-svelte'
|
||||
@@ -9,7 +9,11 @@
|
||||
import ObjectViewer from './propertyPicker/ObjectViewer.svelte'
|
||||
import { CheckCircle2, XCircle } from 'lucide-svelte'
|
||||
import { JobService, type Job, type WorkflowStatus } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { Alert } from '$lib/components/common'
|
||||
import SchemaForm from '$lib/components/SchemaForm.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
interface Props {
|
||||
flow_status: Record<string, WorkflowStatus>
|
||||
@@ -18,6 +22,7 @@
|
||||
result?: any
|
||||
success?: boolean
|
||||
autoExpandResult?: boolean
|
||||
jobId?: string
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -26,7 +31,8 @@
|
||||
stepResults = {},
|
||||
result = undefined,
|
||||
success = true,
|
||||
autoExpandResult = false
|
||||
autoExpandResult = false,
|
||||
jobId = undefined
|
||||
}: Props = $props()
|
||||
|
||||
let resultExpanded = $state(false)
|
||||
@@ -114,6 +120,49 @@
|
||||
}
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
// Approval action state
|
||||
let approvalLoading: Record<string, boolean> = $state({})
|
||||
let approvalFormArgs: Record<string, Record<string, any>> = $state({})
|
||||
|
||||
async function handleApprove(key: string, formSchema: any) {
|
||||
const ws = $workspaceStore
|
||||
if (!ws || !jobId) return
|
||||
approvalLoading[key] = true
|
||||
try {
|
||||
const payload =
|
||||
formSchema && Object.keys(formSchema).length > 0 ? (approvalFormArgs[key] ?? {}) : undefined
|
||||
await JobService.resumeSuspended({
|
||||
workspace: ws,
|
||||
jobId: jobId,
|
||||
requestBody: { payload, approved: true }
|
||||
})
|
||||
sendUserToast('Approval submitted')
|
||||
} catch (e: any) {
|
||||
sendUserToast(e?.body ?? e?.message ?? 'Failed to approve', true)
|
||||
} finally {
|
||||
approvalLoading[key] = false
|
||||
}
|
||||
}
|
||||
|
||||
let cancelLoading = $state(false)
|
||||
async function handleCancel() {
|
||||
const ws = $workspaceStore
|
||||
if (!ws || !jobId) return
|
||||
cancelLoading = true
|
||||
try {
|
||||
await JobService.resumeSuspended({
|
||||
workspace: ws,
|
||||
jobId: jobId,
|
||||
requestBody: { approved: false }
|
||||
})
|
||||
sendUserToast('Job cancelled')
|
||||
} catch (e: any) {
|
||||
sendUserToast(e?.body ?? e?.message ?? 'Failed to cancel', true)
|
||||
} finally {
|
||||
cancelLoading = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if flow_status}
|
||||
@@ -167,22 +216,74 @@
|
||||
<span class="italic">sleep ({(v as any).sleep_duration_s}s)</span>
|
||||
</div>
|
||||
{:else if isApproval}
|
||||
<div class="w-full px-2 py-1.5 text-xs flex items-center gap-2">
|
||||
<div class="w-3 flex-shrink-0"></div>
|
||||
<ShieldCheck
|
||||
size={12}
|
||||
class="flex-shrink-0 {isDone ? 'text-green-600' : 'text-yellow-500'}"
|
||||
/>
|
||||
<span class="italic text-secondary">
|
||||
{v.name ?? stepKey(k)}
|
||||
</span>
|
||||
{#if !isDone}
|
||||
<span class="text-tertiary flex items-center gap-1">
|
||||
<Loader2 size={12} class="animate-spin" />
|
||||
waiting
|
||||
{@const selfApprovalDisabled = (v as any).self_approval_disabled === true}
|
||||
{@const formSchema = (v as any).form?.schema ?? (v as any).form}
|
||||
{@const hasForm =
|
||||
formSchema && typeof formSchema === 'object' && Object.keys(formSchema).length > 0}
|
||||
{@const canApprove = !isDone && jobId}
|
||||
<div class="w-full px-2 py-1.5 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-3 flex-shrink-0"></div>
|
||||
<ShieldCheck
|
||||
size={12}
|
||||
class="flex-shrink-0 {isDone ? 'text-green-600' : 'text-yellow-500'}"
|
||||
/>
|
||||
<span class="italic text-secondary">
|
||||
{v.name ?? stepKey(k)}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-tertiary">{msToSec(v.duration_ms ?? 0)}s</span>
|
||||
{#if !isDone}
|
||||
<span class="text-tertiary flex items-center gap-1">
|
||||
<Loader2 size={12} class="animate-spin" />
|
||||
waiting
|
||||
</span>
|
||||
{#if canApprove}
|
||||
<div class="ml-auto flex gap-1">
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
disabled={approvalLoading[k]}
|
||||
onclick={() => handleApprove(k, formSchema)}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
disabled={approvalLoading[k] || cancelLoading}
|
||||
onclick={() => handleCancel()}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="text-tertiary">{msToSec(v.duration_ms ?? 0)}s</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if canApprove && selfApprovalDisabled && $userStore?.is_admin}
|
||||
<div class="mt-1 ml-5 text-yellow-600 text-2xs">
|
||||
Self-approval is disabled but allowed because you are an admin/owner
|
||||
</div>
|
||||
{/if}
|
||||
{#if canApprove && hasForm}
|
||||
<div class="mt-2 ml-5 max-w-md">
|
||||
{#if emptyString($enterpriseLicense)}
|
||||
<Alert
|
||||
type="warning"
|
||||
title="Adding a form to the approval page is an EE feature"
|
||||
/>
|
||||
{:else}
|
||||
<SchemaForm
|
||||
onlyMaskPassword
|
||||
noVariablePicker
|
||||
schema={{
|
||||
properties: formSchema,
|
||||
order: Object.keys(formSchema)
|
||||
}}
|
||||
bind:args={approvalFormArgs[k]}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
@@ -275,13 +376,13 @@
|
||||
{@const result = stepResults[stepKey(k)]}
|
||||
{#if isDone && result !== undefined}
|
||||
<div>
|
||||
<div class="text-2xs text-secondary font-semibold mb-1">Result</div>
|
||||
<div class="text-2xs text-secondary font-semibold mb-1"> Result </div>
|
||||
<div class="max-h-40 overflow-auto">
|
||||
<ObjectViewer json={result} pureViewer />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-xs text-secondary py-1">Step completed (no result)</div>
|
||||
<div class="text-xs text-secondary py-1"> Step completed (no result) </div>
|
||||
{/if}
|
||||
{:else if loadingJobs[k] && !childJobs[k]}
|
||||
<div class="flex items-center gap-2 text-xs text-secondary py-1">
|
||||
@@ -293,7 +394,7 @@
|
||||
<!-- Logs -->
|
||||
{#if job.logs || isRunning}
|
||||
<div class="mb-2">
|
||||
<div class="text-2xs text-secondary font-semibold mb-1">Logs</div>
|
||||
<div class="text-2xs text-secondary font-semibold mb-1"> Logs </div>
|
||||
<LogViewer
|
||||
content={job.logs ?? ''}
|
||||
jobId={k}
|
||||
@@ -309,7 +410,7 @@
|
||||
<!-- Result -->
|
||||
{#if isDone && job.result !== undefined}
|
||||
<div>
|
||||
<div class="text-2xs text-secondary font-semibold mb-1">Result</div>
|
||||
<div class="text-2xs text-secondary font-semibold mb-1"> Result </div>
|
||||
<div class="max-h-40 overflow-auto">
|
||||
<ObjectViewer json={job.result} pureViewer />
|
||||
</div>
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -23,8 +23,7 @@ import {
|
||||
} from './shared'
|
||||
import type {
|
||||
ChatCompletionMessageParam,
|
||||
ChatCompletionSystemMessageParam,
|
||||
ChatCompletionUserMessageParam
|
||||
ChatCompletionSystemMessageParam
|
||||
} from 'openai/resources/chat/completions.mjs'
|
||||
import {
|
||||
prepareInlineChatSystemPrompt,
|
||||
@@ -37,7 +36,7 @@ import { loadApiTools } from './api/apiTools'
|
||||
import { prepareScriptUserMessage } from './script/core'
|
||||
import { prepareNavigatorUserMessage } from './navigator/core'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { getCompletion, getModelContextWindow, parseOpenAICompletion } from '../lib'
|
||||
import { getModelContextWindow, workspaceAIClients } from '../lib'
|
||||
import { dfs } from '$lib/components/flows/previousResults'
|
||||
import { getStringError } from './utils'
|
||||
import type { FlowModuleState, FlowState } from '$lib/components/flows/flowState'
|
||||
@@ -56,8 +55,7 @@ import type {
|
||||
import type { Selection } from 'monaco-editor'
|
||||
import type AIChatInput from './AIChatInput.svelte'
|
||||
import { prepareApiSystemMessage, prepareApiUserMessage } from './api/core'
|
||||
import { getAnthropicCompletion, parseAnthropicCompletion } from './anthropic'
|
||||
import { getOpenAIResponsesCompletion, parseOpenAIResponsesCompletion } from './openai-responses'
|
||||
import { runChatLoop } from './chatLoop'
|
||||
import type { ReviewChangesOpts } from './monaco-adapter'
|
||||
import { getCurrentModel, tryGetCurrentModel, getCombinedCustomPrompt } from '$lib/aiStore'
|
||||
|
||||
@@ -413,130 +411,63 @@ class AIChatManager {
|
||||
systemMessage?: ChatCompletionSystemMessageParam
|
||||
}) => {
|
||||
try {
|
||||
let addedMessages: ChatCompletionMessageParam[] = []
|
||||
while (true) {
|
||||
const systemMessage = systemMessageOverride ?? this.systemMessage
|
||||
const helpers = this.helpers
|
||||
const tools = this.tools
|
||||
for (const tool of tools) {
|
||||
if (tool.setSchema) {
|
||||
await tool.setSchema(helpers)
|
||||
}
|
||||
}
|
||||
|
||||
let pendingPrompt = this.pendingPrompt
|
||||
let pendingUserMessage: ChatCompletionUserMessageParam | undefined = undefined
|
||||
if (pendingPrompt) {
|
||||
// Use JS getters so runChatLoop re-reads tools/helpers/systemMessage/modelProvider
|
||||
// on each iteration. This is critical for changeModeTool (Navigator → Script/Flow)
|
||||
// which reassigns this.tools, this.helpers, this.systemMessage mid-loop.
|
||||
const self = this
|
||||
const result = await runChatLoop({
|
||||
messages,
|
||||
get systemMessage() {
|
||||
return systemMessageOverride ?? self.systemMessage
|
||||
},
|
||||
get tools() {
|
||||
return self.tools
|
||||
},
|
||||
get helpers() {
|
||||
return self.helpers
|
||||
},
|
||||
abortController,
|
||||
callbacks,
|
||||
get modelProvider() {
|
||||
return getCurrentModel()
|
||||
},
|
||||
clients: {
|
||||
openai: workspaceAIClients.getOpenaiClient(),
|
||||
anthropic: workspaceAIClients.getAnthropicClient()
|
||||
},
|
||||
workspace: get(workspaceStore) ?? '',
|
||||
skipResponsesApi: this.skipResponsesApi,
|
||||
onSkipResponsesApi: () => {
|
||||
this.skipResponsesApi = true
|
||||
},
|
||||
getPendingUserMessage: () => {
|
||||
const pendingPrompt = this.pendingPrompt
|
||||
if (!pendingPrompt) return undefined
|
||||
this.pendingPrompt = ''
|
||||
if (this.mode === AIMode.SCRIPT) {
|
||||
pendingUserMessage = prepareScriptUserMessage(
|
||||
return prepareScriptUserMessage(
|
||||
pendingPrompt,
|
||||
this.contextManager.getSelectedContext()
|
||||
)
|
||||
} else if (this.mode === AIMode.FLOW) {
|
||||
pendingUserMessage = prepareFlowUserMessage(
|
||||
return prepareFlowUserMessage(
|
||||
pendingPrompt,
|
||||
this.flowAiChatHelpers!.getFlowAndSelectedId()
|
||||
)
|
||||
} else if (this.mode === AIMode.NAVIGATOR) {
|
||||
pendingUserMessage = prepareNavigatorUserMessage(pendingPrompt)
|
||||
return prepareNavigatorUserMessage(pendingPrompt)
|
||||
}
|
||||
this.pendingPrompt = ''
|
||||
}
|
||||
|
||||
const model = getCurrentModel()
|
||||
const isOpenAI = model.provider === 'openai' || model.provider === 'azure_openai'
|
||||
const isAnthropic = model.provider === 'anthropic'
|
||||
|
||||
const messageParams = [
|
||||
systemMessage,
|
||||
...messages,
|
||||
...(pendingUserMessage ? [pendingUserMessage] : [])
|
||||
]
|
||||
const toolDefs = tools.map((t) => t.def)
|
||||
|
||||
// For OpenAI/Azure, try Responses API first, fallback to Completions API
|
||||
if (isOpenAI) {
|
||||
let useCompletionsApi = this.skipResponsesApi
|
||||
if (!this.skipResponsesApi) {
|
||||
try {
|
||||
const completion = await getOpenAIResponsesCompletion(
|
||||
messageParams,
|
||||
abortController,
|
||||
toolDefs
|
||||
)
|
||||
const continueCompletion = await parseOpenAIResponsesCompletion(
|
||||
completion,
|
||||
callbacks,
|
||||
messages,
|
||||
addedMessages,
|
||||
tools,
|
||||
helpers
|
||||
)
|
||||
if (!continueCompletion) {
|
||||
break
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('OpenAI Responses API failed, falling back to Completions API:', err)
|
||||
// If the error indicates Responses API is not available in this region, skip it for future requests
|
||||
const errorMessage = err instanceof Error ? err.message : String(err)
|
||||
if (errorMessage.includes('Responses API is not enabled')) {
|
||||
this.skipResponsesApi = true
|
||||
}
|
||||
useCompletionsApi = true
|
||||
}
|
||||
}
|
||||
|
||||
// Use Completions API if Responses API is not available or failed
|
||||
if (useCompletionsApi) {
|
||||
const completion = await getCompletion(messageParams, abortController, toolDefs, {
|
||||
forceCompletions: true
|
||||
})
|
||||
const continueCompletion = await parseOpenAICompletion(
|
||||
completion,
|
||||
callbacks,
|
||||
messages,
|
||||
addedMessages,
|
||||
tools,
|
||||
helpers
|
||||
)
|
||||
if (!continueCompletion) {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if (isAnthropic) {
|
||||
const completion = await getAnthropicCompletion(messageParams, abortController, toolDefs)
|
||||
if (completion) {
|
||||
const continueCompletion = await parseAnthropicCompletion(
|
||||
completion,
|
||||
callbacks,
|
||||
messages,
|
||||
addedMessages,
|
||||
tools,
|
||||
helpers,
|
||||
abortController
|
||||
)
|
||||
if (!continueCompletion) {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const completion = await getCompletion(messageParams, abortController, toolDefs)
|
||||
if (completion) {
|
||||
const continueCompletion = await parseOpenAICompletion(
|
||||
completion,
|
||||
callbacks,
|
||||
messages,
|
||||
addedMessages,
|
||||
tools,
|
||||
helpers
|
||||
)
|
||||
if (!continueCompletion) {
|
||||
break
|
||||
return undefined
|
||||
},
|
||||
onBeforeIteration: async (tools) => {
|
||||
for (const tool of tools) {
|
||||
if (tool.setSchema) {
|
||||
await tool.setSchema(this.helpers)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return addedMessages
|
||||
})
|
||||
return result.addedMessages
|
||||
} catch (err) {
|
||||
console.log('chatRequest error', err)
|
||||
console.error('chatRequest error', err)
|
||||
|
||||
@@ -6,44 +6,77 @@ import { loadAppFixtureForEval } from './appFixtureLoader'
|
||||
import { dirname, join } from 'path'
|
||||
// @ts-ignore - Node.js url
|
||||
import { fileURLToPath } from 'url'
|
||||
import type { AIProvider } from '$lib/gen/types.gen'
|
||||
|
||||
// Get API key from environment - tests will be skipped if not set
|
||||
// Get API keys from environment - tests will be skipped if none are set
|
||||
// @ts-ignore
|
||||
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY
|
||||
// @ts-ignore
|
||||
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY
|
||||
|
||||
// Skip all tests if no API key is provided
|
||||
const describeWithApiKey = OPENROUTER_API_KEY ? describe : describe.skip
|
||||
const hasAnyKey = OPENAI_API_KEY || ANTHROPIC_API_KEY
|
||||
const describeWithApiKey = hasAnyKey ? describe : describe.skip
|
||||
|
||||
// Get __dirname equivalent for ES modules
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
const MODELS = ['google/gemini-2.5-flash', 'anthropic/claude-haiku-4.5', 'openai/gpt-4o']
|
||||
// Build model variants based on available keys
|
||||
interface ModelVariant {
|
||||
model: string
|
||||
provider: AIProvider
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
const MODEL_VARIANTS: ModelVariant[] = [
|
||||
...(OPENAI_API_KEY
|
||||
? [{ model: 'gpt-4o', provider: 'openai' as AIProvider, apiKey: OPENAI_API_KEY }]
|
||||
: []),
|
||||
...(ANTHROPIC_API_KEY
|
||||
? [
|
||||
{
|
||||
model: 'claude-haiku-4-5-20241022',
|
||||
provider: 'anthropic' as AIProvider,
|
||||
apiKey: ANTHROPIC_API_KEY
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
|
||||
const VARIANTS = [
|
||||
...MODELS.map((model) => ({
|
||||
...MODEL_VARIANTS.map((mv) => ({
|
||||
...BASELINE_VARIANT,
|
||||
model,
|
||||
name: `baseline-${model.replace('/', '-')}`
|
||||
model: mv.model,
|
||||
name: `baseline-${mv.provider}-${mv.model}`,
|
||||
_provider: mv.provider,
|
||||
_apiKey: mv.apiKey
|
||||
})),
|
||||
...MODELS.map((model) => ({
|
||||
...MODEL_VARIANTS.map((mv) => ({
|
||||
...STREAMLINED_VARIANT,
|
||||
model,
|
||||
name: `streamlined-${model.replace('/', '-')}`
|
||||
model: mv.model,
|
||||
name: `streamlined-${mv.provider}-${mv.model}`,
|
||||
_provider: mv.provider,
|
||||
_apiKey: mv.apiKey
|
||||
}))
|
||||
]
|
||||
|
||||
describeWithApiKey('App Chat LLM Evaluation', () => {
|
||||
const TEST_TIMEOUT = 120_000
|
||||
if (!OPENROUTER_API_KEY) {
|
||||
console.warn('OPENROUTER_API_KEY is not set, skipping tests')
|
||||
if (!hasAnyKey) {
|
||||
console.warn('No API keys set (OPENAI_API_KEY or ANTHROPIC_API_KEY), skipping tests')
|
||||
}
|
||||
|
||||
it(
|
||||
'test1: creates a simple counter app',
|
||||
async () => {
|
||||
const USER_PROMPT = `Create a counter app with increment/decrement buttons`
|
||||
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!)
|
||||
// Write results to files
|
||||
const results = await runVariantComparison(
|
||||
USER_PROMPT,
|
||||
VARIANTS,
|
||||
VARIANTS[0]._apiKey,
|
||||
undefined,
|
||||
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
|
||||
)
|
||||
const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results)
|
||||
console.log(`\nResults written to: ${summaryPath}`)
|
||||
console.log(`App files: ${appPaths.join(', ')}`)
|
||||
@@ -56,17 +89,21 @@ describeWithApiKey('App Chat LLM Evaluation', () => {
|
||||
it(
|
||||
'test2: modifies existing counter app to add reset button',
|
||||
async () => {
|
||||
// Load initial app from fixture folder
|
||||
const { initialFrontend, initialBackend } = await loadAppFixtureForEval(
|
||||
join(__dirname, 'initial', 'test1_counter_app')
|
||||
)
|
||||
|
||||
const USER_PROMPT = `Add a reset button that sets the counter back to 0`
|
||||
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
|
||||
initialFrontend,
|
||||
initialBackend
|
||||
})
|
||||
// Write results to files
|
||||
const results = await runVariantComparison(
|
||||
USER_PROMPT,
|
||||
VARIANTS,
|
||||
VARIANTS[0]._apiKey,
|
||||
{
|
||||
initialFrontend,
|
||||
initialBackend
|
||||
},
|
||||
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
|
||||
)
|
||||
const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results)
|
||||
console.log(`\nResults written to: ${summaryPath}`)
|
||||
console.log(`App files: ${appPaths.join(', ')}`)
|
||||
@@ -86,10 +123,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => {
|
||||
)
|
||||
|
||||
const USER_PROMPT = `Add a quantity selector (+ and - buttons) to each cart item so users can adjust quantities without removing and re-adding items`
|
||||
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
|
||||
initialFrontend,
|
||||
initialBackend
|
||||
})
|
||||
const results = await runVariantComparison(
|
||||
USER_PROMPT,
|
||||
VARIANTS,
|
||||
VARIANTS[0]._apiKey,
|
||||
{
|
||||
initialFrontend,
|
||||
initialBackend
|
||||
},
|
||||
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
|
||||
)
|
||||
|
||||
const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results)
|
||||
console.log(`\nResults written to: ${summaryPath}`)
|
||||
@@ -108,10 +151,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => {
|
||||
)
|
||||
|
||||
const USER_PROMPT = `Add a discount code input field in the cart. When the code "SAVE10" is entered, apply a 10% discount to the total`
|
||||
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
|
||||
initialFrontend,
|
||||
initialBackend
|
||||
})
|
||||
const results = await runVariantComparison(
|
||||
USER_PROMPT,
|
||||
VARIANTS,
|
||||
VARIANTS[0]._apiKey,
|
||||
{
|
||||
initialFrontend,
|
||||
initialBackend
|
||||
},
|
||||
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
|
||||
)
|
||||
|
||||
const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results)
|
||||
console.log(`\nResults written to: ${summaryPath}`)
|
||||
@@ -132,10 +181,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => {
|
||||
)
|
||||
|
||||
const USER_PROMPT = `Add a search bar in the toolbar that filters files and folders by name as the user types`
|
||||
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
|
||||
initialFrontend,
|
||||
initialBackend
|
||||
})
|
||||
const results = await runVariantComparison(
|
||||
USER_PROMPT,
|
||||
VARIANTS,
|
||||
VARIANTS[0]._apiKey,
|
||||
{
|
||||
initialFrontend,
|
||||
initialBackend
|
||||
},
|
||||
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
|
||||
)
|
||||
|
||||
const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results)
|
||||
console.log(`\nResults written to: ${summaryPath}`)
|
||||
@@ -154,10 +209,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => {
|
||||
)
|
||||
|
||||
const USER_PROMPT = `Show file size (formatted as KB/MB) and modified date in the file list for each item`
|
||||
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
|
||||
initialFrontend,
|
||||
initialBackend
|
||||
})
|
||||
const results = await runVariantComparison(
|
||||
USER_PROMPT,
|
||||
VARIANTS,
|
||||
VARIANTS[0]._apiKey,
|
||||
{
|
||||
initialFrontend,
|
||||
initialBackend
|
||||
},
|
||||
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
|
||||
)
|
||||
|
||||
const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results)
|
||||
console.log(`\nResults written to: ${summaryPath}`)
|
||||
@@ -176,10 +237,16 @@ describeWithApiKey('App Chat LLM Evaluation', () => {
|
||||
)
|
||||
|
||||
const USER_PROMPT = `Add a "Select All" checkbox in the file list header and individual checkboxes for each file. Add a "Delete Selected" button that appears when items are selected`
|
||||
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
|
||||
initialFrontend,
|
||||
initialBackend
|
||||
})
|
||||
const results = await runVariantComparison(
|
||||
USER_PROMPT,
|
||||
VARIANTS,
|
||||
VARIANTS[0]._apiKey,
|
||||
{
|
||||
initialFrontend,
|
||||
initialBackend
|
||||
},
|
||||
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
|
||||
)
|
||||
|
||||
const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results)
|
||||
console.log(`\nResults written to: ${summaryPath}`)
|
||||
@@ -196,7 +263,13 @@ describeWithApiKey('App Chat LLM Evaluation', () => {
|
||||
'test8: create quiz app from scratch',
|
||||
async () => {
|
||||
const USER_PROMPT = `Create a multiple choice quiz app with 5 questions about general knowledge. Show one question at a time with 4 answer options. Track the score and show results at the end with percentage correct.`
|
||||
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!)
|
||||
const results = await runVariantComparison(
|
||||
USER_PROMPT,
|
||||
VARIANTS,
|
||||
VARIANTS[0]._apiKey,
|
||||
undefined,
|
||||
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
|
||||
)
|
||||
|
||||
const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results)
|
||||
console.log(`\nResults written to: ${summaryPath}`)
|
||||
@@ -211,7 +284,13 @@ describeWithApiKey('App Chat LLM Evaluation', () => {
|
||||
'test9: create recipe book from scratch',
|
||||
async () => {
|
||||
const USER_PROMPT = `Create a recipe book app where users can add recipes with a name, ingredients list, and instructions. Include a search bar to filter recipes by name and the ability to delete recipes.`
|
||||
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!)
|
||||
const results = await runVariantComparison(
|
||||
USER_PROMPT,
|
||||
VARIANTS,
|
||||
VARIANTS[0]._apiKey,
|
||||
undefined,
|
||||
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
|
||||
)
|
||||
|
||||
const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results)
|
||||
console.log(`\nResults written to: ${summaryPath}`)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import OpenAI from 'openai'
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import type { AppFiles, BackendRunnable } from '../../app/core'
|
||||
import { BASE_EVALUATOR_RESPONSE_FORMAT } from '../shared'
|
||||
import type { EvaluationResult } from '../shared'
|
||||
@@ -71,12 +71,7 @@ ${BASE_EVALUATOR_RESPONSE_FORMAT}`
|
||||
|
||||
/**
|
||||
* Evaluates how well a generated app fulfills the user's request, considering any initial app state.
|
||||
* This evaluator does not require an expected reference app - it evaluates based on the request alone.
|
||||
*
|
||||
* @param userPrompt The original user request
|
||||
* @param generatedApp The app generated by the AI
|
||||
* @param initialApp Optional initial app state (what the app looked like before AI changes)
|
||||
* @returns Evaluation result with score, statement, and missing requirements
|
||||
* Uses Anthropic API directly.
|
||||
*/
|
||||
export async function evaluateAppGeneration(
|
||||
userPrompt: string,
|
||||
@@ -84,9 +79,17 @@ export async function evaluateAppGeneration(
|
||||
initialApp?: InitialApp
|
||||
): Promise<EvaluationResult> {
|
||||
// @ts-ignore
|
||||
const apiKey = process.env.OPENROUTER_API_KEY
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY
|
||||
if (!apiKey) {
|
||||
return {
|
||||
success: false,
|
||||
resemblanceScore: 0,
|
||||
statement: 'No API key available for evaluation',
|
||||
error: 'ANTHROPIC_API_KEY not set'
|
||||
}
|
||||
}
|
||||
|
||||
const client = new OpenAI({ baseURL: 'https://openrouter.ai/api/v1', apiKey })
|
||||
const client = new Anthropic({ apiKey })
|
||||
|
||||
let userMessage = `## User's Original Request
|
||||
${userPrompt}
|
||||
@@ -117,16 +120,18 @@ Please evaluate how well the generated app:
|
||||
2. ${initialApp ? 'Makes appropriate modifications to the initial app state' : 'Implements a complete and correct new app'}`
|
||||
|
||||
try {
|
||||
const response = await client.chat.completions.create({
|
||||
model: 'anthropic/claude-sonnet-4.5',
|
||||
const response = await client.messages.create({
|
||||
model: 'claude-sonnet-4-5-20250514',
|
||||
max_tokens: 2048,
|
||||
system: APP_GENERATION_EVALUATOR_SYSTEM_PROMPT,
|
||||
messages: [
|
||||
{ role: 'system', content: APP_GENERATION_EVALUATOR_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: userMessage }
|
||||
],
|
||||
temperature: 0
|
||||
})
|
||||
|
||||
const content = response.choices[0]?.message?.content
|
||||
const textBlock = response.content.find((block) => block.type === 'text')
|
||||
const content = textBlock?.text
|
||||
if (!content) {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type VariantDefaults
|
||||
} from '../shared'
|
||||
import { writeAppComparisonResultsToFolders } from './appResultsWriter'
|
||||
import type { AIProvider } from '$lib/gen/types.gen'
|
||||
|
||||
// Re-export for convenience
|
||||
export type { InitialApp } from './appEvalComparison'
|
||||
@@ -38,6 +39,8 @@ export interface AppEvalOptions {
|
||||
variant?: VariantConfig
|
||||
/** Whether to evaluate the generated app with LLM. Default: true. Set to false to skip evaluation. */
|
||||
evaluateWithLLM?: boolean
|
||||
/** AI provider (inferred from model name if omitted) */
|
||||
provider?: AIProvider
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,12 +52,11 @@ const appDefaults: VariantDefaults<AppAIChatHelpers> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an app chat evaluation with real OpenAI API calls.
|
||||
* Executes tool calls using the actual app tools from core.ts or variant-configured tools.
|
||||
* Runs an app chat evaluation using the shared chat loop (same code path as production).
|
||||
*/
|
||||
export async function runAppEval(
|
||||
userPrompt: string,
|
||||
openaiApiKey: string,
|
||||
apiKey: string,
|
||||
options?: AppEvalOptions
|
||||
): Promise<AppEvalResult> {
|
||||
const { helpers, getFiles } = createAppEvalHelpers(
|
||||
@@ -69,7 +71,7 @@ export async function runAppEval(
|
||||
appDefaults,
|
||||
options?.customSystemPrompt
|
||||
)
|
||||
const { toolDefs, tools } = resolveTools(options?.variant, appDefaults)
|
||||
const { tools } = resolveTools(options?.variant, appDefaults)
|
||||
const model = resolveModel(options?.variant, options?.model)
|
||||
|
||||
// Build user message
|
||||
@@ -80,15 +82,15 @@ export async function runAppEval(
|
||||
userPrompt,
|
||||
systemMessage,
|
||||
userMessage,
|
||||
toolDefs,
|
||||
tools,
|
||||
helpers,
|
||||
apiKey: openaiApiKey,
|
||||
apiKey,
|
||||
getOutput: getFiles,
|
||||
options: {
|
||||
maxIterations: options?.maxIterations,
|
||||
model,
|
||||
workspace: 'test-workspace'
|
||||
workspace: 'test-workspace',
|
||||
provider: options?.provider
|
||||
}
|
||||
})
|
||||
|
||||
@@ -114,21 +116,32 @@ export async function runAppEval(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-variant provider override.
|
||||
*/
|
||||
export interface VariantProviderOverride {
|
||||
provider: AIProvider
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the same prompt against multiple variants sequentially for comparison.
|
||||
* Returns results in the same order as the input variants.
|
||||
* Accepts optional per-variant provider/apiKey overrides.
|
||||
*/
|
||||
export async function runVariantComparison(
|
||||
userPrompt: string,
|
||||
variants: VariantConfig[],
|
||||
openaiApiKey: string,
|
||||
baseOptions?: Omit<AppEvalOptions, 'variant'>
|
||||
defaultApiKey: string,
|
||||
baseOptions?: Omit<AppEvalOptions, 'variant'>,
|
||||
providerOverrides?: VariantProviderOverride[]
|
||||
): Promise<AppEvalResult[]> {
|
||||
const results: AppEvalResult[] = await Promise.all(
|
||||
variants.map(async (variant) => {
|
||||
return await runAppEval(userPrompt, openaiApiKey, {
|
||||
variants.map(async (variant, i) => {
|
||||
const override = providerOverrides?.[i]
|
||||
return await runAppEval(userPrompt, override?.apiKey ?? defaultApiKey, {
|
||||
...baseOptions,
|
||||
variant
|
||||
variant,
|
||||
provider: override?.provider ?? baseOptions?.provider
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
@@ -22,35 +22,60 @@ import initialTest6 from './initial/test6_initial.json'
|
||||
// @ts-ignore - JSON import
|
||||
import initialTest7 from './initial/test7_initial.json'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import type { AIProvider } from '$lib/gen/types.gen'
|
||||
|
||||
// Get API key from environment - tests will be skipped if not set
|
||||
// Get API keys from environment - tests will be skipped if none are set
|
||||
// @ts-ignore
|
||||
// const OPENAI_API_KEY = process.env.OPENAI_API_KEY
|
||||
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY
|
||||
// @ts-ignore
|
||||
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY
|
||||
|
||||
// Skip all tests if no API key is provided
|
||||
// const describeWithApiKey = OPENAI_API_KEY ? describe : describe.skip
|
||||
const describeWithApiKey = OPENROUTER_API_KEY ? describe : describe.skip
|
||||
const hasAnyKey = OPENAI_API_KEY || ANTHROPIC_API_KEY
|
||||
const describeWithApiKey = hasAnyKey ? describe : describe.skip
|
||||
|
||||
const MODELS = ['google/gemini-2.5-flash', 'anthropic/claude-haiku-4.5', 'openai/gpt-4o']
|
||||
// Build model variants based on available keys
|
||||
interface ModelVariant {
|
||||
model: string
|
||||
provider: AIProvider
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
const MODEL_VARIANTS: ModelVariant[] = [
|
||||
...(OPENAI_API_KEY
|
||||
? [{ model: 'gpt-4o', provider: 'openai' as AIProvider, apiKey: OPENAI_API_KEY }]
|
||||
: []),
|
||||
...(ANTHROPIC_API_KEY
|
||||
? [
|
||||
{
|
||||
model: 'claude-haiku-4-5-20241022',
|
||||
provider: 'anthropic' as AIProvider,
|
||||
apiKey: ANTHROPIC_API_KEY
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
|
||||
const VARIANTS = [
|
||||
...MODELS.map((model) => ({
|
||||
...MODEL_VARIANTS.map((mv) => ({
|
||||
...BASELINE_VARIANT,
|
||||
model,
|
||||
name: `baseline-${model.replace('/', '-')}`
|
||||
model: mv.model,
|
||||
name: `baseline-${mv.provider}-${mv.model}`,
|
||||
_provider: mv.provider,
|
||||
_apiKey: mv.apiKey
|
||||
})),
|
||||
...MODELS.map((model) => ({
|
||||
...MODEL_VARIANTS.map((mv) => ({
|
||||
...MINIMAL_SINGLE_TOOL_VARIANT,
|
||||
model,
|
||||
name: `minimal-single-tool-${model.replace('/', '-')}`
|
||||
model: mv.model,
|
||||
name: `minimal-single-tool-${mv.provider}-${mv.model}`,
|
||||
_provider: mv.provider,
|
||||
_apiKey: mv.apiKey
|
||||
}))
|
||||
]
|
||||
|
||||
describeWithApiKey('Flow Chat LLM Evaluation', () => {
|
||||
const TEST_TIMEOUT = 120_000
|
||||
if (!OPENROUTER_API_KEY) {
|
||||
console.warn('OPENROUTER_API_KEY is not set, skipping tests')
|
||||
if (!hasAnyKey) {
|
||||
console.warn('No API keys set (OPENAI_API_KEY or ANTHROPIC_API_KEY), skipping tests')
|
||||
}
|
||||
|
||||
it(
|
||||
@@ -65,9 +90,15 @@ STEP 3: Loop on all users
|
||||
STEP 4: Do branches based on user's role, do different action based on that. Roles are admin, user, moderator
|
||||
STEP 5: Return action taken for each user
|
||||
`
|
||||
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
|
||||
expectedFlow: expectedTest1 as ExpectedFlow
|
||||
})
|
||||
const results = await runVariantComparison(
|
||||
USER_PROMPT,
|
||||
VARIANTS,
|
||||
VARIANTS[0]._apiKey,
|
||||
{
|
||||
expectedFlow: expectedTest1 as ExpectedFlow
|
||||
},
|
||||
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
|
||||
)
|
||||
|
||||
// Write results to files
|
||||
const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results)
|
||||
@@ -112,9 +143,15 @@ STEP 5: Branch based on inventory - if all items available, create shipment reco
|
||||
STEP 6: Send confirmation (mock email to customer_email)
|
||||
STEP 7: Return final order summary with status
|
||||
`
|
||||
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
|
||||
expectedFlow: expectedTest2 as ExpectedFlow
|
||||
})
|
||||
const results = await runVariantComparison(
|
||||
USER_PROMPT,
|
||||
VARIANTS,
|
||||
VARIANTS[0]._apiKey,
|
||||
{
|
||||
expectedFlow: expectedTest2 as ExpectedFlow
|
||||
},
|
||||
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
|
||||
)
|
||||
|
||||
const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results)
|
||||
console.log(`\nResults written to: ${summaryPath}`)
|
||||
@@ -161,9 +198,15 @@ STEP 5: Branch based on quality score:
|
||||
- If score < 70: Store in quarantine and send alert
|
||||
STEP 6: Return processing report with statistics (total records, quality score, destination)
|
||||
`
|
||||
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
|
||||
expectedFlow: expectedTest3 as ExpectedFlow
|
||||
})
|
||||
const results = await runVariantComparison(
|
||||
USER_PROMPT,
|
||||
VARIANTS,
|
||||
VARIANTS[0]._apiKey,
|
||||
{
|
||||
expectedFlow: expectedTest3 as ExpectedFlow
|
||||
},
|
||||
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
|
||||
)
|
||||
|
||||
const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results)
|
||||
console.log(`\nResults written to: ${summaryPath}`)
|
||||
@@ -210,9 +253,15 @@ STEP 3: Use an AI agent to handle the customer query. The agent should have acce
|
||||
STEP 4: Log the interaction to audit trail (customer_id, query, response summary)
|
||||
STEP 5: Return the agent's response and any actions taken
|
||||
`
|
||||
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
|
||||
expectedFlow: expectedTest4 as ExpectedFlow
|
||||
})
|
||||
const results = await runVariantComparison(
|
||||
USER_PROMPT,
|
||||
VARIANTS,
|
||||
VARIANTS[0]._apiKey,
|
||||
{
|
||||
expectedFlow: expectedTest4 as ExpectedFlow
|
||||
},
|
||||
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
|
||||
)
|
||||
|
||||
const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results)
|
||||
console.log(`\nResults written to: ${summaryPath}`)
|
||||
@@ -256,11 +305,17 @@ Modify this existing flow to add error handling:
|
||||
- If validation passes, return the data for the next step
|
||||
- Update save_results to handle the validation result appropriately
|
||||
`
|
||||
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
|
||||
initialModules: initialTest5.value.modules as FlowModule[],
|
||||
initialSchema: initialTest5.schema,
|
||||
expectedFlow: expectedTest5 as ExpectedFlow
|
||||
})
|
||||
const results = await runVariantComparison(
|
||||
USER_PROMPT,
|
||||
VARIANTS,
|
||||
VARIANTS[0]._apiKey,
|
||||
{
|
||||
initialModules: initialTest5.value.modules as FlowModule[],
|
||||
initialSchema: initialTest5.schema,
|
||||
expectedFlow: expectedTest5 as ExpectedFlow
|
||||
},
|
||||
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
|
||||
)
|
||||
|
||||
const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results)
|
||||
console.log(`\nResults written to: ${summaryPath}`)
|
||||
@@ -302,11 +357,17 @@ Modify the order processing loop to handle different order types:
|
||||
- Move the original process_order step to the default branch for unknown order types
|
||||
- Each branch step should return the orderId, shipping cost, and shipping type
|
||||
`
|
||||
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
|
||||
initialModules: initialTest6.value.modules as FlowModule[],
|
||||
initialSchema: initialTest6.schema,
|
||||
expectedFlow: expectedTest6 as ExpectedFlow
|
||||
})
|
||||
const results = await runVariantComparison(
|
||||
USER_PROMPT,
|
||||
VARIANTS,
|
||||
VARIANTS[0]._apiKey,
|
||||
{
|
||||
initialModules: initialTest6.value.modules as FlowModule[],
|
||||
initialSchema: initialTest6.schema,
|
||||
expectedFlow: expectedTest6 as ExpectedFlow
|
||||
},
|
||||
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
|
||||
)
|
||||
|
||||
const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results)
|
||||
console.log(`\nResults written to: ${summaryPath}`)
|
||||
@@ -348,11 +409,17 @@ Refactor this flow for better performance by parallelizing the enrichment steps:
|
||||
- The combine_data step should check if any enrichment used a fallback value and set a hasFallbacks flag
|
||||
- Keep get_item as the first step and return_result as the last step unchanged
|
||||
`
|
||||
const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, {
|
||||
initialModules: initialTest7.value.modules as FlowModule[],
|
||||
initialSchema: initialTest7.schema,
|
||||
expectedFlow: expectedTest7 as ExpectedFlow
|
||||
})
|
||||
const results = await runVariantComparison(
|
||||
USER_PROMPT,
|
||||
VARIANTS,
|
||||
VARIANTS[0]._apiKey,
|
||||
{
|
||||
initialModules: initialTest7.value.modules as FlowModule[],
|
||||
initialSchema: initialTest7.schema,
|
||||
expectedFlow: expectedTest7 as ExpectedFlow
|
||||
},
|
||||
VARIANTS.map((v) => ({ provider: v._provider, apiKey: v._apiKey }))
|
||||
)
|
||||
|
||||
const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results)
|
||||
console.log(`\nResults written to: ${summaryPath}`)
|
||||
|
||||
@@ -59,14 +59,10 @@ export async function evaluateFlowComparison(
|
||||
expectedFlow: ExpectedFlow,
|
||||
userPrompt: string
|
||||
): Promise<EvaluationResult> {
|
||||
// @ts-ignore
|
||||
const apiKey = process.env.OPENROUTER_API_KEY
|
||||
|
||||
return evaluateWithLLM({
|
||||
userPrompt,
|
||||
generatedOutput: generatedFlow,
|
||||
expectedOutput: expectedFlow,
|
||||
evaluatorSystemPrompt: FLOW_EVALUATOR_SYSTEM_PROMPT,
|
||||
apiKey
|
||||
evaluatorSystemPrompt: FLOW_EVALUATOR_SYSTEM_PROMPT
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import type { AIProvider } from '$lib/gen/types.gen'
|
||||
import type { ExtendedOpenFlow } from '$lib/components/flows/types'
|
||||
import { flowTools, prepareFlowSystemMessage, prepareFlowUserMessage, type FlowAIChatHelpers } from '../../flow/core'
|
||||
import { createFlowEvalHelpers } from './flowEvalHelpers'
|
||||
@@ -38,6 +39,8 @@ export interface FlowEvalOptions {
|
||||
maxIterations?: number
|
||||
variant?: VariantConfig
|
||||
expectedFlow?: ExpectedFlow
|
||||
/** AI provider (inferred from model name if omitted) */
|
||||
provider?: AIProvider
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,12 +52,11 @@ const flowDefaults: VariantDefaults<FlowAIChatHelpers> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a flow chat evaluation with real OpenAI API calls.
|
||||
* Executes tool calls using the actual flowTools from core.ts or variant-configured tools.
|
||||
* Runs a flow chat evaluation using the shared chat loop (same code path as production).
|
||||
*/
|
||||
export async function runFlowEval(
|
||||
userPrompt: string,
|
||||
openaiApiKey: string,
|
||||
apiKey: string,
|
||||
options?: FlowEvalOptions
|
||||
): Promise<FlowEvalResult> {
|
||||
const { helpers, getFlow } = createFlowEvalHelpers(
|
||||
@@ -65,7 +67,7 @@ export async function runFlowEval(
|
||||
// Resolve variant configuration
|
||||
const variantName = options?.variant?.name ?? 'baseline'
|
||||
const systemMessage = resolveSystemPrompt(options?.variant, flowDefaults, options?.customSystemPrompt)
|
||||
const { toolDefs, tools } = resolveTools(options?.variant, flowDefaults)
|
||||
const { tools } = resolveTools(options?.variant, flowDefaults)
|
||||
const model = resolveModel(options?.variant, options?.model)
|
||||
|
||||
// Build user message
|
||||
@@ -76,15 +78,15 @@ export async function runFlowEval(
|
||||
userPrompt,
|
||||
systemMessage,
|
||||
userMessage,
|
||||
toolDefs,
|
||||
tools,
|
||||
helpers,
|
||||
apiKey: openaiApiKey,
|
||||
apiKey,
|
||||
getOutput: getFlow,
|
||||
options: {
|
||||
maxIterations: options?.maxIterations,
|
||||
model,
|
||||
workspace: 'test-workspace'
|
||||
workspace: 'test-workspace',
|
||||
provider: options?.provider
|
||||
}
|
||||
})
|
||||
|
||||
@@ -111,21 +113,32 @@ export async function runFlowEval(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-variant provider override.
|
||||
*/
|
||||
export interface VariantProviderOverride {
|
||||
provider: AIProvider
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the same prompt against multiple variants sequentially for comparison.
|
||||
* Returns results in the same order as the input variants.
|
||||
* Accepts optional per-variant provider/apiKey overrides.
|
||||
*/
|
||||
export async function runVariantComparison(
|
||||
userPrompt: string,
|
||||
variants: VariantConfig[],
|
||||
openaiApiKey: string,
|
||||
baseOptions?: Omit<FlowEvalOptions, 'variant'>
|
||||
defaultApiKey: string,
|
||||
baseOptions?: Omit<FlowEvalOptions, 'variant'>,
|
||||
providerOverrides?: VariantProviderOverride[]
|
||||
): Promise<FlowEvalResult[]> {
|
||||
const results: FlowEvalResult[] = await Promise.all(
|
||||
variants.map(async (variant) => {
|
||||
return await runFlowEval(userPrompt, openaiApiKey, {
|
||||
variants.map(async (variant, i) => {
|
||||
const override = providerOverrides?.[i]
|
||||
return await runFlowEval(userPrompt, override?.apiKey ?? defaultApiKey, {
|
||||
...baseOptions,
|
||||
variant
|
||||
variant,
|
||||
provider: override?.provider ?? baseOptions?.provider
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import OpenAI, { APIError } from 'openai'
|
||||
import type { ChatCompletionMessageParam, ChatCompletionSystemMessageParam } from 'openai/resources/chat/completions.mjs'
|
||||
import type { ChatCompletionTool } from 'openai/resources/chat/completions.mjs'
|
||||
import OpenAI from 'openai'
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import type {
|
||||
ChatCompletionMessageParam,
|
||||
ChatCompletionSystemMessageParam
|
||||
} from 'openai/resources/chat/completions.mjs'
|
||||
import type { AIProvider, AIProviderModel } from '$lib/gen/types.gen'
|
||||
import type { TokenUsage, ToolCallDetail, EvalRunnerOptions } from './types'
|
||||
import type { Tool } from './baseVariants'
|
||||
import { runChatLoop, type ChatClients } from '../../chatLoop'
|
||||
import type { Tool as ProductionTool, ToolCallbacks } from '../../shared'
|
||||
|
||||
/**
|
||||
* Result from a single eval run (before domain-specific evaluation).
|
||||
@@ -29,13 +35,13 @@ export interface RunEvalParams<THelpers, TOutput> {
|
||||
systemMessage: ChatCompletionSystemMessageParam
|
||||
/** User message for the LLM */
|
||||
userMessage: ChatCompletionMessageParam
|
||||
/** Tool definitions for the LLM API */
|
||||
toolDefs: ChatCompletionTool[]
|
||||
/** Tool definitions for the LLM API (unused — derived from tools) */
|
||||
toolDefs?: unknown
|
||||
/** Full tool implementations for execution */
|
||||
tools: Tool<THelpers>[]
|
||||
/** Domain-specific helpers for tool execution */
|
||||
helpers: THelpers
|
||||
/** API key for OpenRouter */
|
||||
/** API key for the provider */
|
||||
apiKey: string
|
||||
/** Function to get the current output state */
|
||||
getOutput: () => TOutput
|
||||
@@ -44,10 +50,37 @@ export interface RunEvalParams<THelpers, TOutput> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a generic evaluation with real LLM API calls.
|
||||
* Executes tool calls in a loop until the LLM stops calling tools.
|
||||
*
|
||||
* This is the core execution loop shared across all chat eval tests.
|
||||
* Creates SDK clients for the given provider.
|
||||
*/
|
||||
function createEvalClients(provider: AIProvider, apiKey: string): ChatClients {
|
||||
if (provider === 'anthropic') {
|
||||
return {
|
||||
openai: new OpenAI({ apiKey: 'unused' }),
|
||||
anthropic: new Anthropic({ apiKey })
|
||||
}
|
||||
}
|
||||
return {
|
||||
openai: new OpenAI({ apiKey }),
|
||||
anthropic: new Anthropic({ apiKey: 'unused' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves model string to AIProviderModel.
|
||||
*/
|
||||
function resolveModelProvider(
|
||||
model: string,
|
||||
provider?: AIProvider
|
||||
): AIProviderModel {
|
||||
if (provider) return { provider, model }
|
||||
if (model.startsWith('claude')) return { provider: 'anthropic', model }
|
||||
if (model.startsWith('gpt') || model.startsWith('o')) return { provider: 'openai', model }
|
||||
return { provider: 'openai', model }
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a generic evaluation using the shared chat loop (same code path as production).
|
||||
* Uses streaming via real provider SDKs instead of OpenRouter non-streaming.
|
||||
*/
|
||||
export async function runEval<THelpers, TOutput>(
|
||||
params: RunEvalParams<THelpers, TOutput>
|
||||
@@ -55,7 +88,6 @@ export async function runEval<THelpers, TOutput>(
|
||||
const {
|
||||
systemMessage,
|
||||
userMessage,
|
||||
toolDefs,
|
||||
tools,
|
||||
helpers,
|
||||
apiKey,
|
||||
@@ -63,134 +95,82 @@ export async function runEval<THelpers, TOutput>(
|
||||
options
|
||||
} = params
|
||||
|
||||
const client = new OpenAI({ baseURL: 'https://openrouter.ai/api/v1', apiKey })
|
||||
const model = options?.model ?? 'gpt-4o'
|
||||
const maxIterations = options?.maxIterations ?? 20
|
||||
const workspace = options?.workspace ?? 'test-workspace'
|
||||
const provider = options?.provider
|
||||
|
||||
const messages: ChatCompletionMessageParam[] = [systemMessage, userMessage]
|
||||
const totalTokens: TokenUsage = { prompt: 0, completion: 0, total: 0 }
|
||||
const modelProvider = resolveModelProvider(model, provider)
|
||||
const clients = createEvalClients(modelProvider.provider, apiKey)
|
||||
|
||||
const messages: ChatCompletionMessageParam[] = [userMessage]
|
||||
let toolCallsCount = 0
|
||||
const toolsCalled: string[] = []
|
||||
const toolCallDetails: ToolCallDetail[] = []
|
||||
let iterations = 0
|
||||
|
||||
// No-op tool callbacks for eval
|
||||
const toolCallbacks = {
|
||||
// Wrap tools to intercept fn calls for tracking.
|
||||
// Cast to ProductionTool since the eval Tool has a narrower toolCallbacks type
|
||||
// but the actual callbacks passed at runtime will satisfy both interfaces.
|
||||
const wrappedTools = tools.map((tool) => ({
|
||||
...tool,
|
||||
fn: async (p: any) => {
|
||||
toolCallsCount++
|
||||
toolsCalled.push(tool.def.function.name)
|
||||
try {
|
||||
const args =
|
||||
typeof p.args === 'string' ? JSON.parse(p.args) : p.args
|
||||
toolCallDetails.push({ name: tool.def.function.name, arguments: args })
|
||||
} catch {
|
||||
toolCallDetails.push({
|
||||
name: tool.def.function.name,
|
||||
arguments: p.args
|
||||
})
|
||||
}
|
||||
return tool.fn(p)
|
||||
}
|
||||
})) as ProductionTool<THelpers>[]
|
||||
|
||||
// No-op callbacks for eval
|
||||
const callbacks: ToolCallbacks & {
|
||||
onNewToken: (token: string) => void
|
||||
onMessageEnd: () => void
|
||||
} = {
|
||||
setToolStatus: () => {},
|
||||
removeToolStatus: () => {}
|
||||
removeToolStatus: () => {},
|
||||
onNewToken: () => {},
|
||||
onMessageEnd: () => {}
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
|
||||
try {
|
||||
// Tool resolution loop
|
||||
while (iterations < maxIterations) {
|
||||
iterations++
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model,
|
||||
messages,
|
||||
tools: toolDefs,
|
||||
temperature: 0
|
||||
})
|
||||
|
||||
// Track token usage
|
||||
if (response.usage) {
|
||||
totalTokens.prompt += response.usage.prompt_tokens
|
||||
totalTokens.completion += response.usage.completion_tokens
|
||||
totalTokens.total += response.usage.total_tokens
|
||||
}
|
||||
|
||||
if (!response.choices.length) {
|
||||
throw new Error('No response from API')
|
||||
}
|
||||
|
||||
const choice = response.choices[0]
|
||||
const assistantMessage = choice.message
|
||||
|
||||
// Add assistant message to history
|
||||
messages.push(assistantMessage)
|
||||
|
||||
// If no tool calls, we're done
|
||||
if (!assistantMessage.tool_calls?.length) {
|
||||
break
|
||||
}
|
||||
|
||||
// Execute each tool call
|
||||
for (const toolCall of assistantMessage.tool_calls) {
|
||||
toolCallsCount++
|
||||
|
||||
// Type guard: only handle function tool calls
|
||||
if (toolCall.type !== 'function') {
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: toolCall.id,
|
||||
content: `Unsupported tool type: ${toolCall.type}`
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
toolsCalled.push(toolCall.function.name)
|
||||
|
||||
const tool = tools.find((t) => t.def.function.name === toolCall.function.name)
|
||||
if (!tool) {
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: toolCall.id,
|
||||
content: `Unknown tool: ${toolCall.function.name}`
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const args = JSON.parse(toolCall.function.arguments)
|
||||
toolCallDetails.push({ name: toolCall.function.name, arguments: args })
|
||||
const result = await tool.fn({
|
||||
args,
|
||||
workspace,
|
||||
helpers,
|
||||
toolCallbacks,
|
||||
toolId: toolCall.id
|
||||
})
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: toolCall.id,
|
||||
content: result
|
||||
})
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err)
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: toolCall.id,
|
||||
content: `Error: ${errorMessage}`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
const result = await runChatLoop({
|
||||
messages,
|
||||
systemMessage,
|
||||
tools: wrappedTools,
|
||||
helpers,
|
||||
abortController,
|
||||
callbacks,
|
||||
modelProvider,
|
||||
clients,
|
||||
workspace,
|
||||
maxIterations,
|
||||
skipResponsesApi: modelProvider.provider !== 'openai' && modelProvider.provider !== 'azure_openai'
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: getOutput(),
|
||||
tokenUsage: totalTokens,
|
||||
tokenUsage: { prompt: 0, completion: 0, total: 0 },
|
||||
toolCallsCount,
|
||||
toolsCalled,
|
||||
toolCallDetails,
|
||||
iterations,
|
||||
iterations: Math.max(1, result.addedMessages.filter((m) => m.role === 'assistant').length),
|
||||
messages
|
||||
}
|
||||
} catch (err) {
|
||||
// Build detailed error message
|
||||
let errorMessage: string
|
||||
if (err instanceof APIError) {
|
||||
const details: string[] = [`${err.status} ${err.message}`]
|
||||
if (err.code) details.push(`Code: ${err.code}`)
|
||||
if (err.type) details.push(`Type: ${err.type}`)
|
||||
if (err.param) details.push(`Param: ${err.param}`)
|
||||
if (err.requestID) details.push(`Request ID: ${err.requestID}`)
|
||||
if (err.error && typeof err.error === 'object') {
|
||||
details.push(`Response: ${JSON.stringify(err.error, null, 2)}`)
|
||||
}
|
||||
errorMessage = details.join('\n')
|
||||
} else if (err instanceof Error) {
|
||||
if (err instanceof Error) {
|
||||
errorMessage = err.stack ?? err.message
|
||||
} else {
|
||||
errorMessage = String(err)
|
||||
@@ -200,11 +180,11 @@ export async function runEval<THelpers, TOutput>(
|
||||
success: false,
|
||||
output: getOutput(),
|
||||
error: errorMessage,
|
||||
tokenUsage: totalTokens,
|
||||
tokenUsage: { prompt: 0, completion: 0, total: 0 },
|
||||
toolCallsCount,
|
||||
toolsCalled,
|
||||
toolCallDetails,
|
||||
iterations,
|
||||
iterations: 0,
|
||||
messages
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import OpenAI from 'openai'
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import type { EvaluationResult } from './types'
|
||||
|
||||
/**
|
||||
@@ -13,9 +13,9 @@ export interface EvaluateParams {
|
||||
expectedOutput: unknown
|
||||
/** Domain-specific system prompt for the evaluator */
|
||||
evaluatorSystemPrompt: string
|
||||
/** API key for OpenRouter */
|
||||
apiKey: string
|
||||
/** Model to use for evaluation (default: 'anthropic/claude-sonnet-4.5') */
|
||||
/** Anthropic API key for evaluation */
|
||||
apiKey?: string
|
||||
/** Model to use for evaluation (default: 'claude-sonnet-4-5-20250514') */
|
||||
model?: string
|
||||
}
|
||||
|
||||
@@ -41,10 +41,7 @@ Score guidelines:
|
||||
|
||||
/**
|
||||
* Evaluates how well a generated output matches an expected output using an LLM.
|
||||
* Returns a resemblance score (0-100), a qualitative statement, and any missing requirements.
|
||||
*
|
||||
* @param params Evaluation parameters including prompts, outputs, and API configuration
|
||||
* @returns Evaluation result with score, statement, and missing requirements
|
||||
* Uses Anthropic API directly instead of OpenRouter.
|
||||
*/
|
||||
export async function evaluateWithLLM(params: EvaluateParams): Promise<EvaluationResult> {
|
||||
const {
|
||||
@@ -53,10 +50,21 @@ export async function evaluateWithLLM(params: EvaluateParams): Promise<Evaluatio
|
||||
expectedOutput,
|
||||
evaluatorSystemPrompt,
|
||||
apiKey,
|
||||
model = 'anthropic/claude-sonnet-4.5'
|
||||
model = 'claude-sonnet-4-5-20250514'
|
||||
} = params
|
||||
|
||||
const client = new OpenAI({ baseURL: 'https://openrouter.ai/api/v1', apiKey })
|
||||
// @ts-ignore - process.env
|
||||
const anthropicKey = apiKey ?? process.env.ANTHROPIC_API_KEY
|
||||
if (!anthropicKey) {
|
||||
return {
|
||||
success: false,
|
||||
resemblanceScore: 0,
|
||||
statement: 'No API key available for evaluation',
|
||||
error: 'ANTHROPIC_API_KEY not set and no apiKey provided'
|
||||
}
|
||||
}
|
||||
|
||||
const client = new Anthropic({ apiKey: anthropicKey })
|
||||
|
||||
const userMessage = `## User's Original Request
|
||||
${userPrompt}
|
||||
@@ -76,16 +84,18 @@ Please evaluate how well the generated output:
|
||||
2. Matches the structure and logic of the expected reference output`
|
||||
|
||||
try {
|
||||
const response = await client.chat.completions.create({
|
||||
const response = await client.messages.create({
|
||||
model,
|
||||
max_tokens: 2048,
|
||||
system: evaluatorSystemPrompt,
|
||||
messages: [
|
||||
{ role: 'system', content: evaluatorSystemPrompt },
|
||||
{ role: 'user', content: userMessage }
|
||||
],
|
||||
temperature: 0
|
||||
})
|
||||
|
||||
const content = response.choices[0]?.message?.content
|
||||
const textBlock = response.content.find((block) => block.type === 'text')
|
||||
const content = textBlock?.text
|
||||
if (!content) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -98,7 +108,6 @@ Please evaluate how well the generated output:
|
||||
// Parse JSON response - handle potential markdown code blocks
|
||||
let jsonContent = content.trim()
|
||||
if (jsonContent.startsWith('```')) {
|
||||
// Remove markdown code block wrapper
|
||||
jsonContent = jsonContent.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '')
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions.mjs'
|
||||
import type { AIProvider } from '$lib/gen/types.gen'
|
||||
|
||||
/**
|
||||
* Token usage tracking for LLM calls.
|
||||
@@ -83,6 +84,8 @@ export interface EvalRunnerOptions {
|
||||
model?: string
|
||||
/** Workspace ID for tool calls */
|
||||
workspace?: string
|
||||
/** AI provider (inferred from model name if omitted) */
|
||||
provider?: AIProvider
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { OpenAI } from 'openai'
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import type {
|
||||
ChatCompletionMessageParam,
|
||||
ChatCompletionMessageFunctionToolCall
|
||||
@@ -13,19 +14,28 @@ import type {
|
||||
RawMessageStreamEvent
|
||||
} from '@anthropic-ai/sdk/resources'
|
||||
import type { MessageStream } from '@anthropic-ai/sdk/lib/MessageStream'
|
||||
import type { AIProviderModel } from '$lib/gen'
|
||||
import { getProviderAndCompletionConfig, workspaceAIClients } from '../lib'
|
||||
import { processToolCall, type Tool, type ToolCallbacks } from './shared'
|
||||
|
||||
export async function getAnthropicCompletion(
|
||||
messages: ChatCompletionMessageParam[],
|
||||
abortController: AbortController,
|
||||
tools?: OpenAI.Chat.Completions.ChatCompletionFunctionTool[]
|
||||
tools?: OpenAI.Chat.Completions.ChatCompletionFunctionTool[],
|
||||
options?: {
|
||||
forceModelProvider?: AIProviderModel
|
||||
anthropicClient?: Anthropic
|
||||
}
|
||||
): Promise<MessageStream> {
|
||||
const { provider, config } = getProviderAndCompletionConfig({ messages, stream: true })
|
||||
const { provider, config } = getProviderAndCompletionConfig({
|
||||
messages,
|
||||
stream: true,
|
||||
forceModelProvider: options?.forceModelProvider
|
||||
})
|
||||
const { system, messages: anthropicMessages } = convertOpenAIToAnthropicMessages(messages)
|
||||
const anthropicTools = convertOpenAIToolsToAnthropic(tools)
|
||||
|
||||
const anthropicClient = workspaceAIClients.getAnthropicClient()
|
||||
const client = options?.anthropicClient ?? workspaceAIClients.getAnthropicClient()
|
||||
|
||||
const anthropicParams = {
|
||||
model: config.model,
|
||||
@@ -36,7 +46,7 @@ export async function getAnthropicCompletion(
|
||||
...(typeof config.temperature === 'number' && { temperature: config.temperature })
|
||||
}
|
||||
|
||||
const stream = anthropicClient.messages.stream(anthropicParams, {
|
||||
const stream = client.messages.stream(anthropicParams, {
|
||||
signal: abortController.signal,
|
||||
headers: {
|
||||
'X-Provider': provider,
|
||||
@@ -58,7 +68,8 @@ export async function parseAnthropicCompletion(
|
||||
addedMessages: ChatCompletionMessageParam[],
|
||||
tools: Tool<any>[],
|
||||
helpers: any,
|
||||
abortController?: AbortController
|
||||
abortController?: AbortController,
|
||||
options?: { workspace?: string }
|
||||
): Promise<boolean> {
|
||||
let toolCallsToProcess: ChatCompletionMessageFunctionToolCall[] = []
|
||||
let error = null
|
||||
@@ -209,7 +220,8 @@ export async function parseAnthropicCompletion(
|
||||
tools,
|
||||
toolCall,
|
||||
helpers,
|
||||
toolCallbacks: callbacks
|
||||
toolCallbacks: callbacks,
|
||||
workspace: options?.workspace
|
||||
})
|
||||
messages.push(messageToAdd)
|
||||
addedMessages.push(messageToAdd)
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import OpenAI from 'openai'
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import type {
|
||||
ChatCompletionMessageParam,
|
||||
ChatCompletionSystemMessageParam,
|
||||
ChatCompletionUserMessageParam
|
||||
} from 'openai/resources/chat/completions.mjs'
|
||||
import type { AIProviderModel } from '$lib/gen'
|
||||
import { getCompletion, parseOpenAICompletion } from '../lib'
|
||||
import { getAnthropicCompletion, parseAnthropicCompletion } from './anthropic'
|
||||
import {
|
||||
getOpenAIResponsesCompletion,
|
||||
parseOpenAIResponsesCompletion
|
||||
} from './openai-responses'
|
||||
import type { Tool, ToolCallbacks } from './shared'
|
||||
|
||||
export interface ChatClients {
|
||||
openai: OpenAI
|
||||
anthropic: Anthropic
|
||||
}
|
||||
|
||||
export interface ChatLoopConfig {
|
||||
messages: ChatCompletionMessageParam[]
|
||||
/**
|
||||
* System message, tools, helpers, and modelProvider are re-read from this config
|
||||
* on every iteration. Callers can use JS getters to provide dynamic values
|
||||
* (e.g. AIChatManager uses getters so mode changes mid-loop take effect).
|
||||
*/
|
||||
systemMessage: ChatCompletionSystemMessageParam
|
||||
tools: Tool<any>[]
|
||||
helpers: any
|
||||
abortController: AbortController
|
||||
callbacks: ToolCallbacks & {
|
||||
onNewToken: (token: string) => void
|
||||
onMessageEnd: () => void
|
||||
}
|
||||
modelProvider: AIProviderModel
|
||||
clients: ChatClients
|
||||
workspace: string
|
||||
/** Maximum iterations for the loop. undefined = unlimited (production). */
|
||||
maxIterations?: number
|
||||
skipResponsesApi?: boolean
|
||||
onSkipResponsesApi?: () => void
|
||||
/** Return a pending user message to inject between iterations, or undefined. */
|
||||
getPendingUserMessage?: () => ChatCompletionUserMessageParam | undefined
|
||||
/** Called before each iteration (e.g. to refresh tool schemas). */
|
||||
onBeforeIteration?: (tools: Tool<any>[], helpers: any) => Promise<void>
|
||||
}
|
||||
|
||||
export interface ChatLoopResult {
|
||||
addedMessages: ChatCompletionMessageParam[]
|
||||
}
|
||||
|
||||
export async function runChatLoop(config: ChatLoopConfig): Promise<ChatLoopResult> {
|
||||
const {
|
||||
messages,
|
||||
abortController,
|
||||
callbacks,
|
||||
clients,
|
||||
workspace,
|
||||
maxIterations,
|
||||
onSkipResponsesApi,
|
||||
getPendingUserMessage,
|
||||
onBeforeIteration
|
||||
} = config
|
||||
let skipResponsesApi = config.skipResponsesApi ?? false
|
||||
|
||||
const addedMessages: ChatCompletionMessageParam[] = []
|
||||
let iterations = 0
|
||||
|
||||
while (true) {
|
||||
if (maxIterations !== undefined && iterations >= maxIterations) {
|
||||
break
|
||||
}
|
||||
iterations++
|
||||
|
||||
// Re-read these from config each iteration so that mode changes
|
||||
// (e.g. changeModeTool in Navigator) take effect immediately.
|
||||
// Callers can use JS getter properties to provide dynamic values.
|
||||
const tools = config.tools
|
||||
const helpers = config.helpers
|
||||
const systemMessage = config.systemMessage
|
||||
const modelProvider = config.modelProvider
|
||||
|
||||
if (onBeforeIteration) {
|
||||
await onBeforeIteration(tools, helpers)
|
||||
}
|
||||
|
||||
const pendingUserMessage = getPendingUserMessage?.()
|
||||
|
||||
const isOpenAI =
|
||||
modelProvider.provider === 'openai' || modelProvider.provider === 'azure_openai'
|
||||
const isAnthropic = modelProvider.provider === 'anthropic'
|
||||
|
||||
const messageParams = [
|
||||
systemMessage,
|
||||
...messages,
|
||||
...(pendingUserMessage ? [pendingUserMessage] : [])
|
||||
]
|
||||
const toolDefs = tools.map((t) => t.def)
|
||||
const parseOptions = { workspace }
|
||||
|
||||
if (isOpenAI) {
|
||||
let useCompletionsApi = skipResponsesApi
|
||||
if (!skipResponsesApi) {
|
||||
try {
|
||||
const completion = await getOpenAIResponsesCompletion(
|
||||
messageParams,
|
||||
abortController,
|
||||
toolDefs,
|
||||
{
|
||||
forceModelProvider: modelProvider,
|
||||
openaiClient: clients.openai
|
||||
}
|
||||
)
|
||||
const continueCompletion = await parseOpenAIResponsesCompletion(
|
||||
completion,
|
||||
callbacks,
|
||||
messages,
|
||||
addedMessages,
|
||||
tools,
|
||||
helpers,
|
||||
parseOptions
|
||||
)
|
||||
if (!continueCompletion) {
|
||||
break
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'OpenAI Responses API failed, falling back to Completions API:',
|
||||
err
|
||||
)
|
||||
const errorMessage = err instanceof Error ? err.message : String(err)
|
||||
if (errorMessage.includes('Responses API is not enabled')) {
|
||||
skipResponsesApi = true
|
||||
onSkipResponsesApi?.()
|
||||
}
|
||||
useCompletionsApi = true
|
||||
}
|
||||
}
|
||||
|
||||
if (useCompletionsApi) {
|
||||
const completion = await getCompletion(messageParams, abortController, toolDefs, {
|
||||
forceCompletions: true,
|
||||
forceModelProvider: modelProvider,
|
||||
openaiClient: clients.openai
|
||||
})
|
||||
const continueCompletion = await parseOpenAICompletion(
|
||||
completion,
|
||||
callbacks,
|
||||
messages,
|
||||
addedMessages,
|
||||
tools,
|
||||
helpers,
|
||||
undefined,
|
||||
parseOptions
|
||||
)
|
||||
if (!continueCompletion) {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if (isAnthropic) {
|
||||
const completion = await getAnthropicCompletion(
|
||||
messageParams,
|
||||
abortController,
|
||||
toolDefs,
|
||||
{
|
||||
forceModelProvider: modelProvider,
|
||||
anthropicClient: clients.anthropic
|
||||
}
|
||||
)
|
||||
if (completion) {
|
||||
const continueCompletion = await parseAnthropicCompletion(
|
||||
completion,
|
||||
callbacks,
|
||||
messages,
|
||||
addedMessages,
|
||||
tools,
|
||||
helpers,
|
||||
abortController,
|
||||
parseOptions
|
||||
)
|
||||
if (!continueCompletion) {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const completion = await getCompletion(messageParams, abortController, toolDefs, {
|
||||
forceModelProvider: modelProvider,
|
||||
openaiClient: clients.openai
|
||||
})
|
||||
if (completion) {
|
||||
const continueCompletion = await parseOpenAICompletion(
|
||||
completion,
|
||||
callbacks,
|
||||
messages,
|
||||
addedMessages,
|
||||
tools,
|
||||
helpers,
|
||||
undefined,
|
||||
parseOptions
|
||||
)
|
||||
if (!continueCompletion) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { addedMessages }
|
||||
}
|
||||
@@ -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
|
||||
@@ -121,15 +125,24 @@ function convertCompletionConfigToResponsesConfig(
|
||||
export async function getOpenAIResponsesCompletion(
|
||||
messages: ChatCompletionMessageParam[],
|
||||
abortController: AbortController,
|
||||
tools?: OpenAI.Chat.Completions.ChatCompletionTool[]
|
||||
tools?: OpenAI.Chat.Completions.ChatCompletionTool[],
|
||||
options?: {
|
||||
forceModelProvider?: AIProviderModel
|
||||
openaiClient?: OpenAI
|
||||
}
|
||||
) {
|
||||
const { provider, config } = getProviderAndCompletionConfig({ messages, stream: true, tools })
|
||||
const { provider, config } = getProviderAndCompletionConfig({
|
||||
messages,
|
||||
stream: true,
|
||||
tools,
|
||||
forceModelProvider: options?.forceModelProvider
|
||||
})
|
||||
const { instructions, input } = convertMessagesToResponsesInput(messages)
|
||||
const responsesConfig = convertCompletionConfigToResponsesConfig(config)
|
||||
|
||||
const openaiClient = workspaceAIClients.getOpenaiClient()
|
||||
const client = options?.openaiClient ?? workspaceAIClients.getOpenaiClient()
|
||||
|
||||
const runner = openaiClient.responses.stream(
|
||||
const runner = client.responses.stream(
|
||||
{
|
||||
...responsesConfig,
|
||||
input,
|
||||
@@ -204,7 +217,8 @@ export async function parseOpenAIResponsesCompletion(
|
||||
messages: ChatCompletionMessageParam[],
|
||||
addedMessages: ChatCompletionMessageParam[],
|
||||
tools: Tool<any>[],
|
||||
helpers: any
|
||||
helpers: any,
|
||||
options?: { workspace?: string }
|
||||
): Promise<boolean> {
|
||||
let toolCallsToProcess: ChatCompletionMessageFunctionToolCall[] = []
|
||||
let error: OpenAIError | ResponseErrorEvent | null = null
|
||||
@@ -338,7 +352,8 @@ export async function parseOpenAIResponsesCompletion(
|
||||
tools,
|
||||
toolCall,
|
||||
helpers,
|
||||
toolCallbacks: callbacks
|
||||
toolCallbacks: callbacks,
|
||||
workspace: options?.workspace
|
||||
})
|
||||
messages.push(messageToAdd)
|
||||
addedMessages.push(messageToAdd)
|
||||
@@ -354,6 +369,7 @@ export async function getNonStreamingOpenAIResponsesCompletion(
|
||||
abortController: AbortController,
|
||||
testOptions?: {
|
||||
apiKey?: string
|
||||
workspace?: string
|
||||
resourcePath?: string
|
||||
forceModelProvider: AIProviderModel
|
||||
}
|
||||
@@ -390,15 +406,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(
|
||||
{
|
||||
|
||||
@@ -417,12 +417,14 @@ export async function processToolCall<T>({
|
||||
tools,
|
||||
toolCall,
|
||||
helpers,
|
||||
toolCallbacks
|
||||
toolCallbacks,
|
||||
workspace
|
||||
}: {
|
||||
tools: Tool<T>[]
|
||||
toolCall: ChatCompletionMessageFunctionToolCall
|
||||
helpers: T
|
||||
toolCallbacks: ToolCallbacks
|
||||
workspace?: string
|
||||
}): Promise<ChatCompletionMessageParam> {
|
||||
try {
|
||||
const args = JSON.parse(toolCall.function.arguments || '{}')
|
||||
@@ -472,7 +474,7 @@ export async function processToolCall<T>({
|
||||
tools,
|
||||
functionName: toolCall.function.name,
|
||||
args,
|
||||
workspace: get(workspaceStore) ?? '',
|
||||
workspace: workspace ?? get(workspaceStore) ?? '',
|
||||
helpers,
|
||||
toolCallbacks,
|
||||
toolId: toolCall.id
|
||||
|
||||
@@ -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',
|
||||
@@ -289,7 +296,12 @@ function getModelSpecificConfig(
|
||||
) {
|
||||
const defaultMaxTokens = getModelMaxTokens(modelProvider.provider, modelProvider.model)
|
||||
const modelKey = `${modelProvider.provider}:${modelProvider.model}`
|
||||
const customMaxTokensStore = get(copilotInfo)?.maxTokensPerModel
|
||||
let customMaxTokensStore: Record<string, number> | undefined
|
||||
try {
|
||||
customMaxTokensStore = get(copilotInfo)?.maxTokensPerModel
|
||||
} catch {
|
||||
// copilotInfo store may not be initialized in vitest
|
||||
}
|
||||
const maxTokens = customMaxTokensStore?.[modelKey] ?? defaultMaxTokens
|
||||
if (
|
||||
(modelProvider.provider === 'openai' || modelProvider.provider === 'azure_openai') &&
|
||||
@@ -364,38 +376,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 +437,7 @@ export const workspaceAIClients = new WorkspacedAIClients()
|
||||
|
||||
export async function testKey({
|
||||
apiKey,
|
||||
workspace,
|
||||
resourcePath,
|
||||
model,
|
||||
abortController,
|
||||
@@ -424,6 +445,7 @@ export async function testKey({
|
||||
aiProvider
|
||||
}: {
|
||||
apiKey?: string
|
||||
workspace?: string
|
||||
resourcePath?: string
|
||||
model: string | undefined
|
||||
messages: ChatCompletionMessageParam[]
|
||||
@@ -443,6 +465,7 @@ export async function testKey({
|
||||
if (aiProvider === 'anthropic') {
|
||||
await testAnthropicKey({
|
||||
apiKey,
|
||||
workspace,
|
||||
resourcePath,
|
||||
model: modelToTest,
|
||||
abortController,
|
||||
@@ -453,6 +476,7 @@ export async function testKey({
|
||||
|
||||
await getNonStreamingCompletion(messages, abortController, {
|
||||
apiKey,
|
||||
workspace,
|
||||
resourcePath,
|
||||
forceModelProvider: {
|
||||
model: modelToTest,
|
||||
@@ -463,12 +487,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 +515,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 +743,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 +793,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 || ''
|
||||
@@ -861,9 +881,16 @@ export async function getCompletion(
|
||||
tools?: OpenAI.Chat.Completions.ChatCompletionTool[],
|
||||
options?: {
|
||||
forceCompletions?: boolean
|
||||
forceModelProvider?: AIProviderModel
|
||||
openaiClient?: OpenAI
|
||||
}
|
||||
): Promise<Stream<ChatCompletionChunk>> {
|
||||
const { provider, config } = getProviderAndCompletionConfig({ messages, stream: true, tools })
|
||||
const { provider, config } = getProviderAndCompletionConfig({
|
||||
messages,
|
||||
stream: true,
|
||||
tools,
|
||||
forceModelProvider: options?.forceModelProvider
|
||||
})
|
||||
|
||||
// Use Responses API for OpenAI and Azure OpenAI
|
||||
if ((provider === 'openai' || provider === 'azure_openai') && !options?.forceCompletions) {
|
||||
@@ -876,8 +903,8 @@ export async function getCompletion(
|
||||
}
|
||||
|
||||
// Use Completions API for other providers
|
||||
const openaiClient = workspaceAIClients.getOpenaiClient()
|
||||
const completion = openaiClient.chat.completions.create(config, {
|
||||
const client = options?.openaiClient ?? workspaceAIClients.getOpenaiClient()
|
||||
const completion = client.chat.completions.create(config, {
|
||||
signal: abortController.signal,
|
||||
headers: {
|
||||
'X-Provider': provider
|
||||
@@ -906,7 +933,8 @@ export async function parseOpenAICompletion(
|
||||
addedMessages: ChatCompletionMessageParam[],
|
||||
tools: Tool<any>[],
|
||||
helpers: any,
|
||||
_abortController?: AbortController // unused, for signature compatibility with parseAnthropicCompletion
|
||||
_abortController?: AbortController, // unused, for signature compatibility with parseAnthropicCompletion
|
||||
options?: { workspace?: string }
|
||||
): Promise<boolean> {
|
||||
const finalToolCalls: Record<number, ChatCompletionChunk.Choice.Delta.ToolCall> = {}
|
||||
let malformedFunctionCallError = false
|
||||
@@ -1045,7 +1073,8 @@ export async function parseOpenAICompletion(
|
||||
tools,
|
||||
toolCall,
|
||||
helpers,
|
||||
toolCallbacks: callbacks
|
||||
toolCallbacks: callbacks,
|
||||
workspace: options?.workspace
|
||||
})
|
||||
messages.push(messageToAdd)
|
||||
addedMessages.push(messageToAdd)
|
||||
|
||||
@@ -39,27 +39,9 @@
|
||||
render a cancel button, providing the operator with an option to cancel the step. e.g:
|
||||
<Tabs selected="bun" class="pt-4">
|
||||
<Tab value="bun" label="TypeScript (Bun)" />
|
||||
<Tab value="deno" label="TypeScript (Deno)" />
|
||||
<Tab value="python" label="Python" />
|
||||
|
||||
{#snippet content()}
|
||||
<TabContent value="deno" class="p-2">
|
||||
<HighlightCode
|
||||
language={'deno'}
|
||||
code={`import * as wmill from "npm:windmill-client@^1.158.2"
|
||||
|
||||
export async function main() {
|
||||
const urls = await wmill.getResumeUrls("approver1")
|
||||
|
||||
return {
|
||||
resume: urls['resume'],
|
||||
cancel: urls['cancel'],
|
||||
default_args: {}, // optional, see below
|
||||
enums: {} // optional, see below
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value="bun" class="p-2">
|
||||
<HighlightCode
|
||||
language={'deno'}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { getContext, setContext } from 'svelte'
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
import { get } from 'svelte/store'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { JobService, WorkspaceService, ResourceService } from '$lib/gen'
|
||||
import type {
|
||||
GitRepositorySettings as BackendGitRepositorySettings,
|
||||
@@ -646,6 +649,10 @@ export function createGitSyncContext(workspace: string) {
|
||||
}
|
||||
|
||||
function addSyncRepository() {
|
||||
if (!get(enterpriseLicense) && repositories && repositories.length >= 1) {
|
||||
sendUserToast('Multiple repositories requires Enterprise Edition', true)
|
||||
return
|
||||
}
|
||||
repositories.push({
|
||||
git_repo_resource_path: '',
|
||||
script_path: undefined,
|
||||
@@ -669,6 +676,10 @@ export function createGitSyncContext(workspace: string) {
|
||||
}
|
||||
|
||||
function addPromotionRepository() {
|
||||
if (!get(enterpriseLicense)) {
|
||||
sendUserToast('Promotion mode requires Enterprise Edition', true)
|
||||
return
|
||||
}
|
||||
repositories.push({
|
||||
git_repo_resource_path: '',
|
||||
script_path: undefined,
|
||||
|
||||
@@ -6,12 +6,46 @@
|
||||
import GitSyncRepositoryCard from './GitSyncRepositoryCard.svelte'
|
||||
import GitSyncModalManager from './GitSyncModalManager.svelte'
|
||||
import { enterpriseLicense, workspaceStore } from '$lib/stores'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { untrack } from 'svelte'
|
||||
|
||||
// Create context reactively based on workspaceStore
|
||||
const gitSyncContext = $derived($workspaceStore ? setGitSyncContext($workspaceStore) : null)
|
||||
|
||||
// Fetch git sync eligibility
|
||||
let gitSyncStatus = $state<{
|
||||
enabled: boolean
|
||||
reason: string | null
|
||||
max_repos: number | null
|
||||
user_count: number | null
|
||||
max_users: number | null
|
||||
}>({ enabled: false, reason: null, max_repos: null, user_count: null, max_users: null })
|
||||
|
||||
$effect(() => {
|
||||
if ($workspaceStore) {
|
||||
WorkspaceService.getGitSyncEnabled({ workspace: $workspaceStore })
|
||||
.then((status) => {
|
||||
gitSyncStatus = status as typeof gitSyncStatus
|
||||
})
|
||||
.catch(() => {
|
||||
gitSyncStatus = {
|
||||
enabled: false,
|
||||
reason: null,
|
||||
max_repos: null,
|
||||
user_count: null,
|
||||
max_users: null
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const gitSyncAllowed = $derived(gitSyncStatus.enabled)
|
||||
const isFreeTier = $derived(gitSyncAllowed && !$enterpriseLicense)
|
||||
const hasConfiguredRepos = $derived(
|
||||
gitSyncContext?.repositories?.some((r) => r.git_repo_resource_path) ?? false
|
||||
)
|
||||
|
||||
// Load settings when workspace context changes
|
||||
$effect(() => {
|
||||
if (gitSyncContext) {
|
||||
@@ -58,7 +92,7 @@
|
||||
link="https://www.windmill.dev/docs/advanced/git_sync"
|
||||
>
|
||||
{#snippet actions()}
|
||||
{#if $enterpriseLicense && gitSyncContext.repositories != undefined}
|
||||
{#if (gitSyncAllowed || gitSyncStatus.user_count != null) && gitSyncContext?.repositories != undefined}
|
||||
<Button
|
||||
variant="accent"
|
||||
target="_blank"
|
||||
@@ -74,15 +108,26 @@
|
||||
Only new changes matching the filters will trigger a git sync. You still need to initialize the
|
||||
repo to the desired state first.
|
||||
</Alert>
|
||||
{#if !$enterpriseLicense}
|
||||
{#if !gitSyncAllowed}
|
||||
<div class="mb-2"></div>
|
||||
|
||||
<Alert type="warning" title="Syncing workspace to Git is an EE feature">
|
||||
Automatically saving scripts to a Git repository on each deploy is a Windmill EE feature.
|
||||
<Alert type={hasConfiguredRepos ? 'error' : 'warning'} title="Git sync disabled">
|
||||
Git sync is an EE feature provided in CE for testing and hobbyist use when workspace members
|
||||
≤ {gitSyncStatus.max_users}. Your workspace has {gitSyncStatus.user_count} members. Settings
|
||||
below are preserved but sync is inactive until membership is reduced or you upgrade to EE.
|
||||
</Alert>
|
||||
<div class="mb-2"></div>
|
||||
{:else if isFreeTier}
|
||||
<div class="mb-2"></div>
|
||||
|
||||
<Alert type="warning" title="CE Limited Feature">
|
||||
Git sync is an EE feature provided in CE for testing and hobbyist use when workspace members
|
||||
≤ {gitSyncStatus.max_users}. Limited to a single repository. Upgrade to EE for multiple
|
||||
repositories, promotion mode, and GitHub App authentication.
|
||||
</Alert>
|
||||
<div class="mb-2"></div>
|
||||
{/if}
|
||||
{#if $enterpriseLicense && gitSyncContext.repositories != undefined}
|
||||
{#if (gitSyncAllowed || gitSyncStatus.user_count != null) && gitSyncContext?.repositories != undefined}
|
||||
<!-- Primary Sync Repository -->
|
||||
<div class="space-y-6 pt-6">
|
||||
<GitSyncRepositoryCard
|
||||
@@ -95,120 +140,46 @@
|
||||
showEmptyState={primarySync?.repo === null}
|
||||
/>
|
||||
|
||||
<!-- Secondary Sync Repositories -->
|
||||
{#if primarySync && !primarySync.repo?.isUnsavedConnection}
|
||||
{#if secondarySync.length > 0 || secondarySyncExpanded}
|
||||
<div class="mt-4">
|
||||
<button
|
||||
class="flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors"
|
||||
onclick={() => (secondarySyncExpanded = !secondarySyncExpanded)}
|
||||
>
|
||||
{#if secondarySyncExpanded}
|
||||
<ChevronDown size={16} />
|
||||
{:else}
|
||||
<ChevronRight size={16} />
|
||||
{/if}
|
||||
Secondary sync repositories ({secondarySync.length})
|
||||
</button>
|
||||
|
||||
{#if secondarySyncExpanded}
|
||||
<div class="mt-3 space-y-3">
|
||||
{#if secondarySync.length === 0}
|
||||
<div class="text-sm text-secondary italic">
|
||||
No secondary sync repositories configured
|
||||
</div>
|
||||
{:else}
|
||||
{#each secondarySync as { repo, idx } (repo.git_repo_resource_path)}
|
||||
<div class="pl-4">
|
||||
<GitSyncRepositoryCard variant="secondary" {idx} isSecondary={true} />
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if !hasUnsavedSecondary}
|
||||
<div class="pl-4">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
startIcon={{ icon: Plus }}
|
||||
onclick={() => gitSyncContext.addSyncRepository()}
|
||||
>
|
||||
Add secondary sync
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Collapsed state when no secondary repos exist -->
|
||||
{#if !hasUnsavedSecondary}
|
||||
<div class="mt-2">
|
||||
<button
|
||||
class="text-xs text-primary hover:text-secondary transition-colors"
|
||||
onclick={() => {
|
||||
secondarySyncExpanded = true
|
||||
gitSyncContext.addSyncRepository()
|
||||
}}
|
||||
>
|
||||
+ Add secondary sync repository
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Primary Promotion Repository -->
|
||||
<div class="mt-6">
|
||||
<GitSyncRepositoryCard
|
||||
variant="primary-promotion"
|
||||
mode="promotion"
|
||||
idx={primaryPromotion?.idx ?? null}
|
||||
repository={primaryPromotion?.repo ?? null}
|
||||
onAdd={() => gitSyncContext.addPromotionRepository()}
|
||||
isCollapsible={false}
|
||||
showEmptyState={primaryPromotion?.repo === null}
|
||||
/>
|
||||
|
||||
<!-- Secondary Promotion Repositories -->
|
||||
{#if primaryPromotion && !primaryPromotion.repo?.isUnsavedConnection}
|
||||
{#if secondaryPromotion.length > 0 || secondaryPromotionExpanded}
|
||||
{#if $enterpriseLicense}
|
||||
<!-- Secondary Sync Repositories (EE only) -->
|
||||
{#if primarySync && !primarySync.repo?.isUnsavedConnection}
|
||||
{#if secondarySync.length > 0 || secondarySyncExpanded}
|
||||
<div class="mt-4">
|
||||
<button
|
||||
class="flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors"
|
||||
onclick={() => (secondaryPromotionExpanded = !secondaryPromotionExpanded)}
|
||||
onclick={() => (secondarySyncExpanded = !secondarySyncExpanded)}
|
||||
>
|
||||
{#if secondaryPromotionExpanded}
|
||||
{#if secondarySyncExpanded}
|
||||
<ChevronDown size={16} />
|
||||
{:else}
|
||||
<ChevronRight size={16} />
|
||||
{/if}
|
||||
Secondary promotion repositories ({secondaryPromotion.length})
|
||||
Secondary sync repositories ({secondarySync.length})
|
||||
</button>
|
||||
|
||||
{#if secondaryPromotionExpanded}
|
||||
{#if secondarySyncExpanded}
|
||||
<div class="mt-3 space-y-3">
|
||||
{#if secondaryPromotion.length === 0}
|
||||
{#if secondarySync.length === 0}
|
||||
<div class="text-sm text-secondary italic">
|
||||
No secondary promotion repositories configured
|
||||
No secondary sync repositories configured
|
||||
</div>
|
||||
{:else}
|
||||
{#each secondaryPromotion as { repo, idx } (repo.git_repo_resource_path)}
|
||||
{#each secondarySync as { repo, idx } (repo.git_repo_resource_path)}
|
||||
<div class="pl-4">
|
||||
<GitSyncRepositoryCard variant="secondary" {idx} isSecondary={true} />
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if !hasUnsavedSecondaryPromotion}
|
||||
{#if !hasUnsavedSecondary}
|
||||
<div class="pl-4">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
startIcon={{ icon: Plus }}
|
||||
onclick={() => gitSyncContext.addPromotionRepository()}
|
||||
onclick={() => gitSyncContext.addSyncRepository()}
|
||||
>
|
||||
Add secondary promotion
|
||||
Add secondary sync
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -216,23 +187,99 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Collapsed state when no secondary promotion repos exist -->
|
||||
{#if !hasUnsavedSecondaryPromotion}
|
||||
<!-- Collapsed state when no secondary repos exist -->
|
||||
{#if !hasUnsavedSecondary}
|
||||
<div class="mt-2">
|
||||
<button
|
||||
class="text-xs text-primary hover:text-secondary transition-colors"
|
||||
onclick={() => {
|
||||
secondaryPromotionExpanded = true
|
||||
gitSyncContext.addPromotionRepository()
|
||||
secondarySyncExpanded = true
|
||||
gitSyncContext.addSyncRepository()
|
||||
}}
|
||||
>
|
||||
+ Add secondary promotion repository
|
||||
+ Add secondary sync repository
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Primary Promotion Repository (EE only) -->
|
||||
<div class="mt-6">
|
||||
<GitSyncRepositoryCard
|
||||
variant="primary-promotion"
|
||||
mode="promotion"
|
||||
idx={primaryPromotion?.idx ?? null}
|
||||
repository={primaryPromotion?.repo ?? null}
|
||||
onAdd={() => gitSyncContext.addPromotionRepository()}
|
||||
isCollapsible={false}
|
||||
showEmptyState={primaryPromotion?.repo === null}
|
||||
/>
|
||||
|
||||
<!-- Secondary Promotion Repositories -->
|
||||
{#if primaryPromotion && !primaryPromotion.repo?.isUnsavedConnection}
|
||||
{#if secondaryPromotion.length > 0 || secondaryPromotionExpanded}
|
||||
<div class="mt-4">
|
||||
<button
|
||||
class="flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors"
|
||||
onclick={() => (secondaryPromotionExpanded = !secondaryPromotionExpanded)}
|
||||
>
|
||||
{#if secondaryPromotionExpanded}
|
||||
<ChevronDown size={16} />
|
||||
{:else}
|
||||
<ChevronRight size={16} />
|
||||
{/if}
|
||||
Secondary promotion repositories ({secondaryPromotion.length})
|
||||
</button>
|
||||
|
||||
{#if secondaryPromotionExpanded}
|
||||
<div class="mt-3 space-y-3">
|
||||
{#if secondaryPromotion.length === 0}
|
||||
<div class="text-sm text-secondary italic">
|
||||
No secondary promotion repositories configured
|
||||
</div>
|
||||
{:else}
|
||||
{#each secondaryPromotion as { repo, idx } (repo.git_repo_resource_path)}
|
||||
<div class="pl-4">
|
||||
<GitSyncRepositoryCard variant="secondary" {idx} isSecondary={true} />
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if !hasUnsavedSecondaryPromotion}
|
||||
<div class="pl-4">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
startIcon={{ icon: Plus }}
|
||||
onclick={() => gitSyncContext.addPromotionRepository()}
|
||||
>
|
||||
Add secondary promotion
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Collapsed state when no secondary promotion repos exist -->
|
||||
{#if !hasUnsavedSecondaryPromotion}
|
||||
<div class="mt-2">
|
||||
<button
|
||||
class="text-xs text-primary hover:text-secondary transition-colors"
|
||||
onclick={() => {
|
||||
secondaryPromotionExpanded = true
|
||||
gitSyncContext.addPromotionRepository()
|
||||
}}
|
||||
>
|
||||
+ Add secondary promotion repository
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Button } from '$lib/components/common'
|
||||
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { IndexSearchService } from '$lib/gen'
|
||||
import type { GetIndexerStatusResponse } from '$lib/gen'
|
||||
import type { GetIndexerStatusResponse, GetIndexDiskStorageSizesResponse } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { displaySize } from '$lib/utils'
|
||||
import Tooltip from '../Tooltip.svelte'
|
||||
@@ -24,6 +24,7 @@
|
||||
let clearServiceLogsIndexModalOpen = $state(false)
|
||||
|
||||
let status: GetIndexerStatusResponse | undefined = $state(undefined)
|
||||
let diskSizes: GetIndexDiskStorageSizesResponse | undefined = $state(undefined)
|
||||
let statusLoading = $state(true)
|
||||
let statusError = $state(false)
|
||||
|
||||
@@ -41,9 +42,15 @@
|
||||
statusLoading = true
|
||||
statusError = false
|
||||
try {
|
||||
status = await IndexSearchService.getIndexerStatus()
|
||||
const [statusRes, diskRes] = await Promise.all([
|
||||
IndexSearchService.getIndexerStatus(),
|
||||
IndexSearchService.getIndexDiskStorageSizes().catch(() => undefined)
|
||||
])
|
||||
status = statusRes
|
||||
diskSizes = diskRes
|
||||
} catch (e) {
|
||||
status = undefined
|
||||
diskSizes = undefined
|
||||
statusError = true
|
||||
} finally {
|
||||
statusLoading = false
|
||||
@@ -139,7 +146,11 @@
|
||||
: 'bg-red-500'}"
|
||||
></span>
|
||||
<span class="text-primary font-medium">{label}:</span>
|
||||
<span class="font-semibold {entry?.is_alive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}">
|
||||
<span
|
||||
class="font-semibold {entry?.is_alive
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-600 dark:text-red-400'}"
|
||||
>
|
||||
{entry?.is_alive ? 'Running' : 'Stopped'}
|
||||
</span>
|
||||
{#if entry?.last_locked_at}
|
||||
@@ -161,21 +172,21 @@
|
||||
<div class="flex flex-col gap-1 text-2xs text-tertiary">
|
||||
<span>
|
||||
Jobs index:
|
||||
{#if status.job_indexer?.storage?.disk_size_bytes != null}
|
||||
Disk: {displaySize(status.job_indexer.storage.disk_size_bytes) ?? 'N/A'}
|
||||
{#if diskSizes?.job_index_disk_size_bytes != null}
|
||||
Disk: {displaySize(diskSizes.job_index_disk_size_bytes) ?? 'N/A'}
|
||||
{/if}
|
||||
{#if status.job_indexer?.storage?.s3_size_bytes != null}
|
||||
{#if status.job_indexer?.storage?.disk_size_bytes != null}·{/if}
|
||||
{#if diskSizes?.job_index_disk_size_bytes != null}·{/if}
|
||||
S3: {displaySize(status.job_indexer.storage.s3_size_bytes) ?? 'N/A'}
|
||||
{/if}
|
||||
</span>
|
||||
<span>
|
||||
Service logs index:
|
||||
{#if status.log_indexer?.storage?.disk_size_bytes != null}
|
||||
Disk: {displaySize(status.log_indexer.storage.disk_size_bytes) ?? 'N/A'}
|
||||
{#if diskSizes?.log_index_disk_size_bytes != null}
|
||||
Disk: {displaySize(diskSizes.log_index_disk_size_bytes) ?? 'N/A'}
|
||||
{/if}
|
||||
{#if status.log_indexer?.storage?.s3_size_bytes != null}
|
||||
{#if status.log_indexer?.storage?.disk_size_bytes != null}·{/if}
|
||||
{#if diskSizes?.log_index_disk_size_bytes != null}·{/if}
|
||||
S3: {displaySize(status.log_indexer.storage.s3_size_bytes) ?? 'N/A'}
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
@@ -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}
|
||||
@@ -163,6 +163,7 @@
|
||||
stepResults={getStepResults(job.workflow_as_code_status)}
|
||||
result={(job as any).result}
|
||||
success={(job as any).success !== false}
|
||||
jobId={job.id}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -158,6 +158,7 @@
|
||||
result={previewJob?.result}
|
||||
success={previewJob?.success !== false}
|
||||
autoExpandResult
|
||||
jobId={previewJob?.id}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -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>
|
||||
@@ -1601,7 +1601,7 @@ export function canHaveApproval(language: SupportedLanguage | undefined): boolea
|
||||
return false
|
||||
}
|
||||
|
||||
return ['python3', 'bun', 'deno'].includes(language)
|
||||
return ['python3', 'bun'].includes(language)
|
||||
}
|
||||
|
||||
export function canHaveFailure(language: SupportedLanguage | undefined): boolean {
|
||||
|
||||
@@ -797,6 +797,7 @@
|
||||
stepResults={getStepResults(job.workflow_as_code_status)}
|
||||
result={job.result}
|
||||
success={(job as any).success !== false}
|
||||
jobId={job.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -87,7 +87,8 @@
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
if (!emptyString(defaultApp.default_app_path)) {
|
||||
goto(`/apps/get/${defaultApp.default_app_path}`)
|
||||
const prefix = defaultApp.default_app_raw ? '/apps_raw/get' : '/apps/get'
|
||||
goto(`${prefix}/${defaultApp.default_app_path}`)
|
||||
} else {
|
||||
goto(rd ?? '/')
|
||||
}
|
||||
|
||||
@@ -151,7 +151,8 @@
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
if (!emptyString(defaultApp.default_app_path)) {
|
||||
await goto(`/apps/get/${defaultApp.default_app_path}`)
|
||||
const prefix = defaultApp.default_app_raw ? '/apps_raw/get' : '/apps/get'
|
||||
await goto(`${prefix}/${defaultApp.default_app_path}`)
|
||||
} else {
|
||||
if (rd?.startsWith('http')) {
|
||||
window.location.href = rd
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
<script lang="ts">
|
||||
import { type Job, JobService } from '$lib/gen'
|
||||
import { base } from '$lib/base'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import CenteredModal from '$lib/components/CenteredModal.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import FlowMetadata from '$lib/components/FlowMetadata.svelte'
|
||||
import JobArgs from '$lib/components/JobArgs.svelte'
|
||||
import { onDestroy, onMount, untrack } from 'svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import SchemaForm from '$lib/components/SchemaForm.svelte'
|
||||
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
|
||||
import Login from '$lib/components/Login.svelte'
|
||||
import { AlertTriangle, ExternalLink } from 'lucide-svelte'
|
||||
import { mergeSchema } from '$lib/common'
|
||||
import { emptyString } from '$lib/utils'
|
||||
import { Alert } from '$lib/components/common'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import { setLicense } from '$lib/enterpriseUtils'
|
||||
import DisplayResult from '$lib/components/DisplayResult.svelte'
|
||||
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
|
||||
import FlowGraphV2 from '$lib/components/graph/FlowGraphV2.svelte'
|
||||
import { page } from '$app/state'
|
||||
|
||||
$workspaceStore = page.params.workspace
|
||||
let rd = page.url.href.replace(page.url.origin, '')
|
||||
let token = page.url.searchParams.get('token') ?? undefined
|
||||
|
||||
let job: Job | undefined = $state(undefined)
|
||||
let approvalInfo: any = $state(undefined)
|
||||
let completed = $state(false)
|
||||
let error: string | undefined = $state(undefined)
|
||||
let default_payload: any = $state({})
|
||||
let loading = $state(false)
|
||||
let valid = $state(true)
|
||||
|
||||
let pollInterval: number | undefined = undefined
|
||||
let scheduleEditor: ScheduleEditor | undefined = $state(undefined)
|
||||
|
||||
setLicense()
|
||||
|
||||
onMount(() => {
|
||||
window.onunhandledrejection = (event: PromiseRejectionEvent) => {
|
||||
event.preventDefault()
|
||||
pollInterval && clearInterval(pollInterval)
|
||||
if (event.reason?.message) {
|
||||
const { message, body } = event.reason
|
||||
if (body) {
|
||||
sendUserToast(`${body}`, true)
|
||||
error = body.toString()
|
||||
} else {
|
||||
sendUserToast(`${message}`, true)
|
||||
error = message.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
loadData()
|
||||
pollInterval = setInterval(loadData, 2000)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
pollInterval && clearInterval(pollInterval)
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
error = undefined
|
||||
approvalInfo = await JobService.getApprovalInfo({
|
||||
workspace: page.params.workspace ?? '',
|
||||
jobId: page.params.job ?? '',
|
||||
token
|
||||
})
|
||||
} catch (e: any) {
|
||||
error = e?.body ?? e?.message ?? 'Failed to load approval info'
|
||||
pollInterval && clearInterval(pollInterval)
|
||||
return
|
||||
}
|
||||
try {
|
||||
job = (await JobService.getJob({
|
||||
workspace: page.params.workspace ?? '',
|
||||
id: page.params.job ?? ''
|
||||
})) as Job
|
||||
completed = job?.type === 'CompletedJob'
|
||||
} catch {
|
||||
// Job details are optional — page works with just approvalInfo
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUser() {
|
||||
userStore.set(await getUserExt(page.params.workspace ?? ''))
|
||||
}
|
||||
|
||||
async function resume() {
|
||||
loading = true
|
||||
try {
|
||||
await JobService.resumeSuspended({
|
||||
workspace: page.params.workspace ?? '',
|
||||
jobId: page.params.job ?? '',
|
||||
requestBody: {
|
||||
payload: default_payload,
|
||||
approval_token: token,
|
||||
approved: true
|
||||
}
|
||||
})
|
||||
sendUserToast('Flow approved')
|
||||
pollInterval && clearInterval(pollInterval)
|
||||
loadData()
|
||||
} catch (e: any) {
|
||||
sendUserToast(e?.body ?? e?.message ?? 'Failed to approve', true)
|
||||
error = e?.body ?? e?.message
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
loading = true
|
||||
try {
|
||||
await JobService.resumeSuspended({
|
||||
workspace: page.params.workspace ?? '',
|
||||
jobId: page.params.job ?? '',
|
||||
requestBody: {
|
||||
approval_token: token,
|
||||
approved: false
|
||||
}
|
||||
})
|
||||
sendUserToast('Flow denied!')
|
||||
pollInterval && clearInterval(pollInterval)
|
||||
loadData()
|
||||
} catch (e: any) {
|
||||
sendUserToast(e?.body ?? e?.message ?? 'Failed to cancel', true)
|
||||
error = e?.body ?? e?.message
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
let isWac = $derived(!!(job as any)?.workflow_as_code_status)
|
||||
let filteredArgs = $derived.by(() => {
|
||||
if (!job?.args) return job?.args
|
||||
const args = { ...(job.args as any) }
|
||||
delete args['_MODULES']
|
||||
return args
|
||||
})
|
||||
let rawFormSchema = $derived(approvalInfo?.form_schema?.schema ?? approvalInfo?.form_schema ?? {})
|
||||
let schema = $derived.by(() => {
|
||||
if (
|
||||
!rawFormSchema ||
|
||||
typeof rawFormSchema !== 'object' ||
|
||||
Object.keys(rawFormSchema).length === 0
|
||||
)
|
||||
return {}
|
||||
// If the schema already has 'properties', use it as-is (classic flow format)
|
||||
if ('properties' in rawFormSchema) return rawFormSchema
|
||||
// Otherwise wrap as properties (WAC format: form.schema is a flat map of field definitions)
|
||||
return { properties: rawFormSchema, order: Object.keys(rawFormSchema) }
|
||||
})
|
||||
let hasForm = $derived(schema && typeof schema === 'object' && Object.keys(schema).length > 0)
|
||||
let selfApprovalDisabled = $derived(
|
||||
approvalInfo?.approval_conditions?.self_approval_disabled ?? false
|
||||
)
|
||||
let isSelfApprovalBypass = $derived(
|
||||
selfApprovalDisabled &&
|
||||
$userStore &&
|
||||
$userStore.email === (job as any)?.email &&
|
||||
($userStore.is_admin || $userStore.is_super_admin)
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
if (approvalInfo?.user_auth_required && !$userStore) {
|
||||
untrack(() => loadUser())
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<ScheduleEditor bind:this={scheduleEditor} />
|
||||
|
||||
<CenteredModal
|
||||
title="Approval for resuming of {isWac ? 'workflow' : 'flow'}"
|
||||
disableLogo
|
||||
centerVertically={false}
|
||||
>
|
||||
{#if error}
|
||||
<div class="space-y-6">
|
||||
{#if error.includes('logged in') || error.includes('sign in') || error.includes('Not authorized')}
|
||||
<div class="flex flex-row gap-4 justify-center">
|
||||
<AlertTriangle />
|
||||
<p class="text-lg">Not Authorized</p>
|
||||
</div>
|
||||
<p class="text-sm">{error}</p>
|
||||
<Login {rd} />
|
||||
{:else if error.includes('Permission denied') || error.includes('Self-approval')}
|
||||
<div class="flex flex-row gap-4 justify-center">
|
||||
<AlertTriangle />
|
||||
<p class="text-lg">Permission denied</p>
|
||||
</div>
|
||||
<p class="text-sm">{error}</p>
|
||||
{:else}
|
||||
<div class="flex flex-row gap-4 justify-center">
|
||||
<AlertTriangle />
|
||||
<p class="text-lg">Error</p>
|
||||
</div>
|
||||
<p class="text-sm">{error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if approvalInfo}
|
||||
<div class="flex flex-row justify-between flex-wrap sm:flex-nowrap gap-x-4">
|
||||
<div class="w-full">
|
||||
<h2 class="text-sm font-semibold text-emphasis">Approvers</h2>
|
||||
<div class="mt-2 text-xs font-normal text-primary">
|
||||
{#if approvalInfo.approvers?.length > 0}
|
||||
<ul>
|
||||
{#each approvalInfo.approvers as a}
|
||||
<li>
|
||||
<p>
|
||||
{a.approver}
|
||||
<Tooltip>Unique id of approval: {a.resume_id}</Tooltip>
|
||||
</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="text-xs text-secondary">
|
||||
No current approvers for this step (approval steps can require more than one approval)
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
{#if job && job.raw_flow}
|
||||
<FlowMetadata {job} {scheduleEditor} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !completed}
|
||||
<h2 class="mt-4 mb-2 text-sm font-semibold text-emphasis">
|
||||
{isWac ? 'Workflow' : 'Flow'} arguments
|
||||
</h2>
|
||||
<JobArgs
|
||||
id={job?.id}
|
||||
workspace={job?.workspace_id ?? $workspaceStore ?? 'no_w'}
|
||||
args={filteredArgs}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="mt-8"></div>
|
||||
|
||||
<div class="p-4 rounded-md bg-surface-tertiary shadow-md">
|
||||
{#if completed}
|
||||
<Alert type="info" title="Flow completed">
|
||||
The flow is not running anymore. You cannot cancel or resume it.
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
{#if approvalInfo.description != undefined}
|
||||
<DisplayResult noControls result={approvalInfo.description} />
|
||||
{/if}
|
||||
|
||||
{#if hasForm && !completed}
|
||||
{#if emptyString($enterpriseLicense)}
|
||||
<Alert type="warning" title="Adding a form to the approval page is an EE feature" />
|
||||
{:else}
|
||||
<SchemaForm
|
||||
onlyMaskPassword
|
||||
noVariablePicker
|
||||
bind:isValid={valid}
|
||||
schema={mergeSchema(schema, {})}
|
||||
bind:args={default_payload}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !completed && approvalInfo.can_approve}
|
||||
<div class="w-max-md flex flex-row gap-x-4 gap-y-4 justify-between w-full flex-wrap">
|
||||
{#if approvalInfo.hide_cancel !== true}
|
||||
<Button
|
||||
variant="accent"
|
||||
destructive
|
||||
onclick={cancel}
|
||||
size="lg"
|
||||
disabled={completed || loading}
|
||||
>
|
||||
Deny
|
||||
</Button>
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
<Button
|
||||
variant="accent"
|
||||
onclick={resume}
|
||||
size="lg"
|
||||
disabled={completed || !valid || loading}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</div>
|
||||
{:else if !completed && !approvalInfo.can_approve}
|
||||
{#if approvalInfo.user_auth_required && !$userStore}
|
||||
<Login {rd} />
|
||||
{:else}
|
||||
<div class="text-sm text-secondary space-y-1">
|
||||
<p>You are not authorized to approve this flow.</p>
|
||||
{#if approvalInfo.approval_conditions?.self_approval_disabled && $userStore && $userStore.email === (job as any)?.email}
|
||||
<p class="text-yellow-600">Self-approval is disabled for this step.</p>
|
||||
{/if}
|
||||
{#if approvalInfo.approval_conditions?.user_groups_required?.length > 0}
|
||||
<p
|
||||
>Only members of the following groups can approve: <b
|
||||
>{approvalInfo.approval_conditions.user_groups_required.join(', ')}</b
|
||||
></p
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if completed}
|
||||
<!-- already shown above -->
|
||||
{/if}
|
||||
|
||||
{#if !completed && isSelfApprovalBypass}
|
||||
<div class="mt-2">
|
||||
<Alert type="warning" title="Warning">
|
||||
As an administrator, by resuming or cancelling this stage of the flow, you bypass the
|
||||
self-approval interdiction.
|
||||
</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-row flex-wrap justify-between">
|
||||
<a
|
||||
class="text-accent text-xs"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="{base}/run/{job?.id}?workspace={job?.workspace_id}"
|
||||
>
|
||||
Open run details (require auth) <ExternalLink size={12} class="inline" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if job && job.raw_flow && !completed}
|
||||
<h2 class="mt-10 text-sm font-semibold text-emphasis mb-2">Flow details</h2>
|
||||
<div class="rounded-md overflow-hidden">
|
||||
<FlowGraphV2
|
||||
workspace={job.workspace_id}
|
||||
triggerNode={false}
|
||||
earlyStop={job.raw_flow?.skip_expr !== undefined}
|
||||
cache={job.raw_flow?.cache_ttl !== undefined}
|
||||
modules={job.raw_flow?.modules}
|
||||
failureModule={job.raw_flow?.failure_module}
|
||||
preprocessorModule={job.raw_flow?.preprocessor_module}
|
||||
notSelectable
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="text-sm text-secondary">Loading...</p>
|
||||
{/if}
|
||||
</CenteredModal>
|
||||
+1
-1
@@ -4,7 +4,7 @@ verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
wmill = ">=1.663.0"
|
||||
wmill = ">=1.664.0"
|
||||
sendgrid = "*"
|
||||
mysql-connector-python = "*"
|
||||
pymongo = "*"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: '3.0.3'
|
||||
|
||||
info:
|
||||
version: 1.663.0
|
||||
version: 1.664.0
|
||||
title: OpenFlow Spec
|
||||
contact:
|
||||
name: Ruben Fiszel
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
RootModule = 'WindmillClient.psm1'
|
||||
|
||||
# Version number of this module.
|
||||
ModuleVersion = '1.663.0'
|
||||
ModuleVersion = '1.664.0'
|
||||
|
||||
# Supported PSEditions
|
||||
# CompatiblePSEditions = @()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "wmill"
|
||||
version = "1.663.0"
|
||||
version = "1.664.0"
|
||||
description = "A client library for accessing Windmill server wrapping the Windmill client API"
|
||||
license = "Apache-2.0"
|
||||
homepage = "https://windmill.dev"
|
||||
|
||||
@@ -2463,7 +2463,7 @@ class WorkflowCtx:
|
||||
)
|
||||
|
||||
async def _wait_for_approval(
|
||||
self, timeout: int = 1800, form: dict | None = None
|
||||
self, timeout: int = 1800, form: dict | None = None, self_approval: bool = True
|
||||
):
|
||||
key = self._alloc_key("approval")
|
||||
|
||||
@@ -2479,6 +2479,7 @@ class WorkflowCtx:
|
||||
"key": key,
|
||||
"timeout": timeout,
|
||||
"form": form,
|
||||
"self_approval_disabled": not self_approval,
|
||||
"steps": [],
|
||||
})
|
||||
|
||||
@@ -2762,6 +2763,7 @@ async def sleep(seconds: int):
|
||||
async def wait_for_approval(
|
||||
timeout: int = 1800,
|
||||
form: dict | None = None,
|
||||
self_approval: bool = True,
|
||||
) -> dict:
|
||||
"""Suspend the workflow and wait for an external approval.
|
||||
|
||||
@@ -2770,6 +2772,11 @@ async def wait_for_approval(
|
||||
|
||||
Returns a dict with ``value`` (form data), ``approver``, and ``approved``.
|
||||
|
||||
Args:
|
||||
timeout: Approval timeout in seconds (default 1800).
|
||||
form: Optional form schema for the approval page.
|
||||
self_approval: Whether the user who triggered the flow can approve it (default True).
|
||||
|
||||
Example::
|
||||
|
||||
urls = await step("urls", lambda: get_resume_urls())
|
||||
@@ -2778,7 +2785,7 @@ async def wait_for_approval(
|
||||
"""
|
||||
ctx: WorkflowCtx | None = _workflow_ctx.get(None)
|
||||
if ctx is not None:
|
||||
return await ctx._wait_for_approval(timeout=timeout, form=form)
|
||||
return await ctx._wait_for_approval(timeout=timeout, form=form, self_approval=self_approval)
|
||||
raise RuntimeError("wait_for_approval can only be called inside a @workflow")
|
||||
|
||||
|
||||
|
||||
@@ -632,7 +632,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
|
||||
* await step("notify", () => sendEmail(urls.approvalPage));
|
||||
* const { value, approver } = await waitForApproval({ timeout: 3600 });
|
||||
*/
|
||||
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
|
||||
/**
|
||||
* Process items in parallel with optional concurrency control.
|
||||
@@ -1336,12 +1336,17 @@ async def sleep(seconds: int)
|
||||
#
|
||||
# Returns a dict with \`\`value\`\` (form data), \`\`approver\`\`, and \`\`approved\`\`.
|
||||
#
|
||||
# Args:
|
||||
# timeout: Approval timeout in seconds (default 1800).
|
||||
# form: Optional form schema for the approval page.
|
||||
# self_approval: Whether the user who triggered the flow can approve it (default True).
|
||||
#
|
||||
# Example::
|
||||
#
|
||||
# urls = await step("urls", lambda: get_resume_urls())
|
||||
# await step("notify", lambda: send_email(urls["approvalPage"]))
|
||||
# result = await wait_for_approval(timeout=3600)
|
||||
async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict
|
||||
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict
|
||||
|
||||
# Process items in parallel with optional concurrency control.
|
||||
#
|
||||
|
||||
@@ -1605,7 +1605,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
|
||||
* await step("notify", () => sendEmail(urls.approvalPage));
|
||||
* const { value, approver } = await waitForApproval({ timeout: 3600 });
|
||||
*/
|
||||
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
|
||||
/**
|
||||
* Process items in parallel with optional concurrency control.
|
||||
@@ -2309,12 +2309,17 @@ async def sleep(seconds: int)
|
||||
#
|
||||
# Returns a dict with ``value`` (form data), ``approver``, and ``approved``.
|
||||
#
|
||||
# Args:
|
||||
# timeout: Approval timeout in seconds (default 1800).
|
||||
# form: Optional form schema for the approval page.
|
||||
# self_approval: Whether the user who triggered the flow can approve it (default True).
|
||||
#
|
||||
# Example::
|
||||
#
|
||||
# urls = await step("urls", lambda: get_resume_urls())
|
||||
# await step("notify", lambda: send_email(urls["approvalPage"]))
|
||||
# result = await wait_for_approval(timeout=3600)
|
||||
async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict
|
||||
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict
|
||||
|
||||
# Process items in parallel with optional concurrency control.
|
||||
#
|
||||
|
||||
@@ -648,12 +648,17 @@ async def sleep(seconds: int)
|
||||
#
|
||||
# Returns a dict with ``value`` (form data), ``approver``, and ``approved``.
|
||||
#
|
||||
# Args:
|
||||
# timeout: Approval timeout in seconds (default 1800).
|
||||
# form: Optional form schema for the approval page.
|
||||
# self_approval: Whether the user who triggered the flow can approve it (default True).
|
||||
#
|
||||
# Example::
|
||||
#
|
||||
# urls = await step("urls", lambda: get_resume_urls())
|
||||
# await step("notify", lambda: send_email(urls["approvalPage"]))
|
||||
# result = await wait_for_approval(timeout=3600)
|
||||
async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict
|
||||
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict
|
||||
|
||||
# Process items in parallel with optional concurrency control.
|
||||
#
|
||||
|
||||
@@ -481,7 +481,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
|
||||
* await step("notify", () => sendEmail(urls.approvalPage));
|
||||
* const { value, approver } = await waitForApproval({ timeout: 3600 });
|
||||
*/
|
||||
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
|
||||
/**
|
||||
* Process items in parallel with optional concurrency control.
|
||||
|
||||
@@ -610,7 +610,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
|
||||
* await step("notify", () => sendEmail(urls.approvalPage));
|
||||
* const { value, approver } = await waitForApproval({ timeout: 3600 });
|
||||
*/
|
||||
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
|
||||
/**
|
||||
* Process items in parallel with optional concurrency control.
|
||||
|
||||
@@ -608,7 +608,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
|
||||
* await step("notify", () => sendEmail(urls.approvalPage));
|
||||
* const { value, approver } = await waitForApproval({ timeout: 3600 });
|
||||
*/
|
||||
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
|
||||
/**
|
||||
* Process items in parallel with optional concurrency control.
|
||||
|
||||
@@ -614,7 +614,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
|
||||
* await step("notify", () => sendEmail(urls.approvalPage));
|
||||
* const { value, approver } = await waitForApproval({ timeout: 3600 });
|
||||
*/
|
||||
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
|
||||
/**
|
||||
* Process items in parallel with optional concurrency control.
|
||||
|
||||
@@ -575,7 +575,7 @@ workflow<T>(fn: (...args: any[]) => Promise<T>): void
|
||||
* await step("notify", () => sendEmail(urls.approvalPage));
|
||||
* const { value, approver } = await waitForApproval({ timeout: 3600 });
|
||||
*/
|
||||
waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
|
||||
|
||||
/**
|
||||
* Process items in parallel with optional concurrency control.
|
||||
|
||||
@@ -783,12 +783,17 @@ async def sleep(seconds: int)
|
||||
#
|
||||
# Returns a dict with ``value`` (form data), ``approver``, and ``approved``.
|
||||
#
|
||||
# Args:
|
||||
# timeout: Approval timeout in seconds (default 1800).
|
||||
# form: Optional form schema for the approval page.
|
||||
# self_approval: Whether the user who triggered the flow can approve it (default True).
|
||||
#
|
||||
# Example::
|
||||
#
|
||||
# urls = await step("urls", lambda: get_resume_urls())
|
||||
# await step("notify", lambda: send_email(urls["approvalPage"]))
|
||||
# result = await wait_for_approval(timeout=3600)
|
||||
async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> dict
|
||||
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict
|
||||
|
||||
# Process items in parallel with optional concurrency control.
|
||||
#
|
||||
|
||||
@@ -1577,6 +1577,7 @@ export class WorkflowCtx {
|
||||
_waitForApproval(options?: {
|
||||
timeout?: number;
|
||||
form?: object;
|
||||
selfApproval?: boolean;
|
||||
}): PromiseLike<{ value: any; approver: string; approved: boolean }> {
|
||||
const key = this._allocKey("approval");
|
||||
|
||||
@@ -1597,6 +1598,7 @@ export class WorkflowCtx {
|
||||
key,
|
||||
timeout: options?.timeout ?? 1800,
|
||||
form: options?.form,
|
||||
self_approval_disabled: !(options?.selfApproval ?? true),
|
||||
steps: [],
|
||||
});
|
||||
}
|
||||
@@ -1842,6 +1844,7 @@ export function workflow<T>(fn: (...args: any[]) => Promise<T>) {
|
||||
export function waitForApproval(options?: {
|
||||
timeout?: number;
|
||||
form?: object;
|
||||
selfApproval?: boolean;
|
||||
}): PromiseLike<{ value: any; approver: string; approved: boolean }> {
|
||||
const ctx: WorkflowCtx | null = _workflowCtx ?? Reflect.get(globalThis, "__wmill_wf_ctx");
|
||||
if (!ctx) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@windmill/windmill",
|
||||
"version": "1.663.0",
|
||||
"version": "1.664.0",
|
||||
"exports": "./src/index.ts",
|
||||
"publish": {
|
||||
"exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "windmill-client",
|
||||
"description": "Windmill SDK client for browsers and Node.js",
|
||||
"version": "1.663.0",
|
||||
"version": "1.664.0",
|
||||
"author": "Ruben Fiszel",
|
||||
"license": "Apache 2.0",
|
||||
"sideEffects": false,
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.663.0
|
||||
1.664.0
|
||||
|
||||
Reference in New Issue
Block a user