mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
Merge remote-tracking branch 'origin/main' into multi-tab-ai-session-sync
# Conflicts: # frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "1.799.0"
|
||||
".": "1.800.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# Changelog
|
||||
|
||||
## [1.800.0](https://github.com/windmill-labs/windmill/compare/v1.799.0...v1.800.0) (2026-08-31)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add --keep-deleted flag to wmill sync pull and push ([#10878](https://github.com/windmill-labs/windmill/issues/10878)) ([66123f3](https://github.com/windmill-labs/windmill/commit/66123f3a9b8978c0084b02f50b44cffba125a13a))
|
||||
* day-partition the service log index and expire whole chunks ([#10893](https://github.com/windmill-labs/windmill/issues/10893)) ([d91ee46](https://github.com/windmill-labs/windmill/commit/d91ee4614a70f20a194f47e190327129f499ec63))
|
||||
* free AI tokens + home search/filter revamp ([#10020](https://github.com/windmill-labs/windmill/issues/10020)) ([716ce2e](https://github.com/windmill-labs/windmill/commit/716ce2ece00cd5cfb8641afc6432636bc4aa46e9))
|
||||
* make the service log retention period an instance setting ([#10889](https://github.com/windmill-labs/windmill/issues/10889)) ([815de49](https://github.com/windmill-labs/windmill/commit/815de49e2322f85ca92b1e41a2bcd22591ebe93f))
|
||||
* rework the evals dataset drawer and run navigation ([#10884](https://github.com/windmill-labs/windmill/issues/10884)) ([1462f17](https://github.com/windmill-labs/windmill/commit/1462f17643302127b4bd76bacfde80cc03f9d606))
|
||||
* serve service log context from parquet and retire the raw log files ([#10892](https://github.com/windmill-labs/windmill/issues/10892)) ([338d75c](https://github.com/windmill-labs/windmill/commit/338d75cc5227e352cb84828c99bfd3b984cf0fa5))
|
||||
* serve service log retrieval from a columnar parquet store ([#10886](https://github.com/windmill-labs/windmill/issues/10886)) ([7c1a785](https://github.com/windmill-labs/windmill/commit/7c1a785f756ed27e4425f6534709b19971a73a97))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correct the service log ingest flush boundary ([#10898](https://github.com/windmill-labs/windmill/issues/10898)) ([ac56586](https://github.com/windmill-labs/windmill/commit/ac56586c0e56d4022761d3c80306a03d57f8bfcb))
|
||||
* harden the service log indexer's recovery and read paths ([#10904](https://github.com/windmill-labs/windmill/issues/10904)) ([831370c](https://github.com/windmill-labs/windmill/commit/831370cdde8e06f4298b17baa1a0041bacdd98c7))
|
||||
* keep raw-app editor selection consistent across sidebar and tabs ([#10885](https://github.com/windmill-labs/windmill/issues/10885)) ([b57e231](https://github.com/windmill-labs/windmill/commit/b57e231c2bf5e5fe007f0aa7b958a51e32b47141))
|
||||
* register every rotated service log file exactly once ([#10891](https://github.com/windmill-labs/windmill/issues/10891)) ([c817248](https://github.com/windmill-labs/windmill/commit/c8172480b0b1be6c57210212afc71d6ec8711235))
|
||||
* show a loading indicator while the initial data table migration is generated ([#10900](https://github.com/windmill-labs/windmill/issues/10900)) ([b998267](https://github.com/windmill-labs/windmill/commit/b998267c91b9dcf02787768f6205cc5aeda494fb))
|
||||
* track outstanding service log files on the rows themselves ([#10894](https://github.com/windmill-labs/windmill/issues/10894)) ([aa4a6ff](https://github.com/windmill-labs/windmill/commit/aa4a6ffd66813010a79c07741b01a984ed4e7df6))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* add service log documents to the index one batch at a time ([#10906](https://github.com/windmill-labs/windmill/issues/10906)) ([0c2eb0a](https://github.com/windmill-labs/windmill/commit/0c2eb0ae3d18f49c21370131d15011e8dd103746))
|
||||
|
||||
## [1.799.0](https://github.com/windmill-labs/windmill/compare/v1.798.1...v1.799.0) (2026-08-28)
|
||||
|
||||
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT cost_nanos FROM ai_free_token_usage WHERE email = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "cost_nanos",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "247486558e023ec3adf0c1e8f5664fc5e65995ce81e622c174a89befc1a527e5"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO ai_free_token_daily_usage (day, cost_nanos, updated_at)\n VALUES ($2::date, $1::bigint, now())\n ON CONFLICT (day) DO UPDATE\n SET cost_nanos = ai_free_token_daily_usage.cost_nanos + $1::bigint,\n updated_at = now()\n RETURNING cost_nanos",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "cost_nanos",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Date"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "44b9400fed2082c0df9d57f940923f37b8d452ea8e199ccc207da238368f1996"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO ai_free_token_daily_usage (day, cost_nanos, updated_at)\n VALUES ($2::date, GREATEST(0, $1::bigint), now())\n ON CONFLICT (day) DO UPDATE\n SET cost_nanos = GREATEST(0, ai_free_token_daily_usage.cost_nanos + $1::bigint),\n updated_at = now()",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Date"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "acf4a5f4b67ebd06157819677fee6be4cc3adc6aa859c3dbaa57fa50b63741cf"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO ai_free_token_usage (email, cost_nanos, updated_at)\n VALUES ($1, $2::bigint, now())\n ON CONFLICT (email) DO UPDATE\n SET cost_nanos = ai_free_token_usage.cost_nanos + $2::bigint,\n updated_at = now()\n RETURNING cost_nanos",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "cost_nanos",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c29d815cc38493f17950f12e9e5fe34b6c4ed1b2a49ed2b61dc9fb494026e956"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO ai_free_token_usage (email, cost_nanos, updated_at)\n VALUES ($1, GREATEST(0, $2::bigint), now())\n ON CONFLICT (email) DO UPDATE\n SET cost_nanos = GREATEST(0, ai_free_token_usage.cost_nanos + $2::bigint),\n updated_at = now()",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f08ef43b4523c74fcbfc7879c147e1bf2c9a0d11e9841caafca62d85c9fb4c8f"
|
||||
}
|
||||
Generated
+125
-125
@@ -430,7 +430,7 @@ dependencies = [
|
||||
"arrow-schema",
|
||||
"chrono",
|
||||
"half",
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"lexical-core",
|
||||
"memchr",
|
||||
"num",
|
||||
@@ -3247,7 +3247,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"half",
|
||||
"hashbrown 0.14.5",
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"libc",
|
||||
"log",
|
||||
"object_store",
|
||||
@@ -3426,7 +3426,7 @@ dependencies = [
|
||||
"datafusion-functions-aggregate-common",
|
||||
"datafusion-functions-window-common",
|
||||
"datafusion-physical-expr-common",
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"paste",
|
||||
"recursive",
|
||||
"serde_json",
|
||||
@@ -3441,7 +3441,7 @@ checksum = "422ac9cf3b22bbbae8cdf8ceb33039107fde1b5492693168f13bd566b1bcc839"
|
||||
dependencies = [
|
||||
"arrow",
|
||||
"datafusion-common",
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"itertools 0.14.0",
|
||||
"paste",
|
||||
]
|
||||
@@ -3595,7 +3595,7 @@ dependencies = [
|
||||
"datafusion-common",
|
||||
"datafusion-expr",
|
||||
"datafusion-physical-expr",
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"itertools 0.14.0",
|
||||
"log",
|
||||
"recursive",
|
||||
@@ -3618,7 +3618,7 @@ dependencies = [
|
||||
"datafusion-physical-expr-common",
|
||||
"half",
|
||||
"hashbrown 0.14.5",
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"itertools 0.14.0",
|
||||
"log",
|
||||
"paste",
|
||||
@@ -3680,7 +3680,7 @@ dependencies = [
|
||||
"futures",
|
||||
"half",
|
||||
"hashbrown 0.14.5",
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"itertools 0.14.0",
|
||||
"log",
|
||||
"parking_lot",
|
||||
@@ -3722,7 +3722,7 @@ dependencies = [
|
||||
"bigdecimal",
|
||||
"datafusion-common",
|
||||
"datafusion-expr",
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"log",
|
||||
"recursive",
|
||||
"regex",
|
||||
@@ -3853,7 +3853,7 @@ dependencies = [
|
||||
"deno_path_util",
|
||||
"deno_unsync",
|
||||
"futures",
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"libc",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
@@ -4122,7 +4122,7 @@ version = "0.228.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8bf8dbe5abf37d270bb853c5dfe45fbe3b1b6c453877cc11d7fe84e9862a6dbc"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"proc-macro-rules",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -5177,9 +5177,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "frostem"
|
||||
version = "1.20260821.2"
|
||||
version = "1.20260821.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08ed6437a2ed7fc408115e25cdb41e15ba4b17742a4c8d3d1b5276fe9b687209"
|
||||
checksum = "ca2e81eab151ba68484704bb3d21b4b4d2747314d3081fa86fa6c7300a4c42e2"
|
||||
|
||||
[[package]]
|
||||
name = "fs3"
|
||||
@@ -5755,9 +5755,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gosyn"
|
||||
version = "0.2.14"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9758a950dc61a15bc65162f72f5bec7e8efde91f102dc7dce7317f67019a6ba9"
|
||||
checksum = "28f4ed3cbcb66ddad22553bb3b94087573cf492a34501bc025a23167ed57e856"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"strum",
|
||||
@@ -5797,7 +5797,7 @@ dependencies = [
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"http 0.2.12",
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@@ -5816,7 +5816,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http 1.5.0",
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@@ -5898,7 +5898,7 @@ version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd1246c0e5493286aeb2dde35b1f4eb9c4ce00e628641210a5e553fc001a1f26"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
@@ -5951,9 +5951,9 @@ checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
@@ -6570,9 +6570,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
version = "2.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.1",
|
||||
@@ -6958,7 +6958,7 @@ version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ee7893dab2e44ae5f9d0173f26ff4aa327c10b01b06a72b52dd9405b628640d"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8124,7 +8124,7 @@ dependencies = [
|
||||
"dirs-sys 0.4.1",
|
||||
"fancy-regex 0.14.0",
|
||||
"heck",
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"log",
|
||||
"lru 0.12.5",
|
||||
"miette",
|
||||
@@ -8302,7 +8302,7 @@ version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
|
||||
dependencies = [
|
||||
"hermit-abi 0.5.2",
|
||||
"hermit-abi 0.5.3",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -9123,7 +9123,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
|
||||
dependencies = [
|
||||
"fixedbitset",
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9389,7 +9389,7 @@ checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"hermit-abi 0.5.2",
|
||||
"hermit-abi 0.5.3",
|
||||
"pin-project-lite",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -9619,7 +9619,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3ef4f2f0422f23a82ec9f628ea2acd12871c81a9362b02c43c1aa86acfc3ba1"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"nix 0.30.1",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -10565,7 +10565,7 @@ dependencies = [
|
||||
"convert_case 0.10.0",
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -11398,7 +11398,7 @@ version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
@@ -11492,7 +11492,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
"indexmap 1.9.3",
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"jiff",
|
||||
"schemars 0.9.0",
|
||||
"schemars 1.2.2",
|
||||
@@ -11520,7 +11520,7 @@ version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
@@ -11533,7 +11533,7 @@ version = "0.0.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"itoa",
|
||||
"libyml",
|
||||
"memchr",
|
||||
@@ -11953,7 +11953,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"hashbrown 0.15.5",
|
||||
"hashlink",
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"log",
|
||||
"memchr",
|
||||
"once_cell",
|
||||
@@ -12310,7 +12310,7 @@ checksum = "72e90b52ee734ded867104612218101722ad87ff4cf74fe30383bd244a533f97"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes-str",
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"swc_config_macro",
|
||||
@@ -12444,7 +12444,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c6f1b8f4232e7a7f614ff7c0f6ccb89c2d028cdf7629f79ad710cff5b28b62c"
|
||||
dependencies = [
|
||||
"better_scoped_tls",
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"once_cell",
|
||||
"par-core",
|
||||
"phf 0.11.3",
|
||||
@@ -12510,7 +12510,7 @@ checksum = "69ea0052ac23b5b9fbc85bbdb1791b36b918f9d55f594b0ed8e25babb4c32d16"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes-str",
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"once_cell",
|
||||
"rustc-hash 2.1.3",
|
||||
"serde",
|
||||
@@ -12550,7 +12550,7 @@ version = "21.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83259addd99ed4022aa9fc4d39428c008d3d42533769e1a005529da18cde4568"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"num_cpus",
|
||||
"once_cell",
|
||||
"par-core",
|
||||
@@ -13549,7 +13549,7 @@ version = "0.19.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime 0.6.11",
|
||||
@@ -13562,7 +13562,7 @@ version = "0.25.13+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow 1.0.4",
|
||||
@@ -13670,7 +13670,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
"sync_wrapper",
|
||||
@@ -13885,9 +13885,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-language"
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
|
||||
checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081"
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-r"
|
||||
@@ -14746,7 +14746,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -14831,7 +14831,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-ai"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
@@ -14864,7 +14864,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-alerting"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14877,7 +14877,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -14908,7 +14908,7 @@ dependencies = [
|
||||
"hmac",
|
||||
"http 1.5.0",
|
||||
"hyper 1.11.1",
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"itertools 0.14.0",
|
||||
"jsonwebtoken 8.3.0",
|
||||
"lazy_static",
|
||||
@@ -15017,7 +15017,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-agent-workers"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15040,7 +15040,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-assets"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15057,7 +15057,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-auth"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15083,7 +15083,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-client"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -15093,7 +15093,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-configs"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15110,7 +15110,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-debug"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"base64 0.22.1",
|
||||
@@ -15132,7 +15132,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-embeddings"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15155,7 +15155,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flow-conversations"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15171,7 +15171,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flows"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15193,7 +15193,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-groups"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15214,7 +15214,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-inputs"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15228,7 +15228,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-integration-tests"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -15263,7 +15263,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-jobs"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15288,7 +15288,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-npm-proxy"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15316,12 +15316,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-openapi"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
"http 1.5.0",
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
"serde",
|
||||
@@ -15338,7 +15338,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-schedule"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15358,7 +15358,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-scripts"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15396,7 +15396,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-settings"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15424,7 +15424,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-sse"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"serde",
|
||||
@@ -15436,7 +15436,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-users"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum 0.8.9",
|
||||
@@ -15460,7 +15460,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workers"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15474,7 +15474,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workspaces"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15509,7 +15509,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-audit"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"lazy_static",
|
||||
@@ -15523,7 +15523,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-autoscaling"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15542,7 +15542,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
@@ -15580,7 +15580,7 @@ dependencies = [
|
||||
"hex",
|
||||
"hmac",
|
||||
"hyper 1.11.1",
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"itertools 0.14.0",
|
||||
"jsonwebtoken 8.3.0",
|
||||
"lazy_static",
|
||||
@@ -15646,7 +15646,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-dep-map"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"itertools 0.14.0",
|
||||
@@ -15665,7 +15665,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-git-sync"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -15680,7 +15680,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-indexer"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -15707,7 +15707,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-jseval"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -15724,7 +15724,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
@@ -15740,7 +15740,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-mcp"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15761,7 +15761,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-native-triggers"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15792,7 +15792,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-oauth"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -15817,7 +15817,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-object-store"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -15851,7 +15851,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-operator"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -15869,7 +15869,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"convert_case 0.6.0",
|
||||
"serde",
|
||||
@@ -15878,7 +15878,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -15890,7 +15890,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -15902,7 +15902,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -15914,7 +15914,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -15926,7 +15926,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -15938,7 +15938,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -15949,7 +15949,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -15960,7 +15960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -15972,7 +15972,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -15983,7 +15983,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16005,7 +16005,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16017,7 +16017,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16031,7 +16031,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case 0.6.0",
|
||||
@@ -16048,7 +16048,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16061,7 +16061,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -16073,7 +16073,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16091,7 +16091,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -16107,7 +16107,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -16123,7 +16123,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16137,7 +16137,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-queue"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16176,7 +16176,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-runtime-nativets"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"const_format",
|
||||
@@ -16216,7 +16216,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-sql-datatype-parser-wasm"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"wasm-bindgen",
|
||||
@@ -16227,7 +16227,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-store"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16262,7 +16262,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-test-utils"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16286,7 +16286,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16319,7 +16319,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-amqp"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16346,7 +16346,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-azure"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16379,7 +16379,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-email"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16399,7 +16399,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-gcp"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16433,7 +16433,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-http"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16469,7 +16469,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-kafka"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16492,7 +16492,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-mqtt"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16516,7 +16516,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-nats"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -16540,7 +16540,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-postgres"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16575,7 +16575,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-sqs"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16603,7 +16603,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-websocket"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16628,7 +16628,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.13.1",
|
||||
@@ -16647,7 +16647,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-once-cell",
|
||||
@@ -16764,7 +16764,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker-volumes"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
@@ -17563,7 +17563,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"indexmap 2.14.0",
|
||||
"indexmap 2.14.1",
|
||||
"memchr",
|
||||
"typed-path",
|
||||
]
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -88,7 +88,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
df60763d1f243b0048dfc3fe700bc026b257bea8
|
||||
f2a31156ac08ecb02d89dbc66d72be58e9c877ff
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE ai_free_token_daily_usage;
|
||||
DROP TABLE ai_free_token_usage;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- One-time grant of the Windmill-provided free AI tier, measured as cost in nano-dollars
|
||||
-- (1e-9 USD) rather than raw tokens — a prompt-cache hit costs a fraction of a fresh input
|
||||
-- token, so a token count wildly overstates the real bill. The grant never resets: once
|
||||
-- spent, the user must bring their own API key. Keyed by normalized email so the allowance
|
||||
-- is shared across a user's workspaces (and is resistant to +tag / gmail-dot aliasing).
|
||||
CREATE TABLE ai_free_token_usage (
|
||||
email VARCHAR(255) PRIMARY KEY,
|
||||
cost_nanos BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Instance-wide daily cost ceiling (nano-dollars) for the free tier — a kill-switch
|
||||
-- independent of the per-user grant, bounding the blast radius of a bad day. One row per
|
||||
-- UTC day.
|
||||
CREATE TABLE ai_free_token_daily_usage (
|
||||
day DATE PRIMARY KEY,
|
||||
cost_nanos BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
+24
-24
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"anyhow",
|
||||
@@ -6274,7 +6274,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6286,7 +6286,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"serde",
|
||||
@@ -6295,7 +6295,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6307,7 +6307,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6319,7 +6319,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -6331,7 +6331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6343,7 +6343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6355,7 +6355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -6366,7 +6366,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6377,7 +6377,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6389,7 +6389,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6400,7 +6400,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -6422,7 +6422,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6434,7 +6434,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6448,7 +6448,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case",
|
||||
@@ -6465,7 +6465,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6478,7 +6478,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6490,7 +6490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6508,7 +6508,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -6524,7 +6524,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6540,7 +6540,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wasm"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"getrandom 0.2.17",
|
||||
@@ -6572,7 +6572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6586,7 +6586,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
|
||||
@@ -12,7 +12,7 @@ resolver = "2"
|
||||
members = ["."]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
edition = "2021"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ account: workspace_id(char), id(int), expires_at(ts), refresh_token(char), clien
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
agent_token_blacklist: token(char), expires_at(ts), blacklisted_at(ts), blacklisted_by(char)
|
||||
ai_agent_memory: workspace_id(char), conversation_id(uuid), step_id(char), messages(jsonb), created_at(ts), updated_at(ts)
|
||||
ai_free_token_daily_usage: day(date), cost_nanos(bigint), updated_at(ts)
|
||||
ai_free_token_usage: email(char), cost_nanos(bigint), updated_at(ts)
|
||||
ai_token_usage: workspace_id(char), day(date), email(char), provider(char), model(char), session_id(char), input_tokens(bigint), cache_read_tokens(bigint), cache_write_tokens(bigint), output_tokens(bigint), reported_cost_nano_usd(bigint), requests(bigint), updated_at(ts)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
alerts: id(int), alert_type(char), message(text), created_at(ts), acknowledged(bool), workspace_id(text), acknowledged_workspace(bool), resource(text)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.799.0
|
||||
version: 1.800.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -27256,11 +27256,29 @@ components:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 2000000
|
||||
free_tier:
|
||||
$ref: "#/components/schemas/FreeTierInfo"
|
||||
model_pricing:
|
||||
type: object
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/ModelPriceOverride"
|
||||
|
||||
FreeTierInfo:
|
||||
type: object
|
||||
description: >-
|
||||
Read-only. Present when the workspace has no AI provider of its own and is running
|
||||
on Windmill's free tier. Ignored on write.
|
||||
properties:
|
||||
exhausted:
|
||||
type: boolean
|
||||
description: The one-time grant is spent; no provider is served and the user must add their own API key.
|
||||
used_ratio:
|
||||
type: number
|
||||
description: Fraction of the grant consumed, 0 to 1.
|
||||
required:
|
||||
- exhausted
|
||||
- used_ratio
|
||||
|
||||
ModelPriceOverride:
|
||||
type: object
|
||||
description: negotiated rates in USD per million tokens, keyed `provider:model`
|
||||
|
||||
+182
-97
@@ -409,6 +409,19 @@ impl ExpiringProviderCredentials {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set on the copilot config when the workspace has no AI provider of its own and is
|
||||
/// running on Windmill's free tier, so the client can label the lent model as free, warn
|
||||
/// before the grant runs out, and tell the user to add their own key once it has — rather
|
||||
/// than showing the same "no provider configured" state a never-configured workspace gets.
|
||||
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
|
||||
pub struct FreeTierInfo {
|
||||
/// The grant is spent: no provider is served and the user must bring their own key.
|
||||
pub exhausted: bool,
|
||||
/// Fraction of the grant consumed, 0.0..=1.0. A ratio, not a dollar amount — the
|
||||
/// pricing model stays server-side.
|
||||
pub used_ratio: f64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Default)]
|
||||
pub struct AIConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -423,6 +436,11 @@ pub struct AIConfig {
|
||||
pub custom_prompts: Option<HashMap<String, String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_tokens_per_model: Option<HashMap<String, i32>>,
|
||||
/// Response-only: this same struct is the request body for saving a workspace's AI
|
||||
/// config, and `skip_deserializing` is what stops a client from storing a forged
|
||||
/// free-tier marker. Only the server sets it, per-request.
|
||||
#[serde(skip_serializing_if = "Option::is_none", skip_deserializing)]
|
||||
pub free_tier: Option<FreeTierInfo>,
|
||||
/// Per-model price overrides, keyed `provider:model` like `max_tokens_per_model`.
|
||||
/// Only models whose rates differ from the built-in table are stored.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -1013,83 +1031,119 @@ async fn proxy(
|
||||
check_scopes(&authed, || format!("resources:read:{}", resource_path))?;
|
||||
}
|
||||
|
||||
let mut credentials = match workspace_cache {
|
||||
Some(request_cache) if !request_cache.is_expired() && forced_resource_path.is_none() => {
|
||||
request_cache.credentials
|
||||
}
|
||||
_ => {
|
||||
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?;
|
||||
// Set when serving the request through Windmill's free AI tier (the lent key). Holds
|
||||
// the per-user concurrency lock and drives response metering.
|
||||
let mut free_lease: Option<crate::ai_free_tier_oss::FreeTierLease> = None;
|
||||
let mut credentials = 'cred: {
|
||||
match workspace_cache {
|
||||
Some(request_cache)
|
||||
if !request_cache.is_expired() && forced_resource_path.is_none() =>
|
||||
{
|
||||
request_cache.credentials
|
||||
}
|
||||
_ => {
|
||||
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?;
|
||||
|
||||
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 (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());
|
||||
|
||||
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?;
|
||||
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?;
|
||||
|
||||
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(),
|
||||
));
|
||||
let instance_has_config =
|
||||
instance_config.as_ref().is_some_and(|v| {
|
||||
serde_json::from_value::<AIConfig>(v.clone())
|
||||
.ok()
|
||||
.is_some_and(|c| c.has_providers())
|
||||
});
|
||||
match instance_config {
|
||||
// An instance `ai_config` row with no usable provider (e.g. `{}`
|
||||
// or `{"providers":{}}`) is treated as unconfigured, exactly as
|
||||
// build_copilot_settings_state does — otherwise its mere presence
|
||||
// would suppress the free-tier fallback below.
|
||||
Some(config) if instance_has_config => (
|
||||
config,
|
||||
"admins".to_string(),
|
||||
Some(current_instance_ai_config_revision()),
|
||||
),
|
||||
_ => {
|
||||
// Nothing configured: fall back to Windmill's free AI tier
|
||||
// (EE-only) if a lent key is set and both the user's
|
||||
// one-time grant and the instance's daily cap have room.
|
||||
// Errors once the grant is spent, the day is capped, or the
|
||||
// user already has a request in flight; None otherwise.
|
||||
// Ineligible identities (e.g. service accounts) are refused
|
||||
// inside the helper, so every path treats them alike.
|
||||
let free =
|
||||
crate::ai_free_tier_oss::resolve_free_tier_credentials(
|
||||
&provider,
|
||||
&db,
|
||||
&ai_path,
|
||||
&authed.email,
|
||||
&body,
|
||||
)
|
||||
.await?;
|
||||
if let Some((free_credentials, lease)) = free {
|
||||
free_lease = Some(lease);
|
||||
break 'cred free_credentials;
|
||||
}
|
||||
return Err(Error::internal_err(
|
||||
"AI resource not configured".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut ai_config = serde_json::from_value::<AIConfig>(ai_config_value)
|
||||
.map_err(|e| Error::BadRequest(e.to_string()))?;
|
||||
|
||||
let provider_config = ai_config
|
||||
.providers
|
||||
.as_mut()
|
||||
.and_then(|providers| providers.remove(&provider))
|
||||
.ok_or_else(|| {
|
||||
Error::BadRequest(format!("Provider {:?} not configured", provider))
|
||||
})?;
|
||||
|
||||
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 mut ai_config = serde_json::from_value::<AIConfig>(ai_config_value)
|
||||
.map_err(|e| Error::BadRequest(e.to_string()))?;
|
||||
|
||||
let provider_config = ai_config
|
||||
.providers
|
||||
.as_mut()
|
||||
.and_then(|providers| providers.remove(&provider))
|
||||
.ok_or_else(|| {
|
||||
Error::BadRequest(format!("Provider {:?} not configured", provider))
|
||||
})?;
|
||||
|
||||
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,
|
||||
)
|
||||
};
|
||||
|
||||
// For user-specified resources, fetch through an RLS-scoped
|
||||
// connection so PostgreSQL row-level security enforces the same
|
||||
// folder/group boundaries as the regular resource API. For the
|
||||
// workspace/instance ai_config path, the resource_path was already
|
||||
// validated by an admin/devops user when configuring the workspace,
|
||||
// so the raw pool is used.
|
||||
let resource = if is_user_specified_resource {
|
||||
// For user-specified resources, fetch through an RLS-scoped
|
||||
// connection so PostgreSQL row-level security enforces the same
|
||||
// folder/group boundaries as the regular resource API. For the
|
||||
// workspace/instance ai_config path, the resource_path was already
|
||||
// validated by an admin/devops user when configuring the workspace,
|
||||
// so the raw pool is used.
|
||||
let resource = if is_user_specified_resource {
|
||||
let mut tx = user_db.clone().begin(&authed).await?;
|
||||
let res = sqlx::query_scalar::<_, Option<sqlx::types::Json<Box<RawValue>>>>(
|
||||
"SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
|
||||
@@ -1112,38 +1166,45 @@ async fn proxy(
|
||||
.ok_or_else(|| Error::NotFound(format!("Could not find the resource {}, update the resource path in the workspace settings", resource_path)))?
|
||||
.ok_or_else(|| Error::BadRequest(format!("Empty resource value for {}", resource_path)))?;
|
||||
|
||||
let resource = serde_json::from_str::<AIResource>(resource.0.get())
|
||||
.map_err(|e| Error::BadRequest(e.to_string()))?;
|
||||
let resource = serde_json::from_str::<AIResource>(resource.0.get())
|
||||
.map_err(|e| Error::BadRequest(e.to_string()))?;
|
||||
|
||||
// Enforce RLS on $var: resolution when the resource path was
|
||||
// user-specified (X-Resource-Path header) so users can only read
|
||||
// variables they have permission to access.
|
||||
let enforce_authed = if is_user_specified_resource {
|
||||
Some(&authed)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let credentials = resolve_provider_credentials(
|
||||
&provider,
|
||||
&db,
|
||||
&resource_workspace,
|
||||
resource,
|
||||
enforce_authed,
|
||||
)
|
||||
.await?;
|
||||
if save_to_cache {
|
||||
AI_REQUEST_CACHE.insert(
|
||||
(w_id.clone(), provider.clone()),
|
||||
ExpiringProviderCredentials::new(
|
||||
credentials.clone(),
|
||||
instance_ai_config_revision,
|
||||
),
|
||||
);
|
||||
// Enforce RLS on $var: resolution when the resource path was
|
||||
// user-specified (X-Resource-Path header) so users can only read
|
||||
// variables they have permission to access.
|
||||
let enforce_authed = if is_user_specified_resource {
|
||||
Some(&authed)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let credentials = resolve_provider_credentials(
|
||||
&provider,
|
||||
&db,
|
||||
&resource_workspace,
|
||||
resource,
|
||||
enforce_authed,
|
||||
)
|
||||
.await?;
|
||||
if save_to_cache {
|
||||
AI_REQUEST_CACHE.insert(
|
||||
(w_id.clone(), provider.clone()),
|
||||
ExpiringProviderCredentials::new(
|
||||
credentials.clone(),
|
||||
instance_ai_config_revision,
|
||||
),
|
||||
);
|
||||
}
|
||||
credentials
|
||||
}
|
||||
credentials
|
||||
}
|
||||
};
|
||||
|
||||
// Free tier: pin the model and clamp max_tokens server-side before forwarding,
|
||||
// since the request body is otherwise client-controlled.
|
||||
if free_lease.is_some() {
|
||||
body = crate::ai_free_tier_oss::enforce_free_tier_body(&body)?;
|
||||
}
|
||||
|
||||
if let Some(fim_transform) =
|
||||
maybe_transform_fim_request(&provider, &ai_path, &credentials.base_url, &body)?
|
||||
{
|
||||
@@ -1291,8 +1352,32 @@ async fn proxy(
|
||||
|
||||
let status_code = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let is_sse = is_sse_response(&headers);
|
||||
|
||||
// Free tier: reconcile the cost reserved up-front against what the response actually
|
||||
// used, holding the per-user lock (via the lease) until it is recorded. The chat
|
||||
// streams (SSE), where the usage report only arrives in the final chunk; the
|
||||
// non-streaming JSON path is handled for completeness.
|
||||
if let Some(lease) = free_lease {
|
||||
let body = if is_sse {
|
||||
axum::body::Body::from_stream(inject_keepalives(
|
||||
Box::pin(crate::ai_free_tier_oss::meter_usage(
|
||||
response.bytes_stream(),
|
||||
db.clone(),
|
||||
lease,
|
||||
)),
|
||||
Duration::from_secs(KEEPALIVE_INTERVAL_SECS),
|
||||
))
|
||||
} else {
|
||||
let bytes = response.bytes().await.map_err(to_anyhow)?;
|
||||
crate::ai_free_tier_oss::record_json_usage(db.clone(), lease, &bytes);
|
||||
axum::body::Body::from(bytes)
|
||||
};
|
||||
return Ok((status_code, headers, body));
|
||||
}
|
||||
|
||||
let stream = response.bytes_stream();
|
||||
let body = if is_sse_response(&headers) {
|
||||
let body = if is_sse {
|
||||
axum::body::Body::from_stream(inject_keepalives(
|
||||
stream,
|
||||
Duration::from_secs(KEEPALIVE_INTERVAL_SECS),
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
#[cfg(feature = "private")]
|
||||
#[allow(unused)]
|
||||
pub use crate::ai_free_tier_ee::*;
|
||||
|
||||
// Open-source build: Windmill's free AI tier does not exist. These stubs make the
|
||||
// callers in `ai.rs` / `workspaces.rs` compile while disabling the feature entirely —
|
||||
// `resolve_free_tier_credentials` never opts in, so the proxy falls through to its
|
||||
// normal "AI resource not configured" path and the copilot stays hidden.
|
||||
//
|
||||
// Caller contract (enforced by the private impl, restated here for parity): the `email`
|
||||
// passed to `resolve_free_tier_credentials` / `free_tier_copilot_config` MUST be the
|
||||
// authenticated caller's own identity (an `ApiAuthed` email), never a client-supplied one —
|
||||
// it selects whose lent-key grant is spent and whose usage is read.
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
use crate::ai::AIConfig;
|
||||
#[cfg(not(feature = "private"))]
|
||||
use crate::db::DB;
|
||||
#[cfg(not(feature = "private"))]
|
||||
use axum::body::Bytes;
|
||||
#[cfg(not(feature = "private"))]
|
||||
use windmill_ai::ai_providers::AIProvider;
|
||||
#[cfg(not(feature = "private"))]
|
||||
use windmill_ai::credentials::ProviderCredentials;
|
||||
#[cfg(not(feature = "private"))]
|
||||
use windmill_common::error::Result;
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub struct FreeTierLease;
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub async fn resolve_free_tier_credentials(
|
||||
_provider: &AIProvider,
|
||||
_db: &DB,
|
||||
_ai_path: &str,
|
||||
_email: &str,
|
||||
_body: &Bytes,
|
||||
) -> Result<Option<(ProviderCredentials, FreeTierLease)>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn enforce_free_tier_body(body: &Bytes) -> Result<Bytes> {
|
||||
Ok(body.clone())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub async fn free_tier_copilot_config(_db: &DB, _email: &str) -> Result<Option<AIConfig>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn record_json_usage(_db: DB, _lease: FreeTierLease, _bytes: &[u8]) {}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub fn meter_usage<S>(
|
||||
upstream: S,
|
||||
_db: DB,
|
||||
_lease: FreeTierLease,
|
||||
) -> impl futures::Stream<Item = std::result::Result<Bytes, reqwest::Error>>
|
||||
where
|
||||
S: futures::Stream<Item = std::result::Result<Bytes, reqwest::Error>> + Unpin,
|
||||
{
|
||||
upstream
|
||||
}
|
||||
@@ -66,6 +66,9 @@ use crate::scim_oss::has_scim_token;
|
||||
use windmill_common::error::AppError;
|
||||
|
||||
mod ai;
|
||||
#[cfg(feature = "private")]
|
||||
mod ai_free_tier_ee;
|
||||
mod ai_free_tier_oss;
|
||||
mod ai_skills;
|
||||
mod apps;
|
||||
mod apps_raw_bundle;
|
||||
|
||||
@@ -18,7 +18,6 @@ use crate::teams_oss::{
|
||||
connect_teams, edit_teams_command, run_teams_message_test_job,
|
||||
workspaces_list_available_teams_channels, workspaces_list_available_teams_ids,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Path},
|
||||
routing::{get, post},
|
||||
@@ -153,10 +152,23 @@ async fn edit_copilot_config(
|
||||
.await?;
|
||||
let settings_state =
|
||||
build_copilot_settings_state(workspace_has_config, instance_ai_config.as_ref());
|
||||
// A provider-less instance config (e.g. `{}`) is unconfigured, same as build_copilot_settings_state
|
||||
// treats it — so it must not shadow the free-tier fallback here either.
|
||||
let instance_config_with_providers = instance_ai_config
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::from_value::<AIConfig>(v.clone()).ok())
|
||||
.filter(|c| c.has_providers());
|
||||
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 if let Some(instance_config) = instance_config_with_providers {
|
||||
instance_config
|
||||
} else if let Some(free_config) =
|
||||
crate::ai_free_tier_oss::free_tier_copilot_config(&db, &authed.email).await?
|
||||
{
|
||||
// Same fallback as get_copilot_info: with nothing configured, surface Windmill's free
|
||||
// tier (EE-only) so clearing a workspace provider activates it immediately, instead of
|
||||
// returning an empty config that disables AI until the next page reload re-fetches it.
|
||||
free_config
|
||||
} else {
|
||||
AIConfig::default()
|
||||
};
|
||||
@@ -179,6 +191,7 @@ struct EditCopilotConfigResponse {
|
||||
}
|
||||
|
||||
async fn get_copilot_info(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<AIConfig> {
|
||||
@@ -194,16 +207,25 @@ async fn get_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) =
|
||||
let instance_config =
|
||||
sqlx::query_scalar!("SELECT value FROM global_settings WHERE name = 'ai_config'")
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.and_then(|v| serde_json::from_value::<AIConfig>(v).ok())
|
||||
// A provider-less instance config (e.g. `{}`) is unconfigured; don't let it shadow the
|
||||
// free-tier fallback, matching the proxy and edit_copilot_config paths.
|
||||
.filter(|c| c.has_providers());
|
||||
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) = instance_config {
|
||||
Ok(Json(instance_config))
|
||||
} else if let Some(free_config) =
|
||||
crate::ai_free_tier_oss::free_tier_copilot_config(&db, &authed.email).await?
|
||||
{
|
||||
Ok(Json(
|
||||
serde_json::from_value::<AIConfig>(instance_config).unwrap_or_default(),
|
||||
))
|
||||
// Nothing configured: fall back to Windmill's free tier (EE-only). The config
|
||||
// carries a `free_tier` marker even once the user's grant is spent — with no
|
||||
// providers, but telling the client *why* AI is off.
|
||||
Ok(Json(free_config))
|
||||
} else {
|
||||
Ok(Json(AIConfig::default()))
|
||||
}
|
||||
@@ -216,7 +238,14 @@ pub async fn get_critical_alerts(
|
||||
authed: ApiAuthed,
|
||||
Query(params): Query<crate::utils::AlertQueryParams>,
|
||||
) -> JsonResult<serde_json::Value> {
|
||||
require_admin_or_devops(authed.is_admin, &authed.username, &authed.email, authed.job_id.is_some(), &db).await?;
|
||||
require_admin_or_devops(
|
||||
authed.is_admin,
|
||||
&authed.username,
|
||||
&authed.email,
|
||||
authed.job_id.is_some(),
|
||||
&db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
crate::utils::get_critical_alerts(db, params, Some(w_id)).await
|
||||
}
|
||||
@@ -232,7 +261,14 @@ pub async fn acknowledge_critical_alert(
|
||||
Path((w_id, id)): Path<(String, i32)>,
|
||||
authed: ApiAuthed,
|
||||
) -> Result<String> {
|
||||
require_admin_or_devops(authed.is_admin, &authed.username, &authed.email, authed.job_id.is_some(), &db).await?;
|
||||
require_admin_or_devops(
|
||||
authed.is_admin,
|
||||
&authed.username,
|
||||
&authed.email,
|
||||
authed.job_id.is_some(),
|
||||
&db,
|
||||
)
|
||||
.await?;
|
||||
crate::utils::acknowledge_critical_alert(db, Some(w_id), id).await
|
||||
}
|
||||
|
||||
|
||||
+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.799.0";
|
||||
export const VERSION = "v1.800.0";
|
||||
|
||||
export async function login(email: string, password: string): Promise<string> {
|
||||
return await windmill.UserService.login({
|
||||
|
||||
@@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork";
|
||||
// (e.g. utils.ts) can read it without importing main.ts and creating a circular
|
||||
// dependency (main → workspace → utils → main) that triggers a TDZ.
|
||||
// Re-exported from main.ts for backwards compatibility.
|
||||
export const VERSION = "1.799.0";
|
||||
export const VERSION = "1.800.0";
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.799.0",
|
||||
"version": "1.800.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.799.0",
|
||||
"version": "1.800.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.799.0",
|
||||
"version": "1.800.0",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev",
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type AIProviderModel,
|
||||
type AIProvider,
|
||||
type AIConfig,
|
||||
type FreeTierInfo,
|
||||
type ModelPriceOverride
|
||||
} from './gen'
|
||||
import {
|
||||
@@ -49,6 +50,10 @@ export const copilotInfo = writable<{
|
||||
/** Negotiated rates per `provider:model`, overriding the built-in price table. */
|
||||
modelPricing?: Record<string, ModelPriceOverride>
|
||||
webSearchEnabledProviders?: Partial<Record<AIProvider, boolean>>
|
||||
// Set only when the workspace has no AI provider of its own and is running on
|
||||
// Windmill's free tier. `exhausted` means the grant is spent: there is no model, but
|
||||
// that is a different state from "never configured" and the UI must say so.
|
||||
freeTier?: FreeTierInfo
|
||||
}>({
|
||||
enabled: false,
|
||||
codeCompletionModel: undefined,
|
||||
@@ -132,8 +137,9 @@ export function setCopilotInfo(aiConfig: AIConfig) {
|
||||
aiModels: aiModels,
|
||||
customPrompts: aiConfig.custom_prompts ?? {},
|
||||
maxTokensPerModel: aiConfig.max_tokens_per_model ?? {},
|
||||
webSearchEnabledProviders,
|
||||
modelPricing: aiConfig.model_pricing ?? {},
|
||||
webSearchEnabledProviders
|
||||
freeTier: aiConfig.free_tier
|
||||
})
|
||||
} else {
|
||||
copilotSessionModel.set(undefined)
|
||||
@@ -146,8 +152,11 @@ export function setCopilotInfo(aiConfig: AIConfig) {
|
||||
aiModels: [],
|
||||
customPrompts: {},
|
||||
maxTokensPerModel: {},
|
||||
webSearchEnabledProviders: {},
|
||||
modelPricing: {},
|
||||
webSearchEnabledProviders: {}
|
||||
// An exhausted free grant lands here — no providers, but the reason AI is off
|
||||
// is "you used it up", not "you never set it up".
|
||||
freeTier: aiConfig.free_tier
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
type: 'string' | 'number' | 'boolean'
|
||||
allowMultiple?: boolean
|
||||
format?: 'json'
|
||||
/** Boolean only: the value the filter holds while unset (defaults to false).
|
||||
* Selecting a filter whose default is false sets it to true immediately rather
|
||||
* than opening a true/false picker whose only useful choice is true. A default-true
|
||||
* boolean still opens the picker, since choosing false is the meaningful action. */
|
||||
default?: boolean
|
||||
}
|
||||
| {
|
||||
type: 'date'
|
||||
@@ -121,16 +126,34 @@
|
||||
// Create the filter instance object
|
||||
const filterInstance: { val: Partial<FilterInstanceRec<T>> } = $state({ val: {} })
|
||||
|
||||
// Sync URL params to filter instance on initialization and when URL changes
|
||||
// Sync URL params to filter instance, reactively. Reading urlFilter[key] tracked
|
||||
// means browser Back/Forward — which mutates useSearchParams' cells on popstate —
|
||||
// flows into the instance (chips, kind toggle, results), not just the first render.
|
||||
// The write happens untracked so it can't self-trigger, and the equality check plus
|
||||
// the reverse effect's own guard keep the two directions from ping-ponging.
|
||||
for (const key of Object.keys(schemaRec)) {
|
||||
let urlValue = urlFilter[key]
|
||||
if (schemaRec[key].type === 'date' && typeof urlValue === 'string') {
|
||||
const d = new Date(urlValue)
|
||||
urlValue = isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
if (urlValue !== undefined && urlValue !== null) {
|
||||
;(filterInstance.val as any)[key] = urlValue
|
||||
}
|
||||
$effect(() => {
|
||||
let urlValue = urlFilter[key]
|
||||
if (schemaRec[key].type === 'date' && typeof urlValue === 'string') {
|
||||
const d = new Date(urlValue)
|
||||
urlValue = isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
untrack(() => {
|
||||
const current = (filterInstance.val as any)[key]
|
||||
const same =
|
||||
urlValue instanceof Date && current instanceof Date
|
||||
? urlValue.getTime() === current.getTime()
|
||||
: current === (urlValue ?? undefined)
|
||||
if (same) return
|
||||
if (urlValue !== undefined && urlValue !== null) {
|
||||
;(filterInstance.val as any)[key] = urlValue
|
||||
} else if (current !== undefined) {
|
||||
// Key dropped from the URL (Back to a state without it): clear it so a
|
||||
// stale chip / filter doesn't linger against the navigated-to URL.
|
||||
delete (filterInstance.val as any)[key]
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Sync filter instance changes back to URL params
|
||||
@@ -275,6 +298,17 @@
|
||||
class?: string
|
||||
placeholder?: string
|
||||
autofocus?: boolean
|
||||
// Applied as the id of the underlying editable, so a parent can focus it or recognise its
|
||||
// key events by id (the searchbar is a contenteditable, not an <input>).
|
||||
inputId?: string
|
||||
// Free-text mode: while the input holds only free text (no specific filter tag is
|
||||
// being edited and no non-default filter is set), suppress the suggestions dropdown
|
||||
// so it behaves like a plain search box. This frees the arrow keys for the
|
||||
// surrounding UI (e.g. a results list). The dropdown returns the moment a specific
|
||||
// filter is present (e.g. `path: u/me/abc`).
|
||||
hideDropdownOnFreeText?: boolean
|
||||
// Notified whenever the dropdown's effective visibility changes
|
||||
onDropdownVisibleChange?: (visible: boolean) => void
|
||||
}
|
||||
|
||||
type SchemaT = FilterSchemaRec // TODO: Generic
|
||||
@@ -284,7 +318,10 @@
|
||||
presets: _presets = [],
|
||||
class: className,
|
||||
placeholder = 'Filter...',
|
||||
autofocus
|
||||
autofocus,
|
||||
hideDropdownOnFreeText = false,
|
||||
onDropdownVisibleChange,
|
||||
inputId
|
||||
}: Props<SchemaT> = $props()
|
||||
|
||||
let _value = new DebouncedTempValue(
|
||||
@@ -298,6 +335,24 @@
|
||||
let currentTag: keyof SchemaT | undefined = $state()
|
||||
let currentTextSegment = $state({ text: '', start: 0, end: 0 })
|
||||
let open = $state(false)
|
||||
|
||||
// A specific filter is in play when a tag is being edited or any non-free-text filter
|
||||
// is set.
|
||||
let hasSpecificFilter = $derived(
|
||||
!!currentTag || Object.keys(value).some((k) => k !== '_default_')
|
||||
)
|
||||
// A plain search term is being typed (free text, no specific filter).
|
||||
let hasFreeText = $derived(!!String(value['_default_'] ?? '').trim())
|
||||
// Effective dropdown visibility. Free-text mode suppresses the dropdown ONLY while the
|
||||
// user is typing a plain search term: it still opens when the input is empty (so the
|
||||
// available filters stay discoverable) and whenever a specific filter is set or being
|
||||
// edited. That leaves the arrow keys for the surrounding list only during free-text search.
|
||||
let dropdownVisible = $derived(
|
||||
open && (!hideDropdownOnFreeText || hasSpecificFilter || !hasFreeText)
|
||||
)
|
||||
$effect(() => {
|
||||
onDropdownVisibleChange?.(dropdownVisible)
|
||||
})
|
||||
let inputElement: HTMLDivElement | undefined = $state()
|
||||
let highlightedIndex = $state(0)
|
||||
let taggedTextInput: TaggedTextInput | undefined = $state()
|
||||
@@ -347,9 +402,17 @@
|
||||
key,
|
||||
filterSchema,
|
||||
onClick: () => {
|
||||
// Replace the text segment with the new filter tag
|
||||
const before = asText.val.slice(0, currentTextSegment.start)
|
||||
const after = asText.val.slice(currentTextSegment.end)
|
||||
if (schema[key].type === 'boolean' && schema[key].default !== true) {
|
||||
// Set the only useful value and reparse to canonical text. The space is
|
||||
// required: dropping the segment must not fuse the tags that flanked it.
|
||||
asText.val = `${before} ${after}`
|
||||
value[key] = true as any
|
||||
asText.reparse()
|
||||
return
|
||||
}
|
||||
// Replace the text segment with the new (empty) filter tag; the value picker opens.
|
||||
asText.val =
|
||||
`${before}${before && !before.endsWith(' ') ? ' ' : ''}${key}:\\\u00A0${after}`.trim() +
|
||||
'\u00A0'
|
||||
@@ -406,6 +469,28 @@
|
||||
onClick: () => setValueForCurrentTag(false)
|
||||
}
|
||||
]
|
||||
} else if (filter.type === 'string' && filter.format !== 'json') {
|
||||
// A plain string filter has no fixed options, but any presets targeting this tag
|
||||
// (`<tag>:<value>`) are exactly its useful values — surface them as suggestions so
|
||||
// picking one is a click, matching the top-level preset row. Unescape the tagged
|
||||
// syntax's `\ ` back to a real space for the stored value.
|
||||
const prefix = `${String(currentTag)}:`
|
||||
const suffix = String(value[currentTag!] ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return _presets
|
||||
.filter((p) => p.value.startsWith(prefix) && !asText.val.includes(p.value))
|
||||
.map((p) => {
|
||||
const raw = p.value.slice(prefix.length).replace(/^\\ /, '').replace(/\\ /g, ' ')
|
||||
return { name: p.name, raw }
|
||||
})
|
||||
.filter((p) => !suffix || p.raw.toLowerCase().includes(suffix))
|
||||
.map((p) => ({
|
||||
type: 'option' as const,
|
||||
option: { value: p.raw, label: p.name },
|
||||
onClick: () => appendOrSetValueForCurrentTag(p.raw),
|
||||
onNegativeClick: undefined
|
||||
}))
|
||||
}
|
||||
}
|
||||
return []
|
||||
@@ -514,7 +599,9 @@
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (!open) return
|
||||
// In free-text mode the dropdown is hidden; let arrow/enter keys pass through to
|
||||
// the surrounding UI (e.g. list navigation) rather than steering a hidden menu.
|
||||
if (!dropdownVisible) return
|
||||
if (e.key === 'Escape') {
|
||||
open = false
|
||||
return
|
||||
@@ -601,6 +688,7 @@
|
||||
>
|
||||
<TaggedTextInput
|
||||
bind:this={taggedTextInput}
|
||||
id={inputId}
|
||||
bind:value={asText.val}
|
||||
{tags}
|
||||
highlights={[
|
||||
@@ -617,7 +705,19 @@
|
||||
inputSizeClasses.md
|
||||
)}
|
||||
{placeholder}
|
||||
onKeyDown={() => (open = true)}
|
||||
onKeyDown={(e) => {
|
||||
// In free-text mode the searchbar coexists with a list that owns Arrow/Enter, so opening
|
||||
// the dropdown on a bare navigation key would steal them from an empty box. Typing, click,
|
||||
// or an already-open dropdown still open/keep it. Other searchbars keep opening on any key.
|
||||
if (
|
||||
!hideDropdownOnFreeText ||
|
||||
!['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape', 'Tab'].includes(
|
||||
e.key
|
||||
)
|
||||
) {
|
||||
open = true
|
||||
}
|
||||
}}
|
||||
{autofocus}
|
||||
/>
|
||||
{#if asText.val}
|
||||
@@ -630,9 +730,10 @@
|
||||
</div>
|
||||
|
||||
<GenericDropdown
|
||||
{open}
|
||||
open={dropdownVisible}
|
||||
instantClose={hideDropdownOnFreeText}
|
||||
getInputRect={() => inputElement?.getBoundingClientRect() ?? new DOMRect()}
|
||||
innerClass="!max-h-[30rem]"
|
||||
innerClass="!max-h-[25rem]"
|
||||
strictWidth
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
@@ -747,6 +848,20 @@
|
||||
class="border border-border-light rounded min-h-[4rem]"
|
||||
/>
|
||||
</div>
|
||||
{:else if filter.type === 'string'}
|
||||
{#if menuItems.length}
|
||||
<div class="max-h-60 overflow-y-auto">
|
||||
{#each menuItems as item, index}
|
||||
{#if item.type === 'option' && item.option}
|
||||
{@render menuItem({
|
||||
onClick: item.onClick,
|
||||
label: item.option.label || item.option.value,
|
||||
highlighted: index === highlightedIndex
|
||||
})}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
onTextSegmentAtCursorChange,
|
||||
onKeyDown,
|
||||
autofocus,
|
||||
id,
|
||||
class: className = ''
|
||||
}: {
|
||||
tags: { regex: RegExp; id: string; onClear?: () => void }[]
|
||||
@@ -18,6 +19,7 @@
|
||||
onTextSegmentAtCursorChange?: (segment: { text: string; start: number; end: number }) => void
|
||||
onKeyDown?: (e: KeyboardEvent) => void
|
||||
autofocus?: boolean
|
||||
id?: string
|
||||
class?: string
|
||||
} = $props()
|
||||
|
||||
@@ -337,7 +339,13 @@
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
onKeyDown?.(e)
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') return
|
||||
// Single-line filter input: block Enter's default newline insertion. Surrounding handlers
|
||||
// (suggestion select, list open) still run on bubble; only the contenteditable break is gone.
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') return
|
||||
const cursorPos = getCursorPosition()
|
||||
const text = getTextContent()
|
||||
|
||||
@@ -509,6 +517,7 @@
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<div
|
||||
bind:this={contentEditableDiv}
|
||||
{id}
|
||||
contenteditable="true"
|
||||
oninput={handleInput}
|
||||
onpaste={handlePaste}
|
||||
|
||||
@@ -27,18 +27,14 @@
|
||||
let trail = $derived(
|
||||
location ? [{ label: TITLE, onclick: location.back }, { label: location.label }] : undefined
|
||||
)
|
||||
let description = $derived(
|
||||
location
|
||||
? undefined
|
||||
: 'Each run answers a dataset of cases with this agent and scores the answers, so runs can be compared.'
|
||||
)
|
||||
</script>
|
||||
|
||||
<!-- The `sm:` widths are what actually win: the dialog's own are breakpoint-prefixed. -->
|
||||
<Modal
|
||||
title={TITLE}
|
||||
{trail}
|
||||
{description}
|
||||
paginated
|
||||
enterConfirms={false}
|
||||
bind:open
|
||||
kind="X"
|
||||
class="w-[90vw] sm:w-[90vw] max-w-[1400px] sm:max-w-[1400px] h-[85vh]"
|
||||
|
||||
@@ -1,82 +1,76 @@
|
||||
<script lang="ts">
|
||||
import { createGrid, type GridApi } from 'ag-grid-community'
|
||||
import 'ag-grid-community/styles/ag-grid.css'
|
||||
import 'ag-grid-community/styles/ag-theme-alpine.css'
|
||||
import '$lib/components/apps/components/display/table/theme/windmill-theme.css'
|
||||
import { transformColumnDefs } from '$lib/components/apps/components/display/table/utils'
|
||||
import { multilineCellColDef } from '$lib/components/apps/components/display/table/multilineCellEditor'
|
||||
import DarkModeObserver from '$lib/components/DarkModeObserver.svelte'
|
||||
import DataTable from '$lib/components/table/DataTable.svelte'
|
||||
import Head from '$lib/components/table/Head.svelte'
|
||||
import Cell from '$lib/components/table/Cell.svelte'
|
||||
import Row from '$lib/components/table/Row.svelte'
|
||||
import EditableTextarea from '$lib/components/common/EditableTextarea.svelte'
|
||||
import { Button, EmptyState } from '$lib/components/common'
|
||||
import { ListPlus, Plus, Trash2 } from 'lucide-svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import type { CaseDraft } from './evalUtils'
|
||||
|
||||
let {
|
||||
cases = $bindable(),
|
||||
onRemove,
|
||||
locked = false,
|
||||
onEditingChange
|
||||
onAdd,
|
||||
focusCaseId = undefined,
|
||||
locked = false
|
||||
}: {
|
||||
/** The drawer's working copy. Edits land here as they are made; the drawer writes them. */
|
||||
cases: CaseDraft[]
|
||||
/** Asked before a row goes, since a stored case has runs that executed it. */
|
||||
onRemove: (c: CaseDraft) => void
|
||||
/** Adds a case. The empty table offers this itself, where the first row would be. */
|
||||
onAdd: () => void
|
||||
/** A case to open for typing as soon as it appears — the one just added, so a new row is
|
||||
* ready to be filled in rather than waiting to be clicked. */
|
||||
focusCaseId?: string
|
||||
/** The cases are being written: an edit made now is one the request already left behind. */
|
||||
locked?: boolean
|
||||
/** A cell opened or closed. Reported up so Save can be pressed for an edit the list does not
|
||||
* hold yet: the press is what commits the cell, so it has to reach a live button. */
|
||||
onEditingChange?: (editing: boolean) => void
|
||||
} = $props()
|
||||
|
||||
type Row = { id: string; question: string; expected: string }
|
||||
/** A dataset is capped at 1000 cases and every row here mounts two editors and a button, so the
|
||||
* whole set at once is thousands of components for a table you read a screenful of. ag-grid
|
||||
* virtualised its rows; `DataTable` paginates instead, which is the primitive already here. */
|
||||
const CASES_PER_PAGE = 25
|
||||
let currentPage = $state(1)
|
||||
/** Bound to `DataTable`'s own per-page selector, so changing it there re-slices here rather
|
||||
* than only relabelling the footer. */
|
||||
let perPage = $state(CASES_PER_PAGE)
|
||||
let lastPage = $derived(Math.max(1, Math.ceil(cases.length / perPage)))
|
||||
let pageCases = $derived(cases.slice((currentPage - 1) * perPage, currentPage * perPage))
|
||||
// A case removed from the last page leaves it empty; step back rather than show nothing.
|
||||
$effect(() => {
|
||||
if (currentPage > lastPage) currentPage = lastPage
|
||||
})
|
||||
|
||||
/** `expected` is whatever the case holds: a bare string, or a value shown as JSON. Read back
|
||||
* the same way, so a case that held an object keeps holding one. */
|
||||
function expectedToText(value: unknown): string {
|
||||
if (value == undefined) return ''
|
||||
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
|
||||
}
|
||||
|
||||
function toRow(c: CaseDraft): Row {
|
||||
return {
|
||||
id: c.id ?? '',
|
||||
question: c.input?.user_message ?? '',
|
||||
expected: expectedToText(c.expected)
|
||||
}
|
||||
}
|
||||
|
||||
let api: GridApi<any> | undefined = $state()
|
||||
let eGui: HTMLDivElement | undefined = $state()
|
||||
let darkMode = $state(false)
|
||||
|
||||
const defaultColDef = {
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
...multilineCellColDef
|
||||
}
|
||||
|
||||
$effect(() => eGui && untrack(() => mountGrid()))
|
||||
function mountGrid() {
|
||||
if (!eGui || api) return
|
||||
createGrid(eGui, {
|
||||
rowData: untrack(() => cases.map(toRow)),
|
||||
columnDefs: columnDefs(),
|
||||
defaultColDef: { ...defaultColDef, editable: untrack(() => !locked) },
|
||||
onCellValueChanged: (e) => {
|
||||
const target = cases.find((c) => c.id === (e.data as Row).id)
|
||||
if (!target) return
|
||||
if (e.colDef.field === 'question') {
|
||||
target.input = { ...target.input, user_message: e.newValue ?? '' }
|
||||
} else if (e.colDef.field === 'expected') {
|
||||
setExpected(target, e.newValue ?? '')
|
||||
}
|
||||
},
|
||||
onCellEditingStarted: () => onEditingChange?.(true),
|
||||
onCellEditingStopped: () => onEditingChange?.(false),
|
||||
suppressColumnMoveAnimation: true,
|
||||
suppressDragLeaveHidesColumns: true,
|
||||
onGridReady: (e) => (api = e.api)
|
||||
/** The question editors, by case id, so a newly added row can be opened. */
|
||||
let questionEditors = $state<Record<string, EditableTextarea | undefined>>({})
|
||||
$effect(() => {
|
||||
const id = focusCaseId
|
||||
if (!id) return
|
||||
untrack(() => {
|
||||
// Onto the page the case is on before reaching for its editor: paginated, a case added to
|
||||
// a full page is not rendered yet, and there would be nothing to open.
|
||||
const at = cases.findIndex((c) => c.id === id)
|
||||
if (at < 0) return
|
||||
currentPage = Math.floor(at / perPage) + 1
|
||||
// After the row has been rendered and registered itself.
|
||||
requestAnimationFrame(() => questionEditors[id]?.edit())
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/** Text that parses as JSON is stored as JSON, so a structured answer can be written by hand. */
|
||||
function setExpected(c: CaseDraft, text: string) {
|
||||
// Cleared means the case has no expected answer, which is not the same as expecting the
|
||||
// empty string: a scorer reads `undefined` as "nothing to measure here" and leaves the case
|
||||
// out of its mean, where `''` scores it a hard zero.
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) {
|
||||
c.expected = undefined
|
||||
@@ -88,45 +82,93 @@
|
||||
c.expected = text
|
||||
}
|
||||
}
|
||||
|
||||
function columnDefs() {
|
||||
return transformColumnDefs({
|
||||
columnDefs: [
|
||||
{ field: 'question', headerName: 'Question', flex: 3 },
|
||||
{ field: 'expected', headerName: 'Expected', flex: 2 }
|
||||
] as any,
|
||||
onDelete: (values) => {
|
||||
if (locked) return
|
||||
const target = cases.find((c) => c.id === (values as Row).id)
|
||||
if (target) onRemove(target)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Keyed on which cases are in the list rather than on what is in them: pushing rows back on
|
||||
// each keystroke would reset the cell being typed in.
|
||||
let rowKey = $derived(cases.map((c) => c.id).join(','))
|
||||
$effect(() => {
|
||||
rowKey
|
||||
untrack(() => api?.updateGridOptions({ rowData: cases.map(toRow) }))
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
const editable = !locked
|
||||
untrack(() => api?.updateGridOptions({ defaultColDef: { ...defaultColDef, editable } }))
|
||||
})
|
||||
|
||||
/** Commit whatever cell is open into `cases`, and wait for it to land there: the drawer reads
|
||||
* the list to save it, and a cell still being typed in is an edit that press is saving. */
|
||||
export async function flush() {
|
||||
api?.stopEditing()
|
||||
// The commit reaches `cases` through the grid's own event queue, a macrotask away: reading in
|
||||
// the same turn, or after a microtask, reads the list as it was before the cell was typed in.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
</script>
|
||||
|
||||
<DarkModeObserver bind:darkMode />
|
||||
|
||||
<div bind:this={eGui} class="h-full w-full ag-theme-alpine {darkMode ? 'ag-theme-alpine-dark' : ''}"
|
||||
></div>
|
||||
{#if cases.length === 0}
|
||||
<EmptyState
|
||||
icon={ListPlus}
|
||||
title="No cases yet"
|
||||
description="A case is one question this agent is asked, and the answer it is measured against. A dataset needs at least one before it can be saved or run."
|
||||
action={{
|
||||
label: 'Add a case',
|
||||
icon: Plus,
|
||||
onClick: onAdd,
|
||||
// The same lock the header's own button takes: a save reads the case list before it is
|
||||
// sent, so a case added while one is in flight would be dropped when it lands.
|
||||
disabled: locked,
|
||||
// The only live call to action here: Save stays disabled until a case exists.
|
||||
variant: 'accent'
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<DataTable
|
||||
size="xs"
|
||||
tableFixed
|
||||
containerClass="bg-surface-tertiary"
|
||||
paginated={cases.length > perPage}
|
||||
bind:currentPage
|
||||
bind:perPage
|
||||
rowCount={cases.length}
|
||||
hasMore={currentPage < lastPage}
|
||||
showPrev={currentPage > 1}
|
||||
on:next={() => (currentPage = Math.min(currentPage + 1, lastPage))}
|
||||
on:previous={() => (currentPage = Math.max(currentPage - 1, 1))}
|
||||
>
|
||||
<colgroup>
|
||||
<col style="width: 55%" />
|
||||
<col />
|
||||
<col style="width: 3rem" />
|
||||
</colgroup>
|
||||
<Head>
|
||||
<tr>
|
||||
<Cell head first>Question</Cell>
|
||||
<Cell head>Expected</Cell>
|
||||
<Cell head last></Cell>
|
||||
</tr>
|
||||
</Head>
|
||||
<tbody class="divide-y">
|
||||
{#each pageCases as c (c.id)}
|
||||
<Row>
|
||||
<Cell first>
|
||||
<!-- `commitOnInput`: the drawer's Save reads this list, so an edit has to be in it by
|
||||
the time the button is pressed rather than waiting on a blur that the press
|
||||
itself would cause. -->
|
||||
<EditableTextarea
|
||||
bind:this={questionEditors[c.id ?? '']}
|
||||
value={c.input?.user_message ?? ''}
|
||||
placeholder="Question"
|
||||
editable={!locked}
|
||||
commitOnInput
|
||||
class="w-full"
|
||||
textClass="text-xs font-normal"
|
||||
onSave={(v) => (c.input = { ...(c.input ?? {}), user_message: v })}
|
||||
/>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<EditableTextarea
|
||||
value={expectedToText(c.expected)}
|
||||
placeholder="Expected"
|
||||
editable={!locked}
|
||||
commitOnInput
|
||||
class="w-full"
|
||||
textClass="text-xs font-normal"
|
||||
onSave={(v) => setExpected(c, v)}
|
||||
/>
|
||||
</Cell>
|
||||
<Cell last numeric>
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
destructive
|
||||
iconOnly
|
||||
startIcon={{ icon: Trash2 }}
|
||||
title="Delete case"
|
||||
disabled={locked}
|
||||
onclick={() => onRemove(c)}
|
||||
/>
|
||||
</Cell>
|
||||
</Row>
|
||||
{/each}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
{/if}
|
||||
|
||||
@@ -26,8 +26,7 @@
|
||||
onRenamed,
|
||||
onDeleted,
|
||||
onCasesChanged,
|
||||
onScorersChanged,
|
||||
onClosed
|
||||
onScorersChanged
|
||||
}: {
|
||||
workspace: string | undefined
|
||||
/** The agent the dataset is named after and belongs to. */
|
||||
@@ -45,8 +44,6 @@
|
||||
onDeleted: (path: string) => void | Promise<void>
|
||||
onCasesChanged: () => void | Promise<void>
|
||||
onScorersChanged: () => void | Promise<void>
|
||||
/** The drawer is done, whether it saved anything or not. */
|
||||
onClosed?: () => void
|
||||
} = $props()
|
||||
|
||||
let drawer: Drawer | undefined = $state()
|
||||
@@ -92,11 +89,6 @@
|
||||
let storedIds = $state<Set<string>>(new Set())
|
||||
|
||||
let removingCase = $state<CaseDraft | undefined>(undefined)
|
||||
/** The grid, so an open cell can be committed before the list is read to write it. */
|
||||
let casesGrid: EvalCasesGrid | undefined = $state()
|
||||
/** A cell is open in the grid: an edit the list does not hold yet, so Save stays live for the
|
||||
* press that commits it. */
|
||||
let casesEditing = $state(false)
|
||||
|
||||
/** The next free `<agent>_datasetN`, which is what a dataset is called until it is named. */
|
||||
function nextDatasetIndex(): number {
|
||||
@@ -109,9 +101,9 @@
|
||||
export function openDrawer(next: 'new' | 'edit') {
|
||||
mode = next
|
||||
pathError = ''
|
||||
// The grid is rebuilt per open and reports afresh; a cell left open when the drawer closed
|
||||
// would otherwise leave Save live over a set nothing has changed.
|
||||
casesEditing = false
|
||||
// Or the case added last time opens itself when the drawer is next opened on this dataset:
|
||||
// ids survive `fromStoredCase`, so the id would still match a row.
|
||||
focusCaseId = undefined
|
||||
// What was collected for a dataset that was never created belongs to that attempt.
|
||||
pendingScorers = []
|
||||
workingCases = next === 'edit' ? cases.map((c) => fromStoredCase(c) as CaseDraft) : []
|
||||
@@ -145,7 +137,6 @@
|
||||
|
||||
async function createDataset() {
|
||||
if (!workspace || !path || pathError) return
|
||||
await casesGrid?.flush()
|
||||
creating = true
|
||||
const created = path
|
||||
const submittedSummary = summary || undefined
|
||||
@@ -164,9 +155,9 @@
|
||||
creating = false
|
||||
return
|
||||
}
|
||||
// The pane moves onto the created dataset before the drawer closes: closing reopens the Run
|
||||
// dialog, which reads the pane's selection as it opens. A refresh that fails must still not
|
||||
// read as a create that failed, or the retry hits "already exists".
|
||||
// The pane moves onto the created dataset while the drawer is still open — the Run dialog
|
||||
// stands underneath and follows that selection. A refresh that fails must still not read as
|
||||
// a create that failed, or the retry hits "already exists".
|
||||
try {
|
||||
await onCreated(created)
|
||||
} catch (e) {
|
||||
@@ -193,8 +184,6 @@
|
||||
* refuses the case edits with it instead of leaving them written under the old name. */
|
||||
async function saveDataset() {
|
||||
if (!workspace || !datasetPath || !dataset || !path || pathError) return
|
||||
// Before anything reads the list: a cell still open is an edit that must go in with it.
|
||||
await casesGrid?.flush()
|
||||
// Read once, so every step of the save agrees on what was submitted: the fields are locked
|
||||
// while it runs, and the one the drawer navigates to afterwards must be the one written.
|
||||
// `summary` always travels: the server keeps the stored one when it is absent, so clearing
|
||||
@@ -233,8 +222,13 @@
|
||||
saving = false
|
||||
}
|
||||
|
||||
/** The case the grid should open for typing: the one just added. */
|
||||
let focusCaseId = $state<string | undefined>(undefined)
|
||||
|
||||
function addCase() {
|
||||
workingCases = [...workingCases, { ...emptyCase(), id: randomUUID() }]
|
||||
const id = randomUUID()
|
||||
workingCases = [...workingCases, { ...emptyCase(), id }]
|
||||
focusCaseId = id
|
||||
}
|
||||
|
||||
function deleteCase(id: string) {
|
||||
@@ -263,9 +257,15 @@
|
||||
)
|
||||
})
|
||||
let nothingToSave = $derived(metadataUnchanged && !casesChanged)
|
||||
/** A dataset with no cases cannot be run — the server refuses it (`run.rs`, "has no case to
|
||||
* run") — so there is no point creating one. Only creation is blocked: a saved dataset must
|
||||
* stay editable down to its last case, or removing that case, or renaming while it is the only
|
||||
* one left, would have no way to be saved. */
|
||||
let noCases = $derived(workingCases.length === 0)
|
||||
let noCasesTitle = $derived(noCases ? 'Add at least one case first' : undefined)
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={drawer} size="900px" on:close={() => onClosed?.()}>
|
||||
<Drawer bind:this={drawer} size="900px">
|
||||
<!-- Inside the drawer, not beside it. The drawer is portalled to the body and stacked above the
|
||||
pane that opened it, so a modal rendered from here is otherwise trapped under it. -->
|
||||
<ConfirmationModal
|
||||
@@ -354,7 +354,10 @@
|
||||
onWriting={(w) => (scorersWriting = w)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 grow min-h-0">
|
||||
<!-- Sized by its rows, not by what is left of the drawer: a dataset with one case should
|
||||
not be followed by an empty half-screen of table. The drawer body scrolls when the
|
||||
list outgrows it. -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-semibold text-emphasis">Cases</span>
|
||||
<span class="text-2xs text-tertiary">{workingCases.length}</span>
|
||||
@@ -369,13 +372,13 @@
|
||||
Add a case
|
||||
</Button>
|
||||
</div>
|
||||
<div class="grow min-h-0">
|
||||
<div>
|
||||
<EvalCasesGrid
|
||||
bind:this={casesGrid}
|
||||
bind:cases={workingCases}
|
||||
onRemove={removeCase}
|
||||
onAdd={addCase}
|
||||
{focusCaseId}
|
||||
locked={writing}
|
||||
onEditingChange={(v) => (casesEditing = v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -397,7 +400,7 @@
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
loading={saving}
|
||||
disabled={writing || !path || !!pathError || (nothingToSave && !casesEditing)}
|
||||
disabled={writing || !path || !!pathError || nothingToSave}
|
||||
onclick={saveDataset}
|
||||
>
|
||||
Save
|
||||
@@ -408,7 +411,8 @@
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
loading={creating}
|
||||
disabled={creating || !path || !!pathError}
|
||||
disabled={creating || !path || !!pathError || noCases}
|
||||
title={noCasesTitle}
|
||||
onclick={createDataset}
|
||||
>
|
||||
Create dataset
|
||||
|
||||
@@ -47,16 +47,6 @@
|
||||
let hasDraft = $derived(editedConfig !== undefined)
|
||||
let dataset = $state<string | undefined>(undefined)
|
||||
let hoveringDataset = $state(false)
|
||||
/** Set while the dialog stands aside for the dataset drawer, holding the pane's dataset as it
|
||||
* was on the way out: coming back onto a different one is the detour's answer, and adopted. */
|
||||
let steppedAside = $state<{ dataset: string | undefined } | undefined>(undefined)
|
||||
|
||||
/** Hands the screen to the dataset drawer, to be given back when it closes. */
|
||||
function stepAside(go: () => void) {
|
||||
steppedAside = { dataset: defaultDataset }
|
||||
open = false
|
||||
go()
|
||||
}
|
||||
|
||||
/** The agent's versions, for pinning one. Loaded when the dialog opens rather than held: a
|
||||
* version list goes stale the moment the agent is saved again. */
|
||||
@@ -76,21 +66,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the dialog was already open on the previous run of the effect below, which reads it
|
||||
* to tell an open from the pane's dataset moving underneath. */
|
||||
let wasOpen = false
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return
|
||||
const isOpen = open
|
||||
const pane = defaultDataset
|
||||
untrack(() => {
|
||||
loadVersions()
|
||||
const aside = steppedAside
|
||||
steppedAside = undefined
|
||||
if (aside) {
|
||||
// Back from the drawer: a dataset created or edited there moves the pane onto it, and
|
||||
// anything else leaves the field as it was left.
|
||||
if (defaultDataset !== aside.dataset) dataset = defaultDataset
|
||||
if (!isOpen) {
|
||||
wasOpen = false
|
||||
return
|
||||
}
|
||||
// Seeded on every open: the dataset last worked in, and the state of the agent there is
|
||||
// most reason to measure.
|
||||
dataset = defaultDataset
|
||||
// Followed while the dialog stands, not only read at open: the dataset drawer opens over
|
||||
// this dialog rather than in place of it, so creating, renaming or deleting a dataset
|
||||
// there moves the pane's selection with the dialog still up. Nothing else moves it then.
|
||||
dataset = pane
|
||||
if (wasOpen) return
|
||||
wasOpen = true
|
||||
loadVersions()
|
||||
// The state of the agent there is most reason to measure.
|
||||
choice = hasDraft ? 'draft' : 'deployed'
|
||||
})
|
||||
})
|
||||
@@ -184,7 +179,7 @@
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
startIcon={{ icon: Plus }}
|
||||
onclick={() => stepAside(onNewDataset)}
|
||||
onclick={onNewDataset}
|
||||
>
|
||||
New dataset
|
||||
</Button>
|
||||
@@ -215,7 +210,7 @@
|
||||
title="Edit this dataset"
|
||||
on:click={() => {
|
||||
close()
|
||||
stepAside(() => onEditDataset(item.value ?? ''))
|
||||
onEditDataset(item.value ?? '')
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
@@ -228,7 +223,7 @@
|
||||
btnClasses="w-full !h-auto !justify-start !rounded-none flex items-center gap-2 px-3 py-2 text-xs !font-normal text-secondary hover:bg-surface-hover"
|
||||
onClick={() => {
|
||||
close()
|
||||
stepAside(onNewDataset)
|
||||
onNewDataset()
|
||||
}}
|
||||
>
|
||||
<Plus size={13} />
|
||||
@@ -247,7 +242,7 @@
|
||||
startIcon={{ icon: Pencil }}
|
||||
iconOnly
|
||||
title="Edit this dataset"
|
||||
on:click={() => stepAside(() => onEditDataset(dataset ?? ''))}
|
||||
on:click={() => onEditDataset(dataset ?? '')}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -7,17 +7,21 @@
|
||||
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
|
||||
import TimeAgo from '$lib/components/TimeAgo.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { Bot, Code2, Loader2, Plus } from 'lucide-svelte'
|
||||
import { Bot, ChevronRight, Code2, Loader2, Plus } from 'lucide-svelte'
|
||||
import { overlayHostActive, topmostSurface } from '$lib/components/common/overlayHost.svelte'
|
||||
import type { EvalDataset, EvalExperiment, ExperimentScore } from '$lib/gen'
|
||||
import { datasetSummary, experimentName, formatScore, subjectLabel } from './evalUtils'
|
||||
|
||||
let {
|
||||
experiments,
|
||||
datasets,
|
||||
caseProgress,
|
||||
loaded,
|
||||
active = false,
|
||||
deployedHash = undefined,
|
||||
currentVersion = undefined,
|
||||
onOpen,
|
||||
onHighlight,
|
||||
onEditDataset,
|
||||
onNew
|
||||
}: {
|
||||
@@ -25,17 +29,87 @@
|
||||
experiments: EvalExperiment[]
|
||||
/** Whether the list has been read: an empty table is a statement about the agent. */
|
||||
loaded: boolean
|
||||
/** Whether this list is the page on screen. The keyboard is only answered while it is: the
|
||||
* run page keeps its own rows, and both would otherwise move on one press. */
|
||||
active?: boolean
|
||||
/** The workspace's datasets, for naming the one a run is of by what it is for. */
|
||||
datasets: EvalDataset[]
|
||||
/** How many cases each still-running run has finished, keyed by run id. A run the flow has
|
||||
* not been read for yet is at none of them rather than absent: the count is on the row from
|
||||
* the moment it appears, so it never arrives late and shifts the column. */
|
||||
caseProgress: Record<string, number>
|
||||
/** What the agent hashes to as deployed, and the version it is on: they resolve a run of
|
||||
* edits that were later saved, so a run is labelled here as the run picker labels it. */
|
||||
deployedHash?: string
|
||||
currentVersion?: number
|
||||
onOpen: (experiment: EvalExperiment) => void
|
||||
/** The highlighted run, reported up so the surface can act on it — arrowing into the run
|
||||
* page opens the run under the highlight rather than whichever was opened last. */
|
||||
onHighlight?: (id: string | undefined) => void
|
||||
onEditDataset: (dataset: string) => void
|
||||
onNew: () => void
|
||||
} = $props()
|
||||
|
||||
/** The highlighted run, by id. One state for both the pointer and the keyboard, as a melt menu
|
||||
* does it: hovering a row moves the highlight to it, so the arrows carry on from wherever the
|
||||
* pointer left off instead of running a second, invisible cursor of their own. It says where
|
||||
* the highlight is, not what is chosen — a run is not opened until Enter.
|
||||
*
|
||||
* By id and not by index: the list is newest-first and the poll prepends to it, so an index
|
||||
* would quietly come to mean a different run and Enter would open the wrong one. */
|
||||
let cursorId = $state<string | undefined>(undefined)
|
||||
let cursor = $derived(
|
||||
cursorId === undefined ? -1 : experiments.findIndex((e) => e.id === cursorId)
|
||||
)
|
||||
let body: HTMLTableSectionElement | undefined = $state()
|
||||
|
||||
// A window listener answers keys aimed anywhere, so it has to ask two questions the DOM cannot:
|
||||
// is my host the visible one — session preview tabs stay mounted when hidden — and is my surface
|
||||
// still the one on top, rather than under a drawer or a dialog opened since.
|
||||
const hostActive = overlayHostActive()
|
||||
const onTop = topmostSurface()
|
||||
const listening = () => hostActive() && onTop()
|
||||
|
||||
$effect(() => {
|
||||
onHighlight?.(cursorId)
|
||||
})
|
||||
|
||||
// A highlight on a run that has since gone, and the highlight itself when the list is not the
|
||||
// page on screen.
|
||||
$effect(() => {
|
||||
if (!active || (cursorId !== undefined && cursor < 0)) cursorId = undefined
|
||||
})
|
||||
|
||||
function move(by: number) {
|
||||
if (experiments.length === 0) return
|
||||
const from = cursor < 0 ? (by > 0 ? -1 : experiments.length) : cursor
|
||||
const at = Math.max(0, Math.min(experiments.length - 1, from + by))
|
||||
cursorId = experiments[at]?.id
|
||||
// `nearest`, so arrowing through a long list scrolls by a row rather than jumping the table.
|
||||
requestAnimationFrame(() =>
|
||||
body?.querySelectorAll('tr')[at]?.scrollIntoView({ block: 'nearest' })
|
||||
)
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (!active || !listening() || event.metaKey || event.ctrlKey || event.altKey) return
|
||||
const el = event.target as HTMLElement | null
|
||||
if (el?.closest?.('input, textarea, select, [contenteditable="true"], [role="listbox"]')) return
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
move(1)
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
move(-1)
|
||||
} else if (event.key === 'Enter' && experiments[cursor]) {
|
||||
// Enter belongs to whatever is focused if that thing does something with it. A highlighted
|
||||
// row is not a reason to swallow the press on `New evaluation` or a row's dataset button.
|
||||
if (el?.closest?.('button, a[href], [role="button"], summary')) return
|
||||
event.preventDefault()
|
||||
onOpen(experiments[cursor])
|
||||
}
|
||||
}
|
||||
|
||||
/** The one number a column reports: a pass rate where it has a line to pass, the mean where it
|
||||
* does not. */
|
||||
function headline(score: ExperimentScore): string | undefined {
|
||||
@@ -44,13 +118,16 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<DataTable size="sm" tableFixed>
|
||||
<colgroup>
|
||||
<col style="width: 20%" />
|
||||
<col style="width: 22%" />
|
||||
<col style="width: 5rem" />
|
||||
<col style="width: 6rem" />
|
||||
<col />
|
||||
<col style="width: 7rem" />
|
||||
<col style="width: 2rem" />
|
||||
</colgroup>
|
||||
<Head>
|
||||
<tr>
|
||||
@@ -58,12 +135,20 @@
|
||||
<Cell head>Dataset</Cell>
|
||||
<Cell head numeric>Cases</Cell>
|
||||
<Cell head>Scores</Cell>
|
||||
<Cell head last numeric>When</Cell>
|
||||
<Cell head numeric>When</Cell>
|
||||
<Cell head last></Cell>
|
||||
</tr>
|
||||
</Head>
|
||||
<tbody class="divide-y">
|
||||
{#each experiments as experiment (experiment.id)}
|
||||
<Row hoverable on:click={() => onOpen(experiment)}>
|
||||
<tbody class="divide-y" bind:this={body}>
|
||||
{#each experiments as experiment, i (experiment.id)}
|
||||
<!-- No `hoverable`, and not `selected` either: the first is a second hover tint competing
|
||||
with this highlight, the second is the blue that means "chosen". Nothing here is
|
||||
chosen until Enter, so the highlight wears the ordinary hover surface. -->
|
||||
<Row
|
||||
class="cursor-pointer {i === cursor ? 'bg-surface-hover' : ''}"
|
||||
on:click={() => onOpen(experiment)}
|
||||
on:hover={(e) => e.detail && (cursorId = experiment.id)}
|
||||
>
|
||||
<Cell first>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
@@ -99,7 +184,18 @@
|
||||
</Button>
|
||||
</Cell>
|
||||
<Cell numeric>
|
||||
<span class="tabular-nums text-secondary">{experiment.case_count}</span>
|
||||
{#if experiment.running}
|
||||
<!-- The run's only spinner. One per score badge instead read as the judge being
|
||||
slow, when what is unfinished is the run. -->
|
||||
<span
|
||||
class="inline-flex items-center gap-1 tabular-nums text-secondary whitespace-nowrap"
|
||||
>
|
||||
<Loader2 size={11} class="animate-spin text-blue-500 shrink-0" />
|
||||
{caseProgress[experiment.id] ?? 0}/{experiment.case_count}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="tabular-nums text-secondary">{experiment.case_count}</span>
|
||||
{/if}
|
||||
</Cell>
|
||||
<Cell>
|
||||
<div class="flex flex-wrap gap-1 min-w-0">
|
||||
@@ -117,8 +213,6 @@
|
||||
<span class="tabular-nums font-semibold text-emphasis">{value}</span>
|
||||
{:else if score.failed > 0}
|
||||
<span class="text-red-500">failed</span>
|
||||
{:else if experiment.running}
|
||||
<Loader2 size={11} class="animate-spin text-blue-500 self-center" />
|
||||
{:else}
|
||||
<span class="text-tertiary">—</span>
|
||||
{/if}
|
||||
@@ -126,33 +220,34 @@
|
||||
</Badge>
|
||||
{/each}
|
||||
{#if (experiment.scores ?? []).length === 0}
|
||||
{#if experiment.running}
|
||||
<span class="text-2xs text-tertiary inline-flex items-center gap-1">
|
||||
<Loader2 size={11} class="animate-spin text-blue-500" />
|
||||
scoring
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-2xs text-tertiary">not scored</span>
|
||||
{/if}
|
||||
<!-- Not a pending state: a scorer's column is on the run from the moment it is
|
||||
created, with or without a number in it, so nothing arrives here later. -->
|
||||
<span class="text-2xs text-tertiary">not scored</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell last numeric>
|
||||
<Cell numeric>
|
||||
<span class="text-2xs text-tertiary whitespace-nowrap">
|
||||
<TimeAgo date={experiment.created_at} agoOnlyIfRecent />
|
||||
</span>
|
||||
</Cell>
|
||||
<Cell last numeric>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
class="text-tertiary transition-opacity {i === cursor ? 'opacity-100' : 'opacity-0'}"
|
||||
/>
|
||||
</Cell>
|
||||
</Row>
|
||||
{/each}
|
||||
{#if experiments.length === 0 && !loaded}
|
||||
<tr>
|
||||
<td colspan="5" class="p-3">
|
||||
<td colspan="6" class="p-3">
|
||||
<Skeleton layout={[[2], 0.5, [2], 0.5, [2]]} />
|
||||
</td>
|
||||
</tr>
|
||||
{:else if experiments.length === 0}
|
||||
<tr>
|
||||
<td colspan="5" class="p-6">
|
||||
<td colspan="6" class="p-6">
|
||||
<div class="flex flex-col items-center justify-center gap-3 text-center">
|
||||
<span class="text-sm text-emphasis">No runs yet</span>
|
||||
<span class="text-xs text-secondary max-w-md">
|
||||
|
||||
@@ -196,26 +196,31 @@
|
||||
</DropdownV2>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-md">
|
||||
<div>
|
||||
{#if scorers.length === 0}
|
||||
<div class="p-3 text-2xs text-tertiary">
|
||||
<div class="border rounded-md p-3 text-2xs text-tertiary">
|
||||
A scorer reads one run and returns a number. Every run of this dataset is measured by all of
|
||||
them, which is what makes two runs comparable.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y">
|
||||
<!-- Laid out as `RadioCard` is — same padding, icon size, and label-over-description type
|
||||
scale — so a scorer reads as a card of the same family. Not that component: nothing
|
||||
here is being selected. -->
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each scorers as scorer (scorer.id)}
|
||||
<div class="flex items-center gap-2 px-2 py-1.5">
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-md border border-border-light bg-surface-tertiary p-3"
|
||||
>
|
||||
{#if scorer.kind === 'agent'}
|
||||
<Bot size={13} class="text-tertiary shrink-0" />
|
||||
<Bot size={16} class="text-secondary shrink-0" />
|
||||
{:else}
|
||||
<Code2 size={13} class="text-tertiary shrink-0" />
|
||||
<Code2 size={16} class="text-secondary shrink-0" />
|
||||
{/if}
|
||||
<div class="flex flex-col min-w-0 grow">
|
||||
<span class="text-xs text-emphasis truncate leading-tight">
|
||||
<span class="text-xs font-semibold text-emphasis truncate">
|
||||
{scorerLabel(scorer)}
|
||||
</span>
|
||||
<span class="text-2xs text-tertiary truncate leading-tight">{scorer.path}</span>
|
||||
<span class="text-xs font-normal text-secondary truncate mt-0.5">{scorer.path}</span>
|
||||
</div>
|
||||
{#if scorer.pass_if != undefined}
|
||||
<span class="text-2xs text-tertiary shrink-0" title="Pass threshold">
|
||||
|
||||
@@ -9,9 +9,11 @@
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import { Splitpanes, Pane } from 'svelte-splitpanes'
|
||||
import AnimatedPane from '$lib/components/splitPanes/AnimatedPane.svelte'
|
||||
import {
|
||||
type AgentDraft,
|
||||
AiEvalsService,
|
||||
JobService,
|
||||
type EvalCase,
|
||||
type EvalDataset,
|
||||
type EvalExperiment,
|
||||
@@ -35,9 +37,11 @@
|
||||
Code2,
|
||||
ExternalLink
|
||||
} from 'lucide-svelte'
|
||||
import PagedContent from '$lib/components/common/modal/PagedContent.svelte'
|
||||
import EvalDatasetDrawer from './EvalDatasetDrawer.svelte'
|
||||
import EvalRunsList from './EvalRunsList.svelte'
|
||||
import EvalRunDialog from './EvalRunDialog.svelte'
|
||||
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
|
||||
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
|
||||
import {
|
||||
caseLabel,
|
||||
@@ -52,6 +56,10 @@
|
||||
/** A dataset is capped at this many cases, so one page holds the whole set. */
|
||||
const CASE_PAGE_SIZE = 1000
|
||||
|
||||
/** The id the run's flow gives the loop over its cases (`CASES_NODE_ID` in `ai_evals/run.rs`).
|
||||
* Looked up by id rather than by position: the flow has a step after the loop too. */
|
||||
const CASES_MODULE_ID = 'cases'
|
||||
|
||||
let {
|
||||
agentPath,
|
||||
opWorkspace = undefined,
|
||||
@@ -91,12 +99,56 @@
|
||||
/** What the agent hashes to as deployed: a run of edits carrying it ran what was then saved. */
|
||||
let deployedHash = $state<string | undefined>(undefined)
|
||||
let running = $state(false)
|
||||
let scorers = $derived(dataset?.scorers ?? [])
|
||||
/** The run on screen belongs to a dataset still being read. Until it arrives the rows and the
|
||||
* scorer columns would both be built from the *previous* dataset, so both are held back. */
|
||||
let datasetLoading = $state(false)
|
||||
let scorers = $derived(datasetLoading ? [] : (dataset?.scorers ?? []))
|
||||
let selectedCaseId = $state<string | undefined>(undefined)
|
||||
|
||||
let datasetDrawer: EvalDatasetDrawer | undefined = $state()
|
||||
let runDialogOpen = $state(false)
|
||||
let resumeRunDialog = $state(false)
|
||||
/** The run the list has highlighted, so arrowing into the run page opens that one. Without it
|
||||
* the arrow could only fall back to whichever run was opened last, which on a dialog just
|
||||
* opened is none at all. */
|
||||
let highlightedRunId = $state<string | undefined>(undefined)
|
||||
|
||||
/** How many cases each still-running run has finished, keyed by run id. Read from the flow
|
||||
* executing the run: the list carries the case total, and counting the finished ones there
|
||||
* would be a per-case query for every run listed. The flow already records it — one slot per
|
||||
* case in `flow_jobs_success`, null until that case's iteration is over. */
|
||||
let caseProgress = $state<Record<string, number>>({})
|
||||
|
||||
async function readCaseProgress() {
|
||||
const workspace = ws
|
||||
const live = experiments.filter((e) => e.running)
|
||||
if (!workspace || live.length === 0) {
|
||||
if (Object.keys(caseProgress).length > 0) caseProgress = {}
|
||||
return
|
||||
}
|
||||
const read = await Promise.all(
|
||||
live.map(async (e) => {
|
||||
try {
|
||||
const update = await JobService.getJobUpdates({
|
||||
workspace,
|
||||
id: e.run_job_id,
|
||||
running: true,
|
||||
noLogs: true
|
||||
})
|
||||
const cases = update.flow_status?.modules?.find((m) => m.id === CASES_MODULE_ID)
|
||||
if (!cases) return undefined
|
||||
return [
|
||||
e.id,
|
||||
(cases.flow_jobs_success ?? []).filter((s) => s != undefined).length
|
||||
] as const
|
||||
} catch {
|
||||
// Left out of the map, so the row reads `0/total` until a later poll answers. A
|
||||
// flow that cannot be read is already the list's problem to report, not this one's.
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
)
|
||||
caseProgress = Object.fromEntries(read.filter((e) => e !== undefined))
|
||||
}
|
||||
|
||||
let experiment = $derived(experiments.find((e) => e.id === experimentId))
|
||||
|
||||
@@ -120,6 +172,7 @@
|
||||
runsLoadError = false
|
||||
try {
|
||||
experiments = await listSubjectExperiments()
|
||||
await readCaseProgress()
|
||||
} catch (e) {
|
||||
runsLoadError = true
|
||||
sendUserToast(`Failed to load the runs: ${e}`, true)
|
||||
@@ -183,6 +236,8 @@
|
||||
// Switching datasets leaves the previous request in flight; only the newest may write, or a
|
||||
// slow response for the dataset you just left replaces the one you are looking at.
|
||||
let loadGeneration = 0
|
||||
/** Which run the pane is opening; only the newest may clear `datasetLoading`. */
|
||||
let openGeneration = 0
|
||||
|
||||
async function loadDataset(path: string | undefined): Promise<boolean> {
|
||||
const generation = ++loadGeneration
|
||||
@@ -345,6 +400,7 @@
|
||||
await loadResults()
|
||||
} else {
|
||||
experiments = await listSubjectExperiments()
|
||||
await readCaseProgress()
|
||||
}
|
||||
} finally {
|
||||
refreshing = false
|
||||
@@ -354,27 +410,46 @@
|
||||
/** Opens a run, bringing its dataset with it and offering the run before it as the baseline.
|
||||
* Reading the cells is left to the effect on the selection, so every way in opens one alike. */
|
||||
async function openRun(id: string) {
|
||||
// Against the dataset that is loaded, not the one that is selected: skipping on the selection
|
||||
// alone would leave a run open over a dataset whose cases and scorers were never read.
|
||||
const target = experiments.find((e) => e.id === id)
|
||||
if (target && target.dataset !== dataset?.path) {
|
||||
await useDataset(target.dataset)
|
||||
// Only when the run itself changes: re-showing the one already open — arrowing back into it
|
||||
// from the list — must keep whatever comparison the user picked.
|
||||
if (id !== experimentId) {
|
||||
const index = experiments.findIndex((e) => e.id === id)
|
||||
// The run before it *of the same dataset*: the list spans datasets, and a run of another
|
||||
// set of cases is not a baseline for this one.
|
||||
baselineId = experiments.slice(index + 1).find((e) => e.dataset === target?.dataset)?.id
|
||||
}
|
||||
const index = experiments.findIndex((e) => e.id === id)
|
||||
// The run before it *of the same dataset*: the list spans datasets, and a run of another set
|
||||
// of cases is not a baseline for this one.
|
||||
baselineId = experiments.slice(index + 1).find((e) => e.dataset === target?.dataset)?.id
|
||||
experimentId = id
|
||||
viewingRun = true
|
||||
selectedCaseId = undefined
|
||||
// Opened first, read second: the dataset is a request, and waiting on it here is a click
|
||||
// that does nothing at all until the network answers. The page carries the wait instead.
|
||||
//
|
||||
// Against what is *selected* as well as what is loaded. `selectedDataset` moves the moment a
|
||||
// read starts, so a load still in flight for another dataset shows up here: without that
|
||||
// test, opening a run of the dataset already committed would skip `useDataset` entirely and
|
||||
// leave the in-flight one free to commit its cases under this run.
|
||||
const needsDataset =
|
||||
!!target && (target.dataset !== dataset?.path || target.dataset !== selectedDataset)
|
||||
datasetLoading = needsDataset
|
||||
viewingRun = true
|
||||
if (needsDataset) {
|
||||
// Numbered like `loadDataset`'s own read, and for the same reason: opening a second run
|
||||
// while the first is still loading leaves two `finally`s racing, and the loser clearing
|
||||
// the flag would uncover the table with neither dataset in hand.
|
||||
const generation = ++openGeneration
|
||||
try {
|
||||
await useDataset(target!.dataset)
|
||||
} finally {
|
||||
if (generation === openGeneration) datasetLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runAll(runSubject: EvalSubject, path: string): Promise<boolean> {
|
||||
if (!ws || !path) return false
|
||||
running = true
|
||||
let id: string
|
||||
try {
|
||||
id = await AiEvalsService.runExperiment({
|
||||
await AiEvalsService.runExperiment({
|
||||
workspace: ws,
|
||||
requestBody: { dataset: path, subject: runSubject }
|
||||
})
|
||||
@@ -386,9 +461,10 @@
|
||||
// From here the run exists and is billing: what can still fail is reading it back, and
|
||||
// saying "failed to run" to that invites a second, duplicate run.
|
||||
try {
|
||||
// Onto the list rather than into the run: a run that has just started has no answers and
|
||||
// no scores, and the list already fills its row in as they land. Reading it is a click.
|
||||
if (path !== dataset?.path) await useDataset(path)
|
||||
await loadRuns()
|
||||
await openRun(id)
|
||||
} catch (e) {
|
||||
sendUserToast(
|
||||
`The run started but could not be read back: ${e}. Reload the runs list to see it.`,
|
||||
@@ -407,8 +483,6 @@
|
||||
|
||||
/** The dataset is gone and every run of it with it: back to the list, on no dataset. */
|
||||
async function datasetDeleted(path: string) {
|
||||
// A run dialog waiting behind the drawer has nothing to come back to.
|
||||
resumeRunDialog = false
|
||||
if (selectedDataset === path) {
|
||||
viewingRun = false
|
||||
selectedCaseId = undefined
|
||||
@@ -442,6 +516,13 @@
|
||||
}
|
||||
|
||||
let selectedRow = $derived(displayRows.find((row) => row.case_id === selectedCaseId))
|
||||
/** The case the side panel is showing. Held rather than read straight off the selection: the
|
||||
* pane animates shut over a few hundred milliseconds, and the selection is gone on the first
|
||||
* of them, which would empty the panel before it had finished closing. */
|
||||
let openRow = $state<ExperimentRow | undefined>(undefined)
|
||||
$effect(() => {
|
||||
if (selectedRow) openRow = selectedRow
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!ws) return
|
||||
@@ -570,34 +651,52 @@
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full min-h-0">
|
||||
{#if loaded && loadError}
|
||||
<div class="h-full flex flex-col items-center justify-center gap-2 p-6 text-center">
|
||||
<span class="text-sm text-emphasis">Could not load evals</span>
|
||||
<span class="text-xs text-secondary max-w-md">
|
||||
The datasets or runs could not be read. Check your access to this agent and reload.
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Warmed: the run page is a table, a splitter and two selects, and building it on the first
|
||||
click lands that work inside the transition — only the first navigation stutters, which
|
||||
reads as the animation being unreliable rather than as a cost. -->
|
||||
<PagedContent
|
||||
warm
|
||||
class="grow min-h-0"
|
||||
current={!viewingRun || !loaded ? 'list' : 'run'}
|
||||
onNavigate={(key) => {
|
||||
// Right opens the run under the highlight, falling back to whichever was open before;
|
||||
// left is the way back, the same as the breadcrumb.
|
||||
if (key === 'run') {
|
||||
// Both branches go through `openRun`: it is what brings the run's own dataset back,
|
||||
// and the fallback run may be of a dataset the list has since moved off.
|
||||
const id = highlightedRunId ?? experimentId
|
||||
if (id) openRun(id)
|
||||
} else if (key === 'list') {
|
||||
viewingRun = false
|
||||
selectedCaseId = undefined
|
||||
}
|
||||
}}
|
||||
pages={[
|
||||
{ key: 'list', content: listPage },
|
||||
{ key: 'run', content: runPage }
|
||||
]}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet listPage()}
|
||||
<!-- Carried by the level rather than by the dialog: as the dialog's own `description` it
|
||||
vanished the moment a run opened, shrinking the header and jolting the page under it. -->
|
||||
<p class="text-xs text-secondary">
|
||||
Each run answers a dataset of cases with this agent and scores the answers, so runs can be
|
||||
compared.
|
||||
</p>
|
||||
<div class="flex flex-wrap items-end gap-2 py-2">
|
||||
{#if viewingRun}
|
||||
<Label label="Run" class="w-52 shrink">
|
||||
<Select items={experimentItems} bind:value={experimentId} class="text-xs" />
|
||||
</Label>
|
||||
<Label label="Compare to" class="w-48 shrink">
|
||||
<Select
|
||||
items={experimentItems.filter((i) => i.value !== experimentId)}
|
||||
bind:value={baselineId}
|
||||
placeholder="No comparison"
|
||||
clearable
|
||||
disabled={experiments.length < 2}
|
||||
class="text-xs"
|
||||
/>
|
||||
</Label>
|
||||
{/if}
|
||||
<div class="grow"></div>
|
||||
{#if viewingRun && experiment?.run_job_id}
|
||||
<a
|
||||
class="text-xs text-accent hover:underline inline-flex items-center gap-1 shrink-0 pb-2"
|
||||
href={`${base}/run/${experiment.run_job_id}?workspace=${ws}`}
|
||||
target="_blank"
|
||||
>
|
||||
Open the job
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
{/if}
|
||||
{#if !viewingRun && loaded && datasets.length > 0}
|
||||
{#if loaded && datasets.length > 0}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="default"
|
||||
@@ -622,98 +721,115 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grow min-h-0 overflow-auto">
|
||||
<EvalRunsList
|
||||
{experiments}
|
||||
{datasets}
|
||||
{caseProgress}
|
||||
{loaded}
|
||||
active={!viewingRun}
|
||||
{deployedHash}
|
||||
{currentVersion}
|
||||
onOpen={(e) => openRun(e.id)}
|
||||
onHighlight={(id) => (highlightedRunId = id)}
|
||||
onEditDataset={async (path) => {
|
||||
if (await useDataset(path)) datasetDrawer?.openDrawer('edit')
|
||||
}}
|
||||
onNew={() => (runDialogOpen = true)}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet runPage()}
|
||||
<div class="flex flex-wrap items-end gap-2 py-2">
|
||||
<Label label="Run" class="w-52 shrink">
|
||||
<Select items={experimentItems} bind:value={experimentId} class="text-xs" />
|
||||
</Label>
|
||||
<Label label="Compare to" class="w-48 shrink">
|
||||
<Select
|
||||
items={experimentItems.filter((i) => i.value !== experimentId)}
|
||||
bind:value={baselineId}
|
||||
placeholder="No comparison"
|
||||
clearable
|
||||
disabled={experiments.length < 2}
|
||||
class="text-xs"
|
||||
/>
|
||||
</Label>
|
||||
<div class="grow"></div>
|
||||
{#if experiment?.run_job_id}
|
||||
<a
|
||||
class="text-xs text-accent hover:underline inline-flex items-center gap-1 shrink-0 pb-2"
|
||||
href={`${base}/run/${experiment.run_job_id}?workspace=${ws}`}
|
||||
target="_blank"
|
||||
>
|
||||
Open the job
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grow min-h-0">
|
||||
<Splitpanes class="h-full">
|
||||
<Pane size={selectedRow ? 60 : 100} minSize={35}>
|
||||
<Splitpanes class="h-full splitter-hidden">
|
||||
<Pane minSize={35}>
|
||||
<div class="h-full overflow-auto">
|
||||
{#if loaded && loadError}
|
||||
<div class="h-full flex flex-col items-center justify-center gap-2 p-6 text-center">
|
||||
<span class="text-sm text-emphasis">Could not load evals</span>
|
||||
<span class="text-xs text-secondary max-w-md">
|
||||
The datasets or runs could not be read. Check your access to this agent and reload.
|
||||
</span>
|
||||
</div>
|
||||
{:else if loaded && datasets.length === 0}
|
||||
<div class="h-full flex flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<span class="text-sm text-emphasis">No dataset yet</span>
|
||||
<span class="text-xs text-secondary max-w-md">
|
||||
A dataset is the set of cases this agent is measured on. Runs are of a dataset, so
|
||||
it is the first thing to make.
|
||||
</span>
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Plus }}
|
||||
onclick={() => datasetDrawer?.openDrawer('new')}
|
||||
>
|
||||
New dataset
|
||||
</Button>
|
||||
</div>
|
||||
{:else if !viewingRun || !loaded}
|
||||
<EvalRunsList
|
||||
{experiments}
|
||||
{datasets}
|
||||
{loaded}
|
||||
{deployedHash}
|
||||
{currentVersion}
|
||||
onOpen={(e) => openRun(e.id)}
|
||||
onEditDataset={async (path) => {
|
||||
if (await useDataset(path)) datasetDrawer?.openDrawer('edit')
|
||||
}}
|
||||
onNew={() => (runDialogOpen = true)}
|
||||
/>
|
||||
{:else}
|
||||
<DataTable size="sm" tableFixed rounded={!selectedRow}>
|
||||
<colgroup>
|
||||
<col style="width: 24%" />
|
||||
<col style="width: 32%" />
|
||||
{#each scorers as scorer (scorer.id)}
|
||||
<col style="width: 9rem" />
|
||||
{/each}
|
||||
</colgroup>
|
||||
<Head>
|
||||
<tr>
|
||||
<Cell head first>Case</Cell>
|
||||
<Cell head last={scorers.length === 0}>Answer</Cell>
|
||||
{#each scorers as scorer, index (scorer.id)}
|
||||
{@const mean = means.find((m) => m.scorer_id === scorer.id)}
|
||||
{@const headline = columnHeadline(scorer, mean)}
|
||||
<Cell head numeric last={index === scorers.length - 1}>
|
||||
<!-- The second row keeps its height while there is nothing in it, so the table
|
||||
does not move when the first score lands. -->
|
||||
<div class="flex flex-col items-end min-w-0 w-full overflow-hidden">
|
||||
<span
|
||||
class="flex items-center gap-1 min-w-0 max-w-full"
|
||||
title={columnTitle(scorer, mean)}
|
||||
>
|
||||
{#if scorer.kind === 'agent'}
|
||||
<Bot size={13} class="text-tertiary shrink-0" />
|
||||
{:else}
|
||||
<Code2 size={13} class="text-tertiary shrink-0" />
|
||||
{/if}
|
||||
<span class="truncate min-w-0">{scorerLabel(scorer)}</span>
|
||||
</span>
|
||||
<span class="h-4 flex items-baseline gap-1.5 font-normal">
|
||||
{#if headline}
|
||||
<span class="tabular-nums text-emphasis font-semibold">
|
||||
{headline.value}
|
||||
<DataTable size="sm" tableFixed>
|
||||
<colgroup>
|
||||
<col style="width: 24%" />
|
||||
<col style="width: 32%" />
|
||||
{#each scorers as scorer (scorer.id)}
|
||||
<col style="width: 9rem" />
|
||||
{/each}
|
||||
</colgroup>
|
||||
<Head>
|
||||
<tr>
|
||||
<Cell head first>Case</Cell>
|
||||
<Cell head last={scorers.length === 0}>Answer</Cell>
|
||||
{#each scorers as scorer, index (scorer.id)}
|
||||
{@const mean = means.find((m) => m.scorer_id === scorer.id)}
|
||||
{@const headline = columnHeadline(scorer, mean)}
|
||||
<Cell head numeric last={index === scorers.length - 1}>
|
||||
<!-- The second row keeps its height while there is nothing in it, so the table
|
||||
does not move when the first score lands. -->
|
||||
<div class="flex flex-col items-end min-w-0 w-full overflow-hidden">
|
||||
<span
|
||||
class="flex items-center gap-1 min-w-0 max-w-full"
|
||||
title={columnTitle(scorer, mean)}
|
||||
>
|
||||
{#if scorer.kind === 'agent'}
|
||||
<Bot size={13} class="text-tertiary shrink-0" />
|
||||
{:else}
|
||||
<Code2 size={13} class="text-tertiary shrink-0" />
|
||||
{/if}
|
||||
<span class="truncate min-w-0">{scorerLabel(scorer)}</span>
|
||||
</span>
|
||||
<span class="h-4 flex items-baseline gap-1.5 font-normal">
|
||||
{#if headline}
|
||||
<span class="tabular-nums text-emphasis font-semibold">
|
||||
{headline.value}
|
||||
</span>
|
||||
{#if headline.delta && headline.direction !== 0}
|
||||
<span
|
||||
class={`text-2xs tabular-nums ${headline.direction > 0 ? 'text-green-500' : headline.direction < 0 ? 'text-red-500' : 'text-tertiary'}`}
|
||||
>
|
||||
{headline.delta}
|
||||
</span>
|
||||
{#if headline.delta && headline.direction !== 0}
|
||||
<span
|
||||
class={`text-2xs tabular-nums ${headline.direction > 0 ? 'text-green-500' : headline.direction < 0 ? 'text-red-500' : 'text-tertiary'}`}
|
||||
>
|
||||
{headline.delta}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</Cell>
|
||||
{/each}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</Cell>
|
||||
{/each}
|
||||
</tr>
|
||||
</Head>
|
||||
<tbody class="divide-y">
|
||||
{#if datasetLoading}
|
||||
<!-- A run whose *results* are loading keeps its rows instead: `displayRows` already
|
||||
names every case from the dataset in hand, which beats a skeleton. -->
|
||||
<tr>
|
||||
<td colspan={2 + scorers.length} class="p-3">
|
||||
<Skeleton layout={[[2], 0.5, [2], 0.5, [2]]} />
|
||||
</td>
|
||||
</tr>
|
||||
</Head>
|
||||
<tbody class="divide-y">
|
||||
{:else}
|
||||
{#each displayRows as row (row.case_id)}
|
||||
{@const status = statusOf(row.status)}
|
||||
<Row
|
||||
@@ -813,52 +929,74 @@
|
||||
{/each}
|
||||
</Row>
|
||||
{/each}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
{/if}
|
||||
{/if}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
</div>
|
||||
</Pane>
|
||||
{#if selectedRow}
|
||||
{@const openRow = selectedRow}
|
||||
<Pane size={40} minSize={25}>
|
||||
<!-- Animated rather than mounted and unmounted: the pane's own width is what moves, so the
|
||||
cells table reflows alongside it instead of being shoved aside. -->
|
||||
<AnimatedPane size={40} minSize={25} duration={180} opened={!!selectedRow}>
|
||||
{#if openRow}
|
||||
<div class="h-full overflow-auto flex flex-col">
|
||||
<div class="flex items-start gap-2 px-3 py-2 border-b">
|
||||
<span class="text-xs font-semibold text-emphasis break-words">
|
||||
<!-- The title and the actions are different type sizes, so nothing lines them up on
|
||||
its own. `leading-7` gives the title's first line the same box height as the
|
||||
close button, and the actions centre in a row of that height beside it — so a
|
||||
title that wraps grows downwards and leaves the row where it is. -->
|
||||
<div class="flex items-start gap-2 px-3 py-2">
|
||||
<span
|
||||
class="text-xs font-semibold text-emphasis break-words leading-7 flex-1 min-w-0"
|
||||
>
|
||||
{openRow.input?.user_message ?? caseLabel(openRow)}
|
||||
</span>
|
||||
<div class="grow"></div>
|
||||
{#if openRow.job_id}
|
||||
<a
|
||||
class="text-2xs text-accent hover:underline inline-flex items-center gap-1 shrink-0 mt-0.5"
|
||||
href={`${base}/run/${openRow.job_id}?workspace=${ws}`}
|
||||
target="_blank"
|
||||
>
|
||||
Open the case job
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
{/if}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: X }}
|
||||
iconOnly
|
||||
title="Close"
|
||||
onclick={() => (selectedCaseId = undefined)}
|
||||
/>
|
||||
<div class="flex items-center gap-2 shrink-0 h-7">
|
||||
{#if openRow.job_id}
|
||||
<a
|
||||
class="text-2xs text-accent hover:underline inline-flex items-center gap-1"
|
||||
href={`${base}/run/${openRow.job_id}?workspace=${ws}`}
|
||||
target="_blank"
|
||||
>
|
||||
Open the case job
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
{/if}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: X }}
|
||||
iconOnly
|
||||
title="Close"
|
||||
onclick={() => (selectedCaseId = undefined)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 flex flex-col gap-4">
|
||||
<!-- One card per thing the case has, rather than sections divided by rules: each
|
||||
stands on its own and the panel reads as a stack. -->
|
||||
<div class="px-3 pb-3 flex flex-col gap-2">
|
||||
{#if openRow.expected != undefined && openRow.expected !== ''}
|
||||
<Label label="Expected">
|
||||
<span class="text-xs text-secondary whitespace-pre-wrap break-words">
|
||||
{typeof openRow.expected === 'string'
|
||||
? openRow.expected
|
||||
: JSON.stringify(openRow.expected, null, 2)}
|
||||
</span>
|
||||
</Label>
|
||||
<div class="rounded-md border border-light overflow-hidden">
|
||||
<div
|
||||
class="flex items-center gap-2 px-2 py-1 border-b border-light bg-surface-secondary"
|
||||
>
|
||||
<span class="text-2xs font-semibold text-secondary truncate">Expected</span>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<span class="text-xs text-secondary whitespace-pre-wrap break-words">
|
||||
{typeof openRow.expected === 'string'
|
||||
? openRow.expected
|
||||
: JSON.stringify(openRow.expected, null, 2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if scorers.length > 0 && openRow.scores.length > 0}
|
||||
<Label label="Scores">
|
||||
<div class="flex flex-col divide-y border rounded-md">
|
||||
<div class="rounded-md border border-light overflow-hidden">
|
||||
<div
|
||||
class="flex items-center gap-2 px-2 py-1 border-b border-light bg-surface-secondary"
|
||||
>
|
||||
<span class="text-2xs font-semibold text-secondary truncate">Scores</span>
|
||||
</div>
|
||||
<div class="flex flex-col divide-y divide-light">
|
||||
{#each scorers as scorer (scorer.id)}
|
||||
{@const cell = openRow.scores.find((s) => s.scorer_id === scorer.id)}
|
||||
<div class="flex flex-col gap-1 px-2 py-1.5">
|
||||
@@ -899,7 +1037,7 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
{/if}
|
||||
{#if experiment && (openRow.job_id || openRow.output != undefined)}
|
||||
<div class="rounded-md border border-light overflow-hidden">
|
||||
@@ -912,9 +1050,7 @@
|
||||
</div>
|
||||
<div class="p-2">
|
||||
{#if openRow.output != undefined}
|
||||
<div class="text-xs text-secondary break-words">
|
||||
<GfmMarkdown md={openRow.output} noPadding />
|
||||
</div>
|
||||
<GfmMarkdown md={openRow.output} prose="sm" noPadding />
|
||||
{:else if openRow.status === 'running'}
|
||||
<span class="text-xs text-tertiary inline-flex items-center gap-1.5">
|
||||
<Loader2 size={12} class="animate-spin text-blue-500" />
|
||||
@@ -928,11 +1064,11 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Pane>
|
||||
{/if}
|
||||
{/if}
|
||||
</AnimatedPane>
|
||||
</Splitpanes>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<EvalRunDialog
|
||||
bind:open={runDialogOpen}
|
||||
@@ -944,15 +1080,9 @@
|
||||
{running}
|
||||
onRun={runAll}
|
||||
onEditDataset={async (path) => {
|
||||
if (await useDataset(path)) {
|
||||
resumeRunDialog = true
|
||||
datasetDrawer?.openDrawer('edit')
|
||||
}
|
||||
}}
|
||||
onNewDataset={() => {
|
||||
resumeRunDialog = true
|
||||
datasetDrawer?.openDrawer('new')
|
||||
if (await useDataset(path)) datasetDrawer?.openDrawer('edit')
|
||||
}}
|
||||
onNewDataset={() => datasetDrawer?.openDrawer('new')}
|
||||
/>
|
||||
|
||||
<EvalDatasetDrawer
|
||||
@@ -968,11 +1098,14 @@
|
||||
onDeleted={datasetDeleted}
|
||||
onCasesChanged={casesChanged}
|
||||
onScorersChanged={scorersChanged}
|
||||
onClosed={() => {
|
||||
if (!resumeRunDialog) return
|
||||
resumeRunDialog = false
|
||||
// On the dataset the drawer was just in: the dialog opens on the pane's own, which
|
||||
// creating or editing one has already moved to it.
|
||||
runDialogOpen = true
|
||||
}}
|
||||
/>
|
||||
|
||||
<style>
|
||||
/* Direct child only, so the rule cannot reach a Splitpanes nested inside a page. Transparent
|
||||
rather than `opacity: 0`: the gutter stays there to be dragged, it just stops drawing a line
|
||||
between the cells and the case beside them. */
|
||||
:global(.splitter-hidden > .splitpanes__splitter) {
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
/* MultilineCellEditor: a popup positioned over the cell, so it has to paint the cell's own frame
|
||||
rather than inherit it. */
|
||||
.ag-theme-alpine .wm-multiline-cell-editor,
|
||||
.ag-theme-alpine-dark .wm-multiline-cell-editor {
|
||||
background-color: var(--ag-background-color);
|
||||
}
|
||||
.ag-theme-alpine .wm-multiline-cell-editor textarea,
|
||||
.ag-theme-alpine-dark .wm-multiline-cell-editor textarea {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
/* Horizontal only: the vertical padding is set by the editor, which knows the height of the row
|
||||
it is replacing. `line-height` here is what it computes against. */
|
||||
padding: 0 calc(var(--ag-cell-horizontal-padding) - 1px);
|
||||
border: 1px solid var(--ag-input-focus-border-color);
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
resize: none;
|
||||
/* Past this it scrolls rather than growing. */
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
background-color: var(--ag-background-color);
|
||||
color: var(--ag-foreground-color);
|
||||
font: inherit;
|
||||
line-height: 20px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import type { ColDef, ICellEditorComp, ICellEditorParams } from 'ag-grid-community'
|
||||
// Beside the editor rather than in the AgGrid theme: that file is the vendored theme, and a rule
|
||||
// added to it is one the next copy of it drops.
|
||||
import './multilineCellEditor.css'
|
||||
|
||||
/** Kept in step with the `line-height` the stylesheet gives the textarea. */
|
||||
const LINE_HEIGHT = 20
|
||||
|
||||
/**
|
||||
* A text cell editor that starts the height of the cell and grows as lines are added, for columns
|
||||
* holding prose rather than a value. Enter commits, Shift+Enter adds a line, Escape cancels.
|
||||
*
|
||||
* Rendered as a popup positioned over the cell: an in-cell editor is clipped to the row height, so
|
||||
* growing is only visible if the editor is allowed to paint outside it.
|
||||
*/
|
||||
export class MultilineCellEditor implements ICellEditorComp {
|
||||
private eGui!: HTMLDivElement
|
||||
private textarea!: HTMLTextAreaElement
|
||||
private params!: ICellEditorParams
|
||||
private wasEmpty = false
|
||||
|
||||
init(params: ICellEditorParams) {
|
||||
this.params = params
|
||||
this.eGui = document.createElement('div')
|
||||
this.eGui.className = 'wm-multiline-cell-editor'
|
||||
|
||||
this.wasEmpty = params.value == undefined
|
||||
|
||||
this.textarea = document.createElement('textarea')
|
||||
this.textarea.rows = 1
|
||||
// A keystroke that opened the edit replaces the value, as it does in every other cell; F2
|
||||
// and double-click keep it to be edited.
|
||||
this.textarea.value = params.eventKey?.length === 1 ? params.eventKey : (params.value ?? '')
|
||||
this.textarea.style.width = `${params.column.getActualWidth() - 2}px`
|
||||
// Padded so one line fills the cell it replaces and a second costs a line rather than a row.
|
||||
// From the row rather than from `--ag-row-height`, which is the theme's figure and not
|
||||
// necessarily this grid's.
|
||||
const rowHeight = params.node.rowHeight ?? 28
|
||||
const padding = Math.max(0, (rowHeight - LINE_HEIGHT - 2) / 2)
|
||||
this.textarea.style.paddingTop = `${padding}px`
|
||||
this.textarea.style.paddingBottom = `${padding}px`
|
||||
|
||||
this.textarea.addEventListener('input', () => this.resize())
|
||||
this.textarea.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
// Kept from whatever is around the grid: a grid in a drawer or a dialog is under a
|
||||
// surface that closes on Escape, and leaving an edit is not asking to leave that.
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
this.params.api.stopEditing(true)
|
||||
return
|
||||
}
|
||||
if (e.key !== 'Enter' || e.isComposing) return
|
||||
// Both branches keep the key from the grid, which ends the edit on Enter whether or not
|
||||
// Shift is held: Shift+Enter falls through to the textarea's own newline, and plain Enter
|
||||
// ends the edit here instead.
|
||||
e.stopPropagation()
|
||||
if (!e.shiftKey) {
|
||||
e.preventDefault()
|
||||
this.params.stopEditing()
|
||||
}
|
||||
})
|
||||
this.eGui.appendChild(this.textarea)
|
||||
}
|
||||
|
||||
private resize() {
|
||||
this.textarea.style.height = 'auto'
|
||||
this.textarea.style.height = `${this.textarea.scrollHeight}px`
|
||||
}
|
||||
|
||||
getGui() {
|
||||
return this.eGui
|
||||
}
|
||||
|
||||
afterGuiAttached() {
|
||||
this.resize()
|
||||
this.textarea.focus()
|
||||
// At the end rather than selected: a selection is a keystroke away from erasing the cell.
|
||||
const end = this.textarea.value.length
|
||||
this.textarea.setSelectionRange(end, end)
|
||||
}
|
||||
|
||||
getValue() {
|
||||
// Nothing typed into a cell that held nothing is not an edit: returning '' here would write
|
||||
// an empty string over a null, which the grid would see as a change and commit.
|
||||
if (this.wasEmpty && this.textarea.value === '') return this.params.value
|
||||
return this.textarea.value
|
||||
}
|
||||
|
||||
isPopup() {
|
||||
return true
|
||||
}
|
||||
|
||||
getPopupPosition(): 'over' | 'under' {
|
||||
return 'over'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a column of prose needs, ready to spread into a colDef. `suppressKeyboardEvent` as well as
|
||||
* the editor: the grid ends an edit on Enter from a handler a popup editor's DOM does not sit
|
||||
* under, so the editor cannot keep Shift+Enter for itself on its own.
|
||||
*/
|
||||
export const multilineCellColDef: Pick<ColDef, 'cellEditor' | 'suppressKeyboardEvent'> = {
|
||||
cellEditor: MultilineCellEditor,
|
||||
suppressKeyboardEvent: (p) =>
|
||||
p.editing && (p.event as KeyboardEvent).key === 'Enter' && (p.event as KeyboardEvent).shiftKey
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
<!--
|
||||
@component
|
||||
Inline-editable prose. The multiline counterpart to `EditableInput`: idle renders
|
||||
the whole text, wrapped, as a clickable button; clicking swaps to an auto-growing
|
||||
`textarea`. `Enter` or `blur` commits via `onSave`, `Shift+Enter` adds a line,
|
||||
`Escape` discards — except under `commitOnInput`, where every keystroke has
|
||||
already been propagated and there is nothing left to discard.
|
||||
|
||||
Escape is stopped from propagating, which keeps a surrounding `Drawer` open. A `Modal`
|
||||
is the exception: it takes both Enter and Escape on `window` in the capture phase and
|
||||
stops them there, so neither reaches this. Inside one, pass `enterConfirms={false}` to
|
||||
the `Modal` for Enter to commit here rather than confirm the dialog; Escape stays the
|
||||
dialog's either way, so expect it to leave the edit and close the dialog together.
|
||||
|
||||
Use it where a cell or a field holds a sentence rather than a label — a prompt, a
|
||||
description, an expected answer. For a one-line label, use `EditableInput`.
|
||||
|
||||
```svelte
|
||||
<EditableTextarea
|
||||
value={question}
|
||||
placeholder="Question"
|
||||
onSave={(v) => (question = v)}
|
||||
textClass="text-xs font-normal"
|
||||
/>
|
||||
```
|
||||
|
||||
Like `EditableInput`, the value isn't bound: `onSave` fires whenever the draft
|
||||
differs from the prior `value`, including `''` when cleared. Unlike it, the draft
|
||||
goes back verbatim — whitespace a caller stored on purpose survives a focus and a
|
||||
blur. The parent owns the canonical state.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
|
||||
interface Props {
|
||||
/** Current value shown in idle mode and pre-filled when entering edit mode. */
|
||||
value: string
|
||||
/** Shown when `value` is empty, in both idle and editing modes. */
|
||||
placeholder?: string
|
||||
/**
|
||||
* Called when the user commits a changed value (Enter or blur, or every keystroke when
|
||||
* {@link commitOnInput} is set). Fires with the draft exactly as typed, including `''` if the
|
||||
* field was cleared. Not called on Escape, or when the draft matches the prior `value`.
|
||||
*/
|
||||
onSave?: (newValue: string) => void
|
||||
/**
|
||||
* Fire `onSave` on every keystroke instead of only on Enter/blur. Use when the parent
|
||||
* autosaves, or when a control outside this one reads the value on click — the click blurs
|
||||
* the textarea, and a commit-on-blur would land after the read. Escape no longer discards
|
||||
* with this on: the live commits have already propagated.
|
||||
*/
|
||||
commitOnInput?: boolean
|
||||
/** When false, renders as plain text and is not clickable. */
|
||||
editable?: boolean
|
||||
/** Textarea size in editing mode. Idle mode is text only and unaffected. */
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg'
|
||||
/** Wrapper classes. Layout only — margin, width, alignment — not text styling. */
|
||||
class?: string
|
||||
/** Extra classes on the `<textarea>`. Background, border and shadow are reset over these. */
|
||||
inputClass?: string
|
||||
/**
|
||||
* Text styling applied to *both* the idle text and the textarea, so the two render
|
||||
* identically and clicking in does not shift the text.
|
||||
*/
|
||||
textClass?: string
|
||||
}
|
||||
|
||||
let {
|
||||
value,
|
||||
placeholder = '',
|
||||
onSave,
|
||||
commitOnInput = false,
|
||||
editable = true,
|
||||
size = 'sm',
|
||||
class: className = '',
|
||||
inputClass = '',
|
||||
textClass = ''
|
||||
}: Props = $props()
|
||||
|
||||
let editing = $state(false)
|
||||
let draft = $state('')
|
||||
// `TextInput` is generic over its underlying element and defaults to `'input'`; this one renders
|
||||
// a textarea, so the binding has to say so or it is typed as the wrong component.
|
||||
let textInputComponent: TextInput<'textarea'> | undefined = $state(undefined)
|
||||
|
||||
function startEditing() {
|
||||
if (!editable) return
|
||||
editing = true
|
||||
draft = value ?? ''
|
||||
requestAnimationFrame(() => {
|
||||
textInputComponent?.focus()
|
||||
textInputComponent?.select()
|
||||
})
|
||||
}
|
||||
|
||||
/** Open the editor from outside — a row that was just added, say, so the caller does not have
|
||||
* to click into it. */
|
||||
export function edit() {
|
||||
startEditing()
|
||||
}
|
||||
|
||||
function save() {
|
||||
// Re-entry guard: Enter calls `save()` and clears `editing`, which unmounts the textarea and
|
||||
// synchronously fires its `blur` handler — also `save()`. Without this, `onSave` fires twice.
|
||||
if (!editing) return
|
||||
editing = false
|
||||
// Verbatim, not trimmed: a caller's value may hold whitespace that means something, and
|
||||
// merely focusing and blurring a field must never count as an edit.
|
||||
if (draft !== (value ?? '')) {
|
||||
onSave?.(draft)
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
// Kept from a surrounding drawer, which would otherwise close on it. A `Modal` takes
|
||||
// Escape at `window` in the capture phase and is past us before this runs.
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
editing = false
|
||||
return
|
||||
}
|
||||
// Shift+Enter falls through to the textarea's own newline; plain Enter commits, which is
|
||||
// what makes this usable in a table where Enter means "done with this cell".
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
||||
e.preventDefault()
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
function handleLiveInput(e: Event) {
|
||||
const next = (e.currentTarget as HTMLTextAreaElement).value
|
||||
if (next !== (value ?? '')) onSave?.(next)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if editing}
|
||||
<!-- Every override below exists so the two states are the same size with the text in the same
|
||||
place, and clicking in moves nothing: `!min-h-0` (TextInput floors it at 28px), `!block` (an
|
||||
inline-block textarea baseline-aligns and the idle button does not), and never `!h-auto` —
|
||||
`use:autosize` grows the field with an inline `style.height` an `!important` class beats. -->
|
||||
<TextInput
|
||||
bind:this={textInputComponent}
|
||||
bind:value={draft}
|
||||
{size}
|
||||
underlyingInputEl="textarea"
|
||||
autosizeParams={{ minHeight: 0 }}
|
||||
class="!block !bg-transparent !border-0 !shadow-none !m-0 !min-w-0 !min-h-0 !py-0 {textClass} {inputClass} {className}"
|
||||
inputProps={{
|
||||
placeholder,
|
||||
rows: 1,
|
||||
onblur: save,
|
||||
onkeydown: handleKeydown,
|
||||
oninput: commitOnInput ? handleLiveInput : undefined,
|
||||
spellcheck: false,
|
||||
style: 'padding: 2px !important; resize: none'
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<!-- Padding 2px over, 4px under: the same 22px box as the textarea *and* the same 2px above the
|
||||
first line. An even 3px gets the box right and leaves the text a pixel low. -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={startEditing}
|
||||
disabled={!editable}
|
||||
aria-label={editable ? `Edit ${placeholder.toLowerCase() || 'value'}` : undefined}
|
||||
class="w-full text-left whitespace-pre-wrap break-words rounded px-0.5 pt-[2px] pb-[4px] {editable
|
||||
? 'cursor-text hover:bg-surface-hover'
|
||||
: 'cursor-default'} {textClass} {className}"
|
||||
>
|
||||
{value || placeholder}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -8,6 +8,7 @@
|
||||
import { onMount, createEventDispatcher, setContext, untrack } from 'svelte'
|
||||
import { BROWSER } from 'esm-env'
|
||||
import Disposable from './Disposable.svelte'
|
||||
import { setTopmostSurface } from '$lib/components/common/overlayHost.svelte'
|
||||
import ConditionalPortal from './ConditionalPortal.svelte'
|
||||
import { chatState } from '$lib/components/copilot/chat/sharedChatState.svelte'
|
||||
import { useReducedMotion } from '$lib/svelte5Utils.svelte'
|
||||
@@ -51,6 +52,11 @@
|
||||
|
||||
let disposable: Disposable | undefined = $state(undefined)
|
||||
|
||||
// A drawer stacks like a dialog does, so content inside it gets the same answer about whether
|
||||
// its keys are meant for it. Without this, a drawer opened over a dialog would inherit the
|
||||
// dialog's answer — false, because the drawer itself is now on top — and go deaf.
|
||||
setTopmostSurface(() => disposable?.isTopmost() ?? true)
|
||||
|
||||
let reducedMotion = useReducedMotion()
|
||||
let duration = $derived(reducedMotion.val ? 0 : _duration)
|
||||
let durationMs = $derived(duration * 1000)
|
||||
|
||||
@@ -10,6 +10,16 @@
|
||||
label: string
|
||||
icon?: any
|
||||
onClick: () => void
|
||||
/**
|
||||
* `default` unless the surface has no other live call to action. Accent is for the case
|
||||
* where this button is the only thing to press — a form whose submit is disabled until
|
||||
* this is done, say — so it is not competing with one.
|
||||
*/
|
||||
variant?: 'default' | 'accent'
|
||||
/** Same write lock the surface's other controls take. An empty state is still a live
|
||||
* control: without this it stays clickable while a request that has already read the
|
||||
* empty list is in flight, and whatever it adds is discarded when that request lands. */
|
||||
disabled?: boolean
|
||||
aiId?: string
|
||||
aiDescription?: string
|
||||
}
|
||||
@@ -32,11 +42,13 @@
|
||||
{/if}
|
||||
</div>
|
||||
{#if action}
|
||||
<!-- `default`, not `accent`: the page header already carries the accent CTA for
|
||||
this same action, and one accent per view is the rule. -->
|
||||
<!-- `default` by default: the page header usually carries the accent CTA for this same
|
||||
action, and one accent per view is the rule. A caller whose other CTA is disabled until
|
||||
this is done opts into `accent`. -->
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="default"
|
||||
variant={action.variant ?? 'default'}
|
||||
disabled={action.disabled}
|
||||
startIcon={action.icon ? { icon: action.icon } : undefined}
|
||||
onClick={action.onClick}
|
||||
aiId={action.aiId}
|
||||
|
||||
@@ -11,7 +11,11 @@
|
||||
<script lang="ts">
|
||||
import { createBubbler, stopPropagation } from 'svelte/legacy'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-svelte'
|
||||
import { getOverlayHost, overlayHostActive } from '$lib/components/common/overlayHost.svelte'
|
||||
import {
|
||||
getOverlayHost,
|
||||
overlayHostActive,
|
||||
setTopmostSurface
|
||||
} from '$lib/components/common/overlayHost.svelte'
|
||||
|
||||
const bubble = createBubbler()
|
||||
import { createEventDispatcher, untrack } from 'svelte'
|
||||
@@ -38,6 +42,18 @@
|
||||
/** A line under the title saying what the dialog is for; in the header so it does not
|
||||
* scroll away with the body. */
|
||||
description?: string
|
||||
/** The body holds pages that are laid over each other rather than stacked, so the header
|
||||
* keeps its own height and only the pages move. Requires `fillHeight`: pages are absolutely
|
||||
* positioned and need a definite height to fill. Pair with `PagedContent`, which does the
|
||||
* laying over; this only makes room for it. */
|
||||
paginated?: boolean
|
||||
/**
|
||||
* Whether Enter confirms the dialog. On by default, which is right for a form. Turn it off
|
||||
* where the body is a surface with its own meaning for Enter: the handler runs at `window`
|
||||
* in the capture phase and stops propagation, so while it is on nothing inside the dialog
|
||||
* can see the key at all.
|
||||
*/
|
||||
enterConfirms?: boolean
|
||||
/** Make the dialog fill the height it is anchored to and lay its body out as a flex
|
||||
* column, so content can size itself with `h-full` / `flex-1 min-h-0`. Off by default:
|
||||
* the dialog otherwise hugs its content, and percentage heights inside it do not
|
||||
@@ -65,6 +81,8 @@
|
||||
trail = undefined,
|
||||
description = undefined,
|
||||
fillHeight = false,
|
||||
paginated = false,
|
||||
enterConfirms = true,
|
||||
minZIndex: minZIndexProp = undefined,
|
||||
titleBadge,
|
||||
settings,
|
||||
@@ -95,6 +113,10 @@
|
||||
// 1200 to stack above.
|
||||
const minZIndex = $derived(minZIndexProp ?? (chatState.size > 0 ? zIndexes.aiChat + 1 : 0))
|
||||
|
||||
// So content in the body can tell a key meant for this dialog from one meant for whatever was
|
||||
// opened over it. Read lazily: `disposable` is bound after this runs.
|
||||
setTopmostSurface(() => disposable?.isTopmost() ?? true)
|
||||
|
||||
// Both `bind:open` and this $effect are needed: bind:open syncs the
|
||||
// boolean, while the effect calls openDrawer/closeDrawer to register
|
||||
// the disposable in the stacking system (same pattern as Drawer.svelte).
|
||||
@@ -114,6 +136,7 @@
|
||||
if (open) {
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
if (!enterConfirms) break
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
dispatch('confirmed')
|
||||
@@ -231,7 +254,9 @@
|
||||
{@render titleBadge?.()}
|
||||
{/if}
|
||||
{#if i > 0}
|
||||
<ChevronRight size={18} class="text-tertiary shrink-0" />
|
||||
<span class="flex shrink-0" in:fade={{ duration: 150 }}>
|
||||
<ChevronRight size={18} class="text-tertiary shrink-0" />
|
||||
</span>
|
||||
{/if}
|
||||
{#if i === 0}
|
||||
<!-- flex: an inline child holding an icon sits on the baseline and adds
|
||||
@@ -250,7 +275,11 @@
|
||||
{:else if segment.onclick}
|
||||
{@render crumb(segment, i === crumbs.length - 2)}
|
||||
{:else}
|
||||
<span class="text-emphasis truncate" aria-current="page">
|
||||
<span
|
||||
class="text-emphasis truncate"
|
||||
aria-current="page"
|
||||
in:fade={{ duration: 150 }}
|
||||
>
|
||||
{segment.label}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -276,7 +305,13 @@
|
||||
<p class="mt-1 text-xs text-secondary">{description}</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4 text-sm text-primary {fillHeight ? 'flex-1 min-h-0' : ''}">
|
||||
<!-- `mt-1` when paginated: a page carries its own description as its first line,
|
||||
and it belongs where the dialog's own sat, right under the title. -->
|
||||
<div
|
||||
class="{paginated ? 'mt-1' : 'mt-4'} text-sm text-primary {fillHeight
|
||||
? 'flex-1 min-h-0'
|
||||
: ''}"
|
||||
>
|
||||
{@render children_render?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
<script lang="ts" module>
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
/** One level of a paginated dialog. Order is the order given: the page on screen sits at rest
|
||||
* and every other waits off the side it is listed on, so a deeper page arrives from the right
|
||||
* and the way back arrives from the left without anyone naming a direction. */
|
||||
export type ModalPage = { key: string; content: Snippet }
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { overlayHostActive, topmostSurface } from '$lib/components/common/overlayHost.svelte'
|
||||
|
||||
let {
|
||||
pages,
|
||||
current,
|
||||
onNavigate,
|
||||
warm = false,
|
||||
class: c = ''
|
||||
}: {
|
||||
pages: ModalPage[]
|
||||
/** The `key` of the page on screen. */
|
||||
current: string
|
||||
/**
|
||||
* Go to a page. Given, the component answers the left and right arrow keys, stepping between
|
||||
* pages the way the surface's own controls do. Escape is deliberately not taken: the dialog
|
||||
* around this owns it, and a page component that swallowed it would stop the dialog from
|
||||
* closing. Without this prop the pages are still navigable, just not from the keyboard —
|
||||
* the caller owns `current` either way.
|
||||
*/
|
||||
onNavigate?: (key: string) => void
|
||||
/**
|
||||
* Build every page once this one has settled, rather than each on its first visit. Turn it
|
||||
* on where a page is heavy enough that building it costs a frame: otherwise that build
|
||||
* lands on the first click, inside the transition, and only the first navigation stutters.
|
||||
* The cost moves to a moment when nothing is animating and nobody is waiting.
|
||||
*/
|
||||
warm?: boolean
|
||||
class?: string
|
||||
} = $props()
|
||||
|
||||
/** A page is mounted the first time it is opened and never unmounted again, which is the whole
|
||||
* point of this over an `{#if}`: a page keeps its state, its loaded data and its scroll
|
||||
* position while another is on screen, and returning to it costs nothing. */
|
||||
let visited = $state<string[]>([])
|
||||
$effect(() => {
|
||||
if (!visited.includes(current)) visited = [...visited, current]
|
||||
})
|
||||
|
||||
/** Whether the rest of the pages have been built. Deferred a frame past mount rather than done
|
||||
* outright: the page being opened *now* is what the user is waiting on, and building its
|
||||
* neighbours in the same frame would make opening the dialog pay for them. */
|
||||
let warmed = $state(false)
|
||||
$effect(() => {
|
||||
if (!warm || warmed) return
|
||||
const frame = requestAnimationFrame(() => (warmed = true))
|
||||
return () => cancelAnimationFrame(frame)
|
||||
})
|
||||
|
||||
let box: HTMLDivElement | undefined = $state()
|
||||
|
||||
// A window listener answers keys aimed anywhere, so it has to ask two questions the DOM cannot:
|
||||
// is my host the visible one — session preview tabs stay mounted when hidden — and is my surface
|
||||
// still the one on top, rather than under a drawer or a dialog opened since.
|
||||
const hostActive = overlayHostActive()
|
||||
const onTop = topmostSurface()
|
||||
const listening = () => hostActive() && onTop()
|
||||
|
||||
/** Whether the keyboard is ours to answer.
|
||||
*
|
||||
* Deliberately not "focus is inside the pages". Navigating leaves focus wherever the page that
|
||||
* was left put it — the body, once `inert` lands on it, or a button in the surrounding dialog
|
||||
* chrome — so a containment test answers the first press and refuses every one after it. What
|
||||
* is actually being asked is whether the key is meant for something else, and only a control
|
||||
* that reads arrows itself qualifies. */
|
||||
function ownsKeyboard(target: EventTarget | null): boolean {
|
||||
if (!box || !box.isConnected) return false
|
||||
const el = target as HTMLElement | null
|
||||
return !el?.closest?.('input, textarea, select, [contenteditable="true"], [role="listbox"]')
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (!onNavigate || !listening() || event.metaKey || event.ctrlKey || event.altKey) return
|
||||
if (!ownsKeyboard(event.target)) return
|
||||
const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0
|
||||
if (step === 0) return
|
||||
const next = pages[index + step]
|
||||
if (!next) return
|
||||
event.preventDefault()
|
||||
onNavigate(next.key)
|
||||
}
|
||||
|
||||
/** Whether focus is inside the pages right now. Tracked as focus moves, not read when the page
|
||||
* changes: a surrounding `Modal` handles Escape at `window` in the capture phase and steps a
|
||||
* level back from there, and Svelte flushes this component's effects inside that handler —
|
||||
* before a keydown listener of ours would ever run. Only a value already settled survives that.
|
||||
* Plain state, not `$state`: the effect below is keyed on the page, and reading this must not
|
||||
* key it too. */
|
||||
let focusInside = false
|
||||
$effect(() => {
|
||||
if (!box) return
|
||||
const enter = () => (focusInside = true)
|
||||
const leave = (event: FocusEvent) => {
|
||||
focusInside = !!event.relatedTarget && !!box?.contains(event.relatedTarget as Node)
|
||||
}
|
||||
box.addEventListener('focusin', enter)
|
||||
box.addEventListener('focusout', leave)
|
||||
return () => {
|
||||
box?.removeEventListener('focusin', enter)
|
||||
box?.removeEventListener('focusout', leave)
|
||||
}
|
||||
})
|
||||
|
||||
/** Focus follows the page, or a keyboard user is dropped out of the dialog: `inert` on the page
|
||||
* being left resets focus to the body, and so does the trail's back button, which is removed
|
||||
* as the level it returns from closes. Focus is taken when it was in the pages, or when it has
|
||||
* ended up nowhere — never off a control the user is actually on, such as dialog chrome that
|
||||
* outlives the navigation. */
|
||||
let previous = current
|
||||
$effect(() => {
|
||||
const next = current
|
||||
if (next === previous) return
|
||||
const wasInside = focusInside
|
||||
previous = next
|
||||
// Claimed over a few frames rather than one: the browser runs its own fixup for the page
|
||||
// that just became inert, and it lands after this on some paths.
|
||||
let tries = 3
|
||||
let frame = 0
|
||||
const claim = () => {
|
||||
const page = box?.querySelector<HTMLElement>('.paged-content-page:not(.is-hidden)')
|
||||
if (!page) return
|
||||
if ((wasInside && tries === 3) || document.activeElement === document.body) {
|
||||
page.focus({ preventScroll: true })
|
||||
}
|
||||
if (--tries > 0) frame = requestAnimationFrame(claim)
|
||||
}
|
||||
frame = requestAnimationFrame(claim)
|
||||
return () => cancelAnimationFrame(frame)
|
||||
})
|
||||
|
||||
/** How far a page travels, as a share of the box. Deliberately a fraction and not the whole
|
||||
* width: at 100% a page has left the box before its opacity has gone anywhere, so the fade is
|
||||
* spent off-screen and all that is left to see is a slide. */
|
||||
const TRAVEL_PERCENT = 24
|
||||
|
||||
let index = $derived(
|
||||
Math.max(
|
||||
pages.findIndex((p) => p.key === current),
|
||||
0
|
||||
)
|
||||
)
|
||||
</script>
|
||||
|
||||
<!-- The clipper. `overflow` clips descendants and not an element's own transform, so the box that
|
||||
hides a page off the side can never be the page that travels. -->
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<div bind:this={box} class="relative overflow-hidden {c}">
|
||||
{#each pages as page, i (page.key)}
|
||||
{#if visited.includes(page.key) || warmed}
|
||||
<!-- Pages are laid over each other rather than laid out, so the dialog cannot change
|
||||
height as one replaces another. That needs a definite height from the caller.
|
||||
`inert` and not just opacity: an off-screen page keeps its DOM, so without it the
|
||||
tab order and every screen reader still walk through it. -->
|
||||
<!-- `tabindex="-1"`: focusable programmatically, never a tab stop of its own. -->
|
||||
<div
|
||||
tabindex="-1"
|
||||
class="paged-content-page absolute inset-0 flex flex-col"
|
||||
class:is-hidden={i !== index}
|
||||
class:opacity-0={i !== index}
|
||||
class:pointer-events-none={i !== index}
|
||||
style="transform: translateX({(i - index) * TRAVEL_PERCENT}%)"
|
||||
inert={i !== index}
|
||||
>
|
||||
{@render page.content()}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Arriving: slides in and fades up. A transition reads the duration of the state it is moving
|
||||
*to*, which is what lets the two directions differ here. */
|
||||
.paged-content-page {
|
||||
transition-property: transform, opacity;
|
||||
transition-duration: 130ms, 90ms;
|
||||
transition-timing-function: ease-out;
|
||||
}
|
||||
|
||||
/* Leaving: gone at once. Both pages share the box while one replaces the other, so an outgoing
|
||||
page is painted over the incoming one for as long as it still has any opacity — any fade at
|
||||
all is a ghost laid over the page you are trying to look at. It still travels, which costs
|
||||
nothing to watch because it is already invisible. */
|
||||
.paged-content-page.is-hidden {
|
||||
transition-duration: 130ms, 0s;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.paged-content-page {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -72,3 +72,23 @@ export function overlayHostActive(): () => boolean {
|
||||
const host = getOverlayHost()
|
||||
return () => host?.active() ?? true
|
||||
}
|
||||
|
||||
const TOPMOST_SURFACE_KEY = 'topmostSurface'
|
||||
|
||||
/**
|
||||
* Declare whether the surface enclosing this subtree is the one on top. Set by whatever owns the
|
||||
* stacking — a dialog, a drawer — so content inside it can tell a key meant for itself from one
|
||||
* meant for something opened over it.
|
||||
*/
|
||||
export function setTopmostSurface(isTopmost: () => boolean) {
|
||||
setContext(TOPMOST_SURFACE_KEY, isTopmost)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the enclosing surface is on top. True when nothing declared otherwise, so content that
|
||||
* is not inside such a surface is not silently made deaf.
|
||||
*/
|
||||
export function topmostSurface(): () => boolean {
|
||||
const isTopmost = getContext<(() => boolean) | undefined>(TOPMOST_SURFACE_KEY)
|
||||
return () => isTopmost?.() ?? true
|
||||
}
|
||||
|
||||
@@ -109,13 +109,13 @@
|
||||
? `${base}/apps${app.raw_app ? '_raw' : ''}/edit/${app.path}`
|
||||
: `${base}/apps${app.raw_app ? '_raw' : ''}/get/${app.path}`}
|
||||
kind="app"
|
||||
{keyboardSelected}
|
||||
{marked}
|
||||
path={(app as any).draft_path ?? app.path}
|
||||
summary={app.is_draft ? `${app.summary || (app as any).draft_path || app.path}*` : app.summary}
|
||||
workspaceId={app.workspace_id ?? $workspaceStore ?? ''}
|
||||
canFavorite={!app.draft_only}
|
||||
{depth}
|
||||
{keyboardSelected}
|
||||
{rowSelection}
|
||||
>
|
||||
{#snippet badges()}
|
||||
|
||||
@@ -132,13 +132,13 @@
|
||||
: `${base}/flows/get/${flow.path}?workspace=${$workspaceStore}`}
|
||||
kind="flow"
|
||||
workspaceId={flow.workspace_id ?? $workspaceStore ?? ''}
|
||||
{keyboardSelected}
|
||||
{marked}
|
||||
path={flow.draft_path ?? flow.path}
|
||||
summary={flow.is_draft ? `${flow.summary || flow.draft_path || flow.path}*` : flow.summary}
|
||||
{errorHandlerMuted}
|
||||
canFavorite={!flow.draft_only}
|
||||
{depth}
|
||||
{keyboardSelected}
|
||||
{rowSelection}
|
||||
>
|
||||
{#snippet badges()}
|
||||
|
||||
@@ -35,13 +35,13 @@
|
||||
<Row
|
||||
href="{base}/apps_raw/get/{app.path}"
|
||||
kind="raw_app"
|
||||
{keyboardSelected}
|
||||
{marked}
|
||||
path={app.path}
|
||||
summary={app.summary}
|
||||
workspaceId={app.workspace_id ?? $workspaceStore ?? ''}
|
||||
canFavorite={true}
|
||||
{depth}
|
||||
{keyboardSelected}
|
||||
>
|
||||
{#snippet badges()}
|
||||
<SharedBadge canWrite={app.canWrite} extraPerms={app.extra_perms} />
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
interface Props {
|
||||
marked: string | undefined
|
||||
selected?: boolean
|
||||
/** Highlighted by the list's keyboard arrow-navigation (distinct from `selected`,
|
||||
* which is the checkbox multi-select state). Scrolls itself into view. */
|
||||
keyboardSelected?: boolean
|
||||
disabled?: boolean
|
||||
canFavorite?: boolean
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
? `${base}/scripts/edit/${script.path}`
|
||||
: `${base}/scripts/get/${script.hash}?workspace=${$workspaceStore}`}
|
||||
kind="script"
|
||||
{keyboardSelected}
|
||||
{marked}
|
||||
path={script.draft_path ?? script.path}
|
||||
summary={script.is_draft
|
||||
@@ -156,7 +157,6 @@
|
||||
workspaceId={$workspaceStore ?? ''}
|
||||
canFavorite={!script.draft_only}
|
||||
{depth}
|
||||
{keyboardSelected}
|
||||
{rowSelection}
|
||||
>
|
||||
{#snippet badges()}
|
||||
@@ -187,7 +187,9 @@
|
||||
<Badge small color="yellow" baseClass="border">CI test</Badge>
|
||||
</Popover>
|
||||
{/if}
|
||||
{#if script.kind !== 'script'}
|
||||
<!-- Guard on a non-empty kind: a draft-only script can carry an empty `kind`, which
|
||||
still isn't 'script' and would render an empty blue badge. -->
|
||||
{#if script.kind && script.kind !== 'script'}
|
||||
<Badge color="blue" baseClass="border"
|
||||
>{script.kind === 'failure' ? 'Error handler' : capitalize(script.kind)}</Badge
|
||||
>
|
||||
|
||||
@@ -51,20 +51,26 @@
|
||||
aiChatManager.scriptEditorOptions?.lang &&
|
||||
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang))
|
||||
)
|
||||
// A spent free grant is not an unconfigured workspace: AIChatDisplay already shows an
|
||||
// in-thread banner naming the real cause and linking to the key settings, so the generic
|
||||
// "enable Windmill AI" line would both duplicate it and misstate why the chat is off.
|
||||
const freeTierExhausted = $derived($copilotInfo.freeTier?.exhausted === true)
|
||||
const disabledMessage = $derived(
|
||||
forceDisabled
|
||||
? forceDisabledMessage
|
||||
: !hasCopilot
|
||||
? $aiUserDisabled
|
||||
? 'Windmill AI is disabled in your account settings'
|
||||
: isAdmin
|
||||
? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat`
|
||||
: 'Ask an admin to enable Windmill AI in this workspace to use this chat'
|
||||
: aiChatManager.mode === AIMode.SCRIPT &&
|
||||
aiChatManager.scriptEditorOptions?.lang &&
|
||||
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang)
|
||||
? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.`
|
||||
: ''
|
||||
: freeTierExhausted
|
||||
? ''
|
||||
: !hasCopilot
|
||||
? $aiUserDisabled
|
||||
? 'Windmill AI is disabled in your account settings'
|
||||
: isAdmin
|
||||
? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat`
|
||||
: 'Ask an admin to enable Windmill AI in this workspace to use this chat'
|
||||
: aiChatManager.mode === AIMode.SCRIPT &&
|
||||
aiChatManager.scriptEditorOptions?.lang &&
|
||||
!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang)
|
||||
? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.`
|
||||
: ''
|
||||
)
|
||||
|
||||
const suggestions = [
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
Folder,
|
||||
Hand,
|
||||
HistoryIcon,
|
||||
KeyRound,
|
||||
MousePointer2,
|
||||
Plug,
|
||||
Plus,
|
||||
@@ -59,9 +60,22 @@
|
||||
readDroppedEntries
|
||||
} from './files/fsAccess'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import Alert from '$lib/components/common/alert/Alert.svelte'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
import { base } from '$lib/base'
|
||||
|
||||
const MAX_YOLO_TOOLTIP_TOOLS = 8
|
||||
const aiChatManager = getAiChatManager()
|
||||
|
||||
// The user spent their one-time free Windmill AI grant: there is no model left to send
|
||||
// to, so say so in the thread itself rather than only failing on send.
|
||||
let freeTierExhausted = $derived($copilotInfo.freeTier?.exhausted === true)
|
||||
// Still on the free grant: keep how much is left in view right above the composer, so
|
||||
// running out isn't a surprise. Once spent, the exhausted banner replaces it.
|
||||
let freeTier = $derived($copilotInfo.freeTier)
|
||||
let freeTierUsedPct = $derived(Math.min(100, Math.round((freeTier?.used_ratio ?? 0) * 100)))
|
||||
let showFreeTierUsage = $derived(!!freeTier && !freeTier.exhausted)
|
||||
|
||||
// One row per autonomy posture, in picker order, so adding one touches only this
|
||||
// table. `isAvailable` hides the postures that would do nothing in the current AI
|
||||
// mode, which is why the picker can be shorter than this list.
|
||||
@@ -604,6 +618,44 @@
|
||||
)
|
||||
</script>
|
||||
|
||||
{#snippet freeTierExhaustedBanner()}
|
||||
<div class="my-2">
|
||||
<Alert type="info" size="xs" title="Free Windmill AI used up">
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<span>
|
||||
You have used all of your free Windmill AI tokens. Add your own API key to keep using AI.
|
||||
</span>
|
||||
<Button
|
||||
unifiedSize="2xs"
|
||||
variant="accent"
|
||||
startIcon={{ icon: KeyRound }}
|
||||
href="{base}/workspace_settings?tab=ai"
|
||||
>
|
||||
Add your own API key
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet freeTierUsageBanner()}
|
||||
<div
|
||||
class="my-1 flex items-center justify-between gap-2 rounded-md border bg-surface-secondary px-2 py-1"
|
||||
>
|
||||
<span class="text-xs text-secondary tabular-nums">
|
||||
{freeTierUsedPct}% of your free Windmill AI used
|
||||
</span>
|
||||
<Button
|
||||
unifiedSize="2xs"
|
||||
variant="default"
|
||||
startIcon={{ icon: KeyRound }}
|
||||
href="{base}/workspace_settings?tab=ai"
|
||||
>
|
||||
Configure your API key
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- tabindex="-1": clicks on non-focusable chat content must move focus into
|
||||
the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
<div
|
||||
@@ -762,6 +814,11 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
script editor to modify selected lines.</span
|
||||
>
|
||||
{/if}
|
||||
{#if freeTierExhausted}
|
||||
<div class={wideLayout ? 'w-full max-w-3xl mx-auto px-7' : 'w-full max-w-2xl mx-auto px-3'}>
|
||||
{@render freeTierExhaustedBanner()}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if messages.length > 0}
|
||||
@@ -790,6 +847,9 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
isLast={messageIndex === messages.length - 1 && !remoteUserEcho}
|
||||
/>
|
||||
{/each}
|
||||
{#if freeTierExhausted}
|
||||
{@render freeTierExhaustedBanner()}
|
||||
{/if}
|
||||
<!-- Same trailing space the last message reserves: the indicator below
|
||||
is sticky and pulled up over it, so a bubble without it is covered. -->
|
||||
{@render remoteUserEchoBubble('mb-12')}
|
||||
@@ -892,6 +952,9 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
{#if inputPreface}
|
||||
{@render inputPreface()}
|
||||
{/if}
|
||||
{#if showFreeTierUsage}
|
||||
{@render freeTierUsageBanner()}
|
||||
{/if}
|
||||
<AIChatInput
|
||||
bind:this={aiChatInput}
|
||||
bind:selectedContext
|
||||
|
||||
@@ -86,6 +86,8 @@ import { untrack } from 'svelte'
|
||||
import { get } from 'svelte/store'
|
||||
import { BROWSER } from 'esm-env'
|
||||
import { workspaceStore, type DBSchemas } from '$lib/stores'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
import { copilotWorkspaceRequested, loadCopilot } from '$lib/components/copilot/loadCopilot'
|
||||
import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core'
|
||||
import { readDocsPageTool, searchDocsTool } from './docs/core'
|
||||
import { TypewriterReveal } from './typewriterReveal'
|
||||
@@ -366,6 +368,25 @@ function getSendRequestErrorMessage(err: unknown, webSearchUnavailable: boolean)
|
||||
return appendWebSearchErrorHint(message, webSearchUnavailable)
|
||||
}
|
||||
|
||||
/** Re-fetch copilotInfo after a free-tier turn so the usage banner tracks spend live and the
|
||||
* exhausting turn flips `freeTier.exhausted`; otherwise these update only on the next workspace
|
||||
* load. Scoped to a live (non-exhausted) free tier so configured-key users pay no extra request. */
|
||||
async function refreshFreeTierUsage(workspace: string | undefined) {
|
||||
if (!workspace) return
|
||||
// copilotInfo is a singleton shared across sessions: a warm session finishing after a
|
||||
// workspace switch must not loadCopilot for its now-background workspace. Gate on the
|
||||
// most-recently-*requested* workspace (set synchronously) so a refresh can't win the
|
||||
// monotonic token over a newer load still in flight.
|
||||
if (get(copilotWorkspaceRequested) !== workspace) return
|
||||
const info = get(copilotInfo)
|
||||
if (!info.freeTier || info.freeTier.exhausted) return
|
||||
try {
|
||||
await loadCopilot(workspace)
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh free-tier usage', err)
|
||||
}
|
||||
}
|
||||
|
||||
/** A message queued while a turn streams: the draft lanes and the pinned
|
||||
* context snapshot always move together so a flush can't drop one. */
|
||||
type QueuedEntry = {
|
||||
@@ -3917,6 +3938,9 @@ export class AIChatManager {
|
||||
// releases the loop; it never discards uncommitted text.
|
||||
this.replyReveal.reset()
|
||||
this.reasoningReveal.reset()
|
||||
// Refresh the free-tier usage meter after every turn (success or error), and
|
||||
// let the turn that exhausts the grant flip to the exhausted state live.
|
||||
void refreshFreeTierUsage(this.operatingWorkspace)
|
||||
}
|
||||
// Flush the queued message. Send it after a cleanly committed turn OR a
|
||||
// deliberate user cancel (Esc / Stop) — in both cases the user is ready
|
||||
|
||||
@@ -46,6 +46,13 @@
|
||||
)
|
||||
let models = $derived($copilotInfo.aiModels)
|
||||
|
||||
// Free tier: the workspace has no key of its own and is spending Windmill's one-time
|
||||
// grant. Label it so the user knows whose budget this is, and warn before it runs out
|
||||
// rather than letting the grant die mid-task.
|
||||
let freeTier = $derived($copilotInfo.freeTier)
|
||||
let freeUsedPct = $derived(Math.min(100, Math.round((freeTier?.used_ratio ?? 0) * 100)))
|
||||
let freeRunningLow = $derived(!!freeTier && !freeTier.exhausted && freeUsedPct >= 80)
|
||||
|
||||
let capability = $derived(
|
||||
getReasoningCapability(providerModel.provider as AIProvider, providerModel.model)
|
||||
)
|
||||
@@ -312,6 +319,13 @@
|
||||
{#if effortLabel}
|
||||
<span class="shrink-0 text-tertiary">· {effortLabel}</span>
|
||||
{/if}
|
||||
{#if freeTier && !freeTier.exhausted}
|
||||
<span
|
||||
class="shrink-0 rounded-full px-1.5 text-2xs {freeRunningLow
|
||||
? 'bg-yellow-100 text-yellow-600 dark:bg-yellow-900/40'
|
||||
: 'bg-surface-secondary text-tertiary'}">Free</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { getKnownModelContextWindow, getModelContextWindow } from '../modelConfig'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import { AIMode } from './AIChatManager.svelte'
|
||||
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
|
||||
import UsageMeter from './UsageMeter.svelte'
|
||||
import { formatTokenCount } from './tokenUsage'
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
@@ -49,25 +49,11 @@
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<Tooltip small placement="top">
|
||||
<!-- Only a meter when a model is configured: it's a 0–100% reading against the
|
||||
window compaction enforces (known or assumed). With no model there's no max
|
||||
to measure against, so it's a plain labeled indicator (the bar is
|
||||
decorative/full and the token count lives in the tooltip). -->
|
||||
<div
|
||||
class="flex items-center h-5"
|
||||
aria-label="Context window usage"
|
||||
role={fillPct !== undefined ? 'meter' : undefined}
|
||||
aria-valuenow={fillPct}
|
||||
aria-valuemin={fillPct !== undefined ? 0 : undefined}
|
||||
aria-valuemax={fillPct !== undefined ? 100 : undefined}
|
||||
>
|
||||
<div class="w-8 h-1.5 rounded-full bg-surface-secondary overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all {fillClass}" style="width: {fillPct ?? 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{#snippet text()}
|
||||
<!-- Only a meter when we know the window: it's a 0–100% reading. With an unknown
|
||||
window there's no max to measure against, so it's a plain labeled indicator
|
||||
(the bar is decorative/full and the token count lives in the tooltip). -->
|
||||
<UsageMeter {fillPct} {fillClass} ariaLabel="Context window usage">
|
||||
{#snippet tooltip()}
|
||||
<div class="text-xs whitespace-nowrap">
|
||||
<p class="font-semibold">Context usage</p>
|
||||
<p class="mt-1 tabular-nums">
|
||||
@@ -85,5 +71,5 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
</UsageMeter>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
|
||||
|
||||
// Presentational gauge used by the context-usage indicator: a thin bar in a tooltip.
|
||||
// The owner computes the fill and supplies the tooltip content.
|
||||
let {
|
||||
fillPct,
|
||||
fillClass,
|
||||
ariaLabel,
|
||||
tooltip
|
||||
}: {
|
||||
// 0–100. Undefined means "no known max": the bar is decorative (full) and carries
|
||||
// no meter role.
|
||||
fillPct?: number
|
||||
fillClass: string
|
||||
ariaLabel: string
|
||||
tooltip: Snippet
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<Tooltip small placement="top">
|
||||
<div
|
||||
class="flex items-center h-5"
|
||||
aria-label={ariaLabel}
|
||||
role={fillPct !== undefined ? 'meter' : undefined}
|
||||
aria-valuenow={fillPct}
|
||||
aria-valuemin={fillPct !== undefined ? 0 : undefined}
|
||||
aria-valuemax={fillPct !== undefined ? 100 : undefined}
|
||||
>
|
||||
<div class="w-8 h-1.5 rounded-full bg-surface-secondary overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all {fillClass}" style="width: {fillPct ?? 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{#snippet text()}
|
||||
{@render tooltip()}
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
@@ -1,6 +1,14 @@
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { copilotWorkspace, setCopilotInfo } from '$lib/aiStore'
|
||||
import { workspaceAIClients } from './lib'
|
||||
import { writable } from 'svelte/store'
|
||||
|
||||
// The workspace of the most recent loadCopilot *request*, set synchronously before the
|
||||
// await — as opposed to `copilotWorkspace`, which only updates once a load resolves. A
|
||||
// background refresh (e.g. free-tier usage) compares against this so it can't supersede an
|
||||
// in-flight load for a newer workspace (which would otherwise win the token and restore
|
||||
// stale state).
|
||||
export const copilotWorkspaceRequested = writable<string | undefined>(undefined)
|
||||
|
||||
// Lives here, not in $lib/aiStore, purely so that module needs no import of the AI
|
||||
// client — it is the one thing that wanted both. Moving it back recreates the
|
||||
@@ -20,6 +28,7 @@ let loadCopilotToken = 0
|
||||
let inFlight: { workspace: string; promise: Promise<void> } | undefined
|
||||
|
||||
export function loadCopilot(workspace: string): Promise<void> {
|
||||
copilotWorkspaceRequested.set(workspace)
|
||||
if (inFlight?.workspace === workspace) {
|
||||
return inFlight.promise
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
<script lang="ts" module>
|
||||
/** Example prompts: the short `label` is shown as a clickable tag under the chat,
|
||||
* the `prompt` is what gets typed out as the placeholder / dropped into the input. */
|
||||
export const homeAIExamples: { label: string; prompt: string }[] = [
|
||||
{
|
||||
label: 'Sync Salesforce',
|
||||
prompt: 'Sync new Salesforce leads into a postgres table every hour'
|
||||
},
|
||||
{
|
||||
label: 'Ban Discord users',
|
||||
prompt:
|
||||
'Build a workflow that triggers on a Discord message, checks for offensive language using an LLM, and possibly block them'
|
||||
},
|
||||
{
|
||||
label: 'Weekly Slack report',
|
||||
prompt: 'Generate a weekly sales report from postgres and post it to Slack every Monday'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { ArrowUp, ExternalLink, Globe2, KeyRound, PlugZap, Settings } from 'lucide-svelte'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import { Badge } from '../common'
|
||||
import { startSessionWithPrompt } from '../sessions/sessionSwitch.svelte'
|
||||
import { copilotInfo, copilotWorkspace } from '$lib/aiStore'
|
||||
import { loadCopilot } from '$lib/components/copilot/loadCopilot'
|
||||
import { aiUserDisabled, hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores'
|
||||
import { HOME_SHOW_HUB } from '$lib/consts'
|
||||
import { base } from '$lib/base'
|
||||
import AIChatModelSettings from '../copilot/chat/AIChatModelSettings.svelte'
|
||||
import HomeConnectDrawer from './HomeConnectDrawer.svelte'
|
||||
import { USER_SETTINGS_HASH } from '../sidebar/settings'
|
||||
import { prefersSessionHandoff } from '../copilot/chat/global/gate'
|
||||
|
||||
let value = $state('')
|
||||
let placeholder = $state('')
|
||||
let homeConnectDrawer: HomeConnectDrawer | undefined = $state(undefined)
|
||||
|
||||
// In global-AI mode the layout's chat panel is disabled and never loads the copilot
|
||||
// config, so the home chat loads it for the current workspace itself.
|
||||
$effect(() => {
|
||||
if ($workspaceStore) {
|
||||
loadCopilot($workspaceStore)
|
||||
}
|
||||
})
|
||||
|
||||
// Whether the copilot config has actually loaded for the current workspace.
|
||||
let configLoaded = $derived($copilotWorkspace === $workspaceStore)
|
||||
// No usable model (no provider configured, or AI disabled): the composer is blurred and an
|
||||
// overlay explains why and links to the fix. Static, not hover-gated, so keyboard and touch
|
||||
// users see it too. Gate on `configLoaded` so the initial (unloaded) state doesn't flash the
|
||||
// overlay while a provider is in fact configured.
|
||||
let disabled = $derived(configLoaded && !$copilotInfo.enabled)
|
||||
// Submission is stricter than the overlay: block it until the config is loaded AND
|
||||
// enabled. Submitting during the unknown-config window hands the prompt to a session
|
||||
// that only sends once `copilotInfo.enabled` flips true — on an unconfigured/disabled
|
||||
// workspace that never happens and the prompt is silently lost.
|
||||
let canSend = $derived(configLoaded && $copilotInfo.enabled)
|
||||
|
||||
// Applied to the AI-specific parts only (title, input, example tags) when disabled. The
|
||||
// CLI/MCP and Hub buttons are unrelated to AI and stay sharp and clickable.
|
||||
let blurClass = $derived(disabled ? 'blur-sm pointer-events-none select-none' : '')
|
||||
|
||||
// Disabled because the user spent their free Windmill AI grant, not because AI was never
|
||||
// set up — the two look identical otherwise, and the "configure AI" copy would be a lie.
|
||||
let freeTierExhausted = $derived($copilotInfo.freeTier?.exhausted === true)
|
||||
|
||||
// The composer hands off to /sessions, which refuses operators — so hide it from them (the
|
||||
// prompt would be silently dropped) while the AI-independent CLI/MCP row below stays.
|
||||
let showComposer = $derived(prefersSessionHandoff($userStore?.operator))
|
||||
|
||||
let starting = $state(false)
|
||||
async function start() {
|
||||
if (!canSend || starting || !value.trim()) return
|
||||
starting = true
|
||||
try {
|
||||
await startSessionWithPrompt(value, { autoSend: true })
|
||||
} finally {
|
||||
starting = false
|
||||
}
|
||||
}
|
||||
|
||||
// Enter starts the session; Shift+Enter keeps inserting a newline.
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
const prompts = homeAIExamples.map((e) => e.prompt)
|
||||
|
||||
const TYPE_MS = 45
|
||||
const DELETE_MS = 25
|
||||
const HOLD_MS = 1800
|
||||
const PAUSE_MS = 400
|
||||
|
||||
// Typewriter effect: type a prompt out, hold, delete, then advance to the next. Only while the
|
||||
// composer is shown — otherwise (operators) it would loop forever driving an unrendered input.
|
||||
$effect(() => {
|
||||
if (!showComposer) return
|
||||
let promptIndex = 0
|
||||
let charIndex = 0
|
||||
let deleting = false
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
|
||||
function tick() {
|
||||
const current = prompts[promptIndex]
|
||||
if (!deleting) {
|
||||
charIndex++
|
||||
placeholder = current.slice(0, charIndex)
|
||||
if (charIndex >= current.length) {
|
||||
deleting = true
|
||||
timer = setTimeout(tick, HOLD_MS)
|
||||
return
|
||||
}
|
||||
timer = setTimeout(tick, TYPE_MS)
|
||||
} else {
|
||||
charIndex--
|
||||
placeholder = current.slice(0, charIndex)
|
||||
if (charIndex <= 0) {
|
||||
deleting = false
|
||||
promptIndex = (promptIndex + 1) % prompts.length
|
||||
timer = setTimeout(tick, PAUSE_MS)
|
||||
return
|
||||
}
|
||||
timer = setTimeout(tick, DELETE_MS)
|
||||
}
|
||||
}
|
||||
|
||||
timer = setTimeout(tick, TYPE_MS)
|
||||
return () => clearTimeout(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="w-full flex justify-center">
|
||||
<div class="max-w-[40rem] grow relative group">
|
||||
{#if showComposer}
|
||||
<div class={blurClass} inert={disabled}>
|
||||
<div class="flex items-center justify-center gap-2 mb-4">
|
||||
<p class="text-center font-regular text-3xl">Build with AI</p>
|
||||
<Badge color="blue" small>Beta</Badge>
|
||||
</div>
|
||||
<!-- anchors the send button / model settings to the input, not to the whole
|
||||
block — the row below would otherwise push them down -->
|
||||
<div class="relative">
|
||||
<TextInput
|
||||
bind:value
|
||||
class="resize-none px-4 py-3 pb-9 shadow-sm border-accent"
|
||||
underlyingInputEl="textarea"
|
||||
inputProps={{ rows: 4, placeholder, onkeydown: onKeydown }}
|
||||
/>
|
||||
<Button
|
||||
endIcon={starting ? {} : { icon: ArrowUp }}
|
||||
wrapperClasses="absolute right-2 bottom-3.5"
|
||||
variant={value.trim() ? 'accent' : 'subtle'}
|
||||
iconOnly
|
||||
loading={starting}
|
||||
disabled={!value.trim() || starting || !canSend}
|
||||
onclick={start}
|
||||
></Button>
|
||||
<div class="absolute left-3 bottom-4 flex items-center gap-1.5 px-0.5">
|
||||
<AIChatModelSettings />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
{#if showComposer}
|
||||
<div class="flex flex-row flex-wrap items-center gap-1.5 {blurClass}" inert={disabled}>
|
||||
{#each homeAIExamples as example (example.label)}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="xs"
|
||||
btnClasses="!rounded-full !text-2xs !text-hint"
|
||||
onClick={() => (value = example.prompt)}
|
||||
>
|
||||
{example.label}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
|
||||
<!-- Not AI-related, so shown even to operators / when the composer is hidden: kept out of
|
||||
the blurred subtree and above the disabled overlay so it stays sharp and clickable. -->
|
||||
<div class="relative z-20 flex flex-row items-center gap-1">
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
btnClasses="!text-2xs !text-hint"
|
||||
startIcon={{ icon: PlugZap }}
|
||||
onClick={() => homeConnectDrawer?.openDrawer?.()}
|
||||
>
|
||||
CLI / MCP
|
||||
</Button>
|
||||
{#if !$userStore?.operator && HOME_SHOW_HUB}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
btnClasses="!text-2xs !text-hint"
|
||||
startIcon={{ icon: Globe2 }}
|
||||
endIcon={{ icon: ExternalLink }}
|
||||
href={$hubBaseUrlStore}
|
||||
target="_blank"
|
||||
>
|
||||
Hub
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if showComposer && disabled}
|
||||
<div
|
||||
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-md bg-surface/70"
|
||||
>
|
||||
<p class="text-sm text-secondary">
|
||||
{#if $aiUserDisabled}
|
||||
Windmill AI is disabled in your account settings
|
||||
{:else if freeTierExhausted}
|
||||
You have used all of your free Windmill AI tokens
|
||||
{:else}
|
||||
No AI provider is configured
|
||||
{/if}
|
||||
</p>
|
||||
{#if $aiUserDisabled}
|
||||
<!-- The fix lives in account settings (a hash-opened drawer, not a route), so link
|
||||
the hash the sidebar's Account menu uses rather than the workspace AI settings. -->
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="accent"
|
||||
startIcon={{ icon: Settings }}
|
||||
href={USER_SETTINGS_HASH}
|
||||
>
|
||||
Open account settings
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="accent"
|
||||
startIcon={{ icon: freeTierExhausted ? KeyRound : Settings }}
|
||||
href="{base}/workspace_settings?tab=ai"
|
||||
>
|
||||
{freeTierExhausted ? 'Add your own API key' : 'Configure AI'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HomeConnectDrawer bind:this={homeConnectDrawer} />
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import CenteredPage from '$lib/components/CenteredPage.svelte'
|
||||
import { PIPELINE_DRAFT_KIND, pipelineFolderFromBundlePath } from '$lib/pipelinePaths'
|
||||
import { Badge, Button, Skeleton } from '$lib/components/common'
|
||||
import { Button, Skeleton } from '$lib/components/common'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import {
|
||||
AssetService,
|
||||
@@ -24,51 +24,176 @@
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Code2,
|
||||
LayoutDashboard,
|
||||
ListFilterPlus,
|
||||
SearchCode,
|
||||
Tag
|
||||
LayoutDashboard
|
||||
} from 'lucide-svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import CreateActionsMenu from './CreateActionsMenu.svelte'
|
||||
import ContentSearchInner from '$lib/components/ContentSearchInner.svelte'
|
||||
import type { Item as MenuItem } from '$lib/utils'
|
||||
|
||||
import { HOME_SEARCH_SHOW_FLOW, HOME_SEARCH_PLACEHOLDER } from '$lib/consts'
|
||||
|
||||
import SearchItems from '../SearchItems.svelte'
|
||||
import ListFilters from './ListFilters.svelte'
|
||||
import FilterSearchbar, {
|
||||
useUrlSyncedFilterInstance,
|
||||
type FilterSchemaRec
|
||||
} from '$lib/components/FilterSearchbar.svelte'
|
||||
import NoItemFound from './NoItemFound.svelte'
|
||||
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
|
||||
import FlowIcon from './FlowIcon.svelte'
|
||||
import { canWrite, getLocalSetting, isOwner, storeLocalSetting } from '$lib/utils'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { page } from '$app/state'
|
||||
import { setQuery } from '$lib/navigation'
|
||||
import Drawer from '../common/drawer/Drawer.svelte'
|
||||
import HighlightCode from '../HighlightCode.svelte'
|
||||
import DrawerContent from '../common/drawer/DrawerContent.svelte'
|
||||
import Item from './Item.svelte'
|
||||
import TreeViewRoot from './TreeViewRoot.svelte'
|
||||
import { effectivePath, type ItemType } from './treeViewUtils'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import { getContext, tick, untrack } from 'svelte'
|
||||
import { tick, untrack } from 'svelte'
|
||||
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
|
||||
import TextInput from '../text_input/TextInput.svelte'
|
||||
import { NetworkIcon } from 'lucide-svelte'
|
||||
import { base } from '$lib/base'
|
||||
import BulkActionsBar from './BulkActionsBar.svelte'
|
||||
import { HomeSelection, setHomeSelection, toBulkItem } from './homeSelection.svelte'
|
||||
interface Props {
|
||||
filter?: string
|
||||
subtab?: 'flow' | 'script' | 'app'
|
||||
showEditButtons?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
filter = $bindable(''),
|
||||
subtab = $bindable('script'),
|
||||
showEditButtons = true
|
||||
}: Props = $props()
|
||||
let { subtab = $bindable('script'), showEditButtons = true }: Props = $props()
|
||||
|
||||
// Which user-folder scoping toggle (if any) this role gets. Declared before the
|
||||
// FilterSearchbar schema and the derived filters that both read it.
|
||||
let filterUserFoldersType: 'only f/*' | 'u/username and f/*' | undefined = $derived(
|
||||
$userStore?.non_member
|
||||
? 'only f/*'
|
||||
: $userStore?.is_admin || $userStore?.is_super_admin
|
||||
? 'u/username and f/*'
|
||||
: undefined
|
||||
)
|
||||
|
||||
// FilterSearchbar schema — `_default_` is the free-text search; the rest mirror the
|
||||
// boolean/kind list filters. Owner and label scoping are offered as searchbar presets
|
||||
// (searchPresets) and resolve server-side (path_start / label) rather than filtering
|
||||
// client-side. `content` is a distinct mode: it swaps the list for the client-side
|
||||
// content-match view below (usable on any instance, not EE-gated).
|
||||
let searchFilterSchema = $derived({
|
||||
_default_: { type: 'string' as const, hidden: true },
|
||||
content: {
|
||||
type: 'string' as const,
|
||||
label: 'Content',
|
||||
description: 'Search across item contents'
|
||||
},
|
||||
// Owner (u/<user> or f/<folder>) and label are offered as presets built from what the
|
||||
// list actually holds (see searchPresets); they drive the same server path-scope / label
|
||||
// filter the old on-page chips did.
|
||||
owner: { type: 'string' as const, label: 'Owner' },
|
||||
label: { type: 'string' as const, label: 'Label' },
|
||||
kind: {
|
||||
type: 'oneof' as const,
|
||||
label: 'Kind',
|
||||
options: [
|
||||
{ value: 'script', label: 'Script' },
|
||||
...(HOME_SEARCH_SHOW_FLOW ? [{ value: 'flow', label: 'Flow' }] : []),
|
||||
{ value: 'app', label: 'App' }
|
||||
]
|
||||
},
|
||||
archived: { type: 'boolean' as const, label: 'Only archived' },
|
||||
// include_library and only_user_folders are role-dependent, but their KEYS stay unconditional
|
||||
// (toggling `hidden` instead): useUrlSyncedFilterInstance snapshots the key set once and Home
|
||||
// survives workspace switches, so a key first appearing after a role change would never
|
||||
// URL-sync. The searchbar hides the inactive ones.
|
||||
include_library: {
|
||||
type: 'boolean' as const,
|
||||
label: 'Include library scripts',
|
||||
// On by default, so selecting it means "turn it off" — keep the picker.
|
||||
default: true,
|
||||
hidden: !($userStore && !$userStore.operator)
|
||||
},
|
||||
only_user_folders: {
|
||||
type: 'boolean' as const,
|
||||
label:
|
||||
filterUserFoldersType === 'only f/*'
|
||||
? 'Only f/*'
|
||||
: `Only u/${$userStore?.username} and f/*`,
|
||||
hidden: !filterUserFoldersType
|
||||
}
|
||||
} satisfies FilterSchemaRec)
|
||||
|
||||
// Legacy Home links stored free-text in `search`, owner scope in `filter`, and could carry
|
||||
// `kind=all` — none of which the generic searchbar sync (keys `_default_`, `owner`, and a kind
|
||||
// enum without `all`) understands. Rewrite them once, before the sync reads window.location, so
|
||||
// shared/bookmarked URLs still restore and an invalid `kind=all` can't wedge later edits.
|
||||
if (typeof window !== 'undefined') {
|
||||
const url = new URL(window.location.href)
|
||||
const p = url.searchParams
|
||||
let changed = false
|
||||
const legacySearch = p.get('search')
|
||||
if (legacySearch !== null) {
|
||||
if (!p.has('_default_')) p.set('_default_', legacySearch)
|
||||
p.delete('search')
|
||||
changed = true
|
||||
}
|
||||
const legacyOwner = p.get('filter')
|
||||
if (legacyOwner !== null) {
|
||||
if (!p.has('owner')) p.set('owner', legacyOwner)
|
||||
p.delete('filter')
|
||||
changed = true
|
||||
}
|
||||
if (p.get('kind') === 'all') {
|
||||
p.delete('kind')
|
||||
changed = true
|
||||
}
|
||||
if (changed) {
|
||||
history.replaceState(
|
||||
history.state,
|
||||
'',
|
||||
`${url.pathname}${p.toString() ? `?${p}` : ''}${url.hash}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Single URL-synced source of truth for the searchbar-driven filters.
|
||||
let filterValues = useUrlSyncedFilterInstance(untrack(() => searchFilterSchema))
|
||||
|
||||
// Derived views the rest of the data layer reads. The merged-endpoint reload effect
|
||||
// depends on these (search, kind, archived, library, user-folder scope), so changing a
|
||||
// searchbar chip reloads the server stream exactly as toggling the old controls did.
|
||||
let filter = $derived((filterValues.val._default_ ?? '') as string)
|
||||
let itemKind = $derived((filterValues.val.kind ?? 'all') as 'script' | 'flow' | 'app' | 'all')
|
||||
let archived = $derived(!!filterValues.val.archived)
|
||||
let includeWithoutMain = $derived((filterValues.val.include_library ?? true) as boolean)
|
||||
let filterUserFolders = $derived(!!filterValues.val.only_user_folders)
|
||||
|
||||
// Content search is a distinct mode: its results come from ContentSearchInner
|
||||
// (which carries only path + content), so the row-list filters can't
|
||||
// apply to it. When it's active we restrict the searchbar to just the content filter,
|
||||
// clear any other filters so they don't linger as ignored chips, and hide the row-list
|
||||
// controls (kind toggle, tree view) that no longer drive anything.
|
||||
let contentActive = $derived(!!filterValues.val.content)
|
||||
let searchbarSchema = $derived(
|
||||
contentActive ? { content: searchFilterSchema.content } : searchFilterSchema
|
||||
)
|
||||
$effect(() => {
|
||||
if (!contentActive) return
|
||||
untrack(() => {
|
||||
for (const k of Object.keys(filterValues.val)) {
|
||||
if (k !== 'content') delete (filterValues.val as Record<string, unknown>)[k]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Content-filter view: reuse the Ctrl-K "Content" search. It loads its own dataset
|
||||
// via `.open()`, then filters client-side by `search`. The component is
|
||||
// keyed by workspace in the markup, so a workspace switch remounts it (this `bind:this`
|
||||
// then points at the fresh instance and re-runs `open()`); the old instance is discarded,
|
||||
// so its late in-flight responses can't overwrite the new workspace's results.
|
||||
let contentSearchEl: ContentSearchInner | undefined = $state()
|
||||
$effect(() => {
|
||||
const el = contentSearchEl
|
||||
if (el) untrack(() => el.open())
|
||||
})
|
||||
|
||||
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app'> = T & {
|
||||
canWrite: boolean
|
||||
@@ -178,10 +303,6 @@
|
||||
|
||||
let filteredItems: (TableScript | TableFlow | TableApp | TableRawApp)[] = $state([])
|
||||
|
||||
let itemKind = $state(
|
||||
(page.url.searchParams.get('kind') as 'script' | 'flow' | 'app' | 'all') ?? 'all'
|
||||
)
|
||||
|
||||
let loading = $state(true)
|
||||
|
||||
let nbDisplayed = $state(15)
|
||||
@@ -596,8 +717,11 @@
|
||||
return true // should not happen
|
||||
}
|
||||
|
||||
let ownerFilter: string | undefined = $state(undefined)
|
||||
let labelFilter: string | undefined = $state(undefined)
|
||||
// Owner/label scope now live on the URL-synced searchbar filters (set via the presets),
|
||||
// not standalone chip state — the whole data layer below still reads these two, so keep
|
||||
// them as the single derived source. Empty string reads as "no filter".
|
||||
let ownerFilter = $derived((filterValues.val.owner || undefined) as string | undefined)
|
||||
let labelFilter = $derived((filterValues.val.label || undefined) as string | undefined)
|
||||
|
||||
const cmp = new Intl.Collator('en').compare
|
||||
|
||||
@@ -692,20 +816,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
let archived = $state(false)
|
||||
|
||||
const TREE_VIEW_SETTING_NAME = 'treeView'
|
||||
const FILTER_USER_FOLDER_SETTING_NAME = 'filterUserFolders'
|
||||
const INCLUDE_WITHOUT_MAIN_SETTING_NAME = 'includeWithoutMain'
|
||||
let treeView = $state(getLocalSetting(TREE_VIEW_SETTING_NAME) == 'true')
|
||||
let filterUserFoldersType: 'only f/*' | 'u/username and f/*' | undefined = $derived(
|
||||
$userStore?.non_member
|
||||
? 'only f/*'
|
||||
: $userStore?.is_admin || $userStore?.is_super_admin
|
||||
? 'u/username and f/*'
|
||||
: undefined
|
||||
)
|
||||
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
|
||||
|
||||
// Pipeline entries are rendered independently of the item list, so apply the
|
||||
// same gates the items get — otherwise a pipeline would still show under the
|
||||
@@ -725,16 +837,6 @@
|
||||
)
|
||||
)
|
||||
})
|
||||
let includeWithoutMain = $state(
|
||||
getLocalSetting(INCLUDE_WITHOUT_MAIN_SETTING_NAME)
|
||||
? getLocalSetting(INCLUDE_WITHOUT_MAIN_SETTING_NAME) == 'true'
|
||||
: true
|
||||
)
|
||||
|
||||
const openSearchWithPrefilledText: (t?: string) => void = getContext(
|
||||
'openSearchWithPrefilledText'
|
||||
)
|
||||
|
||||
let viewCodeDrawer: Drawer | undefined = $state()
|
||||
let viewCodeTitle: string | undefined = $state()
|
||||
let script: Script | undefined = $state()
|
||||
@@ -1056,15 +1158,25 @@
|
||||
let allLabels = $derived(
|
||||
Array.from(new Set(combinedItems?.flatMap((x) => itemLabels(x)) ?? [])).sort()
|
||||
)
|
||||
// FilterSearchbar presets: the owner prefixes and labels the list actually holds, so
|
||||
// scoping to one is a click in the searchbar dropdown instead of a wall of on-page chips.
|
||||
// Owner sets the `owner` filter (server path-scope), label sets `label` (client filter).
|
||||
// The `:\ ` separator and escaped spaces match the canonical `key:\ value` form parseToText
|
||||
// emits, so the "already applied" check finds them after a reparse and won't re-offer a
|
||||
// duplicate.
|
||||
let searchPresets = $derived([
|
||||
...owners.map((o) => ({ name: o, value: `owner:\\ ${o.replace(/ /g, '\\ ')}` })),
|
||||
...allLabels.map((l) => ({ name: l, value: `label:\\ ${l.replace(/ /g, '\\ ')}` }))
|
||||
])
|
||||
let prevWorkspace: string | undefined = undefined
|
||||
// Clear filters only when the workspace actually changes. The initial
|
||||
// resolution must be left alone so URL-loaded filter values (set by
|
||||
// ListFilters.loadFilterFromUrl on mount) survive the async store settling.
|
||||
// An owner/label from one workspace means nothing in another, so drop them when the
|
||||
// workspace actually changes. The initial resolution is left alone so URL-loaded filter
|
||||
// values survive the async store settling.
|
||||
$effect(() => {
|
||||
const ws = $workspaceStore
|
||||
if (ws && prevWorkspace !== undefined && ws !== prevWorkspace) {
|
||||
ownerFilter = undefined
|
||||
labelFilter = undefined
|
||||
delete filterValues.val.owner
|
||||
delete filterValues.val.label
|
||||
}
|
||||
prevWorkspace = ws
|
||||
})
|
||||
@@ -1211,6 +1323,26 @@
|
||||
selectedIndex = previousNbDisplayed
|
||||
}
|
||||
|
||||
// The searchbar is a contenteditable, not an <input>, so it can't be matched by SKIP_SELECTOR
|
||||
// and has no `.value`/`.selectionEnd`. It owns the arrows only while its suggestion dropdown is
|
||||
// open (free-text mode passes them through to the list); track that so nav stands down then.
|
||||
let searchbarDropdownOpen = $state(false)
|
||||
|
||||
// Caret position inside the searchbar's contenteditable, via the Selection API — the equivalent
|
||||
// of an <input>'s selectionStart/End the list navigation used before the searchbar swap.
|
||||
function searchCaret(el: HTMLElement): { atStart: boolean; atEnd: boolean; empty: boolean } {
|
||||
const text = el.textContent ?? ''
|
||||
const sel = window.getSelection()
|
||||
if (!sel || sel.rangeCount === 0)
|
||||
return { atStart: true, atEnd: true, empty: text.length === 0 }
|
||||
const range = sel.getRangeAt(0)
|
||||
const pre = range.cloneRange()
|
||||
pre.selectNodeContents(el)
|
||||
pre.setEnd(range.endContainer, range.endOffset)
|
||||
const caret = pre.toString().length
|
||||
return { atStart: caret === 0, atEnd: caret >= text.length, empty: text.length === 0 }
|
||||
}
|
||||
|
||||
// Elements that own the keyboard themselves (menus, dialogs, comboboxes): the
|
||||
// list's own shortcuts stand down while one of them has focus.
|
||||
const SKIP_SELECTOR =
|
||||
@@ -1331,6 +1463,8 @@
|
||||
tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable
|
||||
const isOurSearch = target.id === 'home-search-input'
|
||||
if (isEditable && !isOurSearch) return
|
||||
// While the searchbar's suggestion dropdown is open it owns the arrows/Enter itself.
|
||||
if (isOurSearch && searchbarDropdownOpen) return
|
||||
if (target.closest(skipSelector)) return
|
||||
}
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
@@ -1340,8 +1474,8 @@
|
||||
// Guard: if cursor is in the middle of typed search text, let the cursor move.
|
||||
if (e.key === 'ArrowRight') {
|
||||
if (target?.id === 'home-search-input') {
|
||||
const inp = target as HTMLInputElement
|
||||
if (inp.value.length > 0 && inp.selectionEnd !== inp.value.length) return
|
||||
const c = searchCaret(target)
|
||||
if (!c.empty && !c.atEnd) return
|
||||
}
|
||||
if (selectedIndex < 0 || selectedIndex >= displayedItems.length) return
|
||||
const buttons = getSelectedRowActionButtons()
|
||||
@@ -1354,8 +1488,8 @@
|
||||
// ArrowLeft from search input with cursor at start: no-op (let default handle).
|
||||
if (e.key === 'ArrowLeft') {
|
||||
if (target?.id === 'home-search-input') {
|
||||
const inp = target as HTMLInputElement
|
||||
if (inp.value.length > 0 && inp.selectionStart !== 0) return
|
||||
const c = searchCaret(target)
|
||||
if (!c.empty && !c.atStart) return
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1427,13 +1561,6 @@
|
||||
$effect(() => {
|
||||
storeLocalSetting(TREE_VIEW_SETTING_NAME, treeView ? 'true' : undefined)
|
||||
})
|
||||
$effect(() => {
|
||||
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
|
||||
})
|
||||
$effect(() => {
|
||||
storeLocalSetting(INCLUDE_WITHOUT_MAIN_SETTING_NAME, includeWithoutMain ? 'true' : undefined)
|
||||
})
|
||||
|
||||
// Multi-selection + bulk actions. Published through context so the tree's
|
||||
// nested levels don't have to carry it; `Item` is the only reader.
|
||||
const homeSelection = new HomeSelection()
|
||||
@@ -1497,157 +1624,77 @@
|
||||
description: 'Lists of scripts, flows, and apps'
|
||||
}}
|
||||
>
|
||||
<div class="flex justify-start">
|
||||
<ToggleButtonGroup
|
||||
bind:selected={itemKind}
|
||||
onSelected={(v) => {
|
||||
if (itemKind != 'all') {
|
||||
subtab = v
|
||||
}
|
||||
setQuery('kind', v)
|
||||
}}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="all" label="All" size="md" {item} />
|
||||
<ToggleButton value="script" icon={Code2} label="Scripts" size="md" {item} />
|
||||
{#if HOME_SEARCH_SHOW_FLOW}
|
||||
{#if !contentActive}
|
||||
<div class="flex justify-start">
|
||||
<ToggleButtonGroup
|
||||
selected={itemKind}
|
||||
onSelected={(v) => {
|
||||
// itemKind is derived from the shared filter object (which URL-syncs itself);
|
||||
// `all` clears the kind filter (delete, not null, so it doesn't linger as a
|
||||
// `kind: null` chip).
|
||||
if (v === 'all') {
|
||||
delete filterValues.val.kind
|
||||
} else {
|
||||
filterValues.val.kind = v
|
||||
subtab = v
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="all" label="All" size="md" {item} />
|
||||
<ToggleButton value="script" icon={Code2} label="Scripts" size="md" {item} />
|
||||
{#if HOME_SEARCH_SHOW_FLOW}
|
||||
<ToggleButton
|
||||
value="flow"
|
||||
label="Flows"
|
||||
icon={FlowIcon}
|
||||
selectedColor="#14b8a6"
|
||||
size="md"
|
||||
{item}
|
||||
/>
|
||||
{/if}
|
||||
<ToggleButton
|
||||
value="flow"
|
||||
label="Flows"
|
||||
icon={FlowIcon}
|
||||
selectedColor="#14b8a6"
|
||||
value="app"
|
||||
label="Apps"
|
||||
icon={LayoutDashboard}
|
||||
selectedColor="#fb923c"
|
||||
size="md"
|
||||
{item}
|
||||
/>
|
||||
{/if}
|
||||
<ToggleButton
|
||||
value="app"
|
||||
label="Apps"
|
||||
icon={LayoutDashboard}
|
||||
selectedColor="#fb923c"
|
||||
size="md"
|
||||
{item}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
|
||||
<div class="relative text-primary grow min-w-[100px]">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<TextInput
|
||||
inputProps={{
|
||||
autofocus: true,
|
||||
placeholder: HOME_SEARCH_PLACEHOLDER,
|
||||
id: 'home-search-input'
|
||||
}}
|
||||
size="md"
|
||||
bind:value={filter}
|
||||
class="!pr-10"
|
||||
/>
|
||||
<button aria-label="Search" type="submit" class="absolute right-0 top-0 mt-2 mr-4">
|
||||
<svg
|
||||
class="h-4 w-4 fill-current"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
version="1.1"
|
||||
id="Capa_1"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 56.966 56.966"
|
||||
style="enable-background:new 0 0 56.966 56.966;"
|
||||
xml:space="preserve"
|
||||
width="512px"
|
||||
height="512px"
|
||||
>
|
||||
<path
|
||||
d="M55.146,51.887L41.588,37.786c3.486-4.144,5.396-9.358,5.396-14.786c0-12.682-10.318-23-23-23s-23,10.318-23,23 s10.318,23,23,23c4.761,0,9.298-1.436,13.177-4.162l13.661,14.208c0.571,0.593,1.339,0.92,2.162,0.92 c0.779,0,1.518-0.297,2.079-0.837C56.255,54.982,56.293,53.08,55.146,51.887z M23.984,6c9.374,0,17,7.626,17,17s-7.626,17-17,17 s-17-7.626-17-17S14.61,6,23.984,6z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
on:click={() => openSearchWithPrefilledText('#')}
|
||||
variant="default"
|
||||
unifiedSize="md"
|
||||
endIcon={{
|
||||
icon: SearchCode
|
||||
}}
|
||||
>
|
||||
Content
|
||||
</Button>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<ListFilters
|
||||
syncQuery
|
||||
bind:selectedFilter={ownerFilter}
|
||||
filters={owners}
|
||||
maxDisplayed={20}
|
||||
bottomMargin={false}
|
||||
/>
|
||||
{#if allLabels.length > 0}
|
||||
<div class="gap-1.5 w-full flex flex-wrap mt-2">
|
||||
{#each allLabels as label (label)}
|
||||
<Badge
|
||||
color="blue"
|
||||
small
|
||||
clickable
|
||||
selected={label === labelFilter}
|
||||
title="Label: {label}"
|
||||
onclick={() => {
|
||||
labelFilter = labelFilter === label ? undefined : label
|
||||
}}
|
||||
>
|
||||
<Tag size={10} class="inline -mt-px" />{label}
|
||||
{#if label === labelFilter}✗{/if}
|
||||
</Badge>
|
||||
{/each}
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
{/if}
|
||||
{#if filteredItems?.length == 0}
|
||||
<div class="mt-10"></div>
|
||||
{/if}
|
||||
{#if !loading}
|
||||
<div class="flex w-full flex-row-reverse gap-2 mt-2 mb-1 items-center h-6">
|
||||
<Popover floatingConfig={{ placement: 'bottom-end' }}>
|
||||
{#snippet trigger()}
|
||||
<Button
|
||||
startIcon={{
|
||||
icon: ListFilterPlus
|
||||
}}
|
||||
nonCaptureEvent
|
||||
iconOnly
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="default"
|
||||
spacingSize="xs2"
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="p-4">
|
||||
<span class="text-sm font-semibold text-emphasis">Filters</span>
|
||||
<div class="flex flex-col gap-2 mt-2">
|
||||
<Toggle size="xs" bind:checked={archived} options={{ right: 'Only archived' }} />
|
||||
{#if $userStore && !$userStore.operator}
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={includeWithoutMain}
|
||||
options={{ right: 'Include library scripts' }}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{#if filterUserFoldersType === 'only f/*'}
|
||||
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
|
||||
{:else if filterUserFoldersType === 'u/username and f/*'}
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={filterUserFolders}
|
||||
options={{ right: `Only u/${$userStore?.username} and f/*` }}
|
||||
|
||||
{#if !loading && !contentActive}
|
||||
<!-- List controls, between the kind toggle and the searchbar: select mode, tree
|
||||
view, expand/collapse (tree only), sort. -->
|
||||
<div class="flex items-center gap-2">
|
||||
{#if homeSelection.available && !homeSelection.active}
|
||||
<Button
|
||||
startIcon={{ icon: CheckSquare }}
|
||||
iconOnly
|
||||
unifiedSize="xs"
|
||||
variant="default"
|
||||
title="Select items — move, archive, delete or discard several at once"
|
||||
on:click={() => homeSelection.enter()}
|
||||
/>
|
||||
{/if}
|
||||
<Toggle size="xs" bind:checked={treeView} options={{ right: 'Tree view' }} />
|
||||
{#if treeView}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
on:click={() => (collapseAll = !collapseAll)}
|
||||
startIcon={{ icon: collapseAll ? ChevronsUpDown : ChevronsDownUp }}
|
||||
>
|
||||
{#if collapseAll}
|
||||
Expand all
|
||||
{:else}
|
||||
Collapse all
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
<DropdownV2
|
||||
items={sortItems}
|
||||
disabled={filter !== ''}
|
||||
@@ -1661,10 +1708,8 @@
|
||||
nonCaptureEvent
|
||||
disabled={filter !== ''}
|
||||
iconOnly={short === ''}
|
||||
size="xs"
|
||||
color="light"
|
||||
unifiedSize="xs"
|
||||
variant="default"
|
||||
spacingSize="xs2"
|
||||
startIcon={{ icon: ArrowDownUp }}
|
||||
title={filter !== ''
|
||||
? 'Sorting is disabled while searching (results are ranked by relevance)'
|
||||
@@ -1674,42 +1719,48 @@
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
{#if treeView}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
on:click={() => (collapseAll = !collapseAll)}
|
||||
startIcon={{
|
||||
icon: collapseAll ? ChevronsUpDown : ChevronsDownUp
|
||||
}}
|
||||
>
|
||||
{#if collapseAll}
|
||||
Expand all
|
||||
{:else}
|
||||
Collapse all
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if homeSelection.available && !homeSelection.active}
|
||||
<!-- Last child of a flex-row-reverse row, so `mr-auto` absorbs the free
|
||||
space and pins it to the far left, away from the view/sort controls. -->
|
||||
<Button
|
||||
wrapperClasses="mr-auto"
|
||||
startIcon={{ icon: CheckSquare }}
|
||||
iconOnly
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="default"
|
||||
spacingSize="xs2"
|
||||
title="Select items — move, archive, delete or discard several at once"
|
||||
on:click={() => homeSelection.enter()}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex grow items-center justify-end gap-2 min-w-0">
|
||||
<div class="relative text-primary w-full min-w-[200px] max-w-[26rem]">
|
||||
<FilterSearchbar
|
||||
schema={searchbarSchema}
|
||||
bind:value={filterValues.val}
|
||||
placeholder={HOME_SEARCH_PLACEHOLDER}
|
||||
presets={contentActive ? [] : searchPresets}
|
||||
autofocus
|
||||
hideDropdownOnFreeText
|
||||
inputId="home-search-input"
|
||||
onDropdownVisibleChange={(v) => (searchbarDropdownOpen = v)}
|
||||
/>
|
||||
</div>
|
||||
<!-- Same gate the old create actions used: hidden from operators and in workspaces
|
||||
whose direct-deploy protection cleared showEditButtons (NoDirectDeployAlert), since
|
||||
the menu itself does no permission check. -->
|
||||
{#if !$userStore?.operator && showEditButtons}
|
||||
<CreateActionsMenu />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{#if filteredItems == undefined || treeCountsPending}
|
||||
{#if filteredItems?.length == 0}
|
||||
<div class="mt-10"></div>
|
||||
{/if}
|
||||
<div class="mt-3">
|
||||
{#if filterValues.val.content}
|
||||
<!-- Content filter: swap the normal list/tree for the content-match view (the same one
|
||||
used by the Ctrl-K "Content" modal). It loads the workspace's scripts/flows/apps/
|
||||
resources and matches their contents client-side — usable on any instance. Keyed by
|
||||
workspace so a switch remounts a fresh instance and late in-flight responses from
|
||||
the previous workspace can't land in it. -->
|
||||
<!-- -mx-2 cancels ContentSearchInner's own px-2 so its rows line up flush with the
|
||||
runnable list instead of sitting slightly inset. -->
|
||||
<div class="-mx-2">
|
||||
{#key $workspaceStore}
|
||||
<ContentSearchInner bind:this={contentSearchEl} search={filterValues.val.content} />
|
||||
{/key}
|
||||
</div>
|
||||
{:else if filteredItems == undefined || treeCountsPending}
|
||||
<div class="mt-4"></div>
|
||||
<Skeleton layout={[[2], 1]} />
|
||||
{#each new Array(6) as _}
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
{:else}
|
||||
<div class="flex justify-center items-center h-48">
|
||||
<div class="text-primary text-center">
|
||||
<div class="text-lg font-semibold text-emphasis">Welcome to Windmill</div>
|
||||
<div class="text-xs font-normal text-hint">
|
||||
Get started by creating your first script, flow, or app
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import TreeView from './TreeView.svelte'
|
||||
import { onDestroy, untrack } from 'svelte'
|
||||
import ResizeTransitionWrapper from '$lib/components/common/ResizeTransitionWrapper.svelte'
|
||||
|
||||
import { ChevronDown, ChevronUp, Folder, FolderTree, NetworkIcon, User } from 'lucide-svelte'
|
||||
import Item from './Item.svelte'
|
||||
@@ -297,120 +298,124 @@
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if opened || isSearching}
|
||||
<div>
|
||||
{#if hasPipeline && isFolder(item)}
|
||||
<!-- py-3 matches common/table/Row.svelte so this row sits at
|
||||
<!-- ResizeObserver, not a slide: a freshly-opened owner fetches its rows, so its height
|
||||
changes twice (open-empty, then rows land) and a slide would only animate the first. -->
|
||||
<ResizeTransitionWrapper vertical innerClass="w-full">
|
||||
{#if opened || isSearching}
|
||||
<div>
|
||||
{#if hasPipeline && isFolder(item)}
|
||||
<!-- py-3 matches common/table/Row.svelte so this row sits at
|
||||
the same height as the script/flow/app rows that follow
|
||||
it under the same folder; py-2 was visibly shorter. -->
|
||||
<a
|
||||
href="{base}/pipeline/{encodeURIComponent(item.folderName)}"
|
||||
class="flex items-center gap-4 px-4 py-3 border-b text-sm hover:bg-surface-hover transition-colors"
|
||||
style="padding-left: {(depth + 1) * 16}px;"
|
||||
>
|
||||
<NetworkIcon size={16} class="text-emerald-600 dark:text-emerald-400" />
|
||||
<span class="text-xs font-medium text-emphasis">Pipeline</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#each item.items.slice(0, effectiveMax) as subItem, index ((subItem['path'] ? subItem['type'] + '__' + subItem['path'] + '__' + index : undefined) ?? 'folder__' + subItem['folderName'] + '__' + index)}
|
||||
<TreeView
|
||||
{isSearching}
|
||||
{collapseAll}
|
||||
item={subItem}
|
||||
{pipelineFolders}
|
||||
{ownerLoad}
|
||||
{onExpandOwner}
|
||||
{onCollapseOwner}
|
||||
parentPrefix={nodePrefix}
|
||||
ancestorHasMore={nodeHasMore}
|
||||
on:scriptChanged
|
||||
on:flowChanged
|
||||
on:appChanged
|
||||
on:rawAppChanged
|
||||
on:reload
|
||||
{showCode}
|
||||
{showEditButton}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
{/each}
|
||||
{#if effectiveMax < item.items.length}
|
||||
<div
|
||||
class="px-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary"
|
||||
style="padding-left: {(depth + 1) * 16}px;"
|
||||
>
|
||||
<!-- Rows, not items: this slices the node's own entries, where a subfolder
|
||||
is one row standing for everything under it. -->
|
||||
<span class="text-xs text-secondary">
|
||||
Showing {effectiveMax} of {item.items.length} loaded rows
|
||||
</span>
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
on:click={() => {
|
||||
// Grown from what is rendered, not from showMax: the lazy ceiling can
|
||||
// already be showing more than showMax, and stepping that would take
|
||||
// several clicks to change anything on screen.
|
||||
showMax = Math.min(item.items.length, effectiveMax + showMoreStep)
|
||||
}}
|
||||
<a
|
||||
href="{base}/pipeline/{encodeURIComponent(item.folderName)}"
|
||||
class="flex items-center gap-4 px-4 py-3 border-b text-sm hover:bg-surface-hover transition-colors"
|
||||
style="padding-left: {(depth + 1) * 16}px;"
|
||||
>
|
||||
Show more
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if nodePrefix != undefined && ownerLoad != undefined}
|
||||
{#if nodeState?.loading && item.items.length === 0}
|
||||
<!-- Show the spinner only on the first load, when there's nothing yet. A
|
||||
<NetworkIcon size={16} class="text-emerald-600 dark:text-emerald-400" />
|
||||
<span class="text-xs font-medium text-emphasis">Pipeline</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#each item.items.slice(0, effectiveMax) as subItem, index ((subItem['path'] ? subItem['type'] + '__' + subItem['path'] + '__' + index : undefined) ?? 'folder__' + subItem['folderName'] + '__' + index)}
|
||||
<TreeView
|
||||
{isSearching}
|
||||
{collapseAll}
|
||||
item={subItem}
|
||||
{pipelineFolders}
|
||||
{ownerLoad}
|
||||
{onExpandOwner}
|
||||
{onCollapseOwner}
|
||||
parentPrefix={nodePrefix}
|
||||
ancestorHasMore={nodeHasMore}
|
||||
on:scriptChanged
|
||||
on:flowChanged
|
||||
on:appChanged
|
||||
on:rawAppChanged
|
||||
on:reload
|
||||
{showCode}
|
||||
{showEditButton}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
{/each}
|
||||
{#if effectiveMax < item.items.length}
|
||||
<div
|
||||
class="px-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary"
|
||||
style="padding-left: {(depth + 1) * 16}px;"
|
||||
>
|
||||
<!-- Rows, not items: this slices the node's own entries, where a subfolder
|
||||
is one row standing for everything under it. -->
|
||||
<span class="text-xs text-secondary">
|
||||
Showing {effectiveMax} of {item.items.length} loaded rows
|
||||
</span>
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
on:click={() => {
|
||||
// Grown from what is rendered, not from showMax: the lazy ceiling can
|
||||
// already be showing more than showMax, and stepping that would take
|
||||
// several clicks to change anything on screen.
|
||||
showMax = Math.min(item.items.length, effectiveMax + showMoreStep)
|
||||
}}
|
||||
>
|
||||
Show more
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if nodePrefix != undefined && ownerLoad != undefined}
|
||||
{#if nodeState?.loading && item.items.length === 0}
|
||||
<!-- Show the spinner only on the first load, when there's nothing yet. A
|
||||
re-sort/re-filter re-fetch keeps the old rows visible and swaps them
|
||||
in place, so flashing "Loading…" under them would just be noise. -->
|
||||
<div class="text-center text-xs py-2 text-secondary">Loading…</div>
|
||||
{:else if nodeHasMore && (collapseAll || nodeState?.loading || effectiveMax >= item.items.length)}
|
||||
<!-- Every folder pages within its own prefix, so completing a subfolder
|
||||
<div class="text-center text-xs py-2 text-secondary">Loading…</div>
|
||||
{:else if nodeHasMore && (collapseAll || nodeState?.loading || effectiveMax >= item.items.length)}
|
||||
<!-- Every folder pages within its own prefix, so completing a subfolder
|
||||
doesn't mean paging everything its owner holds. Under "expand all" this
|
||||
waits for the client "Show more" above, so the two pagers don't stack
|
||||
under every open node at once — but never while loading, or a long run
|
||||
would unmount its own spinner on its first page. Spelling out the counts
|
||||
is the point: without them this reads as an optional extra rather than
|
||||
as rows still missing. -->
|
||||
<div
|
||||
class="px-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary"
|
||||
style="padding-left: {(depth + 1) * 16}px;"
|
||||
>
|
||||
<span class="text-xs text-secondary">
|
||||
Showing {loadedHere}{ownerTotal != undefined ? ` of ${ownerTotal}` : ''} items in {nodePrefix}
|
||||
</span>
|
||||
<div class="flex flex-row items-center gap-2 shrink-0">
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
loading={nodeState?.loading && !loadingAll}
|
||||
disabled={nodeState?.loading}
|
||||
on:click={() =>
|
||||
nodePrefix != undefined &&
|
||||
onExpandOwner?.(nodePrefix, nodeState?.loaded ?? false)}
|
||||
>
|
||||
Load more
|
||||
</Button>
|
||||
<!-- Same call, paged to the end: a folder several pages deep otherwise
|
||||
<div
|
||||
class="px-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary"
|
||||
style="padding-left: {(depth + 1) * 16}px;"
|
||||
>
|
||||
<span class="text-xs text-secondary">
|
||||
Showing {loadedHere}{ownerTotal != undefined ? ` of ${ownerTotal}` : ''} items in {nodePrefix}
|
||||
</span>
|
||||
<div class="flex flex-row items-center gap-2 shrink-0">
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
loading={nodeState?.loading && !loadingAll}
|
||||
disabled={nodeState?.loading}
|
||||
on:click={() =>
|
||||
nodePrefix != undefined &&
|
||||
onExpandOwner?.(nodePrefix, nodeState?.loaded ?? false)}
|
||||
>
|
||||
Load more
|
||||
</Button>
|
||||
<!-- Same call, paged to the end: a folder several pages deep otherwise
|
||||
takes a click per page to reach an exact count. -->
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
loading={nodeState?.loading && loadingAll}
|
||||
disabled={nodeState?.loading}
|
||||
on:click={() => {
|
||||
if (nodePrefix == undefined) return
|
||||
loadingAll = true
|
||||
onExpandOwner?.(nodePrefix, nodeState?.loaded ?? false, { all: true })
|
||||
}}
|
||||
>
|
||||
Load all
|
||||
</Button>
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
loading={nodeState?.loading && loadingAll}
|
||||
disabled={nodeState?.loading}
|
||||
on:click={() => {
|
||||
if (nodePrefix == undefined) return
|
||||
loadingAll = true
|
||||
onExpandOwner?.(nodePrefix, nodeState?.loaded ?? false, { all: true })
|
||||
}}
|
||||
>
|
||||
Load all
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</ResizeTransitionWrapper>
|
||||
</div>
|
||||
{:else}
|
||||
<Item
|
||||
|
||||
@@ -217,7 +217,9 @@ export function buildRunsFilterSearchbarSchema({
|
||||
show_future_jobs: {
|
||||
type: 'boolean' as const,
|
||||
label: 'Show future jobs (Default: true)',
|
||||
description: 'Include jobs that are planned later'
|
||||
description: 'Include jobs that are planned later',
|
||||
// On by default (useJobsLoader), so selecting it means "turn it off" — keep the picker.
|
||||
default: true
|
||||
},
|
||||
...(isSuperAdminOrDevops &&
|
||||
isAdminsWorkspace && {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
class: className = '',
|
||||
innerClass = '',
|
||||
maxHeight = 256,
|
||||
instantClose = false,
|
||||
getInputRect,
|
||||
children
|
||||
}: {
|
||||
@@ -26,6 +27,9 @@
|
||||
class?: string
|
||||
innerClass?: string
|
||||
maxHeight?: number
|
||||
// Close with no height-collapse animation (still animates open). Used where the
|
||||
// dropdown is toggled off as the user types, so the panel vanishes at once.
|
||||
instantClose?: boolean
|
||||
getInputRect?: () => DOMRect
|
||||
children?: Snippet
|
||||
} = $props()
|
||||
@@ -71,7 +75,11 @@
|
||||
// We do not use Svelte transitions because they can not animate in the opposite direction
|
||||
// when the dropdown is opens above the input
|
||||
// Also CSS transitions are smoother because they do not rely on JS / animation frames
|
||||
let uiState = $state({ domExists: untrack(() => open), visible: untrack(() => open), timeout: null as number | null })
|
||||
let uiState = $state({
|
||||
domExists: untrack(() => open),
|
||||
visible: untrack(() => open),
|
||||
timeout: null as number | null
|
||||
})
|
||||
let initial = true
|
||||
watch(
|
||||
() => open && !disabled,
|
||||
@@ -81,7 +89,10 @@
|
||||
initial = false
|
||||
return
|
||||
}
|
||||
if (reducedMotion.val) {
|
||||
// Reduced motion skips all animation; instantClose skips only the closing
|
||||
// one (open still animates) so the panel disappears the instant it's toggled off.
|
||||
if (reducedMotion.val || (instantClose && !isOpen)) {
|
||||
if (uiState.timeout) clearTimeout(uiState.timeout)
|
||||
uiState = {
|
||||
domExists: open && !disabled,
|
||||
visible: open && !disabled,
|
||||
|
||||
@@ -83,7 +83,11 @@ export async function openEditorInSession(
|
||||
previewParams?: Record<string, string>,
|
||||
opts?: { seedPrompt?: string; autoSend?: boolean }
|
||||
): Promise<void> {
|
||||
await openInSession(withPreviewParams(sessionTargetHref(target), previewParams), workspaceId, opts)
|
||||
await openInSession(
|
||||
withPreviewParams(sessionTargetHref(target), previewParams),
|
||||
workspaceId,
|
||||
opts
|
||||
)
|
||||
}
|
||||
|
||||
// Open a fresh AI session showing a workspace page (Runs, a trigger list) in its
|
||||
|
||||
@@ -81,6 +81,11 @@
|
||||
size?: ButtonType.UnifiedSize
|
||||
unifiedHeight?: boolean
|
||||
underlyingInputEl?: UnderlyingInputElT
|
||||
/**
|
||||
* Passed to the `autosize` action on the `textarea` variant. Chiefly `minHeight: 0`, for a
|
||||
* field that hugs one line instead of reserving the action's 30px floor.
|
||||
*/
|
||||
autosizeParams?: import('$lib/autosize').AutosizeParams
|
||||
}
|
||||
|
||||
export function focus() {
|
||||
@@ -108,7 +113,8 @@
|
||||
error,
|
||||
size = 'md',
|
||||
unifiedHeight = true,
|
||||
underlyingInputEl: _underlyingInputEl
|
||||
underlyingInputEl: _underlyingInputEl,
|
||||
autosizeParams
|
||||
}: Props<UnderlyingInputElT> = $props()
|
||||
|
||||
let underlyingInputEl = $derived(_underlyingInputEl ?? ('input' as const))
|
||||
@@ -152,7 +158,7 @@
|
||||
onpointerdown={(e) => e.stopImmediatePropagation()}
|
||||
bind:this={inputEl}
|
||||
bind:value
|
||||
use:autosize
|
||||
use:autosize={autosizeParams}
|
||||
></textarea>
|
||||
{:else if underlyingInputEl === 'input'}
|
||||
<input
|
||||
|
||||
@@ -5,22 +5,12 @@
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import FlowIcon from '$lib/components/home/FlowIcon.svelte'
|
||||
import CreateActionsMenu from '$lib/components/home/CreateActionsMenu.svelte'
|
||||
import { getScriptByPath } from '$lib/scripts'
|
||||
import type { HubItem } from '$lib/components/flows/pickers/model'
|
||||
import PickHubScript from '$lib/components/flows/pickers/PickHubScript.svelte'
|
||||
import PickHubFlow from '$lib/components/flows/pickers/PickHubFlow.svelte'
|
||||
import HighlightCode from '$lib/components/HighlightCode.svelte'
|
||||
import HomeConnectDrawer from '$lib/components/home/HomeConnectDrawer.svelte'
|
||||
import {
|
||||
ExternalLink,
|
||||
GitFork,
|
||||
Globe2,
|
||||
Loader2,
|
||||
Code,
|
||||
LayoutDashboard,
|
||||
PlugZap
|
||||
} from 'lucide-svelte'
|
||||
import { ExternalLink, GitFork, Globe2, Loader2, Code, LayoutDashboard } from 'lucide-svelte'
|
||||
import { hubBaseUrlStore } from '$lib/stores'
|
||||
import { base } from '$lib/base'
|
||||
|
||||
@@ -28,7 +18,6 @@
|
||||
import PickHubApp from '$lib/components/flows/pickers/PickHubApp.svelte'
|
||||
import { writable } from 'svelte/store'
|
||||
import type { EditorBreakpoint } from '$lib/components/apps/types'
|
||||
import { HOME_SHOW_HUB } from '$lib/consts'
|
||||
import { setQuery } from '$lib/navigation'
|
||||
import { page } from '$app/state'
|
||||
import { goto, replaceState } from '$app/navigation'
|
||||
@@ -42,6 +31,8 @@
|
||||
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
|
||||
import { useSearchParams } from '$lib/svelte5UtilsKit.svelte'
|
||||
import { z } from 'zod'
|
||||
import HomeAIChat from '$lib/components/home/HomeAIChat.svelte'
|
||||
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
|
||||
|
||||
type Tab = 'hub' | 'workspace'
|
||||
|
||||
@@ -97,7 +88,6 @@
|
||||
}
|
||||
|
||||
let workspaceTutorials: WorkspaceTutorials | undefined = $state(undefined)
|
||||
let homeConnectDrawer: HomeConnectDrawer | undefined = $state(undefined)
|
||||
|
||||
// Provide workspaceTutorials to child components via a reactive wrapper
|
||||
let workspaceTutorialsContext = $derived(workspaceTutorials)
|
||||
@@ -272,49 +262,23 @@
|
||||
>
|
||||
<ForkWorkspaceBanner />
|
||||
<WorkspaceDraftsBanner />
|
||||
<div class="max-w-7xl px-4 sm:px-8 md:px-8 h-fit w-full">
|
||||
{#if $workspaceStore == 'admins'}
|
||||
<div class="my-4"></div>
|
||||
<div class="max-w-7xl px-4 sm:px-8 md:px-8 h-fit w-full mb-6">
|
||||
<!-- HomeAIChat carries both the AI composer and the AI-independent CLI/MCP connect row,
|
||||
so it shows whenever the sessions beta is on; the composer itself is gated on operator
|
||||
status inside the component (operators are refused by /sessions). -->
|
||||
{#if isGlobalAiEnabled()}
|
||||
<div class="w-full mb-16 mt-2">
|
||||
<HomeAIChat />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $workspaceStore == 'admins'}
|
||||
<Alert title="Admins workspace">
|
||||
The Admins workspace is for admins only and contains scripts whose purpose is to manage your
|
||||
Windmill instance, such as keeping resource types up to date.
|
||||
</Alert>
|
||||
<div class="my-4"></div>
|
||||
{/if}
|
||||
<div class="flex flex-row flex-wrap justify-between items-center gap-3 pb-2 my-4 mr-2 min-h-16">
|
||||
<h1 class="text-2xl font-semibold text-emphasis whitespace-nowrap leading-6 tracking-tight">
|
||||
Home
|
||||
</h1>
|
||||
<div class="ml-auto flex flex-row gap-2 items-center">
|
||||
{#if !$userStore?.operator && HOME_SHOW_HUB}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: Globe2 }}
|
||||
endIcon={{ icon: ExternalLink }}
|
||||
href={$hubBaseUrlStore}
|
||||
target="_blank"
|
||||
btnClasses="whitespace-nowrap"
|
||||
>
|
||||
Hub
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: PlugZap }}
|
||||
btnClasses="whitespace-nowrap"
|
||||
onClick={() => homeConnectDrawer?.openDrawer?.()}
|
||||
>
|
||||
CLI / MCP
|
||||
</Button>
|
||||
{#if !$userStore?.operator && showCreateButtons}
|
||||
<div class="ml-2">
|
||||
<CreateActionsMenu />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TutorialBanner />
|
||||
|
||||
@@ -396,9 +360,8 @@
|
||||
</div>
|
||||
|
||||
{#if tab == 'workspace'}
|
||||
<ItemsList bind:filter={getFilter, setFilter} bind:subtab showEditButtons={showCreateButtons} />
|
||||
<ItemsList bind:subtab showEditButtons={showCreateButtons} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<WorkspaceTutorials bind:this={workspaceTutorials} />
|
||||
<HomeConnectDrawer bind:this={homeConnectDrawer} />
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
wmill = ">=1.799.0"
|
||||
wmill = ">=1.800.0"
|
||||
sendgrid = "*"
|
||||
mysql-connector-python = "*"
|
||||
pymongo = "*"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: '3.0.3'
|
||||
|
||||
info:
|
||||
version: 1.799.0
|
||||
version: 1.800.0
|
||||
title: OpenFlow Spec
|
||||
contact:
|
||||
name: Ruben Fiszel
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
RootModule = 'WindmillClient.psm1'
|
||||
|
||||
# Version number of this module.
|
||||
ModuleVersion = '1.799.0'
|
||||
ModuleVersion = '1.800.0'
|
||||
|
||||
# Supported PSEditions
|
||||
# CompatiblePSEditions = @()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "wmill"
|
||||
version = "1.799.0"
|
||||
version = "1.800.0"
|
||||
description = "A client library for accessing Windmill server wrapping the Windmill client API"
|
||||
license = "Apache-2.0"
|
||||
homepage = "https://windmill.dev"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@windmill/windmill",
|
||||
"version": "1.799.0",
|
||||
"version": "1.800.0",
|
||||
"exports": "./src/index.ts",
|
||||
"publish": {
|
||||
"exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts", "./wacError.ts"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "windmill-client",
|
||||
"description": "Windmill SDK client for browsers and Node.js",
|
||||
"version": "1.799.0",
|
||||
"version": "1.800.0",
|
||||
"author": "Ruben Fiszel",
|
||||
"license": "Apache 2.0",
|
||||
"homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme",
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.799.0
|
||||
1.800.0
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "windmill-yaml-validator",
|
||||
"version": "1.799.0",
|
||||
"version": "1.800.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "windmill-yaml-validator",
|
||||
"version": "1.799.0",
|
||||
"version": "1.800.0",
|
||||
"license": "Apache 2.0",
|
||||
"dependencies": {
|
||||
"@stoplight/yaml": "^4.3.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "windmill-yaml-validator",
|
||||
"version": "1.799.0",
|
||||
"version": "1.800.0",
|
||||
"description": "YAML validator for Windmill flow, schedule, and trigger files",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
Reference in New Issue
Block a user