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 glm/update-chat-mode-ui
# Conflicts: # frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte # frontend/src/lib/components/copilot/chat/AIChatInput.svelte
This commit is contained in:
@@ -101,7 +101,6 @@ jobs:
|
||||
|
||||
- name: Install OpenSSL via vcpkg
|
||||
run: |
|
||||
vcpkg.exe install openssl-windows:x64-windows
|
||||
vcpkg.exe install openssl:x64-windows-static
|
||||
vcpkg.exe integrate install
|
||||
|
||||
|
||||
@@ -62,7 +62,6 @@ jobs:
|
||||
- name: Cargo build binary windows
|
||||
timeout-minutes: 180
|
||||
run: |
|
||||
vcpkg.exe install openssl-windows:x64-windows
|
||||
vcpkg.exe install openssl:x64-windows-static
|
||||
vcpkg.exe integrate install
|
||||
$env:VCPKGRS_DYNAMIC=1
|
||||
|
||||
@@ -51,7 +51,6 @@ jobs:
|
||||
- name: Cargo build windows
|
||||
timeout-minutes: 180
|
||||
run: |
|
||||
vcpkg.exe install openssl-windows:x64-windows
|
||||
vcpkg.exe install openssl:x64-windows-static
|
||||
vcpkg.exe integrate install
|
||||
$env:VCPKGRS_DYNAMIC=1
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "1.799.0"
|
||||
".": "1.800.1"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,43 @@
|
||||
# Changelog
|
||||
|
||||
## [1.800.1](https://github.com/windmill-labs/windmill/compare/v1.800.0...v1.800.1) (2026-09-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add top margin to the home Build with AI section ([#10909](https://github.com/windmill-labs/windmill/issues/10909)) ([bedf5ae](https://github.com/windmill-labs/windmill/commit/bedf5ae57445025729e94e16f1b5f13f6ff38ffa))
|
||||
* **ai-chat:** consume an @ mention with the message that carried it ([#10907](https://github.com/windmill-labs/windmill/issues/10907)) ([c512110](https://github.com/windmill-labs/windmill/commit/c512110a1f8d0d3437c20048f6446ef62b10222c))
|
||||
* keep a local dbt descriptor under sync pull --keep-deleted ([#10911](https://github.com/windmill-labs/windmill/issues/10911)) ([4b5be38](https://github.com/windmill-labs/windmill/commit/4b5be386ce0f851a087f43c0b0ac6e4b1b055a47))
|
||||
* keep windmill-indexer out of builds without tantivy ([#10908](https://github.com/windmill-labs/windmill/issues/10908)) ([db0f004](https://github.com/windmill-labs/windmill/commit/db0f004613e3f90428fea4c824cc53f1b2fc03b0))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n COUNT(*)::bigint AS \"total!\",\n COUNT(*) FILTER (WHERE name = ANY($2::text[]))::bigint AS \"replacing!\"\n FROM ai_skill\n WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "total!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "replacing!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "002a606e71364b0581dbc496bf4337f276861dc71d2e277a7aef711543eb14d7"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO resource_type (workspace_id, name, schema, description, edited_at)\n VALUES ('admins', $1, $2, $3, now())\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET schema = EXCLUDED.schema, description = EXCLUDED.description, edited_at = now()",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4b93550c7836fd3643180ade3548faa875e471d3f9ca37fc669f359e7a1818bb"
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO ai_skill (workspace_id, name, description, instructions, edited_at, edited_by)\n VALUES ($1, $2, $3, $4, now(), $5)\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET description = EXCLUDED.description,\n instructions = EXCLUDED.instructions,\n edited_at = now(),\n edited_by = EXCLUDED.edited_by",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "734781e8e55e95c55f72e094e96297aa852e20a0f0d20db4b993947792f6b0a8"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH moved AS (\n UPDATE log_file SET indexed_at = CASE\n WHEN log_ts > NOW() - make_interval(secs => $1) THEN NULL\n ELSE now() END\n WHERE indexed_at = 'epoch' RETURNING 1)\n SELECT count(*) AS \"n!\" FROM moved",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "n!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Float8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "872be44352d4a27a2005f6bdc38f927ac4886755fd17ec755037763aa92f6c0e"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE log_file SET indexed_at = now()\n FROM unnest($1::text[], $2::timestamp[]) AS c(hostname, log_ts)\n WHERE log_file.hostname = c.hostname AND log_file.log_ts = c.log_ts",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"TextArray",
|
||||
"TimestampArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "87e8e034b1cf5ea7ce43870d77d33fa0fd05b067454f0d4686d8b155fe562ffe"
|
||||
}
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3)",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3 AND ($5 IS NOT TRUE OR format_extension IS NOT DISTINCT FROM $4))",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -13,12 +13,14 @@
|
||||
"Left": [
|
||||
"Text",
|
||||
"Jsonb",
|
||||
"Text"
|
||||
"Text",
|
||||
"Text",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "1ea97f9085ec018f779e77e0fdbda3d4ecd67b3fbee9a58228ef577f846607ae"
|
||||
"hash": "8ad79b80033b38ebddf6c8cd4d8cb160d41bac4c45a0fc74d9c9e96d3ef4486a"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH moved AS (\n UPDATE log_file SET indexed_at = CASE\n WHEN log_ts > NOW() - make_interval(secs => $1) THEN NULL\n ELSE now() END\n WHERE indexed_at = 'epoch' RETURNING 1)\n SELECT count(*) AS \"n!\" FROM moved",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "n!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Float8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "8e0461855d05dc03919c8979d8acdc85389c0629847d8fabb2ad0aa043957b2f"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE log_file SET indexed_at = now()\n FROM unnest($1::text[], $2::timestamp[]) AS c(hostname, log_ts)\n WHERE log_file.hostname = c.hostname AND log_file.log_ts = c.log_ts",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"TextArray",
|
||||
"TimestampArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "947f7ca06f6f9a3fd50f817bc9b0c06924719189bec8e5c19866db8d0b87df5e"
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, edited_at)\n VALUES ('admins', $1, $2, $3, $4, now())\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET schema = EXCLUDED.schema, description = EXCLUDED.description,\n -- A fileset is a set of files, so it cannot also be one file.\n -- Create and update reject the pair; this writer bypasses both, so\n -- it declines the extension rather than persisting the forbidden\n -- combination onto a same-named local fileset.\n format_extension = CASE\n WHEN resource_type.is_fileset THEN NULL\n WHEN $5 THEN EXCLUDED.format_extension\n ELSE resource_type.format_extension END,\n edited_at = now()",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "972df41db505fbbd20a558b200a2e2e8bc43633707d8365f73130c5bca3923b9"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT name, description FROM ai_skill WHERE workspace_id = $1 ORDER BY name",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "description",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c84087a0669d0b71829b0765c7274ca0a03fb823a781fb46d2b2b6cfc535a16b"
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT is_fileset, format_extension FROM resource_type\n WHERE name = $1 AND workspace_id = $2 FOR UPDATE",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "is_fileset",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "format_extension",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "df13e7bb9c14aa19604c40754509f66af26042464ba199586838e073c318c53a"
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT name, description, instructions FROM ai_skill WHERE workspace_id = $1 AND name = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "description",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "instructions",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e50afd5156b07e550202fb9b33354dce71b37f89f68d78577b250979daa1a87d"
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM ai_skill WHERE workspace_id = $1 AND name = $2 RETURNING name",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e99fe5cd3283f1701d3a361ef31869da89fd10099b76669b9526201c85f71f61"
|
||||
}
|
||||
+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
+127
-127
@@ -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",
|
||||
@@ -10523,9 +10523,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rmcp-macros"
|
||||
version = "3.1.4"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a85d45508e9b4ba024fe996c2638799635d75b6dd0ba8f32ccf08f8026f0c780"
|
||||
checksum = "cdf1c49bd4d52014b94db0877410db273c2008f01628b0252a2e9460ad9b7fda"
|
||||
dependencies = [
|
||||
"darling 0.24.1",
|
||||
"proc-macro2",
|
||||
@@ -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.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -14831,7 +14831,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-ai"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
@@ -14864,7 +14864,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-alerting"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14877,7 +14877,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
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.1"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15040,7 +15040,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-assets"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15057,7 +15057,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-auth"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15083,7 +15083,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-client"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -15093,7 +15093,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-configs"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15110,7 +15110,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-debug"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
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.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15155,7 +15155,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flow-conversations"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15171,7 +15171,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flows"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15193,7 +15193,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-groups"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15214,7 +15214,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-inputs"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15228,7 +15228,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-integration-tests"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -15263,7 +15263,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-jobs"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15288,7 +15288,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-npm-proxy"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15316,12 +15316,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-openapi"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
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.1"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15358,7 +15358,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-scripts"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15396,7 +15396,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-settings"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15424,7 +15424,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-sse"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"serde",
|
||||
@@ -15436,7 +15436,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-users"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum 0.8.9",
|
||||
@@ -15460,7 +15460,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workers"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15474,7 +15474,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workspaces"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -15509,7 +15509,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-audit"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"lazy_static",
|
||||
@@ -15523,7 +15523,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-autoscaling"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -15542,7 +15542,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
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.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"itertools 0.14.0",
|
||||
@@ -15665,7 +15665,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-git-sync"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -15680,7 +15680,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-indexer"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -15707,7 +15707,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-jseval"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -15724,7 +15724,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
@@ -15740,7 +15740,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-mcp"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15761,7 +15761,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-native-triggers"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15792,7 +15792,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-oauth"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -15817,7 +15817,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-object-store"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -15851,7 +15851,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-operator"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -15869,7 +15869,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"convert_case 0.6.0",
|
||||
"serde",
|
||||
@@ -15878,7 +15878,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -15890,7 +15890,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -15902,7 +15902,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -15914,7 +15914,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -15926,7 +15926,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -15938,7 +15938,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -15949,7 +15949,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -15960,7 +15960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -15972,7 +15972,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -15983,7 +15983,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16005,7 +16005,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16017,7 +16017,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16031,7 +16031,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case 0.6.0",
|
||||
@@ -16048,7 +16048,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16061,7 +16061,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -16073,7 +16073,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16091,7 +16091,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -16107,7 +16107,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -16123,7 +16123,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16137,7 +16137,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-queue"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16176,7 +16176,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-runtime-nativets"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"const_format",
|
||||
@@ -16216,7 +16216,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-sql-datatype-parser-wasm"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"wasm-bindgen",
|
||||
@@ -16227,7 +16227,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-store"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16262,7 +16262,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-test-utils"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16286,7 +16286,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16319,7 +16319,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-amqp"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16346,7 +16346,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-azure"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16379,7 +16379,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-email"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16399,7 +16399,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-gcp"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16433,7 +16433,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-http"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16469,7 +16469,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-kafka"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16492,7 +16492,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-mqtt"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16516,7 +16516,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-nats"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -16540,7 +16540,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-postgres"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16575,7 +16575,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-sqs"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16603,7 +16603,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-websocket"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16628,7 +16628,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.13.1",
|
||||
@@ -16647,7 +16647,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-once-cell",
|
||||
@@ -16764,7 +16764,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker-volumes"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
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",
|
||||
]
|
||||
|
||||
+6
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
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.1"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -131,6 +131,10 @@ quickjs = ["windmill-worker/quickjs", "windmill-api/quickjs"]
|
||||
openidconnect = ["windmill-api/openidconnect", "windmill-common/openidconnect", "windmill-object-store/openidconnect"]
|
||||
cloud = ["windmill-queue/cloud", "windmill-worker/cloud", "windmill-common/cloud", "windmill-api/cloud"]
|
||||
jemalloc = ["windmill-common/jemalloc", "dep:tikv-jemallocator", "dep:tikv-jemalloc-sys", "dep:tikv-jemalloc-ctl"]
|
||||
# `tantivy` must stay the only feature that enables windmill-indexer: it is the only one that
|
||||
# also gives it `enterprise` + `parquet`, and its EE sources gate nearly everything on that
|
||||
# pair, so a bare windmill-indexer is a crate of dead code that `-D warnings` rejects. Any
|
||||
# other feature wanting one of its features has to use the optional `windmill-indexer?/` form.
|
||||
tantivy = ["dep:windmill-indexer", "windmill-api/tantivy", "windmill-indexer/enterprise", "windmill-indexer/parquet", "windmill-common/tantivy", "enterprise", "parquet"]
|
||||
sqlx = ["windmill-worker/sqlx"]
|
||||
deno_core = ["windmill-worker/deno_core", "dep:windmill-runtime-nativets", "windmill-test-utils/deno_core"]
|
||||
|
||||
@@ -1 +1 @@
|
||||
bc0c7051585194474078b6c1941a3fb73893d9e5
|
||||
6efe7a73c745c2e1377a34498523c00d89010a3d
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
-- The up migration only ever added: `ai_skill` still holds every skill it copied,
|
||||
-- so there is nothing to restore and nothing to delete. Removing the resources
|
||||
-- would destroy any a user has since edited or created, and removing a folder
|
||||
-- would take whatever else was put in it.
|
||||
--
|
||||
-- The seeded resource type goes. `created_by` only distinguishes this migration's
|
||||
-- row from one a user created by hand: a hub sync updates the schema in place and
|
||||
-- leaves `created_by` alone, so a synced-over row is still removed here and the
|
||||
-- next sync puts it back.
|
||||
DELETE FROM resource_type
|
||||
WHERE workspace_id = 'admins'
|
||||
AND name = 'ai_skill'
|
||||
AND created_by = 'system';
|
||||
@@ -0,0 +1,88 @@
|
||||
-- AI chat skills move from the `ai_skill` table onto ordinary resources, so they
|
||||
-- gain folder ACLs, version history, workspace export and git-sync. An `ai_skill`
|
||||
-- resource holds the SKILL.md body in `value.content`; its description lives in
|
||||
-- the resource's own `description` column and its name is the path basename.
|
||||
--
|
||||
-- Nothing here is destructive. `ai_skill` is left in place, unread, for a later
|
||||
-- release to drop once operators have confirmed the copy. That is what lets every
|
||||
-- step below skip on conflict rather than resolve one: a skipped row is still in
|
||||
-- the table, so it is not lost, and the migration needs no record of what it did
|
||||
-- in order to be reversible.
|
||||
|
||||
-- `format_extension` makes the resource editor render `value.content` as a plain
|
||||
-- .md file. Seeded under 'admins' so every workspace sees it.
|
||||
INSERT INTO resource_type (workspace_id, name, schema, description, created_by, format_extension, edited_at)
|
||||
VALUES (
|
||||
'admins',
|
||||
'ai_skill',
|
||||
'{"type": "object", "properties": {"content": {"type": "string"}}}',
|
||||
'A reusable instruction set for the AI chat, in the SKILL.md format. The resource description is what the assistant sees when deciding whether the skill applies; the file body is the instructions it follows.',
|
||||
'system',
|
||||
'md',
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (workspace_id, name) DO NOTHING;
|
||||
|
||||
-- Shared home matching the admin-only upload these skills had. A workspace that
|
||||
-- already has a `skills` folder keeps it untouched, ACL and all: adopting one
|
||||
-- would hand its own grants — possibly write for everyone — over a set of
|
||||
-- instructions the assistant follows.
|
||||
INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms)
|
||||
SELECT DISTINCT workspace_id, 'skills', 'Skills', ARRAY[]::TEXT[], '{"g/all": false}'::jsonb
|
||||
FROM ai_skill
|
||||
ON CONFLICT (workspace_id, name) DO NOTHING;
|
||||
|
||||
-- Copied only where the destination is free and the folder matches the one above
|
||||
-- exactly, owners included: a pre-existing folder carrying the same ACL but an
|
||||
-- owner would hand that owner update and delete over skills the removed API let
|
||||
-- only workspace admins touch. Anything else stays in `ai_skill` for an operator
|
||||
-- to place deliberately.
|
||||
--
|
||||
-- What was actually inserted is recorded rather than inferred. Inferring it from
|
||||
-- "is there an ai_skill resource at the destination" reports nothing when the
|
||||
-- blocker is itself an ai_skill with different instructions — the one case where
|
||||
-- the skipped skill is least likely to be noticed.
|
||||
CREATE TEMP TABLE ai_skill_copied AS
|
||||
WITH inserted AS (
|
||||
INSERT INTO resource (workspace_id, path, value, description, resource_type, created_by, edited_at)
|
||||
SELECT
|
||||
s.workspace_id,
|
||||
'f/skills/' || s.name,
|
||||
jsonb_build_object('content', s.instructions),
|
||||
s.description,
|
||||
'ai_skill',
|
||||
s.edited_by,
|
||||
s.edited_at
|
||||
FROM ai_skill s
|
||||
JOIN folder f
|
||||
ON f.workspace_id = s.workspace_id
|
||||
AND f.name = 'skills'
|
||||
AND f.extra_perms = '{"g/all": false}'::jsonb
|
||||
AND cardinality(f.owners) = 0
|
||||
ON CONFLICT (workspace_id, path) DO NOTHING
|
||||
RETURNING workspace_id, path
|
||||
)
|
||||
SELECT workspace_id, path FROM inserted;
|
||||
|
||||
-- Anything not copied is still in `ai_skill`, but nothing reads that table any
|
||||
-- more, so from the app's side the skill is missing until an operator places it.
|
||||
-- Name them rather than leaving that to be discovered.
|
||||
DO $$
|
||||
DECLARE
|
||||
leftover RECORD;
|
||||
BEGIN
|
||||
FOR leftover IN
|
||||
SELECT s.workspace_id, s.name
|
||||
FROM ai_skill s
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM ai_skill_copied c
|
||||
WHERE c.workspace_id = s.workspace_id
|
||||
AND c.path = 'f/skills/' || s.name
|
||||
)
|
||||
LOOP
|
||||
RAISE WARNING 'ai_skill %/% was not copied to a resource (its destination or the f/skills folder is already taken); it remains in the ai_skill table',
|
||||
leftover.workspace_id, leftover.name;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
DROP TABLE ai_skill_copied;
|
||||
+24
-24
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"anyhow",
|
||||
@@ -6274,7 +6274,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6286,7 +6286,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"serde",
|
||||
@@ -6295,7 +6295,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6307,7 +6307,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6319,7 +6319,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -6331,7 +6331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6343,7 +6343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6355,7 +6355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -6366,7 +6366,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6377,7 +6377,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6389,7 +6389,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6400,7 +6400,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -6422,7 +6422,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6434,7 +6434,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6448,7 +6448,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case",
|
||||
@@ -6465,7 +6465,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6478,7 +6478,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6490,7 +6490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6508,7 +6508,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -6524,7 +6524,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6540,7 +6540,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wasm"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"getrandom 0.2.17",
|
||||
@@ -6572,7 +6572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6586,7 +6586,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
|
||||
@@ -12,7 +12,7 @@ resolver = "2"
|
||||
members = ["."]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.799.0"
|
||||
version = "1.800.1"
|
||||
edition = "2021"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
|
||||
|
||||
+68
-15
@@ -7,14 +7,14 @@
|
||||
*/
|
||||
use anyhow::Context;
|
||||
use monitor::{
|
||||
load_base_url, load_otel, reload_critical_alerts_on_db_oversize,
|
||||
reload_delete_logs_periodically_setting, reload_indexer_config,
|
||||
reload_instance_python_version_setting, reload_maven_repos_setting,
|
||||
flush_pending_log_files_to_object_store, load_base_url, load_otel,
|
||||
reload_critical_alerts_on_db_oversize, reload_delete_logs_periodically_setting,
|
||||
reload_indexer_config, reload_instance_python_version_setting, reload_maven_repos_setting,
|
||||
reload_maven_settings_xml_setting, reload_no_default_maven_setting,
|
||||
reload_nuget_config_setting, reload_powershell_repo_pat_setting,
|
||||
reload_powershell_repo_url_setting, reload_ruby_repos_setting,
|
||||
reload_timeout_wait_result_setting, reload_workspace_registries_setting,
|
||||
flush_pending_log_files_to_object_store, send_logs_to_object_store, WORKERS_NAMES,
|
||||
send_logs_to_object_store, WORKERS_NAMES,
|
||||
};
|
||||
use rand::Rng;
|
||||
use sqlx::{Pool, Postgres};
|
||||
@@ -406,8 +406,12 @@ struct HubResourceTypeRaw {
|
||||
pub schema: Option<String>,
|
||||
pub app: String,
|
||||
pub description: Option<String>,
|
||||
/// Absent from hubs predating the column, and from caches written before it.
|
||||
#[serde(default)]
|
||||
pub format_extension: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
/// Processed resource type with parsed schema
|
||||
#[derive(serde::Deserialize, serde::Serialize, Clone)]
|
||||
pub struct HubResourceType {
|
||||
@@ -416,6 +420,18 @@ pub struct HubResourceType {
|
||||
pub schema: Option<serde_json::Value>,
|
||||
pub app: String,
|
||||
pub description: Option<String>,
|
||||
/// Doubly optional on purpose. A cache written before this column has no key at
|
||||
/// all (`None`) and must leave the stored extension alone; one written since
|
||||
/// always writes the key, so an explicit null (`Some(None)`) is the hub genuinely
|
||||
/// dropping it and must clear. A single `Option` conflates the two, and picking
|
||||
/// either meaning breaks the other — as does plain serde, which folds `null`
|
||||
/// into the outer `None`, hence the wrapping deserializer.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "windmill_common::more_serde::double_option",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub format_extension: Option<Option<String>>,
|
||||
}
|
||||
|
||||
const HUB_RT_CACHE_FILE: &str = "resource_types.json";
|
||||
@@ -462,6 +478,7 @@ async fn cache_hub_resource_types() -> anyhow::Result<()> {
|
||||
schema,
|
||||
app: rt.app,
|
||||
description: rt.description,
|
||||
format_extension: Some(rt.format_extension),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -503,9 +520,17 @@ pub async fn sync_cached_resource_types(db: &sqlx::Pool<sqlx::Postgres>) -> anyh
|
||||
|
||||
tracing::info!("Found {} cached resource types", cached_types.len());
|
||||
|
||||
// Get existing resource types in admins workspace
|
||||
let existing_types: Vec<(String, Option<serde_json::Value>, Option<String>)> = sqlx::query_as(
|
||||
"SELECT name, schema, description FROM resource_type WHERE workspace_id = 'admins'",
|
||||
// Get existing resource types in admins workspace. `format_extension` is part of
|
||||
// the comparison below, so a type whose only change is gaining or losing it is
|
||||
// not mistaken for unchanged; `is_fileset` decides whether it may take one.
|
||||
let existing_types: Vec<(
|
||||
String,
|
||||
Option<serde_json::Value>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
bool,
|
||||
)> = sqlx::query_as(
|
||||
"SELECT name, schema, description, format_extension, is_fileset FROM resource_type WHERE workspace_id = 'admins'",
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await
|
||||
@@ -513,19 +538,42 @@ pub async fn sync_cached_resource_types(db: &sqlx::Pool<sqlx::Postgres>) -> anyh
|
||||
|
||||
let existing_map: std::collections::HashMap<
|
||||
String,
|
||||
(Option<serde_json::Value>, Option<String>),
|
||||
(Option<serde_json::Value>, Option<String>, Option<String>, bool),
|
||||
> = existing_types
|
||||
.into_iter()
|
||||
.map(|(name, schema, desc)| (name, (schema, desc)))
|
||||
.map(|(name, schema, desc, format_extension, is_fileset)| {
|
||||
(name, (schema, desc, format_extension, is_fileset))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut synced_count = 0;
|
||||
let mut skipped_count = 0;
|
||||
|
||||
for rt in cached_types {
|
||||
// Check if resource type already exists with same schema and description
|
||||
if let Some((existing_schema, existing_desc)) = existing_map.get(&rt.name) {
|
||||
if existing_schema == &rt.schema && existing_desc == &rt.description {
|
||||
let existing = existing_map.get(&rt.name);
|
||||
let is_fileset = existing.map(|(_, _, _, f)| *f).unwrap_or(false);
|
||||
let stored_extension = existing.and_then(|(_, _, e, _)| e.clone());
|
||||
// A fileset is a set of files, so it cannot also be one file. Create, update
|
||||
// and the manual sync all reject the pair; this writer would otherwise
|
||||
// persist it onto a same-named local fileset.
|
||||
//
|
||||
// A cache with no key at all leaves the stored value alone, so the target is
|
||||
// what is already there — which is also what makes the comparison below
|
||||
// agree with the write instead of re-upserting the row on every boot.
|
||||
let format_extension = if is_fileset {
|
||||
None
|
||||
} else {
|
||||
match &rt.format_extension {
|
||||
Some(from_cache) => from_cache.clone(),
|
||||
None => stored_extension.clone(),
|
||||
}
|
||||
};
|
||||
|
||||
if let Some((existing_schema, existing_desc, _, _)) = existing {
|
||||
if existing_schema == &rt.schema
|
||||
&& existing_desc == &rt.description
|
||||
&& stored_extension == format_extension
|
||||
{
|
||||
skipped_count += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -533,14 +581,19 @@ pub async fn sync_cached_resource_types(db: &sqlx::Pool<sqlx::Postgres>) -> anyh
|
||||
|
||||
// Insert or update resource type
|
||||
sqlx::query(
|
||||
"INSERT INTO resource_type (workspace_id, name, schema, description, edited_at)
|
||||
VALUES ('admins', $1, $2, $3, now())
|
||||
// `format_extension` is resolved above rather than coalesced here: a
|
||||
// COALESCE could never clear one, so a hub that dropped an extension
|
||||
// would leave the stale value behind forever.
|
||||
"INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, edited_at)
|
||||
VALUES ('admins', $1, $2, $3, $4, now())
|
||||
ON CONFLICT (workspace_id, name) DO UPDATE
|
||||
SET schema = EXCLUDED.schema, description = EXCLUDED.description, edited_at = now()",
|
||||
SET schema = EXCLUDED.schema, description = EXCLUDED.description,
|
||||
format_extension = EXCLUDED.format_extension, edited_at = now()",
|
||||
)
|
||||
.bind(&rt.name)
|
||||
.bind(&rt.schema)
|
||||
.bind(&rt.description)
|
||||
.bind(&format_extension)
|
||||
.execute(db)
|
||||
.await
|
||||
.with_context(|| format!("Failed to upsert resource type {}", rt.name))?;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -288,7 +288,6 @@ pub enum ScopeDomain {
|
||||
Configs,
|
||||
OAuth,
|
||||
AI,
|
||||
AiSkills,
|
||||
AiEvals, // AI agent eval datasets
|
||||
|
||||
Indexer,
|
||||
@@ -349,7 +348,6 @@ impl ScopeDomain {
|
||||
Self::Configs => "configs",
|
||||
Self::OAuth => "oauth",
|
||||
Self::AI => "ai",
|
||||
Self::AiSkills => "ai_skills",
|
||||
Self::AiEvals => "ai_evals",
|
||||
Self::Capture => "capture",
|
||||
Self::Drafts => "drafts",
|
||||
@@ -405,7 +403,6 @@ impl ScopeDomain {
|
||||
"configs" => Some(Self::Configs),
|
||||
"oauth" => Some(Self::OAuth),
|
||||
"ai" => Some(Self::AI),
|
||||
"ai_skills" => Some(Self::AiSkills),
|
||||
"ai_evals" => Some(Self::AiEvals),
|
||||
"indexer" | "srch" => Some(Self::Indexer),
|
||||
"teams" => Some(Self::Teams),
|
||||
@@ -1202,12 +1199,6 @@ mod tests {
|
||||
assert_eq!(domain, ScopeDomain::FlowConversations);
|
||||
assert_eq!(kind, None);
|
||||
assert_eq!(route_suffix, Some("flow_conversations/list".to_string()));
|
||||
|
||||
let (domain, kind, route_suffix) =
|
||||
extract_domain_from_route("/api/w/test_workspace/ai_skills/list").unwrap();
|
||||
assert_eq!(domain, ScopeDomain::AiSkills);
|
||||
assert_eq!(kind, None);
|
||||
assert_eq!(route_suffix, Some("ai_skills/list".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1368,11 +1359,6 @@ mod tests {
|
||||
ScopeDomain::from_str("flow_conversations"),
|
||||
Some(ScopeDomain::FlowConversations)
|
||||
);
|
||||
assert_eq!(
|
||||
ScopeDomain::from_str("ai_skills"),
|
||||
Some(ScopeDomain::AiSkills)
|
||||
);
|
||||
|
||||
// Test canonical string conversion
|
||||
assert_eq!(ScopeDomain::Acls.as_str(), "acls");
|
||||
assert_eq!(ScopeDomain::RawApps.as_str(), "raw_apps");
|
||||
@@ -1381,41 +1367,6 @@ mod tests {
|
||||
ScopeDomain::FlowConversations.as_str(),
|
||||
"flow_conversations"
|
||||
);
|
||||
assert_eq!(ScopeDomain::AiSkills.as_str(), "ai_skills");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ai_skills_scope_access() {
|
||||
let read_scopes = vec!["ai_skills:read".to_string()];
|
||||
assert!(
|
||||
check_route_access(&read_scopes, "/api/w/test_workspace/ai_skills/list", "GET").is_ok()
|
||||
);
|
||||
assert!(check_route_access(
|
||||
&read_scopes,
|
||||
"/api/w/test_workspace/ai_skills/get/foo",
|
||||
"GET"
|
||||
)
|
||||
.is_ok());
|
||||
assert!(check_route_access(
|
||||
&read_scopes,
|
||||
"/api/w/test_workspace/ai_skills/upload",
|
||||
"POST"
|
||||
)
|
||||
.is_err());
|
||||
|
||||
let write_scopes = vec!["ai_skills:write".to_string()];
|
||||
assert!(check_route_access(
|
||||
&write_scopes,
|
||||
"/api/w/test_workspace/ai_skills/upload",
|
||||
"POST"
|
||||
)
|
||||
.is_ok());
|
||||
assert!(check_route_access(
|
||||
&write_scopes,
|
||||
"/api/w/test_workspace/ai_skills/delete/foo",
|
||||
"DELETE"
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2004,6 +2004,12 @@ struct CachedResourceType {
|
||||
#[allow(dead_code)]
|
||||
app: String,
|
||||
description: Option<String>,
|
||||
/// Doubly optional, and read through a wrapping deserializer: this struct also
|
||||
/// decodes the on-disk cache, where an absent key means "written before the
|
||||
/// column, leave the stored extension alone" and an explicit null means the hub
|
||||
/// dropped it. Plain serde folds both into `None`.
|
||||
#[serde(default, deserialize_with = "windmill_common::more_serde::double_option")]
|
||||
format_extension: Option<Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
@@ -2013,6 +2019,8 @@ struct HubResourceTypeRaw {
|
||||
schema: Option<String>,
|
||||
app: String,
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
format_extension: Option<String>,
|
||||
}
|
||||
|
||||
async fn fetch_resource_types_from_hub() -> error::Result<Vec<CachedResourceType>> {
|
||||
@@ -2054,6 +2062,7 @@ async fn fetch_resource_types_from_hub() -> error::Result<Vec<CachedResourceType
|
||||
schema,
|
||||
app: rt.app,
|
||||
description: rt.description,
|
||||
format_extension: Some(rt.format_extension),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
@@ -2107,10 +2116,12 @@ async fn sync_cached_resource_types(
|
||||
|
||||
for rt in &resource_types {
|
||||
let exists: Option<bool> = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3)",
|
||||
"SELECT EXISTS(SELECT 1 FROM resource_type WHERE workspace_id = 'admins' AND name = $1 AND schema IS NOT DISTINCT FROM $2 AND description IS NOT DISTINCT FROM $3 AND ($5 IS NOT TRUE OR format_extension IS NOT DISTINCT FROM $4))",
|
||||
&rt.name,
|
||||
rt.schema.as_ref(),
|
||||
rt.description.as_deref(),
|
||||
rt.format_extension.clone().flatten(),
|
||||
rt.format_extension.is_some(),
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
@@ -2120,13 +2131,27 @@ async fn sync_cached_resource_types(
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO resource_type (workspace_id, name, schema, description, edited_at)
|
||||
VALUES ('admins', $1, $2, $3, now())
|
||||
// Whether the payload carried the key at all is what decides: present
|
||||
// (even as null) is authoritative and may clear, absent means a cache
|
||||
// written before the column and must leave the stored value alone.
|
||||
"INSERT INTO resource_type (workspace_id, name, schema, description, format_extension, edited_at)
|
||||
VALUES ('admins', $1, $2, $3, $4, now())
|
||||
ON CONFLICT (workspace_id, name) DO UPDATE
|
||||
SET schema = EXCLUDED.schema, description = EXCLUDED.description, edited_at = now()",
|
||||
SET schema = EXCLUDED.schema, description = EXCLUDED.description,
|
||||
-- A fileset is a set of files, so it cannot also be one file.
|
||||
-- Create and update reject the pair; this writer bypasses both, so
|
||||
-- it declines the extension rather than persisting the forbidden
|
||||
-- combination onto a same-named local fileset.
|
||||
format_extension = CASE
|
||||
WHEN resource_type.is_fileset THEN NULL
|
||||
WHEN $5 THEN EXCLUDED.format_extension
|
||||
ELSE resource_type.format_extension END,
|
||||
edited_at = now()",
|
||||
&rt.name,
|
||||
rt.schema.as_ref(),
|
||||
rt.description.as_deref(),
|
||||
rt.format_extension.clone().flatten(),
|
||||
rt.format_extension.is_some(),
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
@@ -16532,202 +16532,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/w/{workspace}/ai_skills/list": {
|
||||
"get": {
|
||||
"summary": "list the workspace AI chat skills (name + description only)",
|
||||
"operationId": "listAiSkills",
|
||||
"tags": [
|
||||
"workspace"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/WorkspaceId"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "skill listing",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"description"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/w/{workspace}/ai_skills/get/{name}": {
|
||||
"get": {
|
||||
"summary": "get a workspace AI chat skill including its instructions",
|
||||
"operationId": "getAiSkill",
|
||||
"tags": [
|
||||
"workspace"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/WorkspaceId"
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "skill",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"description",
|
||||
"instructions"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"instructions": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/w/{workspace}/ai_skills/upload": {
|
||||
"post": {
|
||||
"summary": "upsert workspace AI chat skills (admin only)",
|
||||
"operationId": "uploadAiSkills",
|
||||
"tags": [
|
||||
"workspace"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/WorkspaceId"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"skills"
|
||||
],
|
||||
"properties": {
|
||||
"skills": {
|
||||
"type": "array",
|
||||
"maxItems": 50,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"description",
|
||||
"instructions"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^[a-z0-9-]+$"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1024
|
||||
},
|
||||
"instructions": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 65536
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "uploaded",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/w/{workspace}/ai_skills/delete/{name}": {
|
||||
"delete": {
|
||||
"summary": "delete a workspace AI chat skill (admin only)",
|
||||
"operationId": "deleteAiSkill",
|
||||
"tags": [
|
||||
"workspace"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/WorkspaceId"
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "deleted",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/w/{workspace}/apps/get_data/v/{secretWithExtension}": {
|
||||
"get": {
|
||||
"summary": "get raw app data by",
|
||||
|
||||
@@ -17063,141 +17063,6 @@ paths:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
/w/{workspace}/ai_skills/list:
|
||||
get:
|
||||
summary: list the workspace AI chat skills (name + description only)
|
||||
operationId: listAiSkills
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- name: workspace
|
||||
in: path
|
||||
required: true
|
||||
schema: *ref_4
|
||||
responses:
|
||||
'200':
|
||||
description: skill listing
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- description
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
/w/{workspace}/ai_skills/get/{name}:
|
||||
get:
|
||||
summary: get a workspace AI chat skill including its instructions
|
||||
operationId: getAiSkill
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- name: workspace
|
||||
in: path
|
||||
required: true
|
||||
schema: *ref_4
|
||||
- name: name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: skill
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- description
|
||||
- instructions
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
instructions:
|
||||
type: string
|
||||
/w/{workspace}/ai_skills/upload:
|
||||
post:
|
||||
summary: upsert workspace AI chat skills (admin only)
|
||||
operationId: uploadAiSkills
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- name: workspace
|
||||
in: path
|
||||
required: true
|
||||
schema: *ref_4
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- skills
|
||||
properties:
|
||||
skills:
|
||||
type: array
|
||||
maxItems: 50
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- description
|
||||
- instructions
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
pattern: ^[a-z0-9-]+$
|
||||
description:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 1024
|
||||
instructions:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 65536
|
||||
responses:
|
||||
'200':
|
||||
description: uploaded
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
/w/{workspace}/ai_skills/delete/{name}:
|
||||
delete:
|
||||
summary: delete a workspace AI chat skill (admin only)
|
||||
operationId: deleteAiSkill
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- name: workspace
|
||||
in: path
|
||||
required: true
|
||||
schema: *ref_4
|
||||
- name: name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: deleted
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
/w/{workspace}/apps/get_data/v/{secretWithExtension}:
|
||||
get:
|
||||
summary: get raw app data by
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.799.0
|
||||
version: 1.800.1
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -12534,133 +12534,6 @@ paths:
|
||||
type: boolean
|
||||
description: more buckets matched than were returned, so summing them under-reports
|
||||
|
||||
/w/{workspace}/ai_skills/list:
|
||||
get:
|
||||
summary: list the workspace AI chat skills (name + description only)
|
||||
operationId: listAiSkills
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: skill listing
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- description
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/ai_skills/get/{name}:
|
||||
get:
|
||||
summary: get a workspace AI chat skill including its instructions
|
||||
operationId: getAiSkill
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: skill
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- description
|
||||
- instructions
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
instructions:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/ai_skills/upload:
|
||||
post:
|
||||
summary: upsert workspace AI chat skills (admin only)
|
||||
operationId: uploadAiSkills
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- skills
|
||||
properties:
|
||||
skills:
|
||||
type: array
|
||||
maxItems: 50
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- description
|
||||
- instructions
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
pattern: "^[a-z0-9-]+$"
|
||||
description:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 1024
|
||||
instructions:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 65536
|
||||
responses:
|
||||
"200":
|
||||
description: uploaded
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/ai_skills/delete/{name}:
|
||||
delete:
|
||||
summary: delete a workspace AI chat skill (admin only)
|
||||
operationId: deleteAiSkill
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: deleted
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/apps/get_data/v/{secretWithExtension}:
|
||||
get:
|
||||
summary: get raw app data by
|
||||
@@ -27256,11 +27129,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`
|
||||
@@ -29594,6 +29485,12 @@ components:
|
||||
type: string
|
||||
is_fileset:
|
||||
type: boolean
|
||||
format_extension:
|
||||
type: string
|
||||
nullable: true
|
||||
description: >-
|
||||
File extension for a type whose value is one file rather than a set
|
||||
of fields. Omit to leave it unchanged; send null to clear it.
|
||||
|
||||
TriggerHistoryEntry:
|
||||
type: object
|
||||
|
||||
+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
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2026
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use crate::db::{ApiAuthed, DB};
|
||||
use axum::{
|
||||
extract::{Extension, Json, Path},
|
||||
routing::{delete, get, post},
|
||||
Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use windmill_audit::audit_oss::audit_log;
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{Error, JsonResult, Result},
|
||||
utils::require_admin,
|
||||
};
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/list", get(list_skills))
|
||||
.route("/get/{name}", get(get_skill))
|
||||
.route("/upload", post(upload_skills))
|
||||
.route("/delete/{name}", delete(delete_skill))
|
||||
}
|
||||
|
||||
/// Cheap listing surfaced in the AI chat system prompt — no `instructions` body.
|
||||
#[derive(Serialize)]
|
||||
pub struct SkillListItem {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// Full skill, including the SKILL.md body, fetched on demand by `read_skill`.
|
||||
#[derive(Serialize)]
|
||||
pub struct Skill {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub instructions: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UploadSkills {
|
||||
pub skills: Vec<SkillUpload>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SkillUpload {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub instructions: String,
|
||||
}
|
||||
|
||||
const MAX_SKILLS_PER_UPLOAD: usize = 50;
|
||||
// Every stored skill's name + description is advertised in the global AI chat
|
||||
// system prompt, so bound the total a workspace can accumulate across uploads.
|
||||
const MAX_SKILLS_PER_WORKSPACE: usize = 100;
|
||||
// `name` and `description` follow the Claude SKILL.md spec
|
||||
// (https://platform.claude.com/docs/en/agents-and-tools/agent-skills): both are
|
||||
// loaded into the AI chat system prompt and `name` is the model-facing skill id,
|
||||
// so matching the upstream limits keeps skills portable with Claude Code.
|
||||
const MAX_SKILL_NAME_CHARS: usize = 64;
|
||||
const MAX_SKILL_DESCRIPTION_CHARS: usize = 1_024;
|
||||
// Not a spec field — a payload bound on the SKILL.md body, so measured in bytes.
|
||||
const MAX_SKILL_INSTRUCTIONS_BYTES: usize = 64 * 1024;
|
||||
|
||||
fn validate_skill(skill: &SkillUpload) -> Result<()> {
|
||||
let name = skill.name.trim();
|
||||
if name.is_empty() || name.chars().count() > MAX_SKILL_NAME_CHARS {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"skill name must be between 1 and {MAX_SKILL_NAME_CHARS} characters, got {:?}",
|
||||
skill.name
|
||||
)));
|
||||
}
|
||||
if !name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
{
|
||||
return Err(Error::BadRequest(format!(
|
||||
"skill name {name:?} must only contain lowercase letters, digits or '-'"
|
||||
)));
|
||||
}
|
||||
if skill.description.trim().is_empty() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"skill {name:?} is missing a description (the SKILL.md frontmatter `description`)"
|
||||
)));
|
||||
}
|
||||
if skill.description.chars().count() > MAX_SKILL_DESCRIPTION_CHARS {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"skill {name:?} description must be at most {MAX_SKILL_DESCRIPTION_CHARS} characters"
|
||||
)));
|
||||
}
|
||||
if skill.instructions.trim().is_empty() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"skill {name:?} has an empty SKILL.md body"
|
||||
)));
|
||||
}
|
||||
if skill.instructions.len() > MAX_SKILL_INSTRUCTIONS_BYTES {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"skill {name:?} instructions must be at most {MAX_SKILL_INSTRUCTIONS_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Collect the trimmed skill names, rejecting duplicates within a single upload.
|
||||
/// The insert upserts by name, so a duplicate would silently keep only the last
|
||||
/// and make the reported/audited count wrong.
|
||||
fn collect_upload_names(skills: &[SkillUpload]) -> Result<Vec<String>> {
|
||||
let mut names = Vec::with_capacity(skills.len());
|
||||
let mut seen = HashSet::with_capacity(skills.len());
|
||||
for skill in skills {
|
||||
let name = skill.name.trim().to_string();
|
||||
if !seen.insert(name.clone()) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"duplicate skill name {name:?} in upload"
|
||||
)));
|
||||
}
|
||||
names.push(name);
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// Reject an upload that would push the workspace past `MAX_SKILLS_PER_WORKSPACE`.
|
||||
/// Uploads upsert, so names already present (`replacing`) don't count as new.
|
||||
fn check_workspace_skill_capacity(
|
||||
existing_total: i64,
|
||||
replacing: i64,
|
||||
upload_count: usize,
|
||||
) -> Result<()> {
|
||||
let new_count = upload_count as i64 - replacing;
|
||||
if existing_total + new_count > MAX_SKILLS_PER_WORKSPACE as i64 {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"workspace cannot store more than {MAX_SKILLS_PER_WORKSPACE} skills"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_skills(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<Vec<SkillListItem>> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let rows = sqlx::query!(
|
||||
"SELECT name, description FROM ai_skill WHERE workspace_id = $1 ORDER BY name",
|
||||
&w_id
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(
|
||||
rows.into_iter()
|
||||
.map(|r| SkillListItem { name: r.name, description: r.description })
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_skill(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, name)): Path<(String, String)>,
|
||||
) -> JsonResult<Skill> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let row = sqlx::query!(
|
||||
"SELECT name, description, instructions FROM ai_skill WHERE workspace_id = $1 AND name = $2",
|
||||
&w_id,
|
||||
&name
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
row.map(|r| {
|
||||
Json(Skill { name: r.name, description: r.description, instructions: r.instructions })
|
||||
})
|
||||
.ok_or_else(|| Error::NotFound(format!("no skill named {name:?} in workspace {w_id}")))
|
||||
}
|
||||
|
||||
/// Bulk upsert the uploaded skills by name. Existing skills not in the payload
|
||||
/// are left untouched — removal goes through `delete_skill`.
|
||||
async fn upload_skills(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(payload): Json<UploadSkills>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
if payload.skills.is_empty() {
|
||||
return Err(Error::BadRequest("no skills to upload".to_string()));
|
||||
}
|
||||
if payload.skills.len() > MAX_SKILLS_PER_UPLOAD {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"cannot upload more than {MAX_SKILLS_PER_UPLOAD} skills at a time"
|
||||
)));
|
||||
}
|
||||
for skill in &payload.skills {
|
||||
validate_skill(skill)?;
|
||||
}
|
||||
let names = collect_upload_names(&payload.skills)?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
let counts = sqlx::query!(
|
||||
r#"SELECT
|
||||
COUNT(*)::bigint AS "total!",
|
||||
COUNT(*) FILTER (WHERE name = ANY($2::text[]))::bigint AS "replacing!"
|
||||
FROM ai_skill
|
||||
WHERE workspace_id = $1"#,
|
||||
&w_id,
|
||||
&names
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
check_workspace_skill_capacity(counts.total, counts.replacing, names.len())?;
|
||||
|
||||
for (skill, name) in payload.skills.iter().zip(names.iter()) {
|
||||
sqlx::query!(
|
||||
r#"INSERT INTO ai_skill (workspace_id, name, description, instructions, edited_at, edited_by)
|
||||
VALUES ($1, $2, $3, $4, now(), $5)
|
||||
ON CONFLICT (workspace_id, name) DO UPDATE
|
||||
SET description = EXCLUDED.description,
|
||||
instructions = EXCLUDED.instructions,
|
||||
edited_at = now(),
|
||||
edited_by = EXCLUDED.edited_by"#,
|
||||
&w_id,
|
||||
name,
|
||||
skill.description,
|
||||
skill.instructions,
|
||||
&authed.username,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let audit_resource = names.join(",");
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"ai_skills.upload",
|
||||
ActionKind::Update,
|
||||
&w_id,
|
||||
Some(&audit_resource),
|
||||
Some([("skill_count", &names.len().to_string()[..])].into()),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(format!(
|
||||
"Uploaded {} skill(s) to workspace {}",
|
||||
payload.skills.len(),
|
||||
&w_id
|
||||
))
|
||||
}
|
||||
|
||||
async fn delete_skill(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, name)): Path<(String, String)>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
let deleted = sqlx::query_scalar!(
|
||||
"DELETE FROM ai_skill WHERE workspace_id = $1 AND name = $2 RETURNING name",
|
||||
&w_id,
|
||||
&name
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if deleted.is_none() {
|
||||
tx.commit().await?;
|
||||
return Err(Error::NotFound(format!(
|
||||
"no skill named {name:?} in workspace {w_id}"
|
||||
)));
|
||||
}
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"ai_skills.delete",
|
||||
ActionKind::Delete,
|
||||
&w_id,
|
||||
Some(&name),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(format!("Deleted skill {name} from workspace {w_id}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn skill() -> SkillUpload {
|
||||
SkillUpload {
|
||||
name: "test-skill".to_string(),
|
||||
description: "Useful for tests".to_string(),
|
||||
instructions: "# Test\n\nDo the thing.".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_skill_rejects_oversized_description() {
|
||||
let mut skill = skill();
|
||||
skill.description = "x".repeat(MAX_SKILL_DESCRIPTION_CHARS + 1);
|
||||
|
||||
assert!(matches!(validate_skill(&skill), Err(Error::BadRequest(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_skill_rejects_oversized_instructions() {
|
||||
let mut skill = skill();
|
||||
skill.instructions = "x".repeat(MAX_SKILL_INSTRUCTIONS_BYTES + 1);
|
||||
|
||||
assert!(matches!(validate_skill(&skill), Err(Error::BadRequest(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_skill_rejects_oversized_name() {
|
||||
let mut skill = skill();
|
||||
skill.name = "a".repeat(MAX_SKILL_NAME_CHARS + 1);
|
||||
|
||||
assert!(matches!(validate_skill(&skill), Err(Error::BadRequest(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_skill_rejects_non_slug_name() {
|
||||
// Uppercase, underscore, space and punctuation are all outside the
|
||||
// Claude SKILL.md `[a-z0-9-]` name charset.
|
||||
for bad in ["My-Skill", "my_skill", "my skill", "skill!"] {
|
||||
let mut skill = skill();
|
||||
skill.name = bad.to_string();
|
||||
|
||||
assert!(
|
||||
matches!(validate_skill(&skill), Err(Error::BadRequest(_))),
|
||||
"{bad:?} should be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_skill_counts_description_in_characters() {
|
||||
// 1024 two-byte chars exceed the byte limit but sit exactly on the
|
||||
// character limit, so they must be accepted.
|
||||
let mut skill = skill();
|
||||
skill.description = "é".repeat(MAX_SKILL_DESCRIPTION_CHARS);
|
||||
|
||||
assert!(validate_skill(&skill).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_capacity_allows_replacement_at_cap() {
|
||||
// Already at the cap, but the upload only replaces an existing skill.
|
||||
let at_cap = MAX_SKILLS_PER_WORKSPACE as i64;
|
||||
assert!(check_workspace_skill_capacity(at_cap, 1, 1).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_capacity_rejects_new_skill_over_cap() {
|
||||
let at_cap = MAX_SKILLS_PER_WORKSPACE as i64;
|
||||
assert!(matches!(
|
||||
check_workspace_skill_capacity(at_cap, 0, 1),
|
||||
Err(Error::BadRequest(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_upload_names_trims_and_collects() {
|
||||
let names = collect_upload_names(&[skill()]).unwrap();
|
||||
assert_eq!(names, vec!["test-skill".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_upload_names_rejects_duplicates() {
|
||||
// Names are compared after trimming, so whitespace can't smuggle a dup in.
|
||||
let dup = SkillUpload { name: " test-skill ".to_string(), ..skill() };
|
||||
assert!(matches!(
|
||||
collect_upload_names(&[skill(), dup]),
|
||||
Err(Error::BadRequest(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,9 @@ use crate::scim_oss::has_scim_token;
|
||||
use windmill_common::error::AppError;
|
||||
|
||||
mod ai;
|
||||
mod ai_skills;
|
||||
#[cfg(feature = "private")]
|
||||
mod ai_free_tier_ee;
|
||||
mod ai_free_tier_oss;
|
||||
mod apps;
|
||||
mod apps_raw_bundle;
|
||||
pub use apps::invalidate_app_policy_cache;
|
||||
@@ -710,7 +712,6 @@ pub async fn run_server(
|
||||
Router::new()
|
||||
})
|
||||
.nest("/ai", ai::workspaced_service())
|
||||
.nest("/ai_skills", ai_skills::workspaced_service())
|
||||
.nest("/npm_proxy", windmill_api_npm_proxy::workspaced_service())
|
||||
.nest(
|
||||
"/path_autocomplete",
|
||||
|
||||
@@ -99,7 +99,6 @@ fn build_standard_scope_domains() -> Vec<ScopeDomain> {
|
||||
("configs", "Configs", "Configuration management", false),
|
||||
("oauth", "OAuth", "OAuth management", false),
|
||||
("ai", "AI", "AI feature management", false),
|
||||
("ai_skills", "AI Skills", "AI skill management", false),
|
||||
(
|
||||
"ai_evals",
|
||||
"AI Evals",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +130,12 @@ pub struct EditResourceType {
|
||||
pub schema: Option<serde_json::Value>,
|
||||
pub description: Option<String>,
|
||||
pub is_fileset: Option<bool>,
|
||||
/// Doubly optional so an edit can distinguish the two things a plain
|
||||
/// `Option` conflates: an absent field leaves the extension alone, while an
|
||||
/// explicit `null` clears it. A hub pull relies on both — a type that stops
|
||||
/// being a file type has to stop being one locally too.
|
||||
#[serde(default, deserialize_with = "windmill_common::more_serde::double_option")]
|
||||
pub format_extension: Option<Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(FromRow, Serialize, Deserialize)]
|
||||
@@ -2803,10 +2809,42 @@ async fn update_resource_type(
|
||||
if let Some(is_fileset) = ns.is_fileset {
|
||||
sqlb.set("is_fileset", if is_fileset { "TRUE" } else { "FALSE" });
|
||||
}
|
||||
if let Some(format_extension) = ns.format_extension.clone() {
|
||||
match format_extension {
|
||||
Some(ext) => sqlb.set_str("format_extension", ext),
|
||||
None => sqlb.set("format_extension", "NULL"),
|
||||
};
|
||||
}
|
||||
sqlb.set_str("edited_at", "now()");
|
||||
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
// Creation refuses the pair outright, so an edit must too — otherwise the same
|
||||
// impossible type (a set of files that is also one file) is reachable by setting
|
||||
// either half on an existing row. Whichever half the request omits is read from
|
||||
// the row being edited, inside this transaction and with the row locked: read
|
||||
// outside it, two concurrent edits each supplying one half would both pass.
|
||||
let current = sqlx::query!(
|
||||
"SELECT is_fileset, format_extension FROM resource_type
|
||||
WHERE name = $1 AND workspace_id = $2 FOR UPDATE",
|
||||
&name,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let effective_is_fileset = ns
|
||||
.is_fileset
|
||||
.unwrap_or_else(|| current.as_ref().map(|c| c.is_fileset).unwrap_or(false));
|
||||
let effective_format_extension = match &ns.format_extension {
|
||||
Some(value) => value.clone(),
|
||||
None => current.and_then(|c| c.format_extension),
|
||||
};
|
||||
if effective_is_fileset && effective_format_extension.is_some() {
|
||||
return Err(Error::BadRequest(
|
||||
"A fileset resource type cannot have a format_extension".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query(&sql).execute(&mut *tx).await?;
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
|
||||
@@ -66,3 +66,22 @@ where
|
||||
NumericOrNull::Null => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserializer for a doubly-optional field, so a struct can tell an absent key
|
||||
/// (`None`) from an explicit `null` (`Some(None)`).
|
||||
///
|
||||
/// Plain serde collapses both into the outer `None`, which makes the distinction
|
||||
/// unusable exactly where it matters: a payload that omits a field means "leave it
|
||||
/// alone", while one that sends `null` means "clear it".
|
||||
///
|
||||
/// ```ignore
|
||||
/// #[serde(default, deserialize_with = "double_option", skip_serializing_if = "Option::is_none")]
|
||||
/// pub field: Option<Option<String>>,
|
||||
/// ```
|
||||
pub fn double_option<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
|
||||
where
|
||||
T: serde::Deserialize<'de>,
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
serde::Deserialize::deserialize(deserializer).map(Some)
|
||||
}
|
||||
|
||||
+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.1";
|
||||
|
||||
export async function login(email: string, password: string): Promise<string> {
|
||||
return await windmill.UserService.login({
|
||||
|
||||
@@ -17,6 +17,9 @@ interface HubResourceType {
|
||||
app: string;
|
||||
description: string;
|
||||
is_fileset?: boolean;
|
||||
// Absent from hubs predating the column, so a missing value is "ordinary type",
|
||||
// not "unset it".
|
||||
format_extension?: string | null;
|
||||
}
|
||||
|
||||
export async function pull(opts: GlobalOptions) {
|
||||
@@ -116,7 +119,8 @@ export async function pull(opts: GlobalOptions) {
|
||||
typeof y.schema !== "string" &&
|
||||
deepEqual(y.schema, x.schema) &&
|
||||
y.description === x.description &&
|
||||
(y.is_fileset ?? false) === (x.is_fileset ?? false)
|
||||
(y.is_fileset ?? false) === (x.is_fileset ?? false) &&
|
||||
(y.format_extension ?? null) === (x.format_extension ?? null)
|
||||
)
|
||||
) {
|
||||
log.info("skipping " + x.name + " (same as current)");
|
||||
|
||||
@@ -25,6 +25,9 @@ export interface ResourceTypeFile {
|
||||
schema?: any;
|
||||
description?: string;
|
||||
is_fileset?: boolean;
|
||||
// Extension for a type whose value is one file rather than a set of fields; it
|
||||
// is what makes the resource editor a file editor for that language.
|
||||
format_extension?: string | null;
|
||||
}
|
||||
|
||||
export async function pushResourceType(
|
||||
|
||||
+108
-44
@@ -26,37 +26,33 @@ async function readDirRecursive(
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The workspace's shared UI store, or undefined when it cannot be read. */
|
||||
async function fetchSharedUi(
|
||||
workspace: string,
|
||||
): Promise<Record<string, string> | undefined> {
|
||||
try {
|
||||
const got = await wmill.getSharedUi({ workspace });
|
||||
return got.files ?? {};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export type SharedUiChange =
|
||||
| { type: "added"; path: string }
|
||||
| { type: "edited"; path: string; before: string; after: string }
|
||||
| { type: "deleted"; path: string };
|
||||
|
||||
/**
|
||||
* Diff the local <cwd>/ui/ folder against the workspace's shared UI store in
|
||||
* the push direction (local -> remote), returning entries whose `path` is
|
||||
* prefixed with `ui/`. This is the same comparison pushSharedUi applies, so the
|
||||
* dry-run preview and the real push never diverge.
|
||||
*
|
||||
* Mirrors pushSharedUi's no-op: with no local ui/ folder there is nothing to
|
||||
* push, so the apply is a no-op and the preview must be empty (even when the
|
||||
* remote store is non-empty) to avoid phantom diffs the apply won't perform.
|
||||
* The push-direction diff (local -> remote) of two already-read file maps.
|
||||
* Split out so the preview and the push itself compare identically off one
|
||||
* read of each side.
|
||||
*/
|
||||
export async function diffSharedUi(workspace: string): Promise<SharedUiChange[]> {
|
||||
const localDir = path.join(process.cwd(), SHARED_UI_DIR);
|
||||
if (!fs.existsSync(localDir)) {
|
||||
return [];
|
||||
}
|
||||
const files = await readDirRecursive(localDir);
|
||||
|
||||
let remote: Record<string, string> = {};
|
||||
try {
|
||||
const got = await wmill.getSharedUi({ workspace });
|
||||
remote = got.files ?? {};
|
||||
} catch {
|
||||
// If endpoint missing or unauthorized, treat remote as empty (the push
|
||||
// would attempt the PUT anyway).
|
||||
}
|
||||
|
||||
function sharedUiChanges(
|
||||
files: Record<string, string>,
|
||||
remote: Record<string, string>,
|
||||
keepDeleted?: boolean,
|
||||
): SharedUiChange[] {
|
||||
// Use Object.hasOwn, not `in`: a file named after an Object.prototype member
|
||||
// (e.g. ui/toString) would otherwise register as always-present and be
|
||||
// misdiffed.
|
||||
@@ -69,34 +65,97 @@ export async function diffSharedUi(workspace: string): Promise<SharedUiChange[]>
|
||||
changes.push({ type: "edited", path: p, before: remote[rel], after: content });
|
||||
}
|
||||
}
|
||||
for (const rel of Object.keys(remote)) {
|
||||
if (!Object.hasOwn(files, rel)) {
|
||||
changes.push({ type: "deleted", path: `${SHARED_UI_DIR}/${rel}` });
|
||||
if (!keepDeleted) {
|
||||
for (const rel of Object.keys(remote)) {
|
||||
if (!Object.hasOwn(files, rel)) {
|
||||
changes.push({ type: "deleted", path: `${SHARED_UI_DIR}/${rel}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff the local <cwd>/ui/ folder against the workspace's shared UI store in
|
||||
* the push direction (local -> remote), returning entries whose `path` is
|
||||
* prefixed with `ui/`. This is the same comparison pushSharedUi applies, so the
|
||||
* dry-run preview and the real push never diverge.
|
||||
*
|
||||
* Mirrors pushSharedUi's no-ops: with no local ui/ folder, or under
|
||||
* `keepDeleted` with an unreadable store, the apply does nothing, so the
|
||||
* preview must be empty (even when the remote store is non-empty) to avoid
|
||||
* phantom diffs the apply won't perform.
|
||||
*/
|
||||
export async function diffSharedUi(
|
||||
workspace: string,
|
||||
keepDeleted?: boolean,
|
||||
): Promise<SharedUiChange[]> {
|
||||
const localDir = path.join(process.cwd(), SHARED_UI_DIR);
|
||||
if (!fs.existsSync(localDir)) {
|
||||
return [];
|
||||
}
|
||||
const files = await readDirRecursive(localDir);
|
||||
const remote = await fetchSharedUi(workspace);
|
||||
if (remote === undefined && keepDeleted) {
|
||||
return [];
|
||||
}
|
||||
// If endpoint missing or unauthorized, treat remote as empty (the push
|
||||
// would attempt the PUT anyway).
|
||||
return sharedUiChanges(files, remote ?? {}, keepDeleted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push the local <cwd>/ui/ folder to the workspace's shared UI store.
|
||||
* Returns true if a push was performed, false if the folder is missing or
|
||||
* already matches the remote store. Note an empty-but-existing folder still
|
||||
* pushes an empty map (clearing the remote store) if the remote is non-empty.
|
||||
* pushes an empty map (clearing the remote store) if the remote is non-empty —
|
||||
* unless `keepDeleted`, which folds remote-only files back into the map.
|
||||
*/
|
||||
export async function pushSharedUi(workspace: string): Promise<boolean> {
|
||||
export async function pushSharedUi(
|
||||
workspace: string,
|
||||
keepDeleted?: boolean,
|
||||
): Promise<boolean> {
|
||||
const localDir = path.join(process.cwd(), SHARED_UI_DIR);
|
||||
if (!fs.existsSync(localDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip if no change — reuse diffSharedUi so preview and push never diverge.
|
||||
const diff = await diffSharedUi(workspace);
|
||||
if (diff.length === 0) {
|
||||
const files = await readDirRecursive(localDir);
|
||||
const remote = await fetchSharedUi(workspace);
|
||||
// The store is written whole, so a remote-only file is pruned by omission
|
||||
// alone. An unreadable store leaves no way to carry those files over, so
|
||||
// the shared UI is left untouched rather than cleared.
|
||||
if (remote === undefined && keepDeleted) {
|
||||
log.warn(
|
||||
colors.yellow(
|
||||
"Could not read the shared UI folder from the remote; skipping its push so --keep-deleted does not clear it.",
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Same comparison as diffSharedUi, off the same two maps, so preview and
|
||||
// push never diverge.
|
||||
if (sharedUiChanges(files, remote ?? {}, keepDeleted).length === 0) {
|
||||
log.info(colors.gray("Shared UI folder up to date"));
|
||||
return false;
|
||||
}
|
||||
|
||||
const files = await readDirRecursive(localDir);
|
||||
if (keepDeleted) {
|
||||
for (const [rel, content] of Object.entries(remote!)) {
|
||||
// defineProperty, not assignment: `files.__proto__ = "..."` runs the
|
||||
// inherited setter and creates no own property, so a remote `ui/__proto__`
|
||||
// would be dropped from the whole-map PUT — i.e. deleted despite the flag.
|
||||
if (!Object.hasOwn(files, rel)) {
|
||||
Object.defineProperty(files, rel, {
|
||||
value: content,
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
await wmill.updateSharedUi({
|
||||
workspace,
|
||||
requestBody: { files },
|
||||
@@ -111,9 +170,12 @@ export async function pushSharedUi(workspace: string): Promise<boolean> {
|
||||
|
||||
/**
|
||||
* Pull the workspace's shared UI store into <cwd>/ui/.
|
||||
* Files removed remotely are also removed locally.
|
||||
* Files removed remotely are also removed locally, unless `keepDeleted`.
|
||||
*/
|
||||
export async function pullSharedUi(workspace: string): Promise<boolean> {
|
||||
export async function pullSharedUi(
|
||||
workspace: string,
|
||||
keepDeleted?: boolean,
|
||||
): Promise<boolean> {
|
||||
const localDir = path.join(process.cwd(), SHARED_UI_DIR);
|
||||
let got;
|
||||
try {
|
||||
@@ -145,15 +207,17 @@ export async function pullSharedUi(workspace: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
// Delete locally-orphaned files
|
||||
const known = new Set(Object.keys(files));
|
||||
const local = await readDirRecursive(localDir);
|
||||
for (const rel of Object.keys(local)) {
|
||||
if (!known.has(rel)) {
|
||||
const full = path.join(localDir, rel);
|
||||
try {
|
||||
fs.unlinkSync(full);
|
||||
} catch {
|
||||
// ignore
|
||||
if (!keepDeleted) {
|
||||
const known = new Set(Object.keys(files));
|
||||
const local = await readDirRecursive(localDir);
|
||||
for (const rel of Object.keys(local)) {
|
||||
if (!known.has(rel)) {
|
||||
const full = path.join(localDir, rel);
|
||||
try {
|
||||
fs.unlinkSync(full);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+110
-26
@@ -3174,6 +3174,53 @@ export function untrackedDatatableMigrationDeletions<
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a pull change removes a local dbt descriptor. A dbt project's
|
||||
* descriptor is optional and the remote spells "this project names none" as
|
||||
* empty content, so its removal reaches the apply loop as an add or an edit
|
||||
* whose content is `""` — a `deleted` change is never produced for it.
|
||||
*/
|
||||
function removesDbtDescriptor(change: Change): boolean {
|
||||
return (
|
||||
isDbtDescriptorPath(change.path) &&
|
||||
((change.name === "added" && change.content === "") ||
|
||||
(change.name === "edited" && change.after === ""))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `--keep-deleted`: strip every deletion from the changeset, in place, so the
|
||||
* sync only adds and updates. A path missing on one side is not on its own
|
||||
* evidence that it should go from the other — a partial clone, a scoped
|
||||
* checkout or an item authored in the UI all read as deletions here.
|
||||
*
|
||||
* A dbt descriptor the remote no longer names counts too, but only on a pull,
|
||||
* where applying it removes a local file — `added` as much as `edited`, since a
|
||||
* stateful pull compares `.wmill` and absence from that map says nothing about
|
||||
* the working tree the apply loop deletes from. A push removes nothing: the
|
||||
* descriptor is a script's content, so an empty one updates the script in place.
|
||||
*/
|
||||
export function dropDeletions(
|
||||
changes: Change[],
|
||||
keptOn: "local" | "remote",
|
||||
): void {
|
||||
const isDeletion = (c: Change) =>
|
||||
c.name === "deleted" || (keptOn === "local" && removesDbtDescriptor(c));
|
||||
const deletions = changes.filter(isDeletion);
|
||||
if (deletions.length === 0) return;
|
||||
const kept = changes.filter((c) => !isDeletion(c));
|
||||
changes.length = 0;
|
||||
changes.push(...kept);
|
||||
log.info(
|
||||
colors.yellow(
|
||||
`--keep-deleted: keeping ${deletions.length} item(s) that exist only ` +
|
||||
(keptOn === "local"
|
||||
? `on disk instead of deleting them locally`
|
||||
: `on the remote instead of deleting them from the workspace`),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
interface ChangeTracker {
|
||||
scripts: string[];
|
||||
flows: string[];
|
||||
@@ -3433,6 +3480,7 @@ export async function pull(
|
||||
repository?: string;
|
||||
promotion?: string;
|
||||
branch?: string;
|
||||
keepDeleted?: boolean;
|
||||
useIndividualBranch?: boolean;
|
||||
groupByFolder?: boolean;
|
||||
gitDeployItems?: string;
|
||||
@@ -3684,6 +3732,10 @@ export async function pull(
|
||||
await isCaseInsensitiveFilesystem(process.cwd()),
|
||||
);
|
||||
|
||||
if (opts.keepDeleted) {
|
||||
dropDeletions(changes, "local");
|
||||
}
|
||||
|
||||
log.info(
|
||||
`remote (${workspace.name}) -> local: ${changes.length} changes to apply`,
|
||||
);
|
||||
@@ -3781,22 +3833,17 @@ export async function pull(
|
||||
|
||||
const target = path.join(process.cwd(), targetPath);
|
||||
const stateTarget = path.join(process.cwd(), ".wmill", targetPath);
|
||||
// An empty dbt descriptor is not a file: the remote spells "this project
|
||||
// named no descriptor" as empty content, and writing that would put a
|
||||
// Windmill file inside a project that has none. ABSENCE is the state to
|
||||
// reach, so both copies are removed if present and their being missing —
|
||||
// a project pulled for the first time — is the goal, not an error. The
|
||||
// `.wmill` copy goes too, or the same change is reported on every pull.
|
||||
// ABSENCE is the state to reach, never an empty file: writing one would
|
||||
// put a Windmill file inside a project that has none. So both copies are
|
||||
// removed if present, and their being missing — a project pulled for the
|
||||
// first time — is the goal, not an error. The `.wmill` copy goes too, or
|
||||
// the same change is reported on every pull.
|
||||
//
|
||||
// `force` covers the missing file and NOTHING else: a permission or
|
||||
// read-only-filesystem failure has to surface, or the pull reports
|
||||
// success while the old descriptor — its warehouse, its command, its
|
||||
// arguments — is still what runs locally.
|
||||
if (
|
||||
isDbtDescriptorPath(change.path) &&
|
||||
((change.name === "added" && change.content === "") ||
|
||||
(change.name === "edited" && change.after === ""))
|
||||
) {
|
||||
if (removesDbtDescriptor(change)) {
|
||||
await rm(target, { force: true });
|
||||
if (opts.stateful) {
|
||||
await rm(stateTarget, { force: true });
|
||||
@@ -4117,10 +4164,14 @@ export async function pull(
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await pullSharedUi(workspace.workspaceId);
|
||||
} catch (e) {
|
||||
log.warn(`Failed to pull shared UI folder: ${e}`);
|
||||
// Skipped under --dry-run since pullSharedUi writes to the local ui/ folder.
|
||||
// An empty changeset falls through the return above and reaches here.
|
||||
if (!opts.dryRun) {
|
||||
try {
|
||||
await pullSharedUi(workspace.workspaceId, opts.keepDeleted);
|
||||
} catch (e) {
|
||||
log.warn(`Failed to pull shared UI folder: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Datatable migrations are part of the workspace export now, so they flow
|
||||
@@ -4425,12 +4476,19 @@ function removeSuffix(str: string, suffix: string) {
|
||||
}
|
||||
|
||||
// Shown after a `wmill sync push --dry-run` preview that has changes. `sync push`
|
||||
// deploys to the remote workspace and is destructive (it overwrites and prunes
|
||||
// remote items that differ from or are absent locally), so the preview reminds
|
||||
// the caller — especially an AI agent that ran the dry-run to inspect changes —
|
||||
// to get explicit user confirmation before applying it for real.
|
||||
const SYNC_PUSH_DESTRUCTIVE_WARNING =
|
||||
"`wmill sync push` is destructive: applying it deploys these changes to the remote workspace and overwrites or deletes remote items that differ from or are absent locally — this is not automatically reversible. If you are an AI agent, do NOT run `wmill sync push` (without --dry-run) until the user has explicitly confirmed this deploy, unless your custom instructions explicitly allow bypassing that confirmation.";
|
||||
// deploys to the remote workspace and is destructive (it overwrites remote items
|
||||
// that differ from local, and prunes those absent locally unless --keep-deleted),
|
||||
// so the preview reminds the caller — especially an AI agent that ran the dry-run
|
||||
// to inspect changes — to get explicit user confirmation before applying it for real.
|
||||
function syncPushDestructiveWarning(keepDeleted?: boolean): string {
|
||||
return (
|
||||
"`wmill sync push` is destructive: applying it deploys these changes to the remote workspace and overwrites " +
|
||||
(keepDeleted
|
||||
? "remote items that differ from local"
|
||||
: "or deletes remote items that differ from or are absent locally") +
|
||||
" — this is not automatically reversible. If you are an AI agent, do NOT run `wmill sync push` (without --dry-run) until the user has explicitly confirmed this deploy, unless your custom instructions explicitly allow bypassing that confirmation."
|
||||
);
|
||||
}
|
||||
|
||||
// A script pushed without a local lock queues a server-side dependency job; if
|
||||
// that job fails the script deploys broken (no lock/assets) with no CLI signal.
|
||||
@@ -4490,6 +4548,7 @@ export async function push(
|
||||
SyncOptions & {
|
||||
repository?: string;
|
||||
branch?: string;
|
||||
keepDeleted?: boolean;
|
||||
acceptOverridingPermissionedAsWithSelf?: boolean;
|
||||
},
|
||||
) {
|
||||
@@ -4791,6 +4850,13 @@ export async function push(
|
||||
);
|
||||
}
|
||||
|
||||
// After the shared-lock pass, which reads a shared lockfile's deletion as the
|
||||
// signal that this checkout is not deduplicated — an advisory about the local
|
||||
// tree that holds whether or not remote items are being kept.
|
||||
if (opts.keepDeleted) {
|
||||
dropDeletions(changes, "remote");
|
||||
}
|
||||
|
||||
const autoRegenerate = !!(opts as any).autoMetadata;
|
||||
const staleScripts: string[] = [];
|
||||
const staleFlows: string[] = [];
|
||||
@@ -5103,7 +5169,10 @@ export async function push(
|
||||
// unchanged (pushSharedUi still runs) and the summary count includes ui/.
|
||||
if (opts.dryRun) {
|
||||
try {
|
||||
for (const c of await diffSharedUi(workspace.workspaceId)) {
|
||||
for (const c of await diffSharedUi(
|
||||
workspace.workspaceId,
|
||||
opts.keepDeleted,
|
||||
)) {
|
||||
if (c.type === "added") {
|
||||
changes.push({ name: "added", path: c.path, content: "" });
|
||||
} else if (c.type === "deleted") {
|
||||
@@ -5275,7 +5344,9 @@ export async function push(
|
||||
: {}),
|
||||
})),
|
||||
total: changes.length,
|
||||
...(changes.length > 0 ? { warning: SYNC_PUSH_DESTRUCTIVE_WARNING } : {}),
|
||||
...(changes.length > 0
|
||||
? { warning: syncPushDestructiveWarning(opts.keepDeleted) }
|
||||
: {}),
|
||||
};
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
@@ -5339,7 +5410,9 @@ export async function push(
|
||||
|
||||
if (opts.dryRun) {
|
||||
log.info(colors.gray(`Dry run complete.`));
|
||||
log.warn(colors.yellow(`\n⚠ ${SYNC_PUSH_DESTRUCTIVE_WARNING}`));
|
||||
log.warn(
|
||||
colors.yellow(`\n⚠ ${syncPushDestructiveWarning(opts.keepDeleted)}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6313,7 +6386,7 @@ export async function push(
|
||||
}
|
||||
}
|
||||
try {
|
||||
await pushSharedUi(workspace.workspaceId);
|
||||
await pushSharedUi(workspace.workspaceId, opts.keepDeleted);
|
||||
} catch (e) {
|
||||
log.warn(`Failed to push shared UI folder: ${e}`);
|
||||
}
|
||||
@@ -6415,7 +6488,10 @@ export async function push(
|
||||
let sharedUiPushed = false;
|
||||
if (!opts.dryRun) {
|
||||
try {
|
||||
sharedUiPushed = await pushSharedUi(workspace.workspaceId);
|
||||
sharedUiPushed = await pushSharedUi(
|
||||
workspace.workspaceId,
|
||||
opts.keepDeleted,
|
||||
);
|
||||
} catch (e) {
|
||||
log.warn(`Failed to push shared UI folder: ${e}`);
|
||||
}
|
||||
@@ -6480,6 +6556,10 @@ const command = new Command()
|
||||
.option("--include-groups", "Include syncing groups")
|
||||
.option("--include-settings", "Include syncing workspace settings")
|
||||
.option("--include-key", "Include workspace encryption key")
|
||||
.option(
|
||||
"--keep-deleted",
|
||||
"Do not delete local files for items that no longer exist on the remote workspace. Only adds and updates.",
|
||||
)
|
||||
.option("--skip-branch-validation", "Skip git branch validation and prompts")
|
||||
.option("--json-output", "Output results in JSON format")
|
||||
.option(
|
||||
@@ -6543,6 +6623,10 @@ const command = new Command()
|
||||
"--skip-reencrypt-on-key-change",
|
||||
"When the pushed encryption key differs from the remote, do NOT re-encrypt existing remote secrets. Only safe if they are already encrypted with the new key (e.g. workspace/instance migration). Default is to re-encrypt.",
|
||||
)
|
||||
.option(
|
||||
"--keep-deleted",
|
||||
"Do not delete remote items that no longer exist locally. Only adds and updates.",
|
||||
)
|
||||
.option("--skip-branch-validation", "Skip git branch validation and prompts")
|
||||
.option("--json-output", "Output results in JSON format")
|
||||
.option(
|
||||
|
||||
@@ -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.1";
|
||||
|
||||
Generated
+2
@@ -7519,6 +7519,7 @@ sync local with a remote workspaces or the opposite (push or pull)
|
||||
- \`--include-groups\` - Include syncing groups
|
||||
- \`--include-settings\` - Include syncing workspace settings
|
||||
- \`--include-key\` - Include workspace encryption key
|
||||
- \`--keep-deleted\` - Do not delete local files for items that no longer exist on the remote workspace. Only adds and updates.
|
||||
- \`--skip-branch-validation\` - Skip git branch validation and prompts
|
||||
- \`--json-output\` - Output results in JSON format
|
||||
- \`-i --includes <patterns:file[]>\` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Overrides wmill.yaml includes
|
||||
@@ -7550,6 +7551,7 @@ sync local with a remote workspaces or the opposite (push or pull)
|
||||
- \`--include-settings\` - Include syncing workspace settings
|
||||
- \`--include-key\` - Include workspace encryption key
|
||||
- \`--skip-reencrypt-on-key-change\` - When the pushed encryption key differs from the remote, do NOT re-encrypt existing remote secrets. Only safe if they are already encrypted with the new key (e.g. workspace/instance migration). Default is to re-encrypt.
|
||||
- \`--keep-deleted\` - Do not delete remote items that no longer exist locally. Only adds and updates.
|
||||
- \`--skip-branch-validation\` - Skip git branch validation and prompts
|
||||
- \`--json-output\` - Output results in JSON format
|
||||
- \`-i --includes <patterns:file[]>\` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)
|
||||
|
||||
@@ -7,7 +7,11 @@ import { expect, test, describe, beforeEach, afterEach } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { FSFSElement, elementsToMap } from "../src/commands/sync/sync.ts";
|
||||
import {
|
||||
FSFSElement,
|
||||
dropDeletions,
|
||||
elementsToMap,
|
||||
} from "../src/commands/sync/sync.ts";
|
||||
import { listWorkspacePaths } from "../src/commands/dev/dev.ts";
|
||||
import {
|
||||
DbtPathCollisionError,
|
||||
@@ -293,6 +297,47 @@ describe("a dbt project without a descriptor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Unguarded, `--keep-deleted` lets a pull remove the very file it promises to
|
||||
// keep — the descriptor's warehouse and run arguments with it.
|
||||
describe("--keep-deleted and a descriptor the remote no longer names", () => {
|
||||
const DESCRIPTOR = "f/analytics/analytics__dbt/wm_dbt.yaml";
|
||||
const emptied = {
|
||||
name: "edited",
|
||||
path: DESCRIPTOR,
|
||||
before: "warehouse: wh\n",
|
||||
after: "",
|
||||
};
|
||||
const unknownToTheMap = { name: "added", path: DESCRIPTOR, content: "" };
|
||||
const changeset = (descriptor: unknown) =>
|
||||
[
|
||||
descriptor,
|
||||
{ name: "edited", path: "f/other/script.ts", before: "a", after: "b" },
|
||||
] as Parameters<typeof dropDeletions>[0];
|
||||
|
||||
test("survives a pull", () => {
|
||||
const changes = changeset(emptied);
|
||||
dropDeletions(changes, "local");
|
||||
expect(changes.map((c) => c.path)).toEqual(["f/other/script.ts"]);
|
||||
});
|
||||
|
||||
// A stateful pull compares `.wmill`, so a descriptor missing from the compared
|
||||
// map can still be a working-tree file holding real settings.
|
||||
test("survives a pull that compared a map without it", () => {
|
||||
const changes = changeset(unknownToTheMap);
|
||||
dropDeletions(changes, "local");
|
||||
expect(changes.map((c) => c.path)).toEqual(["f/other/script.ts"]);
|
||||
});
|
||||
|
||||
test("is still pushed, deleting no remote item", () => {
|
||||
const changes = changeset(emptied);
|
||||
dropDeletions(changes, "remote");
|
||||
expect(changes.map((c) => c.path)).toEqual([
|
||||
DESCRIPTOR,
|
||||
"f/other/script.ts",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// `wmill dev` walks basenames, and a dbt script is a DIRECTORY whose descriptor
|
||||
// may not exist — so it is recognized by the project folder or not at all. The
|
||||
// walk must also stop there: the project's own `.sql` models match the script
|
||||
|
||||
@@ -11,12 +11,25 @@ import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
let remoteFiles: Record<string, string> = {};
|
||||
let remoteUnreadable = false;
|
||||
let pushedFiles: Record<string, string> | undefined;
|
||||
|
||||
mock.module("../gen/services.gen.ts", () => ({
|
||||
getSharedUi: async (_args: { workspace: string }) => ({ files: remoteFiles }),
|
||||
getSharedUi: async (_args: { workspace: string }) => {
|
||||
if (remoteUnreadable) throw new Error("shared UI store unreadable");
|
||||
return { files: remoteFiles };
|
||||
},
|
||||
updateSharedUi: async (args: {
|
||||
workspace: string;
|
||||
requestBody: { files: Record<string, string> };
|
||||
}) => {
|
||||
pushedFiles = args.requestBody.files;
|
||||
},
|
||||
}));
|
||||
|
||||
const { diffSharedUi } = await import("../src/commands/shared_ui.ts");
|
||||
const { diffSharedUi, pushSharedUi } = await import(
|
||||
"../src/commands/shared_ui.ts"
|
||||
);
|
||||
|
||||
describe("diffSharedUi", () => {
|
||||
const ws = "test-workspace";
|
||||
@@ -25,6 +38,8 @@ describe("diffSharedUi", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
remoteFiles = {};
|
||||
remoteUnreadable = false;
|
||||
pushedFiles = undefined;
|
||||
prevCwd = process.cwd();
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wm-shared-ui-"));
|
||||
process.chdir(tmpDir);
|
||||
@@ -89,4 +104,46 @@ describe("diffSharedUi", () => {
|
||||
const changes = await diffSharedUi(ws);
|
||||
expect(changes).toEqual([]);
|
||||
});
|
||||
|
||||
test("emits nothing under keepDeleted when the remote store is unreadable", async () => {
|
||||
// pushSharedUi skips the push rather than clearing a store it can't read,
|
||||
// so the preview must show that same nothing.
|
||||
remoteUnreadable = true;
|
||||
writeUi("theme.json", "{}");
|
||||
expect(await diffSharedUi(ws, true)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pushSharedUi with keepDeleted", () => {
|
||||
const ws = "test-workspace";
|
||||
let tmpDir: string;
|
||||
let prevCwd: string;
|
||||
|
||||
beforeEach(() => {
|
||||
remoteFiles = {};
|
||||
remoteUnreadable = false;
|
||||
pushedFiles = undefined;
|
||||
prevCwd = process.cwd();
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "wm-shared-ui-push-"));
|
||||
process.chdir(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(prevCwd);
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("carries remote-only files, including ui/__proto__, into the pushed map", async () => {
|
||||
// JSON.parse, not a literal: `{__proto__: …}` sets the prototype instead of
|
||||
// creating the own property the API response really has.
|
||||
remoteFiles = JSON.parse('{"__proto__":"keep me","extra.json":"1"}');
|
||||
fs.mkdirSync(path.join(tmpDir, "ui"), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpDir, "ui", "theme.json"), "{}", "utf-8");
|
||||
|
||||
expect(await pushSharedUi(ws, true)).toBe(true);
|
||||
// The store is written whole, so anything missing here is deleted.
|
||||
expect(pushedFiles!["extra.json"]).toEqual("1");
|
||||
expect(Object.getOwnPropertyDescriptor(pushedFiles!, "__proto__")?.value)
|
||||
.toEqual("keep me");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2774,3 +2774,102 @@ kind: script
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("keep deleted", () => {
|
||||
test("Integration: --keep-deleted keeps items absent from the other side", async () => {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
const uniqueId = Date.now();
|
||||
const scriptPath = `f/test/keep_deleted_${uniqueId}`;
|
||||
|
||||
const resp = await backend.apiRequest!(
|
||||
`/api/w/${backend.workspace}/scripts/create`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
path: scriptPath,
|
||||
content: 'export async function main() { return "keep me"; }',
|
||||
language: "bun",
|
||||
summary: "Kept by --keep-deleted",
|
||||
schema: {
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
expect(resp.status).toBeLessThan(300);
|
||||
await resp.text();
|
||||
|
||||
await writeWmillYaml(tempDir);
|
||||
expect(
|
||||
(await backend.runCLICommand(["sync", "pull", "--yes"], tempDir)).code
|
||||
).toEqual(0);
|
||||
|
||||
const contentFile = `${scriptPath}.ts`;
|
||||
const metadataFile = `${scriptPath}.script.yaml`;
|
||||
expect(await listFilesRecursive(tempDir)).toContain(contentFile);
|
||||
|
||||
// Push direction: the remote script survives losing its local files.
|
||||
await rm(join(tempDir, contentFile));
|
||||
await rm(join(tempDir, metadataFile));
|
||||
expect(
|
||||
(
|
||||
await backend.runCLICommand(
|
||||
["sync", "push", "--yes", "--keep-deleted"],
|
||||
tempDir
|
||||
)
|
||||
).code
|
||||
).toEqual(0);
|
||||
// A push deletion archives the script rather than removing the row, so
|
||||
// `archived` — not the status code — is what says it survived.
|
||||
const remote = await backend.apiRequest!(
|
||||
`/api/w/${backend.workspace}/scripts/get/p/${scriptPath}`
|
||||
);
|
||||
expect(remote.status).toEqual(200);
|
||||
expect((await remote.json()).archived).not.toEqual(true);
|
||||
|
||||
// Pull direction: a file with no remote counterpart survives the pull.
|
||||
const localOnly = `f/test/local_only_${uniqueId}.ts`;
|
||||
await writeFile(
|
||||
join(tempDir, localOnly),
|
||||
'export async function main() { return "local only"; }',
|
||||
"utf-8"
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await backend.runCLICommand(
|
||||
["sync", "pull", "--yes", "--keep-deleted"],
|
||||
tempDir
|
||||
)
|
||||
).code
|
||||
).toEqual(0);
|
||||
const afterPull = await listFilesRecursive(tempDir);
|
||||
expect(afterPull).toContain(localOnly);
|
||||
// Adds still apply: the script deleted above is written back.
|
||||
expect(afterPull).toContain(contentFile);
|
||||
|
||||
// An empty changeset falls past the dry-run return, on to the shared-UI
|
||||
// step — which writes to disk, so a dry run must skip it.
|
||||
// Including the metadata and lock the pull's auto-fill generated for it.
|
||||
for (const ext of [".ts", ".script.yaml", ".script.lock"]) {
|
||||
await rm(join(tempDir, `f/test/local_only_${uniqueId}${ext}`), {
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
await mkdir(join(tempDir, "ui"), { recursive: true });
|
||||
await writeFile(join(tempDir, "ui", "custom.css"), "body{}", "utf-8");
|
||||
const dryRun = await backend.runCLICommand(
|
||||
["sync", "pull", "--dry-run"],
|
||||
tempDir
|
||||
);
|
||||
expect(dryRun.code).toEqual(0);
|
||||
// Guards against a vacuous pass: a non-empty changeset would return at
|
||||
// the dry-run check above and never reach the shared-UI step.
|
||||
expect(dryRun.stdout + dryRun.stderr).toContain("0 changes to apply");
|
||||
expect(await listFilesRecursive(tempDir)).toContain("ui/custom.css");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.799.0",
|
||||
"version": "1.800.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.799.0",
|
||||
"version": "1.800.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.799.0",
|
||||
"version": "1.800.1",
|
||||
"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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
interface Props {
|
||||
/** File path → content map. Keys use / prefix (e.g. /index.html). */
|
||||
files: Record<string, string>
|
||||
/** Currently selected path (/-prefixed). Read-only; changes via onSelectPath callback. */
|
||||
/** Currently selected file (/-prefixed). Read-only; changes via onSelectPath callback. */
|
||||
selectedPath?: string | undefined
|
||||
/** Called when user clicks a path (file or folder). */
|
||||
/** Called when the user clicks a file. Folders aren't selectable — clicking
|
||||
* one only expands it — so the only non-file paths this reports are the root
|
||||
* row under `showRoot`, and '' when the last file is deleted. */
|
||||
onSelectPath?: (path: string) => void
|
||||
/** Extra tree nodes appended after the main tree (e.g. read-only wmill.ts). */
|
||||
extraNodes?: TreeNode[]
|
||||
@@ -80,6 +82,12 @@
|
||||
onSelectPath?.(path)
|
||||
}
|
||||
|
||||
function parentFolderOfSelection(): string {
|
||||
if (!selectedPath || selectedPath === '/') return '/'
|
||||
const pathParts = selectedPath.split('/').filter(Boolean)
|
||||
return pathParts.length > 1 ? '/' + pathParts.slice(0, -1).join('/') + '/' : '/'
|
||||
}
|
||||
|
||||
function handleAddFile(folderPath: string) {
|
||||
const normalizedFolder = folderPath.endsWith('/') ? folderPath : folderPath + '/'
|
||||
const basePath = normalizedFolder + 'newfile.txt'
|
||||
@@ -88,20 +96,10 @@
|
||||
pathToEdit = newPath
|
||||
}
|
||||
|
||||
export function handleAddRootFile() {
|
||||
let basePath: string
|
||||
if (selectedPath && selectedPath !== '/') {
|
||||
if (selectedPath.endsWith('/')) {
|
||||
basePath = selectedPath + 'newfile.txt'
|
||||
} else {
|
||||
const pathParts = selectedPath.split('/').filter(Boolean)
|
||||
const parentPath =
|
||||
pathParts.length > 1 ? '/' + pathParts.slice(0, -1).join('/') + '/' : '/'
|
||||
basePath = parentPath + 'newfile.txt'
|
||||
}
|
||||
} else {
|
||||
basePath = '/newfile.txt'
|
||||
}
|
||||
// New entries land beside the selected file; with nothing selected, at the
|
||||
// root. To create inside another folder, use that folder row's own menu.
|
||||
export function handleAddFileBesideSelection() {
|
||||
const basePath = parentFolderOfSelection() + 'newfile.txt'
|
||||
const newPath = getUniquePath(basePath)
|
||||
pendingNewFilePath = newPath
|
||||
pathToEdit = newPath
|
||||
@@ -115,20 +113,8 @@
|
||||
pathToEdit = newPath
|
||||
}
|
||||
|
||||
export function handleAddRootFolder() {
|
||||
let basePath: string
|
||||
if (selectedPath && selectedPath !== '/') {
|
||||
if (selectedPath.endsWith('/')) {
|
||||
basePath = selectedPath + 'newfolder/'
|
||||
} else {
|
||||
const pathParts = selectedPath.split('/').filter(Boolean)
|
||||
const parentPath =
|
||||
pathParts.length > 1 ? '/' + pathParts.slice(0, -1).join('/') + '/' : '/'
|
||||
basePath = parentPath + 'newfolder/'
|
||||
}
|
||||
} else {
|
||||
basePath = '/newfolder/'
|
||||
}
|
||||
export function handleAddFolderBesideSelection() {
|
||||
const basePath = parentFolderOfSelection() + 'newfolder/'
|
||||
const newPath = getUniquePath(basePath)
|
||||
pendingNewFilePath = newPath
|
||||
pathToEdit = newPath
|
||||
@@ -168,9 +154,7 @@
|
||||
}
|
||||
// Also rename in emptyFolders
|
||||
emptyFolders = emptyFolders.map((f) =>
|
||||
f === oldPath || f.startsWith(oldPath)
|
||||
? newPath + f.substring(oldPath.length)
|
||||
: f
|
||||
f === oldPath || f.startsWith(oldPath) ? newPath + f.substring(oldPath.length) : f
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -187,7 +171,13 @@
|
||||
|
||||
files = nfiles
|
||||
pathToEdit = undefined
|
||||
onSelectPath?.(newPath)
|
||||
if (!isFolder) {
|
||||
onSelectPath?.(newPath)
|
||||
} else if (selectedPath?.startsWith(oldPath)) {
|
||||
// A folder isn't selectable, but the selected file moved with it — follow
|
||||
// it to its new path, or the caller keeps editing a key that's now gone.
|
||||
onSelectPath?.(newPath + selectedPath.slice(oldPath.length))
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(path: string) {
|
||||
@@ -208,12 +198,8 @@
|
||||
files = nfiles
|
||||
|
||||
if (selectedPath === path || (isFolder && selectedPath?.startsWith(path))) {
|
||||
const remaining = Object.keys(nfiles)
|
||||
if (remaining.length > 0) {
|
||||
onSelectPath?.(remaining[0])
|
||||
} else {
|
||||
onSelectPath?.(showRoot ? '/' : '')
|
||||
}
|
||||
const remainingFile = Object.keys(nfiles).find((key) => !key.endsWith('/'))
|
||||
onSelectPath?.(remainingFile ?? (showRoot ? '/' : ''))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -223,7 +209,7 @@
|
||||
<span class="text-xs font-semibold text-emphasis">Files</span>
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
onClick={handleAddRootFile}
|
||||
onClick={handleAddFileBesideSelection}
|
||||
title="Add file"
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
@@ -233,7 +219,7 @@
|
||||
<File size={12} />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddRootFolder}
|
||||
onClick={handleAddFolderBesideSelection}
|
||||
title="Add folder"
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
@@ -255,8 +241,7 @@
|
||||
: ''}"
|
||||
>
|
||||
<FolderOpen size={12} class="flex-shrink-0 text-secondary" />
|
||||
<span
|
||||
class="truncate text-primary font-normal {selectedPath === '/' ? 'text-accent' : ''}"
|
||||
<span class="truncate text-primary font-normal {selectedPath === '/' ? 'text-accent' : ''}"
|
||||
>/</span
|
||||
>
|
||||
</button>
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -1070,8 +1070,9 @@
|
||||
<li
|
||||
>feature usage (counts of which product features are used, including AI provider and
|
||||
model identifiers, the names of public hub scripts used, the languages debug sessions
|
||||
are started for, and the plan tier and quota shown when the execution meter is
|
||||
opened, last 30 days)</li
|
||||
are started for, whether AI chat skills are turned on or off and how often one is
|
||||
loaded, and the plan tier and quota shown when the execution meter is opened, last 30
|
||||
days)</li
|
||||
>
|
||||
<li
|
||||
>feature adoption (counts of which flow, script, trigger and worker features your
|
||||
@@ -1123,8 +1124,9 @@
|
||||
<li
|
||||
>feature usage (counts of which product features are used, including AI provider and
|
||||
model identifiers, the names of public hub scripts used, the languages debug sessions
|
||||
are started for, and the plan tier and quota shown when the execution meter is
|
||||
opened, last 30 days)</li
|
||||
are started for, whether AI chat skills are turned on or off and how often one is
|
||||
loaded, and the plan tier and quota shown when the execution meter is opened, last 30
|
||||
days)</li
|
||||
>
|
||||
<li
|
||||
>feature adoption (counts of which flow, script, trigger and worker features your
|
||||
@@ -1152,20 +1154,19 @@
|
||||
<div class="pb-4">
|
||||
<Alert type="info" title="Log files stay on local disk" size="xs">
|
||||
Instance object storage is not configured, so every server and worker keeps its log
|
||||
files on its own disk. This page lists what each host wrote, but can only open the
|
||||
files belonging to the replica serving the request — another host's are listed and
|
||||
not readable — and a host's files go with it when it is replaced. Retention below
|
||||
still governs the entries in the database and the files on disk.
|
||||
files on its own disk. This page lists what each host wrote, but can only open the files
|
||||
belonging to the replica serving the request — another host's are listed and not
|
||||
readable — and a host's files go with it when it is replaced. Retention below still
|
||||
governs the entries in the database and the files on disk.
|
||||
</Alert>
|
||||
</div>
|
||||
{:else if !$enterpriseLicense}
|
||||
<div class="pb-4">
|
||||
<Alert type="info" title="Raw log files accumulate without the indexer" size="xs">
|
||||
Log files are uploaded to instance object storage, and the indexer that would ingest
|
||||
them into the columnar store and delete each one afterwards is an enterprise
|
||||
feature. Retention below expires the database entries and the local files; the
|
||||
uploaded copies are only removed when <b>Delete logs from s3 periodically</b> is on
|
||||
under Object Storage.
|
||||
them into the columnar store and delete each one afterwards is an enterprise feature.
|
||||
Retention below expires the database entries and the local files; the uploaded copies
|
||||
are only removed when <b>Delete logs from s3 periodically</b> is on under Object Storage.
|
||||
</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
AlertTriangle,
|
||||
ArrowDown,
|
||||
AtSign,
|
||||
BookOpen,
|
||||
ChevronDown,
|
||||
ChevronsRight,
|
||||
CheckIcon,
|
||||
@@ -15,6 +16,7 @@
|
||||
Folder,
|
||||
Hand,
|
||||
HistoryIcon,
|
||||
KeyRound,
|
||||
MousePointer2,
|
||||
Plug,
|
||||
Plus,
|
||||
@@ -34,6 +36,7 @@
|
||||
import ContextUsageIndicator from './ContextUsageIndicator.svelte'
|
||||
import AIChatModelSettings from './AIChatModelSettings.svelte'
|
||||
import McpConnections from './McpConnections.svelte'
|
||||
import SkillsPicker from './SkillsPicker.svelte'
|
||||
import ChatMode from './ChatMode.svelte'
|
||||
import DatatableCreationPolicy from './DatatableCreationPolicy.svelte'
|
||||
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
|
||||
@@ -60,9 +63,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 chatHost = getChatViewHost()
|
||||
|
||||
// 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.
|
||||
@@ -209,6 +225,7 @@
|
||||
|
||||
let aiChatInput: AIChatInput | undefined = $state()
|
||||
let mcpConnections: McpConnections | undefined = $state()
|
||||
let skillsPicker: SkillsPicker | undefined = $state()
|
||||
let plusMenuOpen = $state(false)
|
||||
let editingMessageIndex = $state<number | null>(null)
|
||||
|
||||
@@ -613,6 +630,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
|
||||
@@ -726,6 +781,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}
|
||||
@@ -750,6 +810,9 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
isLast={messageIndex === messages.length - 1}
|
||||
/>
|
||||
{/each}
|
||||
{#if freeTierExhausted}
|
||||
{@render freeTierExhaustedBanner()}
|
||||
{/if}
|
||||
{#if showTypingIndicator}
|
||||
<div
|
||||
class={twMerge(
|
||||
@@ -849,6 +912,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
|
||||
@@ -921,43 +987,64 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
{/if}
|
||||
{#if canAttachFiles}
|
||||
<DropdownV2
|
||||
items={async () => [
|
||||
{
|
||||
displayName: 'Attach file or image',
|
||||
icon: FileText,
|
||||
action: () => {
|
||||
plusMenuOpen = false
|
||||
linkFiles()
|
||||
}
|
||||
},
|
||||
...(canLinkFolders
|
||||
? [
|
||||
{
|
||||
// A real (live) link needs the File System Access API; without it the
|
||||
// folder is only snapshotted, so call it "Add folder", not "Link folder".
|
||||
displayName: canUseFsAccess ? 'Link folder' : 'Add folder',
|
||||
icon: Folder,
|
||||
tooltip: canUseFsAccess
|
||||
? 'Linked live — the assistant reads the folder’s current files from disk and refreshes each turn.'
|
||||
: 'Loaded as a snapshot — the folder’s files are copied into your browser (they won’t auto-update). For a live link that refreshes from disk, use a Chromium-based browser (Chrome, Edge).',
|
||||
action: () => {
|
||||
plusMenuOpen = false
|
||||
linkFolder()
|
||||
items={async () => {
|
||||
// Both submenus fetch on the menu's first open, so they start
|
||||
// together: awaited inline they queue, and the whole menu —
|
||||
// attachments included — waits out two round trips.
|
||||
const closeMenu = () => (plusMenuOpen = false)
|
||||
const inGlobal = chatHost.mode === AIMode.GLOBAL
|
||||
const [skillItems, mcpItems] = await Promise.all([
|
||||
inGlobal ? skillsPicker?.menuItems(closeMenu) : undefined,
|
||||
inGlobal ? mcpConnections?.menuItems(closeMenu) : undefined
|
||||
])
|
||||
return [
|
||||
{
|
||||
displayName: 'Attach file or image',
|
||||
icon: FileText,
|
||||
action: () => {
|
||||
plusMenuOpen = false
|
||||
linkFiles()
|
||||
}
|
||||
},
|
||||
...(canLinkFolders
|
||||
? [
|
||||
{
|
||||
// A real (live) link needs the File System Access API; without it the
|
||||
// folder is only snapshotted, so call it "Add folder", not "Link folder".
|
||||
displayName: canUseFsAccess ? 'Link folder' : 'Add folder',
|
||||
icon: Folder,
|
||||
tooltip: canUseFsAccess
|
||||
? 'Linked live — the assistant reads the folder’s current files from disk and refreshes each turn.'
|
||||
: 'Loaded as a snapshot — the folder’s files are copied into your browser (they won’t auto-update). For a live link that refreshes from disk, use a Chromium-based browser (Chrome, Edge).',
|
||||
action: () => {
|
||||
plusMenuOpen = false
|
||||
linkFolder()
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(chatHost.mode === AIMode.GLOBAL && mcpConnections
|
||||
? [
|
||||
{
|
||||
displayName: 'MCP connections',
|
||||
icon: Plug,
|
||||
separatorTop: true,
|
||||
submenuItems: await mcpConnections.menuItems(() => (plusMenuOpen = false))
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]}
|
||||
]
|
||||
: []),
|
||||
...(skillItems
|
||||
? [
|
||||
{
|
||||
displayName: 'Skills',
|
||||
icon: BookOpen,
|
||||
separatorTop: true,
|
||||
submenuItems: skillItems
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(mcpItems
|
||||
? [
|
||||
{
|
||||
displayName: 'MCP connections',
|
||||
icon: Plug,
|
||||
separatorTop: !skillItems,
|
||||
submenuItems: mcpItems
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
}}
|
||||
placement="bottom-start"
|
||||
fixedHeight={false}
|
||||
closeOnItemClick={false}
|
||||
@@ -1100,6 +1187,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
{/if}
|
||||
{@render footerSettings?.()}
|
||||
{#if chatHost.mode === AIMode.GLOBAL}
|
||||
<SkillsPicker bind:this={skillsPicker} />
|
||||
<McpConnections bind:this={mcpConnections} />
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { AIMode } from './AIChatManager.svelte'
|
||||
import { CHAT_INPUT_PADDING } from './aiChatManagerContext'
|
||||
import { getChatViewHost } from './chatViewHost'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import { formatMention } from './mention'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { tick, untrack, type Snippet } from 'svelte'
|
||||
@@ -746,6 +747,17 @@
|
||||
selectedContext = [...selectedContext, contextToAdd]
|
||||
}
|
||||
|
||||
/** Consume inside the submit gesture, never after it: the manager's preflight
|
||||
* awaits attachment upkeep and a session fork that can take seconds, and
|
||||
* consuming past them would hand this message a mention the user picked for
|
||||
* the next one. */
|
||||
function consumeMentionsIfGlobal() {
|
||||
if (chatHost.mode !== AIMode.GLOBAL) return
|
||||
// The mention context belongs to the copilot's own ContextManager, which only
|
||||
// the manager has — the GLOBAL guard above means this host is always it.
|
||||
getAiChatManager().contextManager?.consumeMentionContext()
|
||||
}
|
||||
|
||||
function sendRequest() {
|
||||
// The send button is disabled while decoding, but Enter reaches here directly.
|
||||
// Sending now would drop the in-flight attachments onto the following message.
|
||||
@@ -763,6 +775,10 @@
|
||||
])
|
||||
) {
|
||||
draft.take()
|
||||
// The answer carries only its choice strings, so a mention here rides
|
||||
// nothing — but clearForSend below keeps the selection, which would hand
|
||||
// it to the next turn.
|
||||
consumeMentionsIfGlobal()
|
||||
contextTextareaComponent?.clearForSend()
|
||||
return
|
||||
}
|
||||
@@ -786,6 +802,8 @@
|
||||
[...selectedContext],
|
||||
sent.files
|
||||
)
|
||||
// Consumed at enqueue, not at flush: the entry above pinned them.
|
||||
consumeMentionsIfGlobal()
|
||||
contextTextareaComponent?.clearForSend()
|
||||
}
|
||||
return
|
||||
@@ -806,18 +824,25 @@
|
||||
onEditEnd()
|
||||
} else {
|
||||
const sent = draft.take()
|
||||
// Pin before consuming: the manager falls back to the live selection only
|
||||
// when given no override, and the consume below empties it.
|
||||
const carried = chatHost.mode === AIMode.GLOBAL ? [...selectedContext] : undefined
|
||||
consumeMentionsIfGlobal()
|
||||
chatHost.sendRequest({
|
||||
instructions: sent.text,
|
||||
pastes: sent.pastes,
|
||||
images: sent.images,
|
||||
files: sent.files,
|
||||
blobs: sent.blobs
|
||||
blobs: sent.blobs,
|
||||
contextOverride: carried,
|
||||
contextOverrideOrigin: carried ? 'pinned' : undefined
|
||||
})
|
||||
// clearForSend() pre-zaps the textarea's mention-sync so the wipe
|
||||
// doesn't drop `selectedContext` before `AIChatManager.beforeSend`
|
||||
// snapshots it. Only mounted in SCRIPT/FLOW/GLOBAL — APP and the
|
||||
// fallback textarea still rely on the draft reset alone (no
|
||||
// `@`-mention state to coordinate).
|
||||
// clearForSend() pre-zaps the textarea's mention-sync so the wipe doesn't
|
||||
// drop `selectedContext` before the send has settled its context: the pin
|
||||
// above in GLOBAL, the manager's own read of the live selection in
|
||||
// SCRIPT/FLOW. Only mounted in SCRIPT/FLOW/GLOBAL — APP and the fallback
|
||||
// textarea still rely on the draft reset alone (no `@`-mention state to
|
||||
// coordinate).
|
||||
contextTextareaComponent?.clearForSend()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -94,6 +96,7 @@ import {
|
||||
createAppBackendRunnableContextElement,
|
||||
createAppFrontendFileContextElement,
|
||||
flattenDatatablesToAppContextElements,
|
||||
isMentionContext,
|
||||
isSameContextElement,
|
||||
type ContextElement,
|
||||
type AppDatatableElement
|
||||
@@ -366,6 +369,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 = {
|
||||
@@ -1132,9 +1154,10 @@ export class AIChatManager implements ChatViewHost {
|
||||
}
|
||||
}
|
||||
|
||||
// Workspace AI skills (name + description) advertised in the GLOBAL system
|
||||
// prompt and surfaced as slash commands in session chat. Loaded
|
||||
// asynchronously when entering GLOBAL mode; the system message is rebuilt
|
||||
// The `ai_skill` resources this user turned on for the operating workspace,
|
||||
// advertised in the GLOBAL system prompt and surfaced as slash commands in
|
||||
// session chat. Loaded asynchronously when entering GLOBAL mode and again
|
||||
// whenever the picker changes the selection; the system message is rebuilt
|
||||
// once they resolve.
|
||||
globalSkills = $state<AiSkillListItem[]>([])
|
||||
private globalSkillsRefreshId = 0
|
||||
@@ -1170,9 +1193,10 @@ export class AIChatManager implements ChatViewHost {
|
||||
]
|
||||
|
||||
// Built-ins followed by workspace skills, with any skill whose name collides
|
||||
// with a built-in dropped: the picker keys leaves by name, so a duplicate
|
||||
// would break its keyed list and ambiguous-resolve nav. Built-ins win — they
|
||||
// already shadow same-named skills at execution (the submit interception).
|
||||
// with a built-in dropped. Built-ins win — they already shadow same-named
|
||||
// skills at execution (the submit interception), so listing both would offer
|
||||
// a row that cannot run. Two skills may still share a name; the picker keys
|
||||
// those by path and the submit path declines to guess between them.
|
||||
sessionCommands: ChatCommandItem[] = $derived([
|
||||
...this.sessionBuiltinCommands,
|
||||
...this.globalSkills
|
||||
@@ -1851,6 +1875,25 @@ export class AIChatManager implements ChatViewHost {
|
||||
}
|
||||
}
|
||||
|
||||
/** Give back the mentions a send carried when its text returns to the composer,
|
||||
* so its `@` tokens still have entries to bind to. Additive, unlike the DOM
|
||||
* restore above: dropping the entries this send did not carry would strand the
|
||||
* tokens naming them in a draft whose text now shares the same composer.
|
||||
*
|
||||
* `originMode` is the mode the send was submitted in, and is required: the
|
||||
* composer only consumes in GLOBAL, so reading the mode at restore time would
|
||||
* strand a send whose mode changed mid-turn and resurrect chips for one that
|
||||
* never consumed. Every caller states which mode it means. */
|
||||
#restoreMentionContext(context: ContextElement[] | undefined, originMode: AIMode) {
|
||||
if (originMode !== AIMode.GLOBAL) return
|
||||
const mentions = (context ?? []).filter(isMentionContext)
|
||||
if (mentions.length === 0) return
|
||||
const selection = this.contextManager?.getSelectedContext() ?? []
|
||||
const missing = mentions.filter((m) => !selection.some((s) => isSameContextElement(s, m)))
|
||||
if (missing.length === 0) return
|
||||
this.contextManager?.setSelectedContext([...selection, ...missing])
|
||||
}
|
||||
|
||||
/** Send `text` as a turn, or queue it when one is already streaming. Callers
|
||||
* that send programmatically (an editor button, an arriving hand-off) must go
|
||||
* through this rather than `sendRequest`: a second concurrent loop shares this
|
||||
@@ -1883,6 +1926,10 @@ export class AIChatManager implements ChatViewHost {
|
||||
// is selected now. If its text was prepended onto an existing draft, that
|
||||
// draft's chips are kept too — both instructions now share one composer.
|
||||
this.#restoreDomContext(queued.context, mergedIntoDraft)
|
||||
// The queue aggregates several enqueues into one entry and records no
|
||||
// originating mode, so the mode now is the closest signal available. A
|
||||
// recall after a mid-turn mode switch can therefore miss a restore.
|
||||
this.#restoreMentionContext(queued.context, this.mode)
|
||||
}
|
||||
|
||||
/** Put what the user typed back where they can see it: into the input
|
||||
@@ -2110,7 +2157,11 @@ export class AIChatManager implements ChatViewHost {
|
||||
if (refreshId !== this.globalSkillsRefreshId) {
|
||||
return
|
||||
}
|
||||
this.globalSkills = skills
|
||||
// Newest-wins is not enough: a refresh for the workspace just left can still
|
||||
// hold the newest id, and installing it would advertise that workspace's
|
||||
// skills to a chat now acting elsewhere. Same check the identity and MCP
|
||||
// refreshes make.
|
||||
this.globalSkills = workspace === (this.operatingWorkspace ?? '') ? skills : []
|
||||
if (this.mode === AIMode.GLOBAL) {
|
||||
this.configureGlobalMode()
|
||||
}
|
||||
@@ -2198,16 +2249,25 @@ export class AIChatManager implements ChatViewHost {
|
||||
if (!this.isSessionChat || this.mode !== AIMode.GLOBAL || !instructions.startsWith('/')) {
|
||||
return instructions
|
||||
}
|
||||
const match = /^\/([a-z0-9-]+)(?:\s+([\s\S]*))?$/.exec(instructions)
|
||||
// Accepts a bare name or a whole resource path: names are what people type,
|
||||
// but the picker inserts the path when two folders answer to the same name.
|
||||
// Unicode-aware rather than `\w`, which is ASCII-only — a resource path may
|
||||
// hold any word character, and the picker can insert one the user must then
|
||||
// be able to send (`f/équipe/deploy`).
|
||||
const match = /^\/([\p{L}\p{N}_\-/]+)(?:\s+([\s\S]*))?$/u.exec(instructions)
|
||||
if (!match) {
|
||||
return instructions
|
||||
}
|
||||
const skill = this.globalSkills.find((s) => s.name === match[1])
|
||||
if (!skill) {
|
||||
// A path identifies one skill; a name shared by two would otherwise silently
|
||||
// apply instructions the user did not choose, so it is left unexpanded.
|
||||
const byPath = this.globalSkills.find((s) => s.path === match[1])
|
||||
const matches = byPath ? [byPath] : this.globalSkills.filter((s) => s.name === match[1])
|
||||
if (matches.length !== 1) {
|
||||
return instructions
|
||||
}
|
||||
const rest = match[2]?.trim()
|
||||
return rest ? `Use the "${skill.name}" skill. ${rest}` : `Use the "${skill.name}" skill.`
|
||||
const use = `Use the skill at "${matches[0].path}".`
|
||||
return rest ? `${use} ${rest}` : use
|
||||
}
|
||||
|
||||
canApplyCode = $derived(this.allowedModes.script && this.mode === AIMode.SCRIPT)
|
||||
@@ -2811,8 +2871,8 @@ export class AIChatManager implements ChatViewHost {
|
||||
lang?: ScriptLang | 'bunnative'
|
||||
isPreprocessor?: boolean
|
||||
// Use this selected-context snapshot for the turn instead of the live
|
||||
// contextManager. Set when flushing a queued message that captured its
|
||||
// context at submit time; the live selection is left untouched.
|
||||
// contextManager. Set whenever a send settles its context ahead of the
|
||||
// turn: a composer submit at the click, a queued message at enqueue.
|
||||
contextOverride?: ContextElement[]
|
||||
/** Where `contextOverride` came from. 'pinned' (default): the chips were
|
||||
* selected for THIS message, so they are consumed from the live selection
|
||||
@@ -2962,12 +3022,18 @@ export class AIChatManager implements ChatViewHost {
|
||||
// re-reserves them, so release this send's outgoing-files reservation.
|
||||
this.#releaseOutgoingReservation(reservationKey)
|
||||
if (!options.queued) {
|
||||
this.aiChatInput?.restoreInstructions(
|
||||
this.instructions,
|
||||
pastes,
|
||||
options.images ?? [],
|
||||
options.files ?? []
|
||||
)
|
||||
// Reached only once the mode has already moved off GLOBAL, so the
|
||||
// restore is keyed to requestedMode: a GLOBAL submit whose mode
|
||||
// flipped during the upkeep awaits above still gets its mentions
|
||||
// back, while a send that started outside GLOBAL consumed none.
|
||||
const taken =
|
||||
this.aiChatInput?.restoreInstructions(
|
||||
this.instructions,
|
||||
pastes,
|
||||
options.images ?? [],
|
||||
options.files ?? []
|
||||
) === true
|
||||
if (taken) this.#restoreMentionContext(options.contextOverride, requestedMode)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -2988,6 +3054,11 @@ export class AIChatManager implements ChatViewHost {
|
||||
// put them back in the composer instead of silently discarding them
|
||||
// (the input already cleared itself optimistically on send). Queued
|
||||
// drafts are the caller's to restore (it re-queues on false).
|
||||
//
|
||||
// No mention restore: an entry exists only while its `@` token is in the
|
||||
// text (the picker adds both, the textarea's sync drops the entry when
|
||||
// the token goes), and this branch requires empty text. A mention source
|
||||
// that does not write a token would break that and need one here.
|
||||
if (!this.instructions.trim() && files.length === 0) {
|
||||
sendUserToast(`${sendModel.model} can't read images. Switch to a vision model first.`, true)
|
||||
if (!options.queued) this.restoreToInput('', requestedImages)
|
||||
@@ -3044,7 +3115,13 @@ export class AIChatManager implements ChatViewHost {
|
||||
console.error('AIChatManager beforeSend hook failed', e)
|
||||
rollbackOptimisticSend()
|
||||
if (!options.queued) {
|
||||
this.aiChatInput?.restoreInstructions(this.instructions, pastes, images, files)
|
||||
// Mentions were consumed at submit, so they come back with the text or
|
||||
// not at all. Only when the composer took it: it declines when the
|
||||
// user has started a new draft, and restoring then would put these
|
||||
// mentions on that draft and every turn after it.
|
||||
const taken =
|
||||
this.aiChatInput?.restoreInstructions(this.instructions, pastes, images, files) === true
|
||||
if (taken) this.#restoreMentionContext(options.contextOverride, requestedMode)
|
||||
}
|
||||
sendUserToast(
|
||||
`Could not prepare the session before sending: ${
|
||||
@@ -3084,7 +3161,11 @@ export class AIChatManager implements ChatViewHost {
|
||||
})
|
||||
if (accepted === false) this.#restoreQueue(next)
|
||||
} else {
|
||||
this.aiChatInput?.restoreInstructions(this.instructions, pastes, images, files)
|
||||
// Same pairing as the beforeSend catch above: mentions ride back with the
|
||||
// text, only if the composer took it.
|
||||
const taken =
|
||||
this.aiChatInput?.restoreInstructions(this.instructions, pastes, images, files) === true
|
||||
if (taken) this.#restoreMentionContext(options.contextOverride, requestedMode)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -3179,9 +3260,10 @@ export class AIChatManager implements ChatViewHost {
|
||||
hideTarget?.removeEventListener('visibilitychange', checkpointOnHide)
|
||||
}
|
||||
try {
|
||||
// A queued message carries its own context snapshot (contextOverride); use
|
||||
// it verbatim and leave the live selection alone (it belongs to whatever the
|
||||
// user has selected since). Otherwise read the current selection.
|
||||
// A pinned snapshot (a queued message, or a composer submit settling its
|
||||
// context at the click) is used verbatim, leaving the live selection alone —
|
||||
// it belongs to whatever the user has selected since. Otherwise read the
|
||||
// current selection.
|
||||
const oldSelectedContext =
|
||||
options.contextOverride ?? this.contextManager?.getSelectedContext() ?? []
|
||||
// DOM selector chips are one-shot: they ride with this message (captured in
|
||||
@@ -3192,9 +3274,9 @@ export class AIChatManager implements ChatViewHost {
|
||||
// context, consumed on its original send. The live selection belongs to
|
||||
// the composer's own draft — touching it here would strip it.
|
||||
} else if (options.contextOverride) {
|
||||
// Queued message: only the chips it carried are consumed. Drop just
|
||||
// those from the live selection (still there if the user didn't
|
||||
// re-select); a newer selection made since is left intact.
|
||||
// A pinned submit consumes only the chips it carried. Drop just those
|
||||
// from the live selection (still there if the user didn't re-select); a
|
||||
// newer selection made since is left intact.
|
||||
for (const c of options.contextOverride) {
|
||||
if (c.type === 'app_dom_selector') {
|
||||
// Match appPath too: another app's live chip can share this
|
||||
@@ -3613,6 +3695,7 @@ export class AIChatManager implements ChatViewHost {
|
||||
// retarget whatever draft is sitting there.
|
||||
if (textRestored) {
|
||||
this.#restoreDomContext(oldSelectedContext)
|
||||
this.#restoreMentionContext(oldSelectedContext, requestedMode)
|
||||
}
|
||||
if (this.displayMessages.length === 0) {
|
||||
// saveChat no-ops on an empty transcript; the chat persisted earlier
|
||||
@@ -3728,6 +3811,9 @@ export class AIChatManager implements ChatViewHost {
|
||||
// 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
|
||||
|
||||
@@ -33,7 +33,7 @@ const mocks = vi.hoisted(() => ({
|
||||
getAnthropicClient: vi.fn(),
|
||||
getNonStreamingCompletion: vi.fn(),
|
||||
runChatLoop: vi.fn(),
|
||||
listAiSkills: vi.fn(),
|
||||
listResource: vi.fn(),
|
||||
getJob: vi.fn(),
|
||||
whoami: vi.fn(),
|
||||
workspace: 'test_workspace' as string | undefined,
|
||||
@@ -49,8 +49,9 @@ vi.mock('monaco-editor', () => ({
|
||||
vi.mock('$lib/utils/featureUsage', () => ({ logFeatureUsage: vi.fn() }))
|
||||
|
||||
vi.mock('$lib/gen', () => ({
|
||||
WorkspaceService: {
|
||||
listAiSkills: mocks.listAiSkills
|
||||
WorkspaceService: {},
|
||||
ResourceService: {
|
||||
listResource: mocks.listResource
|
||||
},
|
||||
ScriptService: {},
|
||||
FlowService: {},
|
||||
@@ -169,7 +170,7 @@ beforeEach(() => {
|
||||
mocks.isWebSearchEnabledForProvider.mockReturnValue(true)
|
||||
mocks.getOpenaiClient.mockReturnValue({})
|
||||
mocks.getAnthropicClient.mockReturnValue({})
|
||||
mocks.listAiSkills.mockResolvedValue([])
|
||||
mocks.listResource.mockResolvedValue([])
|
||||
mocks.workspace = 'test_workspace'
|
||||
mocks.runChatLoop.mockResolvedValue({
|
||||
addedMessages: [],
|
||||
@@ -330,17 +331,29 @@ describe('AIChatManager global skills', () => {
|
||||
mocks.tryGetCurrentModel.mockReturnValue(model)
|
||||
})
|
||||
|
||||
// Only selected skills reach the prompt, and the selection is keyed by
|
||||
// workspace and account (see skills/enabledSkills.ts).
|
||||
function selectSkills(workspace: string, ...paths: string[]) {
|
||||
const stored = JSON.parse(localStorage.getItem('wm_skills_enabled') ?? '{}')
|
||||
stored[`${workspace}:${TEST_EMAIL}`] = paths
|
||||
localStorage.setItem('wm_skills_enabled', JSON.stringify(stored))
|
||||
}
|
||||
|
||||
it('loads skills after beforeSend commits the session workspace', async () => {
|
||||
let resolveParentSkills: ((skills: { name: string; description: string }[]) => void) | undefined
|
||||
const parentSkills = new Promise<{ name: string; description: string }[]>((resolve) => {
|
||||
let resolveParentSkills: ((skills: unknown[]) => void) | undefined
|
||||
const parentSkills = new Promise<unknown[]>((resolve) => {
|
||||
resolveParentSkills = resolve
|
||||
})
|
||||
mocks.workspace = 'parent'
|
||||
mocks.listAiSkills.mockImplementation(({ workspace }: { workspace: string }) => {
|
||||
selectSkills('parent', 'f/skills/parent-skill')
|
||||
selectSkills('child', 'f/skills/child-skill')
|
||||
mocks.listResource.mockImplementation(({ workspace }: { workspace: string }) => {
|
||||
if (workspace === 'parent') {
|
||||
return parentSkills
|
||||
}
|
||||
return Promise.resolve([{ name: 'child-skill', description: 'child workspace skill' }])
|
||||
return Promise.resolve([
|
||||
{ path: 'f/skills/child-skill', description: 'child workspace skill' }
|
||||
])
|
||||
})
|
||||
mocks.runChatLoop.mockImplementation(async (config: any) => {
|
||||
expect(config.workspace).toBe('child')
|
||||
@@ -362,22 +375,45 @@ describe('AIChatManager global skills', () => {
|
||||
}
|
||||
|
||||
await manager.sendRequest({ instructions: 'first', mode: AIMode.GLOBAL })
|
||||
resolveParentSkills?.([{ name: 'parent-skill', description: 'parent workspace skill' }])
|
||||
resolveParentSkills?.([
|
||||
{ path: 'f/skills/parent-skill', description: 'parent workspace skill' }
|
||||
])
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mocks.listAiSkills).toHaveBeenCalledWith({ workspace: 'parent' })
|
||||
expect(mocks.listAiSkills).toHaveBeenCalledWith({ workspace: 'child' })
|
||||
expect(mocks.listResource).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ workspace: 'parent', resourceType: 'ai_skill' })
|
||||
)
|
||||
expect(mocks.listResource).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ workspace: 'child', resourceType: 'ai_skill' })
|
||||
)
|
||||
expect(manager.systemMessage.content).toContain('child-skill')
|
||||
expect(manager.systemMessage.content).not.toContain('parent-skill')
|
||||
})
|
||||
|
||||
it('expands a leading slash skill command for the model while preserving the displayed text', async () => {
|
||||
mocks.listAiSkills.mockResolvedValue([
|
||||
{ name: 'review-code', description: 'review code for bugs' }
|
||||
it('leaves a readable but unselected skill out of the prompt', async () => {
|
||||
mocks.listResource.mockResolvedValue([
|
||||
{ path: 'f/skills/selected', description: 'the one turned on' },
|
||||
{ path: 'f/skills/unselected', description: 'readable but never turned on' }
|
||||
])
|
||||
selectSkills('test_workspace', 'f/skills/selected')
|
||||
|
||||
const manager = new AIChatManager()
|
||||
manager.isSessionChat = true
|
||||
await manager.refreshGlobalSkills('test_workspace')
|
||||
await manager.changeMode(AIMode.GLOBAL)
|
||||
|
||||
expect(manager.systemMessage.content).toContain('f/skills/selected')
|
||||
expect(manager.systemMessage.content).not.toContain('f/skills/unselected')
|
||||
})
|
||||
|
||||
it('expands a leading slash skill command for the model while preserving the displayed text', async () => {
|
||||
mocks.listResource.mockResolvedValue([
|
||||
{ path: 'u/admin/review-code', description: 'review code for bugs' }
|
||||
])
|
||||
selectSkills('test_workspace', 'u/admin/review-code')
|
||||
mocks.runChatLoop.mockImplementation(async (config: any) => {
|
||||
const userMessage = config.messages[config.messages.length - 1]
|
||||
expect(userMessage.content).toContain('Use the "review-code" skill. find bugs')
|
||||
expect(userMessage.content).toContain('Use the skill at "u/admin/review-code". find bugs')
|
||||
expect(userMessage.content).not.toContain('/review-code find bugs')
|
||||
const message = { role: 'assistant' as const, content: 'done' }
|
||||
config.addedMessages?.push(message)
|
||||
@@ -395,6 +431,33 @@ describe('AIChatManager global skills', () => {
|
||||
|
||||
expect(manager.displayMessages[0]?.content).toBe('/review-code find bugs')
|
||||
})
|
||||
|
||||
it('does not expand a slash command two folders both answer to', async () => {
|
||||
mocks.listResource.mockResolvedValue([
|
||||
{ path: 'u/admin/deploy', description: 'personal deploy steps' },
|
||||
{ path: 'f/team/deploy', description: 'the team deploy steps' }
|
||||
])
|
||||
selectSkills('test_workspace', 'u/admin/deploy', 'f/team/deploy')
|
||||
mocks.runChatLoop.mockImplementation(async (config: any) => {
|
||||
// Picking either one would silently apply instructions the user did not
|
||||
// choose, so the text is left alone for the model to ask about.
|
||||
const userMessage = config.messages[config.messages.length - 1]
|
||||
expect(userMessage.content).toContain('/deploy ship it')
|
||||
expect(userMessage.content).not.toContain('Use the skill at')
|
||||
const message = { role: 'assistant' as const, content: 'done' }
|
||||
config.addedMessages?.push(message)
|
||||
return {
|
||||
addedMessages: [message],
|
||||
tokenUsage: { prompt: 0, completion: 0, total: 0 },
|
||||
hitMaxIterations: false
|
||||
}
|
||||
})
|
||||
|
||||
const manager = new AIChatManager()
|
||||
manager.isSessionChat = true
|
||||
|
||||
await manager.sendRequest({ instructions: '/deploy ship it', mode: AIMode.GLOBAL })
|
||||
})
|
||||
})
|
||||
|
||||
describe('AIChatManager global prompt identity', () => {
|
||||
@@ -404,7 +467,7 @@ describe('AIChatManager global prompt identity', () => {
|
||||
localStorage.clear()
|
||||
mocks.getCurrentModel.mockReturnValue(model)
|
||||
mocks.tryGetCurrentModel.mockReturnValue(model)
|
||||
mocks.listAiSkills.mockResolvedValue([])
|
||||
mocks.listResource.mockResolvedValue([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -2164,6 +2227,98 @@ describe('AIChatManager queued messages', () => {
|
||||
expect(chips.map((c) => c.selector)).toEqual(['div.card'])
|
||||
})
|
||||
|
||||
// The composer consumes an `@` mention on send, so a send that never became a
|
||||
// turn has to give it back — otherwise the restored text keeps its `@` token
|
||||
// with nothing behind it.
|
||||
it('restores a cancelled GLOBAL send’s @ mentions', async () => {
|
||||
const manager = createManager(createInputMock())
|
||||
manager.mode = AIMode.GLOBAL
|
||||
const cm = manager.contextManager
|
||||
const mention = {
|
||||
type: 'workspace_script' as const,
|
||||
path: 'f/etl/sync',
|
||||
title: 'f/etl/sync'
|
||||
}
|
||||
// What the composer does at submit: pin what it carries, then consume.
|
||||
const carried = [mention]
|
||||
cm.setSelectedContext([])
|
||||
mocks.runChatLoop.mockImplementationOnce(async ({ abortController }: any) => {
|
||||
abortController.abort('user_cancelled')
|
||||
throw new Error('aborted')
|
||||
})
|
||||
|
||||
await manager.sendRequest({
|
||||
instructions: 'why does this retry',
|
||||
contextOverride: carried,
|
||||
contextOverrideOrigin: 'pinned'
|
||||
})
|
||||
|
||||
expect(cm.getSelectedContext()).toEqual([mention])
|
||||
})
|
||||
|
||||
// The mode switcher stays live while a turn streams, so the restore is keyed
|
||||
// to the mode the send was submitted in. Reading the mode at rollback time
|
||||
// would strand the mention behind a `@` token that resolves to nothing.
|
||||
it('restores a GLOBAL send’s @ mentions after a mid-turn switch to SCRIPT', async () => {
|
||||
const manager = createManager(createInputMock())
|
||||
manager.mode = AIMode.GLOBAL
|
||||
const cm = manager.contextManager
|
||||
const mention = { type: 'workspace_script' as const, path: 'f/etl/sync', title: 'f/etl/sync' }
|
||||
cm.setSelectedContext([])
|
||||
mocks.runChatLoop.mockImplementationOnce(async ({ abortController }: any) => {
|
||||
// The user navigates to a script editor while the turn is streaming.
|
||||
manager.mode = AIMode.SCRIPT
|
||||
abortController.abort('user_cancelled')
|
||||
throw new Error('aborted')
|
||||
})
|
||||
|
||||
await manager.sendRequest({
|
||||
instructions: 'why does this retry',
|
||||
contextOverride: [mention],
|
||||
contextOverrideOrigin: 'pinned'
|
||||
})
|
||||
|
||||
expect(cm.getSelectedContext()).toEqual([mention])
|
||||
})
|
||||
|
||||
// Attachments are refused outside GLOBAL, and the refusal sits past the
|
||||
// preflight awaits — so it is reached exactly when a GLOBAL submit's mode
|
||||
// changed underneath it, and owes that send its mentions back.
|
||||
it('restores mentions when a GLOBAL send is refused for switching modes with attachments', async () => {
|
||||
const manager = createManager(createInputMock())
|
||||
manager.mode = AIMode.GLOBAL
|
||||
const cm = manager.contextManager
|
||||
const mention = { type: 'workspace_script' as const, path: 'f/etl/sync', title: 'f/etl/sync' }
|
||||
cm.setSelectedContext([])
|
||||
|
||||
const pending = manager.sendRequest({
|
||||
instructions: 'describe this image',
|
||||
images: [{ id: 'img-1', dataUrl: 'data:image/png;base64,AAAA' } as any],
|
||||
contextOverride: [mention],
|
||||
contextOverrideOrigin: 'pinned'
|
||||
})
|
||||
manager.mode = AIMode.SCRIPT
|
||||
await pending
|
||||
|
||||
expect(cm.getSelectedContext()).toEqual([mention])
|
||||
})
|
||||
|
||||
// The editor modes show mentions as chips the user deletes by hand, so the
|
||||
// restore must never re-add one they removed.
|
||||
it('leaves an editor mode’s context alone when a queued draft comes back', () => {
|
||||
const manager = createManager(createInputMock())
|
||||
manager.mode = AIMode.SCRIPT
|
||||
const cm = manager.contextManager
|
||||
const mention = { type: 'workspace_script' as const, path: 'f/etl/sync', title: 'f/etl/sync' }
|
||||
manager.queueMessage('fix this', [], [mention])
|
||||
// The user deletes the chip while the turn streams.
|
||||
cm.setSelectedContext([])
|
||||
|
||||
manager.dequeueMessage()
|
||||
|
||||
expect(cm.getSelectedContext()).toEqual([])
|
||||
})
|
||||
|
||||
it('restores a dequeued inline prompt’s pinned DOM context, replacing the live selection', () => {
|
||||
const manager = createManager(createInputMock())
|
||||
const cm = manager.contextManager
|
||||
@@ -2281,6 +2436,90 @@ describe('AIChatManager queued messages', () => {
|
||||
expect(manager.displayMessages).toHaveLength(0)
|
||||
})
|
||||
|
||||
// The composer consumes mentions before calling in, so a send that never left
|
||||
// the preflight has to give them back with the text. GLOBAL renders no chip for
|
||||
// them, so a mention lost here is invisible: the restored `@` token silently
|
||||
// resolves to nothing, and a mention-only draft comes back empty.
|
||||
it('restores consumed mentions when beforeSend rejects a GLOBAL send', async () => {
|
||||
const input = createInputMock()
|
||||
const manager = createManager(input)
|
||||
manager.mode = AIMode.GLOBAL
|
||||
const mention = { type: 'workspace_script' as const, path: 'f/etl/sync', title: 'f/etl/sync' }
|
||||
manager.contextManager.setSelectedContext([])
|
||||
manager.beforeSend = vi.fn().mockRejectedValue(new Error('workspace fork failed'))
|
||||
|
||||
const accepted = await manager.sendRequest({
|
||||
instructions: '@f/etl/sync fix this',
|
||||
contextOverride: [mention],
|
||||
contextOverrideOrigin: 'pinned'
|
||||
})
|
||||
|
||||
expect(accepted).toBe(false)
|
||||
expect(manager.contextManager.getSelectedContext()).toEqual([mention])
|
||||
})
|
||||
|
||||
it('restores consumed mentions when a GLOBAL send is cancelled during the preflight', async () => {
|
||||
const input = createInputMock()
|
||||
const manager = createManager(input)
|
||||
manager.mode = AIMode.GLOBAL
|
||||
const mention = { type: 'workspace_script' as const, path: 'f/etl/sync', title: 'f/etl/sync' }
|
||||
manager.contextManager.setSelectedContext([])
|
||||
// Stop/Escape while "Creating workspace fork..." is showing.
|
||||
manager.beforeSend = vi.fn().mockImplementation(async () => {
|
||||
manager.cancel('user_cancelled')
|
||||
})
|
||||
|
||||
await manager.sendRequest({
|
||||
instructions: '@f/etl/sync fix this',
|
||||
contextOverride: [mention],
|
||||
contextOverrideOrigin: 'pinned'
|
||||
})
|
||||
|
||||
expect(mocks.runChatLoop).not.toHaveBeenCalled()
|
||||
expect(manager.contextManager.getSelectedContext()).toEqual([mention])
|
||||
})
|
||||
|
||||
// The gate, and the reason the restore is not unconditional: an occupied
|
||||
// composer declines the dead draft's text, and its mentions would then ride the
|
||||
// draft the user is writing now and every turn after it.
|
||||
it('does not restore mentions into a draft the composer kept', async () => {
|
||||
const input = createInputMock()
|
||||
input.restoreInstructions.mockReturnValue(false)
|
||||
const manager = createManager(input)
|
||||
manager.mode = AIMode.GLOBAL
|
||||
const mention = { type: 'workspace_script' as const, path: 'f/etl/sync', title: 'f/etl/sync' }
|
||||
manager.contextManager.setSelectedContext([])
|
||||
manager.beforeSend = vi.fn().mockRejectedValue(new Error('workspace fork failed'))
|
||||
|
||||
await manager.sendRequest({
|
||||
instructions: '@f/etl/sync fix this',
|
||||
contextOverride: [mention],
|
||||
contextOverrideOrigin: 'pinned'
|
||||
})
|
||||
|
||||
expect(manager.contextManager.getSelectedContext()).toEqual([])
|
||||
})
|
||||
|
||||
// The bit-identity tripwire: an editor mode reaches these same bailouts with a
|
||||
// contextOverride (a replay, a queued flush) and must come out of them with the
|
||||
// selection main would have left.
|
||||
it('leaves an editor mode’s selection untouched through the same bailout', async () => {
|
||||
const input = createInputMock()
|
||||
const manager = createManager(input)
|
||||
manager.mode = AIMode.SCRIPT
|
||||
const mention = { type: 'workspace_script' as const, path: 'f/etl/sync', title: 'f/etl/sync' }
|
||||
manager.contextManager.setSelectedContext([])
|
||||
manager.beforeSend = vi.fn().mockRejectedValue(new Error('workspace fork failed'))
|
||||
|
||||
await manager.sendRequest({
|
||||
instructions: 'fix this',
|
||||
contextOverride: [mention],
|
||||
contextOverrideOrigin: 'pinned'
|
||||
})
|
||||
|
||||
expect(manager.contextManager.getSelectedContext()).toEqual([])
|
||||
})
|
||||
|
||||
it('drops the queued message when switching conversations (no cross-chat leak)', async () => {
|
||||
const manager = createManager(createInputMock())
|
||||
|
||||
@@ -2923,8 +3162,8 @@ describe('AIChatManager manual compaction', () => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getCurrentModel.mockReturnValue(model)
|
||||
mocks.tryGetCurrentModel.mockReturnValue(model)
|
||||
// changeMode(GLOBAL) refreshes workspace skills; keep it a no-op here.
|
||||
mocks.listAiSkills.mockResolvedValue([])
|
||||
// changeMode(GLOBAL) refreshes the selected skills; keep it a no-op here.
|
||||
mocks.listResource.mockResolvedValue([])
|
||||
})
|
||||
|
||||
function seedExchange(manager: AIChatManager) {
|
||||
@@ -3139,15 +3378,19 @@ describe('AIChatManager manual compaction', () => {
|
||||
expect(mocks.getNonStreamingCompletion).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shadows a workspace skill that collides with a built-in command', () => {
|
||||
it('shadows a selected skill that collides with a built-in command', () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.globalSkills = [
|
||||
{ name: 'compact', description: 'a workspace skill that happens to be named compact' },
|
||||
{ name: 'review-code', description: 'review code for bugs' }
|
||||
{
|
||||
path: 'u/admin/compact',
|
||||
name: 'compact',
|
||||
description: 'a skill that happens to be named compact'
|
||||
},
|
||||
{ path: 'u/admin/review-code', name: 'review-code', description: 'review code for bugs' }
|
||||
]
|
||||
|
||||
// Built-ins come first and the colliding skill is dropped, so the picker
|
||||
// never renders two leaves with the same `skill:compact` key.
|
||||
// Built-ins come first and the colliding skill is dropped: the built-in
|
||||
// wins at execution too, so listing both would offer a row that cannot run.
|
||||
const names = manager.sessionCommands.map((c) => c.name)
|
||||
expect(names).toEqual(['compact', 'clear', 'review-code'])
|
||||
expect(manager.sessionCommands[0].description).toBe(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import DrillPicker from '$lib/components/DrillPicker.svelte'
|
||||
import type { DrillLeaf, DrillNode } from '$lib/components/drillPicker'
|
||||
import type { ChatCommandItem } from './global/core'
|
||||
import { ambiguousSkillNames } from './skills/skillResources'
|
||||
|
||||
interface Props {
|
||||
skills: ChatCommandItem[]
|
||||
@@ -24,15 +25,22 @@
|
||||
skill: 'Skills'
|
||||
}
|
||||
|
||||
// No `secondary`: rows show just the command; the full description lives in
|
||||
// the hover tooltip (rowTooltip below). It stays in `searchableText` so
|
||||
// filtering by description keeps working.
|
||||
// Two folders can each hold a skill of the same name, and `/name` cannot then
|
||||
// say which one is meant. Those rows show their path so the two are at least
|
||||
// distinguishable; unambiguous rows stay bare, with the description in the
|
||||
// hover tooltip (rowTooltip below) and in `searchableText` so filtering by it
|
||||
// keeps working.
|
||||
const ambiguous = $derived(ambiguousSkillNames(skills.filter((s) => s.path !== undefined)))
|
||||
|
||||
const tree = $derived<DrillNode<ChatCommandItem>[]>(
|
||||
skills.map((skill) => ({
|
||||
type: 'leaf' as const,
|
||||
key: `skill:${skill.name}`,
|
||||
// Keyed by path where there is one: names are not unique across folders,
|
||||
// and a duplicate key breaks the keyed list and its ambiguous-resolve nav.
|
||||
key: `skill:${skill.path ?? skill.name}`,
|
||||
label: `/${skill.name}`,
|
||||
searchableText: `${skill.name} ${skill.description}`,
|
||||
secondary: ambiguous.has(skill.name) ? skill.path : undefined,
|
||||
searchableText: `${skill.name} ${skill.path ?? ''} ${skill.description}`,
|
||||
section: skill.kind ? SECTION_LABELS[skill.kind] : undefined,
|
||||
data: skill
|
||||
}))
|
||||
|
||||
@@ -2,7 +2,12 @@ import { ResourceService, type Flow, type ListResourceResponse, type ScriptLang
|
||||
import { scriptLangToEditorLang } from '$lib/scripts'
|
||||
import { SQLSchemaLanguages, type DBSchemas } from '$lib/stores'
|
||||
import { diffLines } from 'diff'
|
||||
import { createAppDomSelectorElement, type ContextElement, type FlowModuleElement } from './context'
|
||||
import {
|
||||
createAppDomSelectorElement,
|
||||
isMentionContext,
|
||||
type ContextElement,
|
||||
type FlowModuleElement
|
||||
} from './context'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
|
||||
import type { DisplayMessage } from './shared'
|
||||
@@ -330,6 +335,13 @@ export default class ContextManager {
|
||||
this.selectedContext = newSelectedContext
|
||||
}
|
||||
|
||||
/** Callers must gate on mode: only a GLOBAL send owns its mentions. Outside
|
||||
* GLOBAL these are chips the user removes by hand, and dropping them here
|
||||
* would delete a selection they still expect to see. */
|
||||
consumeMentionContext() {
|
||||
this.selectedContext = this.selectedContext.filter((c) => !isMentionContext(c))
|
||||
}
|
||||
|
||||
getAvailableContext() {
|
||||
return this.availableContext
|
||||
}
|
||||
|
||||
@@ -575,7 +575,10 @@
|
||||
|
||||
function getCommandFilter(text: string): string | undefined {
|
||||
if (aiChatManager.mode !== AIMode.GLOBAL || !aiChatManager.isSessionChat) return undefined
|
||||
const match = /^\/([a-z0-9-]*)$/.exec(text)
|
||||
// Same character set the submit path expands, so a name the picker can insert
|
||||
// does not close the picker as soon as it is typed. Paths reach here too, via
|
||||
// the row inserted for an ambiguous name.
|
||||
const match = /^\/([\p{L}\p{N}_\-/]*)$/u.exec(text)
|
||||
return match?.[1]
|
||||
}
|
||||
|
||||
@@ -640,8 +643,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleCommandSelection(skill: { name: string }) {
|
||||
value = `/${skill.name} `
|
||||
function handleCommandSelection(skill: { name: string; path?: string }) {
|
||||
// The picker lists a row per skill, so two folders holding the same name are
|
||||
// two distinct rows — but `/name` could not say which one was clicked, and
|
||||
// submission refuses to guess. Those insert the path the row stands for.
|
||||
const ambiguous = commandSkills.filter((c) => c.name === skill.name).length > 1
|
||||
value = `/${ambiguous && skill.path ? skill.path : skill.name} `
|
||||
showCommandTooltip = false
|
||||
setTimeout(() => textarea?.focus(), 0)
|
||||
}
|
||||
@@ -736,9 +743,9 @@
|
||||
textarea?.focus()
|
||||
}
|
||||
|
||||
// Wipe after dispatching a send: pre-zero `prevMentionedTitles` so the
|
||||
// effect above sees no diff when `value` clears, leaving `selectedContext`
|
||||
// untouched until `AIChatManager.beforeSend` snapshots it. A manual
|
||||
// Wipe after dispatching a send: pre-zero `prevMentionedTitles` so the effect
|
||||
// above sees no diff when `value` clears, leaving `selectedContext` for the
|
||||
// send that is already carrying it to settle (see the caller). A manual
|
||||
// textarea clear by the user keeps the old behaviour (badges drop).
|
||||
export function clearForSend() {
|
||||
prevMentionedTitles = new Set()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { getKnownModelContextWindow, getModelContextWindow } from '../modelConfig'
|
||||
import { getChatViewHost } from './chatViewHost'
|
||||
import { AIMode } from './AIChatManager.svelte'
|
||||
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
|
||||
import UsageMeter from './UsageMeter.svelte'
|
||||
import { formatTokenCount } from './tokenUsage'
|
||||
|
||||
const chatHost = getChatViewHost()
|
||||
@@ -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,857 @@
|
||||
<script lang="ts">
|
||||
import { Alert, Button, Drawer } from '$lib/components/common'
|
||||
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import Modal2 from '$lib/components/common/modal/Modal2.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
import FileInput from '$lib/components/common/fileInput/FileInput.svelte'
|
||||
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
|
||||
import Markdown from 'svelte-exmarkdown'
|
||||
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
|
||||
import { markdownProse } from '$lib/components/markdownProse'
|
||||
import { workspaceStore, userStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { logFeatureUsage } from '$lib/utils/featureUsage'
|
||||
import type { Item } from '$lib/utils'
|
||||
import { untrack } from 'svelte'
|
||||
import {
|
||||
BookOpen,
|
||||
ClipboardPaste,
|
||||
Eye,
|
||||
List,
|
||||
Pencil,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
Trash2
|
||||
} from 'lucide-svelte'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import { isSkillEnabled, setSkillEnabled } from './skills/enabledSkills'
|
||||
import {
|
||||
ambiguousSkillNames,
|
||||
deleteSkillResource,
|
||||
listSkillResources,
|
||||
readSkillBody,
|
||||
saveSkillResource,
|
||||
updateSkillResource,
|
||||
type SkillResource
|
||||
} from './skills/skillResources'
|
||||
import {
|
||||
buildSkillMd,
|
||||
parseAndValidateSkill,
|
||||
parseSkillMd,
|
||||
type SkillUpload
|
||||
} from './skills/skillMd'
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
|
||||
// A session chat operates on its own (possibly forked) workspace without
|
||||
// switching `workspaceStore`, and that is the workspace the chat reads the
|
||||
// enabled set under. Key everything here the same way or a toggle lands under
|
||||
// a key nothing reads.
|
||||
//
|
||||
// `operatingWorkspace` is a plain getter over untracked state, so the store is
|
||||
// read unconditionally rather than behind `??`: short-circuiting it would leave
|
||||
// this derived with no dependency at all, frozen on the workspace it first saw.
|
||||
let ws = $derived.by(() => {
|
||||
const active = $workspaceStore
|
||||
return aiChatManager.operatingWorkspace ?? active!
|
||||
})
|
||||
|
||||
// A session whose fork is still staged has no workspace of its own yet, so `ws`
|
||||
// resolves to the PARENT. Authoring through the picker would then edit the live
|
||||
// parent, and a toggle would be stored under it and quietly stop applying the
|
||||
// moment the first send commits the fork. Read at use, not once: the fork
|
||||
// commits mid-session.
|
||||
function pendingForkParent(): string | undefined {
|
||||
return aiChatManager.sessionContextResolver?.()?.pendingForkOf
|
||||
}
|
||||
let forkPending = $state(false)
|
||||
function refreshForkPending() {
|
||||
forkPending = pendingForkParent() !== undefined
|
||||
}
|
||||
/** Guards every mutating action. Returns true when the caller must not proceed. */
|
||||
function blockedByPendingFork(): boolean {
|
||||
const parent = pendingForkParent()
|
||||
if (parent === undefined) return false
|
||||
refreshForkPending()
|
||||
sendUserToast(
|
||||
`This session has not created its workspace yet, so a skill would be written to "${parent}" instead. Send a message first.`,
|
||||
true
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
// `<root>/<skill>/SKILL.md` is 3 path segments; SKILL.md files nested deeper
|
||||
// are likely vendored/incidental and are skipped so importing a parent dir
|
||||
// doesn't sweep in unrelated skills.
|
||||
const MAX_SKILL_DEPTH = 3
|
||||
const MAX_SKILLS_PER_IMPORT = 50
|
||||
/** Stands in for the name when only the description and body are being checked. */
|
||||
const VALID_NAME_PLACEHOLDER = 'placeholder'
|
||||
// A new skill opens on this as real, editable text rather than ghost placeholder
|
||||
// text: the format is the point of the sample, and a SKILL.md is easier to adapt
|
||||
// than to recall. `name` seeds the path above until the user edits the path
|
||||
// themselves, so renaming here renames the skill.
|
||||
const SKILL_TEMPLATE = `---
|
||||
name: my-skill
|
||||
description: What this skill covers, and when the assistant should reach for it
|
||||
---
|
||||
|
||||
# My skill
|
||||
|
||||
What the assistant should do when this skill applies.
|
||||
|
||||
## Steps
|
||||
|
||||
1. First thing to do.
|
||||
2. Second thing to do.
|
||||
`
|
||||
|
||||
type Row = SkillResource & { enabled: boolean }
|
||||
|
||||
let drawer: Drawer | undefined = $state(undefined)
|
||||
let skills = $state<Row[]>([])
|
||||
let loading = $state(false)
|
||||
let loadError = $state<string | undefined>(undefined)
|
||||
let listNotice = $state<string | undefined>(undefined)
|
||||
let saving = $state(false)
|
||||
let toDelete: Row | undefined = $state(undefined)
|
||||
let importFiles = $state<File[] | undefined>(undefined)
|
||||
|
||||
// Paste/edit modal. `editing` is the row being edited (undefined while
|
||||
// creating), held so a path change can be applied as a move.
|
||||
let editorOpen = $state(false)
|
||||
let editing = $state<Row | undefined>(undefined)
|
||||
let content = $state('')
|
||||
let originalContent = $state('')
|
||||
let path = $state('')
|
||||
let pathError = $state('')
|
||||
// Set by Path once the user edits the path themselves, which is what stops the
|
||||
// frontmatter from overwriting their choice below.
|
||||
let pathDirty = $state(false)
|
||||
let detailMode: 'view' | 'edit' = $state('view')
|
||||
|
||||
// Staged folder import, confirmed before anything is written.
|
||||
let pendingImport: SkillUpload[] | undefined = $state(undefined)
|
||||
let pendingSkipped: string[] = $state([])
|
||||
let overwriteChoices: Record<string, boolean> = $state({})
|
||||
|
||||
let ambiguous = $derived(ambiguousSkillNames(skills))
|
||||
let parsed = $derived(parseSkillMd(content))
|
||||
// The Path field is what names the skill, and Path validates it. The frontmatter
|
||||
// `name` only seeds that field and is never persisted, so validating it here
|
||||
// would block saving a skill whose path is legal but whose name is not — resource
|
||||
// paths admit `_` and uppercase, SKILL.md names do not. The folder importer keeps
|
||||
// validating, because there the name really does become the path segment.
|
||||
let validated = $derived(parseAndValidateSkill(content, VALID_NAME_PLACEHOLDER))
|
||||
let contentError = $derived('error' in validated ? validated.error : undefined)
|
||||
// Measured against what the modal opened with, which for a new skill is the
|
||||
// sample. Saving it untouched would store a skill called "my-skill", so an
|
||||
// unedited body is not something to save; Reset restores exactly this baseline.
|
||||
let contentChanged = $derived(content !== originalContent)
|
||||
let pathChanged = $derived(!!editing && path !== editing.path)
|
||||
let canSave = $derived(
|
||||
!saving && !contentError && !!path && !pathError && (contentChanged || pathChanged)
|
||||
)
|
||||
// Keyed by the path the import would write to, not by bare name: a `deploy` that
|
||||
// exists only in someone else's folder is not something this import overwrites,
|
||||
// and offering it as a conflict would ask the user about a collision that isn't.
|
||||
let existingPaths = $derived(new Set(skills.map((s) => s.path)))
|
||||
let importTargets = $derived(
|
||||
(pendingImport ?? ([] as SkillUpload[])).map((s) => ({
|
||||
skill: s,
|
||||
path: `${defaultOwner()}/${s.name}`
|
||||
}))
|
||||
)
|
||||
let pendingConflicts = $derived(
|
||||
importTargets.filter((t) => existingPaths.has(t.path)).map((t) => t.skill)
|
||||
)
|
||||
let pendingNew = $derived(
|
||||
importTargets.filter((t) => !existingPaths.has(t.path)).map((t) => t.skill)
|
||||
)
|
||||
|
||||
// Rows describe one workspace. A switch while the drawer is open must not leave
|
||||
// A's rows on screen while the actions below target B: same path, different
|
||||
// skill, and delete would remove the wrong one. Dropping them is all this does:
|
||||
// this component mounts with the chat toolbar, so loading here would list
|
||||
// resources for every user who never opens the menu. The two entry points load
|
||||
// what they need.
|
||||
let loadSeq = 0
|
||||
$effect(() => {
|
||||
const target = ws
|
||||
untrack(() => {
|
||||
loadSeq++
|
||||
skills = []
|
||||
listNotice = undefined
|
||||
toDelete = undefined
|
||||
pendingImport = undefined
|
||||
// The editor holds one workspace's skill but saves to whatever `ws` is by
|
||||
// then, so a switch mid-edit would write A's body into B at the same path.
|
||||
// Closing it is the honest outcome: there is no version of that save the
|
||||
// user asked for.
|
||||
editorOpen = false
|
||||
editing = undefined
|
||||
// A drawer already on screen is neither entry point, and would sit there
|
||||
// reporting that the new workspace has no skills.
|
||||
if (drawer?.isOpen()) void loadSkills(target)
|
||||
})
|
||||
})
|
||||
|
||||
async function loadSkills(target = ws) {
|
||||
// Checked before the sequence is taken, not only after the await: a stale
|
||||
// action calling refresh(A) would otherwise claim the newest sequence and
|
||||
// make the legitimate load for B discard its own result, leaving the drawer
|
||||
// blank for the workspace actually on screen.
|
||||
if (!target || target !== ws) return
|
||||
refreshForkPending()
|
||||
listNotice = undefined
|
||||
const seq = ++loadSeq
|
||||
loading = true
|
||||
loadError = undefined
|
||||
try {
|
||||
const { skills: found, truncated } = await listSkillResources(target, $userStore ?? undefined)
|
||||
// Newest-request-wins is not enough on its own: an action started in A and
|
||||
// finishing after a switch to B holds the newest sequence, and would put
|
||||
// A's rows on screen under B — where the next row action would edit or
|
||||
// delete that path in B. The workspace has to still be the one asked for.
|
||||
if (seq !== loadSeq || target !== ws) return
|
||||
skills = found.map((s) => ({ ...s, enabled: isSkillEnabled(target, s.path) }))
|
||||
// A notice, not `loadError`: that one replaces the list, and a truncated
|
||||
// read still has skills worth showing.
|
||||
listNotice = truncated
|
||||
? `Showing the first ${found.length} skills; this workspace has more. Delete unused ones so the rest can be selected.`
|
||||
: undefined
|
||||
} catch (e) {
|
||||
if (seq !== loadSeq || target !== ws) return
|
||||
// Without this the drawer would render the empty state, which reads as
|
||||
// "this workspace has no skills" rather than "we could not load them".
|
||||
loadError = e.body ?? e.message
|
||||
} finally {
|
||||
if (seq === loadSeq) loading = false
|
||||
}
|
||||
}
|
||||
|
||||
export async function open() {
|
||||
drawer?.openDrawer()
|
||||
await loadSkills()
|
||||
}
|
||||
|
||||
// A menu is a shortcut, not a directory: past this many the list stops being
|
||||
// scannable, so the rest are reached through the drawer rather than dropped.
|
||||
const MAX_MENU_SKILLS = 8
|
||||
|
||||
/** Rows for the chat's "+" menu: one per skill, checked when it is on, then
|
||||
* the way to manage them. Loaded on open so the checks are current. */
|
||||
export async function menuItems(closeMenu?: () => void): Promise<Item[]> {
|
||||
// The menu opens on what is already known and refreshes behind it: waiting on
|
||||
// a round trip would stall the whole `+` menu, attachments included.
|
||||
if (skills.length === 0) {
|
||||
await loadSkills()
|
||||
} else {
|
||||
void loadSkills()
|
||||
}
|
||||
// Enabled first: those are the ones a quick visit is most likely about.
|
||||
const ordered = [...skills].sort(
|
||||
(a, b) => Number(b.enabled) - Number(a.enabled) || a.path.localeCompare(b.path)
|
||||
)
|
||||
const shown = ordered.slice(0, MAX_MENU_SKILLS)
|
||||
return [
|
||||
...shown.map(({ path: p, name }) => ({
|
||||
// Ambiguous names are shown by path — two rows reading `deploy` would
|
||||
// leave the choice between them to chance.
|
||||
displayName: ambiguous.has(name) ? p : name,
|
||||
icon: BookOpen,
|
||||
// Getters, not snapshots: the menu stays open across a click, and it has
|
||||
// to read through the live list rather than the row captured here, since
|
||||
// a reload replaces every row object and a getter bound to the old one
|
||||
// would go on reporting the state it was built with.
|
||||
get toggle() {
|
||||
return row(p)?.enabled ?? false
|
||||
},
|
||||
action: () => toggle(p, !row(p)?.enabled)
|
||||
})),
|
||||
...(ordered.length > shown.length
|
||||
? [
|
||||
{
|
||||
displayName: `Show all ${ordered.length}`,
|
||||
icon: List,
|
||||
action: () => {
|
||||
closeMenu?.()
|
||||
void open()
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: skills.length > 0 ? 'Manage skills' : 'Add a skill',
|
||||
icon: Plus,
|
||||
separatorTop: skills.length > 0,
|
||||
action: () => {
|
||||
closeMenu?.()
|
||||
void open()
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function row(p: string) {
|
||||
return skills.find((s) => s.path === p)
|
||||
}
|
||||
|
||||
async function toggle(p: string, enabled: boolean) {
|
||||
if (blockedByPendingFork()) return
|
||||
// Pinned for the whole call, like the other actions: the selection is stored
|
||||
// per workspace, and the refresh below must not hand these skills to a chat
|
||||
// that has since moved on.
|
||||
const target = ws
|
||||
if (!setSkillEnabled(target, p, enabled)) {
|
||||
sendUserToast('Could not save the selection for this account.', true)
|
||||
return
|
||||
}
|
||||
const skill = skills.find((s) => s.path === p)
|
||||
if (skill) skill.enabled = enabled
|
||||
// Whether people select skills at all. Never the skill itself: a path is
|
||||
// workspace-authored text.
|
||||
logFeatureUsage('ai_session', 'skill_toggle', {
|
||||
key: enabled ? 'on' : 'off',
|
||||
workspace: target
|
||||
})
|
||||
// The prompt lists exactly the enabled skills, so it has to be rebuilt
|
||||
// before the next message rather than on the next mode change. A workspace
|
||||
// switch during that rebuild is discarded inside refreshGlobalSkills.
|
||||
await aiChatManager.refreshGlobalSkills(target)
|
||||
}
|
||||
|
||||
/** Personal folder the folder import writes into. The username is not always a
|
||||
* legal path segment — a superadmin who is not a member of the workspace gets
|
||||
* their email back from `whoami` — and `resource.path` is CHECK-constrained, so
|
||||
* it is narrowed the same way Path.svelte narrows it. */
|
||||
function defaultOwner() {
|
||||
const username = $userStore?.username ?? 'user'
|
||||
const narrowed = username.includes('@')
|
||||
? username.split('@')[0].replace(/[^a-zA-Z0-9_]/g, '')
|
||||
: username
|
||||
// Narrowing can empty the string outright (an all-punctuation local part),
|
||||
// and `u//name` fails the path CHECK with nothing to explain it.
|
||||
return `u/${narrowed || 'user'}`
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
if (blockedByPendingFork()) return
|
||||
editing = undefined
|
||||
content = SKILL_TEMPLATE
|
||||
originalContent = SKILL_TEMPLATE
|
||||
path = ''
|
||||
pathDirty = false
|
||||
detailMode = 'edit'
|
||||
editorOpen = true
|
||||
}
|
||||
|
||||
/** Back to what the modal opened with: the sample for a new skill, the saved
|
||||
* body for one being edited. */
|
||||
function resetContent() {
|
||||
content = originalContent
|
||||
}
|
||||
|
||||
async function openSkill(skill: Row, mode: 'view' | 'edit') {
|
||||
// Pinned across the await: the workspace can change while the body loads, and
|
||||
// closing the editor then would not stop this from reopening it with the old
|
||||
// workspace's content — which submitEditor would save into the new one.
|
||||
const source = ws
|
||||
try {
|
||||
const instructions = await readSkillBody(source, skill.path)
|
||||
if (source !== ws) return
|
||||
content = buildSkillMd({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
instructions
|
||||
})
|
||||
originalContent = content
|
||||
editing = skill
|
||||
path = skill.path
|
||||
pathDirty = false
|
||||
detailMode = mode
|
||||
editorOpen = true
|
||||
} catch (e) {
|
||||
sendUserToast(`Failed to load skill: ${e.body ?? e.message}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Path opens on a generated name, so the frontmatter `name` has to replace that
|
||||
// last segment rather than fill a blank. Its owner is left alone (Path picks up
|
||||
// where the user last created something), and the whole thing stops as soon as
|
||||
// they edit the path themselves.
|
||||
//
|
||||
// `path` is a dependency, not just a value read: Path settles it asynchronously,
|
||||
// after the template's name is already parsed, so an effect watching only the
|
||||
// content would run once against an empty path and never again. Writing back the
|
||||
// value it already holds is what would loop, hence the equality guard.
|
||||
$effect(() => {
|
||||
const suggested = parsed.name
|
||||
const current = path
|
||||
untrack(() => {
|
||||
if (!editorOpen || editing || pathDirty || !suggested) return
|
||||
const owner = current.split('/').slice(0, -1).join('/')
|
||||
if (!owner) return
|
||||
const next = `${owner}/${suggested}`
|
||||
if (next !== current) path = next
|
||||
})
|
||||
})
|
||||
|
||||
async function submitEditor() {
|
||||
if (!('skill' in validated) || !path || pathError) return
|
||||
if (blockedByPendingFork()) return
|
||||
const target = ws
|
||||
saving = true
|
||||
try {
|
||||
if (editing) {
|
||||
await updateSkillResource(
|
||||
target,
|
||||
editing.path,
|
||||
path,
|
||||
validated.skill.description,
|
||||
validated.skill.instructions
|
||||
)
|
||||
// A move leaves the old path selected but gone; carry the choice over
|
||||
// so an edit that renames does not silently switch the skill off.
|
||||
if (editing.path !== path && isSkillEnabled(target, editing.path)) {
|
||||
setSkillEnabled(target, editing.path, false)
|
||||
setSkillEnabled(target, path, true)
|
||||
}
|
||||
} else {
|
||||
await saveSkillResource(
|
||||
target,
|
||||
path,
|
||||
validated.skill.description,
|
||||
validated.skill.instructions
|
||||
)
|
||||
// Authoring a skill is the act of choosing it.
|
||||
setSkillEnabled(target, path, true)
|
||||
}
|
||||
editorOpen = false
|
||||
await refresh(target)
|
||||
sendUserToast(editing ? `Saved ${path}` : `Added ${path}`)
|
||||
} catch (e) {
|
||||
sendUserToast(`Failed to save skill: ${e.body ?? e.message}`, true)
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(skill: Row) {
|
||||
if (blockedByPendingFork()) return
|
||||
const target = ws
|
||||
try {
|
||||
await deleteSkillResource(target, skill.path)
|
||||
// A later skill at this path is a different one; it must be turned on
|
||||
// deliberately rather than inherit this one's selection.
|
||||
setSkillEnabled(target, skill.path, false)
|
||||
sendUserToast(`Deleted ${skill.path}`)
|
||||
await refresh(target)
|
||||
} catch (e) {
|
||||
sendUserToast(`Failed to delete skill: ${e.body ?? e.message}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh(target = ws) {
|
||||
await loadSkills(target)
|
||||
// Same reason: refreshing the chat with a workspace it has since left would
|
||||
// advertise A's skills to a session now acting on B.
|
||||
if (target !== ws) return
|
||||
await aiChatManager.refreshGlobalSkills(target)
|
||||
}
|
||||
|
||||
/** Where the file sat in the chosen folder. Clicking through sets
|
||||
* `webkitRelativePath`; dropping sets `path`, which the picker's tree walk fills. */
|
||||
function relativePathOf(f: File & { path?: string }): string {
|
||||
return f.webkitRelativePath || f.path || f.name
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter a folder's files down to in-depth SKILL.md, read them, and stage the
|
||||
* result for confirmation.
|
||||
*/
|
||||
async function processFolderFiles(files: File[]) {
|
||||
if (blockedByPendingFork()) return
|
||||
// Pick SKILL.md files within the depth limit BEFORE reading any content,
|
||||
// so a huge tree never gets read in full.
|
||||
const skipped: string[] = []
|
||||
const eligible: File[] = []
|
||||
for (const f of files) {
|
||||
const filePath = relativePathOf(f)
|
||||
const segments = filePath.split('/')
|
||||
if (segments[segments.length - 1]?.toLowerCase() !== 'skill.md') continue
|
||||
if (segments.length > MAX_SKILL_DEPTH) {
|
||||
skipped.push(`${filePath} (nested deeper than ${MAX_SKILL_DEPTH} folder levels)`)
|
||||
continue
|
||||
}
|
||||
eligible.push(f)
|
||||
}
|
||||
|
||||
if (eligible.length === 0) {
|
||||
sendUserToast(
|
||||
`No SKILL.md found within ${MAX_SKILL_DEPTH} folder levels.${
|
||||
skipped.length ? ` Skipped ${skipped.length} deeper file(s).` : ''
|
||||
}`,
|
||||
true
|
||||
)
|
||||
return
|
||||
}
|
||||
if (eligible.length > MAX_SKILLS_PER_IMPORT) {
|
||||
sendUserToast(
|
||||
`Found ${eligible.length} skills in this folder; imports are limited to ${MAX_SKILLS_PER_IMPORT} at a time.`,
|
||||
true
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const collected: SkillUpload[] = []
|
||||
const parseSkipped: string[] = []
|
||||
for (const f of eligible) {
|
||||
const filePath = relativePathOf(f)
|
||||
const segments = filePath.split('/')
|
||||
// The skill's id is the folder holding its SKILL.md, which is what
|
||||
// becomes the resource path's name segment.
|
||||
const folderName = segments.length >= 2 ? segments[segments.length - 2] : ''
|
||||
const result = parseAndValidateSkill(await f.text(), folderName)
|
||||
if ('error' in result) {
|
||||
parseSkipped.push(`${folderName || filePath} (${result.error})`)
|
||||
continue
|
||||
}
|
||||
// Two folders under different parents can share a leaf name, and both
|
||||
// would target one path — the second silently replacing the first.
|
||||
if (collected.some((s) => s.name === result.skill.name)) {
|
||||
parseSkipped.push(
|
||||
`${filePath} (another skill in this folder is already named ${result.skill.name})`
|
||||
)
|
||||
continue
|
||||
}
|
||||
collected.push(result.skill)
|
||||
}
|
||||
|
||||
const allSkipped = [...skipped, ...parseSkipped]
|
||||
if (collected.length === 0) {
|
||||
sendUserToast(
|
||||
`No valid skill found.${allSkipped.length ? ` Skipped: ${allSkipped.join(', ')}` : ''}`,
|
||||
true
|
||||
)
|
||||
return
|
||||
}
|
||||
// Confirm before writing — the import can pull in several skills at once,
|
||||
// and any that collide with existing skills default to overwrite.
|
||||
pendingSkipped = allSkipped
|
||||
const owner = defaultOwner()
|
||||
overwriteChoices = Object.fromEntries(
|
||||
collected.filter((s) => existingPaths.has(`${owner}/${s.name}`)).map((s) => [s.name, true])
|
||||
)
|
||||
pendingImport = collected
|
||||
}
|
||||
|
||||
async function onDirSelected(event: CustomEvent<File[] | undefined>) {
|
||||
const files = event.detail ?? []
|
||||
// Clearing lets the same folder be chosen again — the component keeps the
|
||||
// last selection on screen otherwise, and re-picking would look inert.
|
||||
importFiles = undefined
|
||||
if (files.length) await processFolderFiles(files)
|
||||
}
|
||||
|
||||
/** `overwrite` is granted only for destinations the confirmation listed as an
|
||||
* existing skill. Everything else is created, never upserted: a path the user
|
||||
* was told was free may hold an unrelated resource — a credential, say — and an
|
||||
* upsert would replace its value and type with a skill. Losing the import of a
|
||||
* name is recoverable; losing what was there is not. */
|
||||
async function importSkills(
|
||||
toImport: { skill: SkillUpload; overwrite: boolean }[],
|
||||
skipped: string[]
|
||||
) {
|
||||
const target = ws
|
||||
const owner = defaultOwner()
|
||||
saving = true
|
||||
let written = 0
|
||||
const failed: string[] = []
|
||||
try {
|
||||
for (const { skill, overwrite } of toImport) {
|
||||
const dest = `${owner}/${skill.name}`
|
||||
try {
|
||||
await saveSkillResource(target, dest, skill.description, skill.instructions, {
|
||||
overwrite
|
||||
})
|
||||
setSkillEnabled(target, dest, true)
|
||||
written++
|
||||
} catch (e) {
|
||||
failed.push(`${skill.name} (${e.body ?? e.message})`)
|
||||
}
|
||||
}
|
||||
let message = `Added ${written} skill(s) under ${owner}`
|
||||
if (skipped.length) message += `; skipped ${skipped.length}`
|
||||
if (failed.length) message += `; failed: ${failed.join(', ')}`
|
||||
sendUserToast(message, failed.length > 0)
|
||||
await refresh(target)
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={drawer} size="700px">
|
||||
<DrawerContent
|
||||
title="Skills"
|
||||
on:close={() => drawer?.closeDrawer()}
|
||||
tooltip="Reusable instruction sets for this chat, stored as ai_skill resources. Turning one on is personal to you and to this workspace — the assistant only sees the ones you selected."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: ClipboardPaste }}
|
||||
disabled={saving || forkPending}
|
||||
onclick={openCreate}
|
||||
>
|
||||
New skill
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
{#if listNotice}
|
||||
<Alert type="warning" title="Not all skills are shown" size="xs" class="mb-4">
|
||||
{listNotice}
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
{#if forkPending}
|
||||
<Alert type="info" title="This session has no workspace yet" size="xs" class="mb-4">
|
||||
Skills are read-only until the first message creates this session's fork. Editing or
|
||||
selecting one now would apply to the parent workspace and stop applying once the fork is
|
||||
created.
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
<FileInput
|
||||
disabled={forkPending}
|
||||
folderOnly
|
||||
bind:files={importFiles}
|
||||
on:change={onDirSelected}
|
||||
class="mb-4 !py-5"
|
||||
iconSize={20}
|
||||
>
|
||||
<span class="text-xs text-secondary">
|
||||
Drop a folder of <span class="font-mono">SKILL.md</span> files to import, or click to choose
|
||||
one
|
||||
</span>
|
||||
</FileInput>
|
||||
|
||||
{#if loading}
|
||||
<div class="text-xs text-secondary p-4 text-center">Loading skills…</div>
|
||||
{:else if loadError}
|
||||
<div class="text-xs text-red-600 dark:text-red-400">
|
||||
Failed to load skills: {loadError}
|
||||
</div>
|
||||
{:else if skills.length === 0}
|
||||
<div class="rounded-md border border-dashed px-3 py-6 text-center text-xs text-secondary">
|
||||
No skills in this workspace yet. Paste a SKILL.md or import a folder of them.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col divide-y border rounded-md bg-surface-tertiary">
|
||||
{#each skills as skill (skill.path)}
|
||||
<div class="flex items-center gap-3 px-4 py-3">
|
||||
<BookOpen size={16} class="shrink-0 text-tertiary" />
|
||||
<div class="min-w-0 grow">
|
||||
<div class="text-xs font-semibold text-emphasis truncate">
|
||||
{ambiguous.has(skill.name) ? skill.path : skill.name}
|
||||
</div>
|
||||
{#if skill.description}
|
||||
<div class="text-xs text-secondary truncate">{skill.description}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<Toggle
|
||||
size="xs"
|
||||
disabled={forkPending}
|
||||
checked={skill.enabled}
|
||||
on:change={async (e) => await toggle(skill.path, e.detail)}
|
||||
/>
|
||||
<DropdownV2
|
||||
size="sm"
|
||||
items={[
|
||||
{
|
||||
displayName: skill.canWrite ? 'Edit' : 'View',
|
||||
icon: skill.canWrite ? Pencil : Eye,
|
||||
action: () => openSkill(skill, skill.canWrite ? 'edit' : 'view')
|
||||
},
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash2,
|
||||
type: 'delete',
|
||||
disabled: !skill.canWrite || forkPending,
|
||||
action: () => (toDelete = skill)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ConfirmationModal
|
||||
open={toDelete !== undefined}
|
||||
title="Delete skill"
|
||||
confirmationText="Delete"
|
||||
onConfirmed={async () => {
|
||||
const skill = toDelete
|
||||
toDelete = undefined
|
||||
if (skill) await remove(skill)
|
||||
}}
|
||||
onCanceled={() => (toDelete = undefined)}
|
||||
>
|
||||
<span class="text-xs text-primary">
|
||||
This deletes the resource at <span class="font-semibold">{toDelete?.path}</span>, so
|
||||
everyone who selected it loses the skill.
|
||||
</span>
|
||||
</ConfirmationModal>
|
||||
|
||||
<ConfirmationModal
|
||||
open={pendingImport !== undefined}
|
||||
title="Import skills"
|
||||
type="info"
|
||||
confirmationText="Import"
|
||||
onConfirmed={async () => {
|
||||
const toImport = [
|
||||
...pendingNew.map((skill) => ({ skill, overwrite: false })),
|
||||
...pendingConflicts
|
||||
.filter((s) => overwriteChoices[s.name])
|
||||
.map((skill) => ({ skill, overwrite: true }))
|
||||
]
|
||||
const skipped = pendingSkipped
|
||||
pendingImport = undefined
|
||||
pendingSkipped = []
|
||||
overwriteChoices = {}
|
||||
if (toImport.length) await importSkills(toImport, skipped)
|
||||
else sendUserToast('No skills imported.')
|
||||
}}
|
||||
onCanceled={() => {
|
||||
pendingImport = undefined
|
||||
pendingSkipped = []
|
||||
overwriteChoices = {}
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col gap-3 text-xs">
|
||||
<span class="text-secondary">
|
||||
Skills are added under <span class="font-mono">{defaultOwner()}</span>. Move one to a
|
||||
shared folder from the resources page to share it.
|
||||
</span>
|
||||
{#if pendingNew.length}
|
||||
<div>
|
||||
<span class="font-medium text-primary">Add {pendingNew.length} new skill(s):</span>
|
||||
<span class="font-mono text-secondary">{pendingNew.map((s) => s.name).join(', ')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if pendingConflicts.length}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="font-medium text-primary">
|
||||
{pendingConflicts.length} skill(s) already exist — choose which to overwrite:
|
||||
</span>
|
||||
<div class="rounded-md border divide-y">
|
||||
{#each pendingConflicts as conflict (conflict.name)}
|
||||
<div class="flex items-center justify-between gap-4 px-3 py-2">
|
||||
<span class="font-mono truncate">{conflict.name}</span>
|
||||
<Toggle
|
||||
bind:checked={overwriteChoices[conflict.name]}
|
||||
size="xs"
|
||||
options={{ right: 'Overwrite' }}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if pendingSkipped.length}
|
||||
<span class="text-secondary">{pendingSkipped.length} file(s) will be skipped.</span>
|
||||
{/if}
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Modal2
|
||||
title={editing ? editing.name : 'New skill'}
|
||||
bind:isOpen={editorOpen}
|
||||
fixedWidth="md"
|
||||
fixedHeight="adaptive"
|
||||
>
|
||||
{#snippet headerRight()}
|
||||
{#if editing}
|
||||
<ToggleButtonGroup bind:selected={detailMode}>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="view" label="View" icon={Eye} {item} small />
|
||||
<ToggleButton
|
||||
value="edit"
|
||||
label="Edit"
|
||||
icon={Pencil}
|
||||
{item}
|
||||
small
|
||||
disabled={!editing?.canWrite}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
{/if}
|
||||
{/snippet}
|
||||
<div class="w-full flex flex-col gap-3">
|
||||
{#if detailMode === 'view'}
|
||||
{#if parsed.description}
|
||||
<p class="text-xs text-secondary">{parsed.description}</p>
|
||||
{/if}
|
||||
<div class="border rounded-md p-3 overflow-auto max-h-[60vh] space-y-2 {markdownProse.sm}">
|
||||
<Markdown md={parsed.instructions} plugins={[gfmPlugin()]} />
|
||||
</div>
|
||||
{:else}
|
||||
<Path
|
||||
bind:path
|
||||
bind:error={pathError}
|
||||
bind:dirty={pathDirty}
|
||||
initialPath={editing?.path ?? ''}
|
||||
namePlaceholder="skill"
|
||||
kind="resource"
|
||||
workspaceOverride={ws}
|
||||
autofocus={false}
|
||||
/>
|
||||
<!-- The same editor `/resources` gives these resources, so a skill reads the
|
||||
same in both places. `fixedOverflowWidgets` off keeps its autocomplete
|
||||
inside the modal instead of clipped behind it. -->
|
||||
<div class="border border-border-light rounded-md overflow-hidden">
|
||||
<SimpleEditor
|
||||
autoHeight
|
||||
lang="md"
|
||||
bind:code={content}
|
||||
fixedOverflowWidgets={false}
|
||||
class="min-h-24"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-2xs text-red-500 min-w-0">{contentError ?? ''}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
onclick={resetContent}
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: RotateCcw }}
|
||||
disabled={saving || !contentChanged}
|
||||
title={editing ? 'Discard unsaved changes to this skill' : 'Restore the sample skill'}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
onclick={submitEditor}
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: editing ? Pencil : Plus }}
|
||||
disabled={!canSave}
|
||||
>
|
||||
{editing ? 'Save skill' : 'Add skill'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Modal2>
|
||||
@@ -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>
|
||||
@@ -20,6 +20,14 @@ export type ChatSendRequestOptions = {
|
||||
images?: AttachedImage[]
|
||||
files?: AttachedTextFile[]
|
||||
blobs?: AttachedBlob[]
|
||||
/** Selected-context snapshot for this turn, in place of the live selection. Set
|
||||
* whenever a send settles its context ahead of the turn. A host with no context
|
||||
* of its own ignores it. */
|
||||
contextOverride?: ContextElement[]
|
||||
/** Where `contextOverride` came from. 'pinned': chips picked for THIS message, so
|
||||
* they are consumed from the live selection on send. 'replay': an edit or retry
|
||||
* resending an older message's context, already consumed long ago. */
|
||||
contextOverrideOrigin?: 'pinned' | 'replay'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -331,6 +331,13 @@ export function contextElementKey(c: ContextElement): string {
|
||||
return c.type === 'app_dom_selector' ? `dom:${c.appPath}:${c.selector}` : `${c.type}:${c.title}`
|
||||
}
|
||||
|
||||
/** An `@`-mentioned workspace item, attached to one message the way a DOM pick
|
||||
* is. Membership only: this does not gate on mode, so every caller must. Outside
|
||||
* GLOBAL these stay selected as chips the user removes by hand. */
|
||||
export function isMentionContext(c: ContextElement): boolean {
|
||||
return c.type === 'workspace_script' || c.type === 'workspace_flow' || c.type === 'workspace_app'
|
||||
}
|
||||
|
||||
export function isSameContextElement(a: ContextElement, b: ContextElement): boolean {
|
||||
if (a.type !== b.type) return false
|
||||
if (a.type === 'app_dom_selector' && b.type === 'app_dom_selector') {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { get } from 'svelte/store'
|
||||
import { userStore } from '$lib/stores'
|
||||
|
||||
/**
|
||||
* A set of workspace-object paths the chat may act through, remembered per
|
||||
* workspace and per account.
|
||||
*
|
||||
* Being able to read a resource is not the same as wanting the chat to use it: a
|
||||
* resource in a shared folder is readable by a whole team, and each enabled entry
|
||||
* costs something on every turn — an MCP server puts its tool descriptions in the
|
||||
* model's context and reaches an external system, a skill puts its description
|
||||
* there. So an entry is off until it is turned on.
|
||||
*
|
||||
* Stored per browser, like the chat's other per-user preferences, but keyed by
|
||||
* email as well as workspace: browser storage outlives a logout, and inheriting
|
||||
* the previous account's selection would hand the next person capabilities they
|
||||
* never turned on. Workspace ids cannot contain `:`, so the composite key is
|
||||
* unambiguous.
|
||||
*/
|
||||
export type EnabledPathsPreference = {
|
||||
enabledPaths: (workspace: string) => string[]
|
||||
isEnabled: (workspace: string, path: string) => boolean
|
||||
/** Returns false when there is no account to record the preference against, so
|
||||
* a caller that just created the object can say it did not stay on. */
|
||||
setEnabled: (workspace: string, path: string, enabled: boolean) => boolean
|
||||
}
|
||||
|
||||
export function createEnabledPathsPreference(storageKey: string): EnabledPathsPreference {
|
||||
function scope(workspace: string): string | undefined {
|
||||
const email = get(userStore)?.email
|
||||
return email ? `${workspace}:${email}` : undefined
|
||||
}
|
||||
|
||||
function read(): Record<string, string[]> {
|
||||
if (typeof localStorage === 'undefined') return {}
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(storageKey) ?? '{}')
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function write(all: Record<string, string[]>) {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(all))
|
||||
} catch (e) {
|
||||
console.error(`Failed to persist ${storageKey}`, e)
|
||||
}
|
||||
}
|
||||
|
||||
function enabledPaths(workspace: string): string[] {
|
||||
const key = scope(workspace)
|
||||
return key ? (read()[key] ?? []) : []
|
||||
}
|
||||
|
||||
return {
|
||||
enabledPaths,
|
||||
isEnabled: (workspace, path) => enabledPaths(workspace).includes(path),
|
||||
setEnabled: (workspace, path, enabled) => {
|
||||
const key = scope(workspace)
|
||||
if (!key) return false
|
||||
const all = read()
|
||||
const current = new Set(all[key] ?? [])
|
||||
if (enabled) {
|
||||
current.add(path)
|
||||
} else {
|
||||
current.delete(path)
|
||||
}
|
||||
all[key] = [...current]
|
||||
write(all)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,7 +210,8 @@ vi.mock('$lib/gen', async () => {
|
||||
}),
|
||||
createResource: vi.fn(async () => 'created'),
|
||||
updateResource: vi.fn(async () => 'updated'),
|
||||
deleteResource: vi.fn(async () => 'deleted')
|
||||
deleteResource: vi.fn(async () => 'deleted'),
|
||||
getResourceValue: vi.fn(async () => ({ content: 'skill body' }))
|
||||
}),
|
||||
VariableService: wrapService(actual.VariableService, {
|
||||
existsVariable: vi.fn(async () => false),
|
||||
@@ -5435,6 +5436,18 @@ describe('session-only preview tools gating', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('read_skill', () => {
|
||||
it('refuses a path the user has not selected, without reading it', async () => {
|
||||
localStorage.clear()
|
||||
userStore.set({ username: 'bob', email: 'bob@windmill.dev', workspace_id: WORKSPACE } as any)
|
||||
|
||||
const res = await callGlobalTool('read_skill', { path: 'u/someone/private-notes' })
|
||||
|
||||
expect(res).toContain('not one of the skills selected')
|
||||
expect(vi.mocked(ResourceService.getResourceValue)).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('update_user_instructions', () => {
|
||||
function makeHelpers(initial = '') {
|
||||
let value = initial
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user