Merge origin/main into flow-chat-sdk-turns

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-18 14:14:48 +02:00
co-authored by Claude Opus 5
66 changed files with 3150 additions and 537 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.813.0"
".": "1.814.0"
}
+26
View File
@@ -1,5 +1,31 @@
# Changelog
## [1.814.0](https://github.com/windmill-labs/windmill/compare/v1.813.0...v1.814.0) (2026-09-17)
### Features
* **ai-chat:** add list_workers and list_data_metrics global tools ([#11143](https://github.com/windmill-labs/windmill/issues/11143)) ([e954d33](https://github.com/windmill-labs/windmill/commit/e954d33613e4ff5027667eb8f646615d9bbd499d))
* **ai-chat:** merge get_job_logs and get_flow_run_details into get_run ([#11172](https://github.com/windmill-labs/windmill/issues/11172)) ([5bb37ca](https://github.com/windmill-labs/windmill/commit/5bb37ca3388666fba72c55534e37f37bb3e9299e))
* allow git sync auto-pull, promotion and PRs on Pro licenses ([#11173](https://github.com/windmill-labs/windmill/issues/11173)) ([02e47de](https://github.com/windmill-labs/windmill/commit/02e47de8b4c4f3f54753aabf8c67bc8e71ffb957))
* badge chat-input flows on the home list ([#11164](https://github.com/windmill-labs/windmill/issues/11164)) ([3d08197](https://github.com/windmill-labs/windmill/commit/3d0819718221f885b61e73d02b43dcc853c7d02a))
* collect flow conversations and agent memory once their last message goes ([#11178](https://github.com/windmill-labs/windmill/issues/11178)) ([23c24a9](https://github.com/windmill-labs/windmill/commit/23c24a9688d4c8c462f53221334d538280f16bca))
* flow chat model picker on a shared model-settings component ([#11187](https://github.com/windmill-labs/windmill/issues/11187)) ([189793c](https://github.com/windmill-labs/windmill/commit/189793c2e4db7f1c853695ebcc895c1ec82ed19f))
* keep flow inputs and seed the agent when chat mode is enabled ([#11177](https://github.com/windmill-labs/windmill/issues/11177)) ([68f2248](https://github.com/windmill-labs/windmill/commit/68f2248018fc218a090bf939e1eb22ff97d5bc22))
* let plan mode search and read connected mcp servers ([#11205](https://github.com/windmill-labs/windmill/issues/11205)) ([5371519](https://github.com/windmill-labs/windmill/commit/5371519f0f5ce7750982dcdb374dca72115902e7))
* let test_run_flow name the conversation of a chat-mode test run ([#11198](https://github.com/windmill-labs/windmill/issues/11198)) ([6e1ef93](https://github.com/windmill-labs/windmill/commit/6e1ef93f329cb396ffc3df3304d592e8fa0e0e71))
* managed memory with an inherited or custom memory id per step ([#11118](https://github.com/windmill-labs/windmill/issues/11118)) ([c297ed0](https://github.com/windmill-labs/windmill/commit/c297ed0052d998fb8f063faa2a36c6eb03e327be))
* render the flow chat through the shared session chat components ([#11175](https://github.com/windmill-labs/windmill/issues/11175)) ([a9ec0ae](https://github.com/windmill-labs/windmill/commit/a9ec0aec3ac0c6b0f7919d0eb2168816923826d7))
* show flow step detail inside the graph tab on narrow detail layouts ([#11168](https://github.com/windmill-labs/windmill/issues/11168)) ([64dffe6](https://github.com/windmill-labs/windmill/commit/64dffe6106ad6a55b61a423c855a4b5b0cef533e))
* store mcp tool call, result and reasoning on flow conversation rows ([#11176](https://github.com/windmill-labs/windmill/issues/11176)) ([a571117](https://github.com/windmill-labs/windmill/commit/a571117f3fd2cef14c920770645c60ee358fdfdd))
* tell test flow conversations from deployed ones and rename a chat ([#11179](https://github.com/windmill-labs/windmill/issues/11179)) ([4eab995](https://github.com/windmill-labs/windmill/commit/4eab995cf7cf091a5e4640da4cb77e0921bb7fdf))
### Bug Fixes
* disable a schedule whose cron has no run left instead of panicking ([#11195](https://github.com/windmill-labs/windmill/issues/11195)) ([381d447](https://github.com/windmill-labs/windmill/commit/381d4470ef699ea82283742132e56556b95d2bd2))
* skip expiry notifications for app embed and SDK tokens ([#11169](https://github.com/windmill-labs/windmill/issues/11169)) ([9d348f8](https://github.com/windmill-labs/windmill/commit/9d348f84c7830f36b6153472556fd70e3d84cd24))
## [1.813.0](https://github.com/windmill-labs/windmill/compare/v1.812.0...v1.813.0) (2026-09-16)
+124 -125
View File
@@ -728,7 +728,7 @@ dependencies = [
"futures-lite 2.6.1",
"parking",
"polling 3.11.0",
"rustix 1.1.4",
"rustix 1.1.5",
"slab",
"windows-sys 0.61.2",
]
@@ -873,7 +873,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -1976,7 +1976,7 @@ dependencies = [
"prettyplease 0.3.0",
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -2000,7 +2000,7 @@ dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -2144,7 +2144,7 @@ checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -2164,9 +2164,9 @@ dependencies = [
[[package]]
name = "bytes-str"
version = "0.2.8"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "577d2bf5650f8554d5a372af5ac93535110a0fc75b3e702bb853369febf227c2"
checksum = "4dde6d05e75a31ec9610eb6446a6f0a10dd30ff5100d720fee4c7c7a9008b5ba"
dependencies = [
"bytes",
"serde",
@@ -2338,9 +2338,9 @@ dependencies = [
[[package]]
name = "cfg-if"
version = "1.0.4"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "cfg_aliases"
@@ -2454,7 +2454,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -3047,7 +3047,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim 0.11.1",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -3102,7 +3102,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
dependencies = [
"darling_core 0.24.1",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -4580,7 +4580,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"redox_users 0.5.3",
"windows-sys 0.61.2",
]
@@ -4603,7 +4603,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -4850,7 +4850,7 @@ checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -5198,7 +5198,7 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4"
dependencies = [
"rustix 1.1.4",
"rustix 1.1.5",
"windows-sys 0.59.0",
]
@@ -5319,7 +5319,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -9438,7 +9438,7 @@ dependencies = [
"concurrent-queue",
"hermit-abi 0.5.3",
"pin-project-lite",
"rustix 1.1.4",
"rustix 1.1.5",
"windows-sys 0.61.2",
]
@@ -9574,7 +9574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
dependencies = [
"proc-macro2",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -10235,11 +10235,10 @@ dependencies = [
[[package]]
name = "redox_users"
version = "0.5.2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 2.0.20",
]
@@ -10261,7 +10260,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -10580,7 +10579,7 @@ dependencies = [
"proc-macro2",
"quote",
"serde_json",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -10798,9 +10797,9 @@ dependencies = [
[[package]]
name = "rustix"
version = "1.1.4"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
"bitflags 2.13.2",
"errno",
@@ -11217,7 +11216,7 @@ dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals 0.30.0",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -11416,7 +11415,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -11438,7 +11437,7 @@ checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -11492,7 +11491,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -11560,7 +11559,7 @@ dependencies = [
"darling 0.24.1",
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -12708,9 +12707,9 @@ dependencies = [
[[package]]
name = "syn"
version = "3.0.5"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
@@ -12745,7 +12744,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -13036,7 +13035,7 @@ dependencies = [
"fastrand 2.5.0",
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.4",
"rustix 1.1.5",
"windows-sys 0.61.2",
]
@@ -13055,7 +13054,7 @@ version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [
"rustix 1.1.4",
"rustix 1.1.5",
"windows-sys 0.61.2",
]
@@ -13115,7 +13114,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -14075,7 +14074,7 @@ checksum = "f153acc4e99a5f2a5aefa09fb078be54e26271b2813f6041200b224c098d8328"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -14179,9 +14178,9 @@ checksum = "81b79ad29b5e19de4260020f8919b443b2ef0277d242ce532ec7b7a2cc8b6007"
[[package]]
name = "unicode-ident"
version = "1.0.24"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-normalization"
@@ -14546,7 +14545,7 @@ dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
"wasm-bindgen-shared",
]
@@ -14589,7 +14588,7 @@ checksum = "8c89dcab8b516b6b603baca9d550b7282d68fcc7f367e3956cff7ebf406a3f12"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -14794,7 +14793,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-nats",
@@ -14882,7 +14881,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"async-stream",
"async-trait",
@@ -14916,7 +14915,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14929,7 +14928,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"argon2",
@@ -15069,7 +15068,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15092,7 +15091,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15109,7 +15108,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15135,7 +15134,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -15145,7 +15144,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15162,7 +15161,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"base64 0.22.1",
@@ -15184,7 +15183,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15207,7 +15206,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15223,7 +15222,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15245,7 +15244,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15267,7 +15266,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15281,7 +15280,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-nats",
@@ -15316,7 +15315,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15341,7 +15340,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15369,7 +15368,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15391,7 +15390,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15411,7 +15410,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15449,7 +15448,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15477,7 +15476,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"lazy_static",
"serde",
@@ -15489,7 +15488,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"argon2",
"axum 0.8.9",
@@ -15513,7 +15512,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15527,7 +15526,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15562,7 +15561,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"chrono",
"lazy_static",
@@ -15576,7 +15575,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15595,7 +15594,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -15702,7 +15701,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"chrono",
"futures",
@@ -15722,7 +15721,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"regex",
"serde",
@@ -15739,7 +15738,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -15766,7 +15765,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"futures",
@@ -15783,7 +15782,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -15799,7 +15798,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15820,7 +15819,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15851,7 +15850,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -15876,7 +15875,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-stream",
@@ -15911,7 +15910,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"futures",
@@ -15929,7 +15928,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -15938,7 +15937,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15950,7 +15949,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde_json",
@@ -15962,7 +15961,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"gosyn",
@@ -15974,7 +15973,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15986,7 +15985,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde_json",
@@ -15998,7 +15997,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -16009,7 +16008,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16020,7 +16019,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16032,7 +16031,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -16043,7 +16042,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16065,7 +16064,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16077,7 +16076,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16091,7 +16090,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -16108,7 +16107,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16121,7 +16120,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde",
@@ -16133,7 +16132,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16151,7 +16150,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -16167,7 +16166,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -16183,7 +16182,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16197,7 +16196,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16236,7 +16235,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"const_format",
@@ -16276,7 +16275,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -16287,7 +16286,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16322,7 +16321,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16346,7 +16345,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16379,7 +16378,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-amqp"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16406,7 +16405,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16439,7 +16438,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16459,7 +16458,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16493,7 +16492,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16529,7 +16528,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16552,7 +16551,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16576,7 +16575,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-nats",
@@ -16600,7 +16599,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16635,7 +16634,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16663,7 +16662,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16688,7 +16687,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"bitflags 2.13.2",
@@ -16707,7 +16706,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -16825,7 +16824,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"bytes",
"futures",
@@ -17458,7 +17457,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix 1.1.4",
"rustix 1.1.5",
]
[[package]]
@@ -17519,7 +17518,7 @@ checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
"synstructure 0.14.0",
]
@@ -17560,7 +17559,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
"synstructure 0.14.0",
]
@@ -17616,7 +17615,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.813.0"
version = "1.814.0"
authors.workspace = true
edition.workspace = true
@@ -88,7 +88,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.813.0"
version = "1.814.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+24 -24
View File
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6274,7 +6274,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6286,7 +6286,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"convert_case",
"serde",
@@ -6295,7 +6295,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6307,7 +6307,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6319,7 +6319,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6331,7 +6331,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6343,7 +6343,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6355,7 +6355,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6366,7 +6366,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6377,7 +6377,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6389,7 +6389,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6400,7 +6400,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6422,7 +6422,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6434,7 +6434,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6448,7 +6448,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6465,7 +6465,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6478,7 +6478,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde",
@@ -6490,7 +6490,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6508,7 +6508,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6524,7 +6524,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6540,7 +6540,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6572,7 +6572,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6586,7 +6586,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.813.0"
version = "1.814.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.813.0"
version = "1.814.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+60
View File
@@ -1199,3 +1199,63 @@ async fn test_wm_labels_from_result_merged_with_static_labels(
Ok(())
}
/// `tag` lives only on `v2_job`, which count_jobs joins only when `tags` is set.
#[sqlx::test(fixtures("base"))]
async fn test_count_completed_jobs_tags_filter(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let client = windmill_api_client::create_client(
&format!("http://localhost:{port}"),
"SECRET_TOKEN".to_string(),
);
for (ws, tag, status) in [
("test-workspace", "deno", "success"),
("test-workspace", "deno", "failure"),
("test-workspace", "python3", "success"),
("other-workspace", "deno", "success"),
] {
let id = uuid::Uuid::new_v4();
sqlx::query("INSERT INTO v2_job (id, workspace_id, tag) VALUES ($1, $2, $3)")
.bind(id)
.bind(ws)
.bind(tag)
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO v2_job_completed (id, workspace_id, status, duration_ms) VALUES ($1, $2, $3::job_status, 0)",
)
.bind(id)
.bind(ws)
.bind(status)
.execute(&db)
.await?;
}
for (query, expected) in [
("", 3),
("tags=deno", 2),
("tags=deno&success=true", 1),
("tags=deno,python3&completed_after_s_ago=3600", 3),
] {
let response = client
.client()
.get(format!(
"{}/w/test-workspace/jobs/completed/count_jobs?{query}",
client.baseurl()
))
.send()
.await?;
assert!(
response.status().is_success(),
"{query}: {}",
response.text().await?
);
assert_eq!(response.json::<i64>().await?, expected, "{query}");
}
Ok(())
}
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.813.0
version: 1.814.0
title: Windmill API
contact:
+47 -25
View File
@@ -4369,12 +4369,12 @@ async fn count_completed_jobs_detail(
Query(query): Query<CountCompletedJobsQuery>,
) -> error::JsonResult<i64> {
let mut sqlb = SqlBuilder::select_from("v2_job_completed");
//FOR RLS
sqlb.join("v2_job USING (id)");
sqlb.field("COUNT(*) as count");
// Filtering on v2_job.workspace_id instead would keep the planner off
// ix_job_workspace_id_completed_at_all and scan the whole retention window.
if !(w_id == "admins" && query.all_workspaces.unwrap_or(false)) {
sqlb.and_where_eq("v2_job.workspace_id", "?".bind(&w_id));
sqlb.and_where_eq("v2_job_completed.workspace_id", "?".bind(&w_id));
}
if let Some(after_s_ago) = query.completed_after_s_ago {
@@ -4393,6 +4393,7 @@ async fn count_completed_jobs_detail(
}
if let Some(tags) = query.tags {
sqlb.join("v2_job USING (id)");
sqlb.and_where_in(
"v2_job.tag",
&tags.split(",").map(|t| quote(t)).collect::<Vec<_>>(),
@@ -4400,7 +4401,19 @@ async fn count_completed_jobs_detail(
}
let sql = sqlb.sql()?;
let stats = sqlx::query_scalar::<_, i64>(&sql).fetch_one(&db).await?;
let mut tx = db.begin().await?;
set_list_jobs_statement_timeout(&mut tx).await?;
let stats = sqlx::query_scalar::<_, i64>(&sql)
.fetch_one(&mut *tx)
.await
.map_err(|e| {
list_jobs_timeout_error(
e,
"Counting completed jobs",
"Lower completed_after_s_ago or narrow the filters.",
)
})?;
tx.commit().await?;
Ok(Json(stats))
}
@@ -4429,6 +4442,33 @@ lazy_static::lazy_static! {
.unwrap_or(30);
}
/// A client that gives up does not cancel its query, so without this bound every retry of a
/// slow filter stacks another scan running until the connection-wide 5min timeout.
async fn set_list_jobs_statement_timeout(tx: &mut Transaction<'_, Postgres>) -> error::Result<()> {
let timeout_secs = *LIST_JOBS_STATEMENT_TIMEOUT_SECS;
if timeout_secs > 0 {
sqlx::query(&format!("SET LOCAL statement_timeout = '{timeout_secs}s'"))
.execute(&mut **tx)
.await?;
}
Ok(())
}
fn list_jobs_timeout_error(e: sqlx::Error, action: &str, hint: &str) -> Error {
let timeout_secs = *LIST_JOBS_STATEMENT_TIMEOUT_SECS;
match e {
sqlx::Error::Database(ref db_err)
if timeout_secs > 0 && db_err.code().as_deref() == Some("57014") =>
{
Error::Generic(
StatusCode::BAD_REQUEST,
format!("{action} took more than {timeout_secs}s and was stopped. {hint}"),
)
}
e => e.into(),
}
}
async fn list_jobs(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
@@ -4545,32 +4585,14 @@ async fn list_jobs(
};
// tracing::info!("sql: {}", &sql);
let mut tx: Transaction<'_, Postgres> = user_db.begin(&authed).await?;
// A client that gives up does not cancel its query, so without this bound every retry of a
// slow filter stacks another scan running until the connection-wide 5min timeout.
let timeout_secs = *LIST_JOBS_STATEMENT_TIMEOUT_SECS;
if timeout_secs > 0 {
sqlx::query(&format!("SET LOCAL statement_timeout = '{timeout_secs}s'"))
.execute(&mut *tx)
.await?;
}
set_list_jobs_statement_timeout(&mut tx).await?;
let jobs: Vec<UnifiedJob> = sqlx::query_as(&sql)
.fetch_all(&mut *tx)
.warn_after_seconds_with_sql(5, format!("list_jobs: {}", sql))
.await
.map_err(|e| match e {
sqlx::Error::Database(ref db_err)
if timeout_secs > 0 && db_err.code().as_deref() == Some("57014") =>
{
Error::Generic(
StatusCode::BAD_REQUEST,
format!(
"Listing jobs took more than {timeout_secs}s and was stopped. Set a start date or narrow the filters."
),
)
}
e => e.into(),
.map_err(|e| {
list_jobs_timeout_error(e, "Listing jobs", "Set a start date or narrow the filters.")
})?;
tx.commit().await?;
+1 -1
View File
@@ -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.813.0";
export const VERSION = "v1.814.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+31 -3
View File
@@ -183,7 +183,7 @@ await chat.sendMessage('Hello')
| `workspace` | Detected inside a raw app. |
| `token` | A token, or a function returning one (called before every request, so it can fetch a short-lived token from your backend). Omit it inside a raw app. |
| `history` | `'server'`, `'local'` or `'none'`, see [History](#history). Defaults to `'server'` with a viewer session and `'local'` with an explicit `token`. |
| `inputs` | Extra flow inputs sent with every message. `sendMessage(text, { inputs })` adds per-message ones. |
| `inputs` | Extra flow inputs sent with every message. `sendMessage(text, { inputs })` adds per-message ones, and `{ attachments, attachmentsInput }` files, see [Attachments](#attachments). |
| `storageKey` | Namespace for `local` history, e.g. the signed-in user's id. Local history is per browser and per flow; without it, users sharing a browser share it. |
| `fetch`, `storage` | Replacements for the globals, for tests and unusual runtimes. |
| `pageSize` | Messages and conversations per page of server history. Default 50. |
@@ -226,8 +226,8 @@ A turn goes `submitted` (the flow is queued) → `streaming` (the answer is arri
answer, an `assistant` message with `success: false`. `status: 'error'` (with `error`
set) means the turn could not run or be followed at all, such as a refused request.
Methods: `sendMessage(text, { inputs? })`, `stop()`, `newConversation()`,
`selectConversation(id)`, `loadConversations({ page?, perPage?, kind? })`,
Methods: `sendMessage(text, { inputs?, attachments?, attachmentsInput? })`, `stop()`,
`newConversation()`, `selectConversation(id)`, `loadConversations({ page?, perPage?, kind? })`,
`deleteConversation(id)`, `renameConversation(id, title)`, `loadOlderMessages()`,
`resumeTurn(turn)`, `refreshMessages()` (reads what another tab added to the open
conversation), `destroy()`. `kind` lists the flow editor's test chats (`'test'`), the deployed flow's
@@ -236,6 +236,34 @@ own (`'deployed'`, the server's default) or both (`'all'`); each `Conversation`
stops following the current answer; the flow keeps running and, with server history,
its answer is there when you come back.
## Attachments
A flow whose AI agent step reads `user_attachments` from an `s3object[]` (or a single
`s3object`) flow input takes files with a message:
```ts
await chat.sendMessage('What does this contract say?', {
attachments: [{ name: file.name, data: file }], // a Blob/File, or a `data:` URL
attachmentsInput: { name: 'files', multiple: true }
})
```
Each file is uploaded to the workspace's object storage under
`windmill_uploads/chat/<turn>/<index>/<name>` and handed to the input as `{ s3, filename }`
objects (the object for a single-file input). Once the uploads return, the pending user
message lists them in `attachments`, as `{ input, s3, filename }` references. The name's
extension is corrected to the file's media type for PNG, JPEG and PDF, because the worker
reads the type off the key.
Files need message text to go with them. A failed upload rejects `sendMessage` before any
run starts, and `stop()` during the upload aborts it; both leave the transcript as it was.
The chat never deletes uploads, so files of a send that did not run stay in storage. The
workspace needs object storage set up. With Enterprise advanced storage permissions, the
user needs read and write on `windmill_uploads/*`, which the default rules grant. The upload goes through
`job_helpers`, so a restricted token needs `job_helpers:write`; a sandboxed raw app cannot
request that scope today, so attachments are not available there yet.
## One turn at a time
A conversation answers one message at a time, and Windmill enforces it: a message sent
while its previous turn still runs (from another tab, or before a reload) is refused.
`sendMessage` then rejects with a `TurnRunningError` and shows nothing of the message.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-chat",
"version": "1.813.0",
"version": "1.814.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-chat",
"version": "1.813.0",
"version": "1.814.0",
"license": "Apache-2.0",
"devDependencies": {
"@ai-sdk/react": "^4.0.102",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "windmill-chat",
"description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app",
"version": "1.813.0",
"version": "1.814.0",
"author": "Ruben Fiszel",
"license": "Apache-2.0",
"homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme",
+27 -1
View File
@@ -281,6 +281,27 @@ export class WindmillChatApi {
return (await res.json()) as FlowConversationMessage[]
}
/**
* Puts bytes in the workspace's object storage under `fileKey` and returns the key they were
* stored under (the server may rewrite it). Needs the workspace to have object storage set.
*/
async uploadFile(
fileKey: string,
body: Blob,
options: { contentType?: string; signal?: AbortSignal } = {}
): Promise<{ file_key: string }> {
const query: Record<string, string> = { file_key: fileKey }
if (options.contentType) query.content_type = options.contentType
const res = await this.#request('job_helpers/upload_s3_file', {
method: 'POST',
query,
raw: body,
contentType: options.contentType || 'application/octet-stream',
signal: options.signal
})
return (await res.json()) as { file_key: string }
}
async deleteConversation(conversationId: string): Promise<void> {
await this.#request(`flow_conversations/delete/${encodeURIComponent(conversationId)}`, {
method: 'DELETE'
@@ -292,7 +313,11 @@ export class WindmillChatApi {
init: {
method?: string
query?: Record<string, string>
/** JSON-encoded. */
body?: unknown
/** Sent as is, under `contentType`. */
raw?: Blob
contentType?: string
accept?: string
signal?: AbortSignal
} = {}
@@ -303,13 +328,14 @@ export class WindmillChatApi {
const headers: Record<string, string> = {}
if (init.accept) headers['Accept'] = init.accept
if (init.body !== undefined) headers['Content-Type'] = 'application/json'
else if (init.raw !== undefined) headers['Content-Type'] = init.contentType ?? 'application/octet-stream'
const token = typeof this.#token === 'function' ? await this.#token() : this.#token
if (token) headers['Authorization'] = `Bearer ${token}`
const res = await this.#fetch(url.toString(), {
method: init.method ?? 'GET',
headers,
body: init.body === undefined ? undefined : JSON.stringify(init.body),
body: init.body === undefined ? init.raw : JSON.stringify(init.body),
// A token must not be paired with ambient cookies; without one, the cookie is
// the credential and only rides same-origin requests.
credentials: token ? 'omit' : 'same-origin',
+116
View File
@@ -0,0 +1,116 @@
import type { WindmillChatApi } from './api'
import type { AttachmentUpload } from './types'
import { abortError, isAbortError } from './utils'
/**
* Where a chat's uploads live in the workspace's object storage. Under `windmill_uploads/`
* because the default Enterprise storage permissions grant every user write and read there
* and deny any other top-level prefix: a key outside it is refused for non-admins, both on
* upload and when the agent's job reads the file back.
*/
export const CHAT_UPLOADS_PREFIX = 'windmill_uploads/chat'
/** What an AI agent step reads out of `user_attachments`. */
export interface UploadedAttachment {
s3: string
filename: string
}
/**
* The extension each type must be stored under. The worker reads an attachment's media type
* from the key's extension only (`mime_guess` in `windmill-ai/src/image_handler.rs`, falling
* back to `image/png`), never from the stored content type, so the extension must be true.
*/
const EXTENSION_BY_MEDIA_TYPE: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'application/pdf': 'pdf'
}
/**
* The name an attachment is stored under: the picked name with the extension its media type
* needs, e.g. a `photo.webp` re-encoded to PNG becomes `photo.png`. Other types keep their name.
*/
export function storedAttachmentName(filename: string, mediaType: string): string {
const extension = EXTENSION_BY_MEDIA_TYPE[mediaType]
if (!extension) return filename
const stem = filename.replace(/\.[^./]+$/, '')
return `${stem || filename}.${extension}`
}
/** The bytes of an attachment as a Blob carrying its media type. */
export function attachmentBlob(attachment: AttachmentUpload): Blob {
const data =
typeof attachment.data === 'string'
? dataUrlToBlob(attachment.data, 'application/octet-stream')
: attachment.data
return attachment.mediaType && attachment.mediaType !== data.type
? new Blob([data], { type: attachment.mediaType })
: data
}
function dataUrlToBlob(dataUrl: string, fallbackType: string): Blob {
const comma = dataUrl.indexOf(',')
if (!dataUrl.startsWith('data:') || comma === -1) {
throw new Error('windmill-chat: an attachment given as a string must be a data: URL')
}
const header = dataUrl.slice(5, comma)
const isBase64 = header.endsWith(';base64')
const mediaType = (isBase64 ? header.slice(0, -';base64'.length) : header) || fallbackType
const payload = dataUrl.slice(comma + 1)
if (!isBase64) return new Blob([decodeURIComponent(payload)], { type: mediaType })
const binary = atob(payload)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
return new Blob([bytes], { type: mediaType })
}
/**
* Put each attachment in the workspace's object storage and hand back what the agent reads.
* The key's turn prefix and per-file index keep two files with the same name, in this turn or
* an earlier one, from overwriting each other; the name stays the last segment.
*/
export async function uploadAttachments(
api: WindmillChatApi,
attachments: AttachmentUpload[],
turnId: string,
signal?: AbortSignal
): Promise<UploadedAttachment[]> {
const prefix = `${CHAT_UPLOADS_PREFIX}/${turnId}`
// One failed upload aborts the rest. Nothing already stored is deleted: the chat never
// removes objects from the workspace's storage, so a send that does not run leaves them.
if (signal?.aborted) throw abortError()
const batch = new AbortController()
const abortBatch = () => batch.abort()
signal?.addEventListener('abort', abortBatch, { once: true })
try {
const results = await Promise.allSettled(
attachments.map(async (attachment, index) => {
try {
const blob = attachmentBlob(attachment)
const filename = storedAttachmentName(
attachment.name || `attachment-${index + 1}`,
blob.type
)
const { file_key } = await api.uploadFile(`${prefix}/${index}/${filename}`, blob, {
contentType: blob.type,
signal: batch.signal
})
return { s3: file_key, filename }
} catch (e) {
batch.abort()
throw e
}
})
)
const reasons = results.flatMap((r) => (r.status === 'rejected' ? [r.reason] : []))
// A stop that lands once every upload has answered still withdraws the batch.
if (reasons.length === 0 && !signal?.aborted) {
return results.flatMap((r) => (r.status === 'fulfilled' ? [r.value] : []))
}
// The failure that started it, not the aborts it caused in the other uploads.
throw reasons.find((reason) => !isAbortError(reason)) ?? reasons[0] ?? abortError()
} finally {
signal?.removeEventListener('abort', abortBatch)
}
}
+106 -21
View File
@@ -10,6 +10,7 @@ import {
import { resolveConfig, type ResolvedConfig } from './config'
import { followJob } from './follow'
import { createLocalHistory, type LocalHistory } from './history'
import { uploadAttachments } from './attachments'
import type { AgentStreamEvent } from './stream'
import type {
Chat,
@@ -17,6 +18,7 @@ import type {
ChatOptions,
ChatState,
Conversation,
SendMessageOptions,
ToolInvocation
} from './types'
import {
@@ -24,6 +26,7 @@ import {
truncateTitle,
errorResultMessage,
extractChatAnswer,
abortError,
isAbortError,
isErrorResult,
now,
@@ -45,6 +48,12 @@ interface Turn {
conversationId: string
/** Id of the turn's user message; the answer is whatever follows it. */
userMessageId: string
/** The turn opened the conversation; withdrawing it closes the conversation again. */
isNew: boolean
/** The run was asked for. Before that, a failure or a stop withdraws the turn instead of failing it. */
started: boolean
/** Both `stop()` and the send's own rejection withdraw; only the first may. */
withdrawn: boolean
jobId?: string
/** The flow job and its step jobs; a persisted answer carries one of them as `job_id`. */
jobIds?: Set<string>
@@ -107,21 +116,34 @@ class ChatImpl implements Chat {
}
}
sendMessage = async (
text: string,
options: { inputs?: Record<string, unknown> } = {}
): Promise<void> => {
sendMessage = async (text: string, options: SendMessageOptions = {}): Promise<void> => {
const content = text.trim()
if (!content) return
if (!content) {
// A run needs a message; files alone would otherwise be dropped without a word.
if (options.attachments?.length) throw new Error('windmill-chat: attachments need a message to go with them')
return
}
if (this.#turn) {
throw new Error('windmill-chat: a message is already being answered; call stop() first')
}
const attachments = options.attachments ?? []
const attachmentsInput = options.attachmentsInput
if (attachments.length > 0 && !attachmentsInput) {
throw new Error('windmill-chat: attachments need `attachmentsInput`, the flow input that takes them')
}
if (attachmentsInput && !attachmentsInput.multiple && attachments.length > 1) {
// Uploading all of them would run with the first and leave the rest stranded in storage.
throw new Error(`windmill-chat: \`${attachmentsInput.name}\` holds one file; got ${attachments.length}`)
}
const isNew = this.#state.conversationId === undefined
const conversationId = this.#state.conversationId ?? randomId()
const turn: Turn = {
controller: new AbortController(),
conversationId,
userMessageId: `pending-${randomId()}`,
isNew,
started: false,
withdrawn: false,
streamedText: false
}
this.#turn = turn
@@ -131,10 +153,8 @@ class ChatImpl implements Chat {
(c) => c.id === conversationId
) ?? { id: conversationId, title: conversationTitle(content), createdAt: timestamp, updatedAt: timestamp }
const touched = { ...conversation, updatedAt: timestamp }
const listed = this.#state.conversations
this.#set({
conversationId,
conversations: [touched, ...listed.filter((c) => c.id !== conversationId)],
messages: [
...this.#state.messages,
{ id: turn.userMessageId, role: 'user', content, success: true, createdAt: timestamp, pending: true }
@@ -142,30 +162,47 @@ class ChatImpl implements Chat {
status: 'submitted',
error: undefined
})
this.#rememberConversation()
try {
const args = { ...this.#config.inputs, ...options.inputs, user_message: content }
const args: Record<string, unknown> = { ...this.#config.inputs, ...options.inputs, user_message: content }
if (attachmentsInput && attachments.length > 0) {
// Uploaded with the turn already shown as submitted: the message is in the transcript
// and `stop()` can abort the upload, while a second send is refused as usual.
const uploaded = await uploadAttachments(this.#api, attachments, randomId(), turn.controller.signal)
args[attachmentsInput.name] = attachmentsInput.multiple ? uploaded : uploaded[0]
// Shown on the pending message until its server row replaces it, carrying its own.
const carried = uploaded.map((u) => ({ input: attachmentsInput.name, s3: u.s3, filename: u.filename }))
if (this.#turnActive(turn)) {
this.#set({
messages: this.#state.messages.map((m) => (m.id === turn.userMessageId ? { ...m, attachments: carried } : m))
})
}
}
// Nothing may start once stop() or a conversation switch has withdrawn the turn, including
// a stop from a subscriber told of the attachments just above.
if (turn.controller.signal.aborted) throw abortError()
turn.started = true
// Listed only once the run is asked for: a send that never runs (an upload that failed
// or was stopped) then has no conversation entry to take back.
this.#set({
conversations: [touched, ...this.#state.conversations.filter((c) => c.id !== conversationId)]
})
this.#rememberConversation()
const context = { memoryId: conversationId, conversationId, signal: turn.controller.signal }
turn.jobId = this.#config.run
? await this.#config.run(args, context)
: await this.#api.runFlow(this.#config.flowPath, args, context)
} catch (e) {
try {
// stop() and a conversation switch abort the turn and settle the state themselves.
if (turn.controller.signal.aborted || isAbortError(e)) return
if (e instanceof TurnRunningError) {
// Nothing of this message reached the server: it goes, and the caller decides
// whether to follow the running turn and send it again.
if (this.#turnActive(turn)) {
this.#set({
messages: this.#state.messages.filter((m) => m.id !== turn.userMessageId),
conversations: listed,
status: 'idle'
})
}
// Nothing of this message reached the server, whether the conversation was already
// answering one sent elsewhere or an attachment never uploaded: the message is taken
// back rather than shown as a failed turn, and the caller gets the reason.
if (e instanceof TurnRunningError || !turn.started) {
this.#withdrawTurn(turn)
throw e
}
// stop() and a conversation switch abort the turn and settle the state themselves.
if (turn.controller.signal.aborted || isAbortError(e)) return
this.#failTurn(turn, e)
return
} finally {
@@ -183,6 +220,11 @@ class ChatImpl implements Chat {
conversationId,
userMessageId: '',
jobId,
// Its run was asked for elsewhere and is already going: there is nothing to withdraw,
// and the conversation it belongs to is listed.
isNew: false,
started: true,
withdrawn: false,
streamedText: false
}
this.#turn = turn
@@ -230,6 +272,13 @@ class ChatImpl implements Chat {
}
await this.#finishTurn(turn, result, isNew)
} catch (e) {
if (!turn.started) {
// Nothing ran: the message is withdrawn rather than shown as a failed turn, and the
// caller gets the reason (an upload that failed, or the AbortError of a stop()).
if (this.#turn === turn) this.#turn = undefined
this.#withdrawTurn(turn)
throw e
}
// stop() and a conversation switch abort the turn and settle the state themselves.
if (turn.controller.signal.aborted || isAbortError(e)) return
this.#failTurn(turn, e)
@@ -242,6 +291,12 @@ class ChatImpl implements Chat {
const turn = this.#turn
if (!turn) return
this.#detachTurn()
if (!turn.started) {
// Still uploading its attachments: there is no run to cancel, and the message the
// reader took back must not stay in the transcript as sent.
this.#withdrawTurn(turn)
return
}
if (this.#state.conversationId === turn.conversationId) {
this.#set({ messages: finalized(this.#state.messages), status: 'idle' })
this.#persistLocal()
@@ -708,6 +763,36 @@ class ChatImpl implements Chat {
}
}
/**
* Take back the user message of a turn that never ran. A conversation it would have opened
* was never listed (see `sendMessage`), so only the message goes, and, while it is the turn
* on screen, the busy status. A switch away mid-upload has already written the message to
* local history, so it is removed there too.
*/
#withdrawTurn(turn: Turn): void {
if (turn.withdrawn) return
turn.withdrawn = true
const id = turn.conversationId
const withoutTurn = (messages: ChatMessage[]) => messages.filter((m) => m.id !== turn.userMessageId)
if (this.#state.conversationId === id) {
const messages = withoutTurn(this.#state.messages)
// A turn started since, such as a resend right after Stop, owns the status.
const newerTurn = this.#turn !== undefined && this.#turn !== turn
if (newerTurn) {
this.#set({ messages })
} else {
const unopened = turn.isNew && messages.length === 0
this.#set({ messages, status: 'idle', error: undefined, ...(unopened ? { conversationId: undefined } : {}) })
}
this.#persistLocal()
}
if (this.#state.history === 'local' && this.#state.conversationId !== id) {
const stored = withoutTurn(this.#local.getMessages(id))
if (stored.length > 0) this.#local.saveMessages(id, stored)
else if (!this.#state.conversations.some((c) => c.id === id)) this.#local.deleteConversation(id)
}
}
#failTurn(turn: Turn, e: unknown): void {
if (!this.#turnActive(turn)) return
const error = toError(e)
+4
View File
@@ -17,7 +17,10 @@ export {
export { parseStreamEvents, createStreamEventParser, type AgentStreamEvent } from './stream'
export { followJob, type FollowEvent } from './follow'
export { extractChatAnswer, conversationIdFor } from './utils'
export { storedAttachmentName, uploadAttachments, CHAT_UPLOADS_PREFIX, type UploadedAttachment } from './attachments'
export type {
AttachmentsInput,
AttachmentUpload,
Chat,
ChatAttachment,
ChatMessage,
@@ -28,6 +31,7 @@ export type {
Conversation,
FetchLike,
HistoryMode,
SendMessageOptions,
StorageLike,
TokenSource,
ToolInvocation
+34 -4
View File
@@ -134,17 +134,47 @@ export interface ChatOptions {
onError?: (error: Error, turn: { conversationId: string; jobId?: string }) => void
}
/** A file sent with a message. It is uploaded to the workspace's object storage before the run starts. */
export interface AttachmentUpload {
/** Kept as the last segment of the stored key, its extension corrected to the media type for PNG, JPEG and PDF. */
name: string
/** The bytes: a Blob, or a `data:` URL of them. */
data: Blob | string
/** The file's media type. Defaults to the Blob's own type, or the data URL's. */
mediaType?: string
}
/** The flow input the uploaded attachments are handed to: an `s3object` (`multiple: false`) or an `s3object[]`. */
export interface AttachmentsInput {
name: string
multiple: boolean
}
export interface SendMessageOptions {
/** Extra flow inputs for this message, on top of `ChatOptions.inputs`. */
inputs?: Record<string, unknown>
/**
* Files to upload and hand to the flow as `{ s3, filename }` objects in `attachmentsInput`,
* the way an AI agent step reads `user_attachments`. A failed upload rejects `sendMessage`
* and the run never starts; `stop()` during the upload does the same with an `AbortError`.
*/
attachments?: AttachmentUpload[]
/** Required with `attachments`, which also need message text. With `multiple: false`, more than one attachment is refused before anything uploads. */
attachmentsInput?: AttachmentsInput
}
export interface Chat {
getState(): ChatState
/** Calls `listener` now and on every change; returns the unsubscribe function (Svelte store contract). */
subscribe(listener: (state: ChatState) => void): () => void
/**
* Sends a message in the current conversation, starting one when there is none. Resolves
* when the answer is complete. Rejects with `TurnRunningError`, and shows nothing of the
* message, when the conversation is still answering one sent elsewhere: follow that turn
* with `resumeTurn`, then send again.
* when the answer is complete. Rejects when the message could not be sent at all — a turn
* already running, an attachment that failed to upload — without touching the transcript.
* A conversation still answering a message sent elsewhere rejects with `TurnRunningError`:
* follow that turn with `resumeTurn`, then send again.
*/
sendMessage(text: string, options?: { inputs?: Record<string, unknown> }): Promise<void>
sendMessage(text: string, options?: SendMessageOptions): Promise<void>
/**
* Follows a turn of the current conversation that this chat did not start, as named by
* `Conversation.runningTurn` or a `TurnRunningError`: its answer streams into `messages`
+444
View File
@@ -0,0 +1,444 @@
import { describe, expect, test } from 'bun:test'
import { WindmillChatApi } from '../src/api'
import { storedAttachmentName, uploadAttachments } from '../src/attachments'
import { createChat } from '../src/chat'
import type { ChatOptions } from '../src/types'
import { abortError } from '../src/utils'
import { fetchMock, json, memoryStorage, sse, text, type RecordedCall, type Route } from './support'
const BASE = 'http://wm.test'
const FLOW = 'f/chat/agent'
const UPLOAD_PATH = '/api/w/ws/job_helpers/upload_s3_file'
const run: Route = (c) =>
c.method === 'POST' && c.url.pathname === `/api/w/ws/jobs/run/f/${FLOW}`
? text('job-1')
: undefined
/** Stores under the key it was asked to, like the server with a `file_key`. */
const upload: Route = (c) =>
c.url.pathname === UPLOAD_PATH
? json({ file_key: c.url.searchParams.get('file_key') })
: undefined
const answer: Route = (c) =>
c.url.pathname === '/api/w/ws/jobs_u/getupdate_sse/job-1'
? sse([
{
type: 'update',
completed: true,
only_result: { output: 'ok', messages: [] }
}
])
: undefined
function options(fetch: ChatOptions['fetch']): ChatOptions {
return {
flowPath: FLOW,
baseUrl: BASE,
workspace: 'ws',
token: 'tok',
fetch,
storage: memoryStorage()
}
}
const uploads = (calls: RecordedCall[]) => calls.filter((c) => c.url.pathname === UPLOAD_PATH)
const runs = (calls: RecordedCall[]) =>
calls.filter((c) => c.url.pathname.startsWith('/api/w/ws/jobs/run/'))
const png = new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], {
type: 'image/png'
})
const pdf = new Blob(['%PDF-1.7'], { type: 'application/pdf' })
describe('storedAttachmentName', () => {
// The worker reads the media type from the key's extension, so it has to match the bytes.
test('renames a re-encoded image and gives a bare name its extension', () => {
expect(storedAttachmentName('photo.webp', 'image/png')).toBe('photo.png')
expect(storedAttachmentName('holiday.png', 'image/jpeg')).toBe('holiday.jpg')
expect(storedAttachmentName('contract', 'application/pdf')).toBe('contract.pdf')
expect(storedAttachmentName('report.2026.final.webp', 'image/png')).toBe(
'report.2026.final.png'
)
})
test('leaves a type it does not know alone', () => {
expect(storedAttachmentName('notes.csv', 'text/csv')).toBe('notes.csv')
})
})
describe('sendMessage with attachments', () => {
test('uploads each file under the turn prefix and hands the list to the input', async () => {
const { fetch, calls } = fetchMock(upload, run, answer)
const chat = createChat(options(fetch))
await chat.sendMessage('read these', {
inputs: { locale: 'fr' },
attachments: [
{ name: 'photo.webp', data: png },
{ name: 'contract', data: pdf },
// A data URL is decoded to its bytes; the mediaType names what they are.
{
name: 'photo.webp',
data: `data:image/png;base64,${btoa('\x89PNG')}`
}
],
attachmentsInput: { name: 'files', multiple: true }
})
const keys = uploads(calls).map((c) => c.url.searchParams.get('file_key')!)
expect(keys).toHaveLength(3)
const prefix = keys[0].split('/').slice(0, 3).join('/')
expect(prefix).toMatch(/^windmill_uploads\/chat\/[0-9a-f-]{36}$/)
expect(keys).toEqual([
`${prefix}/0/photo.png`,
`${prefix}/1/contract.pdf`,
`${prefix}/2/photo.png`
])
expect(uploads(calls).map((c) => c.url.searchParams.get('content_type'))).toEqual([
'image/png',
'application/pdf',
'image/png'
])
expect(uploads(calls).map((c) => c.headers['content-type'])).toEqual([
'image/png',
'application/pdf',
'image/png'
])
expect(new Uint8Array(await uploads(calls)[2].raw!.arrayBuffer())).toEqual(
new Uint8Array([0x89, 0x50, 0x4e, 0x47])
)
expect(runs(calls)[0].body).toEqual({
locale: 'fr',
user_message: 'read these',
files: [
{ s3: `${prefix}/0/photo.png`, filename: 'photo.png' },
{ s3: `${prefix}/1/contract.pdf`, filename: 'contract.pdf' },
{ s3: `${prefix}/2/photo.png`, filename: 'photo.png' }
]
})
expect(chat.getState().status).toBe('idle')
})
test('hands a single object to an input that holds one file', async () => {
const { fetch, calls } = fetchMock(upload, run, answer)
const chat = createChat(options(fetch))
await chat.sendMessage('read this', {
attachments: [{ name: 'contract.pdf', data: pdf }],
attachmentsInput: { name: 'file', multiple: false }
})
const body = runs(calls)[0].body as Record<string, unknown>
expect(body.file).toEqual({
s3: expect.stringMatching(/\/0\/contract\.pdf$/),
filename: 'contract.pdf'
})
})
test('refuses several files for an input that holds one, before uploading any', async () => {
const { fetch, calls } = fetchMock(upload, run, answer)
const chat = createChat(options(fetch))
await expect(
chat.sendMessage('read these', {
attachments: [
{ name: 'a.pdf', data: pdf },
{ name: 'b.png', data: png }
],
attachmentsInput: { name: 'file', multiple: false }
})
).rejects.toThrow('holds one file')
expect(calls).toHaveLength(0)
expect(chat.getState().messages).toEqual([])
})
test('refuses attachments without message text, before uploading', async () => {
const { fetch, calls } = fetchMock(upload, run, answer)
const chat = createChat(options(fetch))
await expect(
chat.sendMessage(' ', {
attachments: [{ name: 'a.pdf', data: pdf }],
attachmentsInput: { name: 'files', multiple: true }
})
).rejects.toThrow('need a message')
expect(calls).toHaveLength(0)
})
test('refuses attachments without an input to put them in', async () => {
const { fetch, calls } = fetchMock(upload, run, answer)
const chat = createChat(options(fetch))
await expect(
chat.sendMessage('hi', { attachments: [{ name: 'a.pdf', data: pdf }] })
).rejects.toThrow('attachmentsInput')
expect(calls).toHaveLength(0)
})
test('a failed upload rejects without a run, and withdraws the message', async () => {
const { fetch, calls } = fetchMock(
(c) => (c.url.pathname === UPLOAD_PATH ? text('no object storage', 500) : undefined),
run,
answer
)
const chat = createChat(options(fetch))
const statuses: string[] = []
chat.subscribe((s) => statuses.push(s.status))
await expect(
chat.sendMessage('read this', {
attachments: [{ name: 'contract.pdf', data: pdf }],
attachmentsInput: { name: 'files', multiple: true }
})
).rejects.toThrow('no object storage')
expect(runs(calls)).toHaveLength(0)
// Shown as submitted while uploading, then withdrawn whole: no message, no conversation.
expect(statuses).toContain('submitted')
const state = chat.getState()
expect(state.status).toBe('idle')
expect(state.messages).toEqual([])
expect(state.conversationId).toBeUndefined()
expect(state.conversations).toEqual([])
// The chat is free for the next message.
await chat.sendMessage('plain')
expect(runs(calls)).toHaveLength(1)
})
test('stop() during the upload aborts it and withdraws the message', async () => {
const { fetch, calls } = fetchMock(
(c) =>
c.url.pathname === UPLOAD_PATH
? new Promise((_, reject) =>
c.signal!.addEventListener('abort', () => reject(abortError()))
)
: undefined,
run,
answer
)
const chat = createChat(options(fetch))
const sending = chat.sendMessage('read this', {
attachments: [{ name: 'contract.pdf', data: pdf }],
attachmentsInput: { name: 'files', multiple: true }
})
await new Promise((r) => setTimeout(r, 0))
expect(chat.getState().status).toBe('submitted')
await chat.stop()
await expect(sending).rejects.toMatchObject({ name: 'AbortError' })
expect(runs(calls)).toHaveLength(0)
expect(chat.getState()).toMatchObject({
status: 'idle',
messages: [],
conversationId: undefined
})
})
test('a switch away mid-upload leaves no conversation behind', async () => {
const storage = memoryStorage()
const { fetch, calls } = fetchMock(
(c) =>
c.url.pathname === UPLOAD_PATH
? new Promise((_, reject) =>
c.signal!.addEventListener('abort', () => reject(abortError()))
)
: undefined,
run,
answer
)
const chat = createChat({ ...options(fetch), storage })
const sending = chat.sendMessage('never runs', {
attachments: [{ name: 'contract.pdf', data: pdf }],
attachmentsInput: { name: 'files', multiple: true }
})
await new Promise((r) => setTimeout(r, 0))
const opened = chat.getState().conversationId!
chat.newConversation()
await expect(sending).rejects.toMatchObject({ name: 'AbortError' })
expect(runs(calls)).toHaveLength(0)
expect(chat.getState().conversations.map((c) => c.id)).not.toContain(opened)
const reloaded = createChat({ ...options(fetch), storage })
expect((await reloaded.loadConversations()).map((c) => c.id)).not.toContain(opened)
})
test('a failed upload aborts the rest of its batch and deletes nothing', async () => {
let first: (r: Response) => void = () => {}
const { fetch, calls } = fetchMock(
(c) => {
if (c.url.pathname !== UPLOAD_PATH) return undefined
const key = c.url.searchParams.get('file_key')!
// The first file lands after the second has already failed.
if (key.includes('/0/')) return new Promise<Response>((resolve) => (first = resolve))
setTimeout(() => first(json({ file_key: keys()[0] })), 5)
return text('quota exceeded', 507)
},
run,
answer
)
const keys = () => uploads(calls).map((c) => c.url.searchParams.get('file_key')!)
const chat = createChat(options(fetch))
await expect(
chat.sendMessage('read these', {
attachments: [
{ name: 'a.pdf', data: pdf },
{ name: 'b.png', data: png }
],
attachmentsInput: { name: 'files', multiple: true }
})
).rejects.toThrow('quota exceeded')
// The upload still in flight when the other failed was told to stop.
expect(uploads(calls)[0].signal?.aborted).toBe(true)
expect(calls.filter((c) => c.method === 'DELETE')).toEqual([])
expect(runs(calls)).toHaveLength(0)
})
test('a send made right after stop() is not reset by the stopped upload', async () => {
let releaseRun: (r: Response) => void = () => {}
const { fetch, calls } = fetchMock(
(c) =>
c.url.pathname === UPLOAD_PATH
? new Promise((_, reject) =>
c.signal!.addEventListener('abort', () => reject(abortError()))
)
: undefined,
(c) =>
c.method === 'POST' && c.url.pathname === `/api/w/ws/jobs/run/f/${FLOW}`
? new Promise<Response>((resolve) => (releaseRun = resolve))
: undefined,
answer
)
const chat = createChat(options(fetch))
// An existing conversation, so the stopped turn and the next one share it.
const first = chat.sendMessage('first')
await new Promise((r) => setTimeout(r, 0))
releaseRun(text('job-1'))
await first
const stopped = chat.sendMessage('with a file', {
attachments: [{ name: 'a.pdf', data: pdf }],
attachmentsInput: { name: 'files', multiple: true }
})
await new Promise((r) => setTimeout(r, 0))
void chat.stop()
const next = chat.sendMessage('right after')
await expect(stopped).rejects.toMatchObject({ name: 'AbortError' })
await new Promise((r) => setTimeout(r, 0))
expect(chat.getState().status).toBe('submitted')
expect(chat.getState().messages.map((m) => m.content)).toContain('right after')
expect(chat.getState().messages.map((m) => m.content)).not.toContain('with a file')
releaseRun(text('job-1'))
await next
expect(runs(calls)).toHaveLength(2)
})
test('a conversation is listed only once its run starts', async () => {
let failUpload: (r: Response) => void = () => {}
const { fetch } = fetchMock(
(c) =>
c.url.pathname === UPLOAD_PATH
? new Promise<Response>((resolve) => (failUpload = resolve))
: undefined,
run,
answer
)
const chat = createChat(options(fetch))
const sending = chat.sendMessage('read this', {
attachments: [{ name: 'a.pdf', data: pdf }],
attachmentsInput: { name: 'files', multiple: true }
})
await new Promise((r) => setTimeout(r, 0))
expect(chat.getState()).toMatchObject({ status: 'submitted', conversations: [] })
failUpload(text('boom', 500))
await expect(sending).rejects.toThrow('boom')
expect(chat.getState().conversations).toEqual([])
})
test('an already aborted signal uploads nothing', async () => {
const { fetch, calls } = fetchMock(upload)
const api = new WindmillChatApi({ baseUrl: BASE, workspace: 'ws', token: 'tok', fetch })
const controller = new AbortController()
controller.abort()
await expect(
uploadAttachments(api, [{ name: 'a.pdf', data: pdf }], 'turn', controller.signal)
).rejects.toMatchObject({ name: 'AbortError' })
expect(calls).toHaveLength(0)
})
test('the pending user message carries its uploaded files before the run returns', async () => {
let releaseRun: (r: Response) => void = () => {}
const { fetch, calls } = fetchMock(
upload,
(c) =>
c.method === 'POST' && c.url.pathname === `/api/w/ws/jobs/run/f/${FLOW}`
? new Promise<Response>((resolve) => (releaseRun = resolve))
: undefined,
answer
)
const chat = createChat(options(fetch))
const sending = chat.sendMessage('read these', {
attachments: [
{ name: 'photo.webp', data: png },
{ name: 'contract', data: pdf }
],
attachmentsInput: { name: 'files', multiple: true }
})
while (runs(calls).length === 0) await new Promise((r) => setTimeout(r, 1))
const keys = uploads(calls).map((c) => c.url.searchParams.get('file_key')!)
const pending = chat.getState().messages.find((m) => m.role === 'user')!
expect(pending.pending).toBe(true)
expect(pending.attachments).toEqual([
{ input: 'files', s3: keys[0], filename: 'photo.png' },
{ input: 'files', s3: keys[1], filename: 'contract.pdf' }
])
releaseRun(text('job-1'))
await sending
})
test('an explicit mediaType wins over the type a data URL declares', async () => {
const { fetch, calls } = fetchMock(upload, run, answer)
const chat = createChat(options(fetch))
await chat.sendMessage('read this', {
attachments: [
{
name: 'contract',
data: `data:application/octet-stream;base64,${btoa('%PDF')}`,
mediaType: 'application/pdf'
}
],
attachmentsInput: { name: 'files', multiple: true }
})
const call = uploads(calls)[0]
expect(call.url.searchParams.get('file_key')).toMatch(/\/0\/contract\.pdf$/)
expect(call.url.searchParams.get('content_type')).toBe('application/pdf')
})
test('stop() after the uploads land but before the run starts runs nothing', async () => {
const { fetch, calls } = fetchMock(upload, run, answer)
const chat = createChat(options(fetch))
const sending = chat.sendMessage('read this', {
attachments: [{ name: 'contract.pdf', data: pdf }],
attachmentsInput: { name: 'files', multiple: true }
})
// The upload responds at once; Stop lands before the send resumes after it.
while (uploads(calls).length === 0) await Promise.resolve()
await chat.stop()
await expect(sending).rejects.toMatchObject({ name: 'AbortError' })
expect(runs(calls)).toHaveLength(0)
expect(calls.filter((c) => c.method === 'DELETE')).toEqual([])
expect(chat.getState()).toMatchObject({ status: 'idle', messages: [], conversations: [] })
})
test('a subscriber stopping when the attachments appear runs nothing', async () => {
const { fetch, calls } = fetchMock(upload, run, answer)
const chat = createChat(options(fetch))
chat.subscribe((s) => {
if (s.messages.some((m) => m.attachments)) void chat.stop()
})
await expect(
chat.sendMessage('read this', {
attachments: [{ name: 'contract.pdf', data: pdf }],
attachmentsInput: { name: 'files', multiple: true }
})
).rejects.toMatchObject({ name: 'AbortError' })
expect(runs(calls)).toHaveLength(0)
expect(calls.filter((c) => c.method === 'DELETE')).toEqual([])
expect(chat.getState()).toMatchObject({ status: 'idle', messages: [], conversations: [] })
})
})
+2 -1
View File
@@ -962,7 +962,8 @@ describe('createChat with server history', () => {
const state = chat.getState()
expect(state.messages.map((m) => m.content)).toEqual(['earlier'])
expect(state.status).toBe('idle')
expect(state.conversations).toEqual([])
// The conversation is a real one, answering elsewhere: it stays listed, its message gone.
expect(state.conversations.map((c) => c.id)).toEqual(['conv'])
})
test('resuming a turn whose message is off the first page replays it without duplicating rows', async () => {
+6 -1
View File
@@ -5,6 +5,9 @@ export interface RecordedCall {
url: URL
headers: Record<string, string>
body: unknown
/** A body sent as is rather than as JSON (an upload). */
raw?: Blob
signal?: AbortSignal
}
export type Route = (call: RecordedCall) => Response | Promise<Response> | undefined
@@ -20,7 +23,9 @@ export function fetchMock(...routes: Route[]): { fetch: FetchLike; calls: Record
headers: Object.fromEntries(
Object.entries((init?.headers as Record<string, string>) ?? {}).map(([k, v]) => [k.toLowerCase(), v])
),
body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined
body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined,
raw: init?.body instanceof Blob ? init.body : undefined,
signal: init?.signal ?? undefined
}
calls.push(call)
for (const route of routes) {
+1 -1
View File
@@ -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.813.0";
export const VERSION = "1.814.0";
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@windmill-labs/components",
"version": "1.813.0",
"version": "1.814.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/components",
"version": "1.813.0",
"version": "1.814.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill-labs/components",
"version": "1.813.0",
"version": "1.814.0",
"scripts": {
"dev": "vite dev",
"dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev",
@@ -475,7 +475,9 @@
return jobId ?? ''
}}
conversationKind="test"
frame="boxed"
path={$pathStore}
identity={$initialPathStore || fakeInitialPath}
inputSchema={flowStore.val.schema}
flowModules={flowStore.val.value?.modules}
/>
@@ -562,7 +564,13 @@
</div>
{/if}
{/if}
<div class="pt-4 flex flex-col border-t relative">
<!-- The rule divides the inputs form from its results. Chat mode has no form: the
chat is its own panel, and a second line right under it reads as a stray edge. -->
<div
class="pt-4 flex flex-col relative {flowStore.val.value?.chat_input_enabled
? ''
: 'border-t'}"
>
{#if flowHasChanged()}
<div class="pb-2">
<div
@@ -8,7 +8,7 @@
import type { PickableProperties } from './flows/previousResults'
import InputTransformForm from './InputTransformForm.svelte'
import InputTransformPickers from './InputTransformPickers.svelte'
import { useS3StorageConfigured } from './inputTransformEnv.svelte'
import { useWorkspaceStorageConfigured } from './inputTransformEnv.svelte'
import type ItemPicker from './ItemPicker.svelte'
import type VariableEditor from './VariableEditor.svelte'
import ResizeTransitionWrapper from './common/ResizeTransitionWrapper.svelte'
@@ -86,7 +86,7 @@
let itemPicker: ItemPicker | undefined = $state(undefined)
let variableEditor: VariableEditor | undefined = $state(undefined)
const s3Storage = useS3StorageConfigured(() => ws)
const s3Storage = useWorkspaceStorageConfigured(() => ws)
let keys: string[] = $state([])
$effect(() => {
@@ -0,0 +1,66 @@
<script lang="ts">
/**
* A soft edge on a scroller, so content scrolling out of view fades instead of being
* cut against whatever borders it.
*
* Rendered as an overlay in the scroller's positioned ancestor rather than inside the
* scroller: `sticky` would resolve against the scroller's padding box and leave the
* first few pixels unfaded. It shows only when there is something hidden in that
* direction, so a transcript that fits shows no edge at all.
*/
import { twMerge } from 'tailwind-merge'
interface Props {
/** The scrolling element this masks. */
scroller: HTMLElement | undefined
edge?: 'top' | 'bottom'
/** Tailwind colour stop to fade from — the surface the scroller sits on. */
from?: string
/** Tailwind height of the fade band. */
height?: string
class?: string
}
let {
scroller,
edge = 'top',
from = 'from-surface',
height = 'h-4',
class: className = ''
}: Props = $props()
let hidden = $state(true)
$effect(() => {
const el = scroller
if (!el) return
const update = () => {
// A pixel of slack: fractional scroll offsets otherwise leave the bottom edge
// showing on a scroller that is already at its end.
hidden =
edge === 'top' ? el.scrollTop <= 1 : el.scrollTop + el.clientHeight >= el.scrollHeight - 1
}
update()
el.addEventListener('scroll', update, { passive: true })
// Content arriving or the pane resizing changes what is hidden without a scroll.
const observer = new ResizeObserver(update)
observer.observe(el)
if (el.firstElementChild) observer.observe(el.firstElementChild)
return () => {
el.removeEventListener('scroll', update)
observer.disconnect()
}
})
</script>
<div
class={twMerge(
'pointer-events-none absolute inset-x-0 transition-opacity duration-150',
edge === 'top' ? 'top-0 bg-gradient-to-b' : 'bottom-0 bg-gradient-to-t',
from,
'to-transparent',
height,
hidden ? 'opacity-0' : 'opacity-100',
className
)}
></div>
@@ -35,6 +35,7 @@
import ChatQuickActions from './ChatQuickActions.svelte'
import ContextUsageIndicator from './ContextUsageIndicator.svelte'
import AIChatModelSettings from './AIChatModelSettings.svelte'
import ScrollFade from '$lib/components/ScrollFade.svelte'
import AssistantSettingsModal from './AssistantSettingsModal.svelte'
import { SkillsMenu } from './skills/skillsMenu.svelte'
import { McpMenu } from '$lib/components/mcp/mcpMenu.svelte'
@@ -361,7 +362,15 @@
chatHost.mode === AIMode.SCRIPT || chatHost.mode === AIMode.FLOW || chatHost.mode === AIMode.APP
)
const canAttachFiles = $derived(chatHost.supportsMessageAttachments && !disabled)
// Why attaching is off, when this chat takes attachments but cannot right now. The `+` is
// kept and disabled rather than dropped: the input is the composer's either way, so the
// reader has to be able to see here why nothing can be attached.
const attachmentsOffReason = $derived(
chatHost.supportsMessageAttachments ? chatHost.attachmentsUnavailableReason : undefined
)
const canAttachFiles = $derived(
chatHost.supportsMessageAttachments && !disabled && !attachmentsOffReason
)
// Folders are linked as session-wide assets, which only a host that reads files in
// the browser can do — a host running the turn server-side takes attachments only.
const canLinkFolders = $derived(chatHost.supportsLinkedFolders && !disabled)
@@ -430,24 +439,32 @@
return Array.from(e.dataTransfer?.types ?? []).includes('Files')
}
// A drop is claimed while attaching is off for a stated reason, too: the browser would
// otherwise navigate to the dropped file, and the reader is owed the reason instead.
const panelTakesDrops = $derived(canAttachFiles || attachmentsOffReason !== undefined)
function onPanelDragEnter(e: DragEvent) {
if (!canAttachFiles || !dragHasFiles(e)) return
if (!panelTakesDrops || !dragHasFiles(e)) return
e.preventDefault()
dragDepth++
}
function onPanelDragOver(e: DragEvent) {
if (!canAttachFiles || !dragHasFiles(e)) return
if (!panelTakesDrops || !dragHasFiles(e)) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
function onPanelDragLeave(_e: DragEvent) {
if (!canAttachFiles) return
if (!panelTakesDrops) return
dragDepth = Math.max(0, dragDepth - 1)
}
async function onPanelDrop(e: DragEvent) {
dragDepth = 0
if (!canAttachFiles || !dragHasFiles(e)) return
if (!panelTakesDrops || !dragHasFiles(e)) return
e.preventDefault()
if (attachmentsOffReason) {
sendUserToast(attachmentsOffReason, true)
return
}
const dt = e.dataTransfer
if (!dt) return
// Images and loose text files attach to the message; folders link as session
@@ -484,9 +501,8 @@
handles.length === 0
? flatFiles
: await Promise.all(handles.filter(isFileHandle).map((h) => h.getFile()))
// Loose text files attach to the message, like images.
const textFiles = looseFiles.filter((f) => !isImageFile(f))
if (textFiles.length > 0) await aiChatInput?.addTextFiles(textFiles)
// Loose files attach to the message, like images.
await attachNonImageFiles(looseFiles.filter((f) => !isImageFile(f)))
// Folders link as a live handle.
const dirs = handles.filter(isDirectoryHandle)
if (dirs.length > 0 && !canLinkFolders) {
@@ -523,24 +539,31 @@
if (canLinkFolders) await handleAddFiles(folderEntries)
else sendUserToast('Folders cannot be attached in this chat — drop individual files.', true)
}
if (topLevelText.length > 0) await aiChatInput?.addTextFiles(topLevelText)
await attachNonImageFiles(topLevelText)
}
}
async function onFileInputChange(e: Event) {
const input = e.currentTarget as HTMLInputElement
if (input.files && input.files.length > 0) {
const picked = Array.from(input.files)
const imageFiles = picked.filter(isImageFile)
const textFiles = picked.filter((f) => !isImageFile(f))
// Reserved before the text work is awaited — see onPanelDrop.
const imageWork = imageFiles.length > 0 ? aiChatInput?.addImages(imageFiles) : undefined
if (textFiles.length > 0) await aiChatInput?.addTextFiles(textFiles)
await imageWork
await attachPickedFiles(Array.from(input.files))
}
input.value = '' // allow re-selecting the same file
}
async function attachNonImageFiles(files: File[]) {
await aiChatInput?.addNonImageFiles(files)
}
async function attachPickedFiles(picked: File[]) {
const imageFiles = picked.filter(isImageFile)
const others = picked.filter((f) => !isImageFile(f))
// Reserved before the other work is awaited — see onPanelDrop.
const imageWork = imageFiles.length > 0 ? aiChatInput?.addImages(imageFiles) : undefined
await attachNonImageFiles(others)
await imageWork
}
function onFolderInputChange(e: Event) {
const input = e.currentTarget as HTMLInputElement
// webkitdirectory files carry webkitRelativePath (`folder/sub/file`); addFiles groups
@@ -569,6 +592,15 @@
// The typing-dots indicator implies the AI is busy, which is misleading while
// the loop is parked on the user; surface a text pill instead so users know to
// act on the tool above.
// A step name hangs its icon in the column's left padding (see AssistantMessage), so a
// transcript carrying one widens the padding, on both sides to keep the column centred.
const agentGutter = $derived(messages.some((m) => m.role === 'assistant' && m.stepName))
const columnClass = $derived(
wideLayout
? `w-full max-w-3xl mx-auto ${agentGutter ? 'px-8' : 'px-7'}`
: `w-full max-w-2xl mx-auto ${agentGutter ? 'px-8' : 'px-3'}`
)
const waitingForUserAction = $derived(chatHost.loading && !!pendingUserAction(messages))
// Gated on `loading` because a card restored from history still looks parked:
@@ -622,6 +654,7 @@
const showFooterLeftControls = $derived(
!footerMessageShown &&
(canAttachFiles ||
attachmentsOffReason !== undefined ||
showContextPicker ||
showAutonomyModeSelector ||
(chatHost.mode === AIMode.SCRIPT && hasDiff))
@@ -800,12 +833,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
bind:this={scrollElement}
onscroll={onScroll}
>
<div
class={wideLayout
? 'w-full max-w-3xl mx-auto px-7 flex flex-col pb-2'
: 'w-full max-w-2xl mx-auto px-3 flex flex-col pb-2'}
bind:clientHeight={height}
>
<div class="{columnClass} flex flex-col pb-2" bind:clientHeight={height}>
{#each messages as message, messageIndex (messageIndex)}
<AIChatMessage
{message}
@@ -844,6 +872,8 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/if}
</div>
</div>
<!-- Sits below the scroll-to-latest button, which carries z-10. -->
<ScrollFade scroller={scrollElement} />
{#if showScrollToLatest}
<div
transition:fade={{ duration: 120 }}
@@ -869,11 +899,9 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
</div>
{/if}
<div
class={wideLayout
? 'relative w-full max-w-3xl mx-auto px-6 pb-2'
: 'relative w-full max-w-2xl mx-auto px-2 pb-2'}
>
<!-- Same horizontal padding as the transcript above: the composer's edges line up with
the messages rather than sitting closer to the panel edge. -->
<div class="relative {columnClass} pb-2">
{#if showFlowPendingActionControls}
<div class="absolute -top-10 w-full flex flex-row justify-center gap-2">
<Button
@@ -991,7 +1019,21 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/snippet}
</Popover>
{/if}
{#if canAttachFiles}
{#if attachmentsOffReason}
<Tooltip small placement="top">
<Button
nonCaptureEvent
unifiedSize="2xs"
variant="default"
iconOnly
disabled
startIcon={{ icon: Plus }}
/>
{#snippet text()}
<div class="max-w-64 text-xs">{attachmentsOffReason}</div>
{/snippet}
</Tooltip>
{:else if canAttachFiles}
<DropdownV2
items={async () => {
// Both submenus fetch on the menu's first open, so they start
@@ -42,6 +42,13 @@
textByteLength,
type AttachedTextFile
} from './textFileUtils'
import {
fileToAttachedBlob,
matchesAccept,
MAX_ATTACHED_BLOBS,
MAX_BLOB_BYTES,
type AttachedBlob
} from './blobUtils'
import { MessageDraft } from './messageDraft.svelte'
import ExpandableImage, {
isImageViewerOpen
@@ -213,6 +220,45 @@
: undefined
)
/**
* Free slots in one attachment lane, against both that lane's own cap and any limit the
* host's consumer imposes on the turn as a whole — a flow input holding a single file
* caps images and blobs together, not one each. In-flight decodes count: two drops that
* both read the staged count before either resolves would claim the same slots twice.
*/
function attachmentSlots(laneCap: number, laneStaged: number): number {
const laneRemaining = laneCap - laneStaged
const turnCap = chatHost.maxMessageAttachments
if (turnCap === undefined) return laneRemaining
const staged =
draft.images.length +
pendingImages +
draft.files.length +
pendingFiles +
draft.blobs.length +
pendingBlobs
// A queue counts too: what is held mid-run merges into one turn on flush, so a
// second file accepted now would be dropped there instead of refused here.
const queued =
chatHost.queuedImages.length + chatHost.queuedFiles.length + chatHost.queuedBlobs.length
return Math.min(laneRemaining, Math.max(0, turnCap - staged - queued))
}
/** What to say when the host's own limit is the one that bit. */
function turnCapMessage(): string {
const turnCap = chatHost.maxMessageAttachments
return turnCap === 1
? 'This chat sends one attachment per message.'
: `This chat sends up to ${turnCap} attachments per message.`
}
/** Why some of what was picked did not fit, naming whichever limit actually bit. */
function skippedMessage(laneCap: number, lane: 'images' | 'files', skipped: number): string {
return chatHost.maxMessageAttachments !== undefined
? `${turnCapMessage()} ${skipped} file(s) were not attached.`
: `You can attach up to ${laneCap} ${lane}; ${skipped} were skipped.`
}
// Images being decoded right now. Holds off sending so a message can never go
// out without an attachment the user already dropped, and reserves cap slots
// against a concurrent drop.
@@ -221,6 +267,14 @@
/** Attach dropped/pasted image files (downscaled + bounded). */
export async function addImages(files: (File | Blob)[]) {
if (!chatHost.supportsMessageAttachments) return
// Attaching can be off despite the chat taking attachments — no object storage to
// upload to, say. The `+` renders disabled with the reason; a drop and a paste reach
// here instead, and would otherwise become a chip that only fails once sent.
const unavailable = chatHost.attachmentsUnavailableReason
if (unavailable) {
sendUserToast(unavailable, true)
return
}
const imageFiles = files.filter(isImageFile)
if (imageFiles.length === 0) return
// The vision check is about the model this composer's own turn will hit, so it
@@ -240,9 +294,14 @@
// Count decodes already in flight: two drops that both read the image count
// before either resolves would each claim the same free slots and overshoot
// the cap.
const remaining = MAX_ATTACHED_IMAGES - draft.images.length - pendingImages
const remaining = attachmentSlots(MAX_ATTACHED_IMAGES, draft.images.length + pendingImages)
if (remaining <= 0) {
sendUserToast(`You can attach up to ${MAX_ATTACHED_IMAGES} images.`, true)
sendUserToast(
chatHost.maxMessageAttachments !== undefined
? turnCapMessage()
: `You can attach up to ${MAX_ATTACHED_IMAGES} images.`,
true
)
return
}
const oversized = imageFiles.filter((f) => f.size > MAX_IMAGE_BYTES)
@@ -255,7 +314,7 @@
const batch = usable.slice(0, remaining)
if (batch.length < usable.length) {
sendUserToast(
`You can attach up to ${MAX_ATTACHED_IMAGES} images; ${usable.length - batch.length} were skipped.`,
skippedMessage(MAX_ATTACHED_IMAGES, 'images', usable.length - batch.length),
true
)
}
@@ -326,9 +385,14 @@
export async function addTextFiles(candidates: File[]) {
if (!chatHost.supportsMessageAttachments) return
if (candidates.length === 0) return
const remaining = MAX_ATTACHED_FILES - draft.files.length - pendingFiles
const remaining = attachmentSlots(MAX_ATTACHED_FILES, draft.files.length + pendingFiles)
if (remaining <= 0) {
sendUserToast(`You can attach up to ${MAX_ATTACHED_FILES} files.`, true)
sendUserToast(
chatHost.maxMessageAttachments !== undefined
? turnCapMessage()
: `You can attach up to ${MAX_ATTACHED_FILES} files.`,
true
)
return
}
const oversized = candidates.filter((f) => f.size > MAX_TEXT_FILE_BYTES)
@@ -343,10 +407,7 @@
if (usable.length === 0) return
let batch = usable.slice(0, remaining)
if (batch.length < usable.length) {
sendUserToast(
`You can attach up to ${MAX_ATTACHED_FILES} files; ${usable.length - batch.length} were skipped.`,
true
)
sendUserToast(skippedMessage(MAX_ATTACHED_FILES, 'files', usable.length - batch.length), true)
}
// Conversation-level byte budget: transcript + queue + every live
// composer's stage (this one and, mid-edit, the other) + this composer's
@@ -420,6 +481,82 @@
draft.files = draft.files.filter((_, i) => i !== index)
}
// Blobs being read right now — same send-hold/slot-reservation role as pendingImages.
let pendingBlobs = $state(0)
/**
* Attach non-image files through the lane the host reads: text for a host that decodes
* them, blobs for one that forwards them verbatim. The picker, a drop and both pastes all
* route here, and `accept` is re-applied since drops and pastes bypass the picker's filter.
*/
export async function addNonImageFiles(files: File[]) {
if (files.length === 0) return
// Same reason as in addImages.
const unavailable = chatHost.attachmentsUnavailableReason
if (unavailable) {
sendUserToast(unavailable, true)
return
}
if (!chatHost.attachmentsAsBlobs) {
await addTextFiles(files)
return
}
const allowed = files.filter((f) => matchesAccept(f, chatHost.attachmentAccept))
if (allowed.length < files.length) {
sendUserToast(
`${files.length - allowed.length} file(s) skipped — this chat accepts ${chatHost.attachmentAccept}.`,
true
)
}
await addBlobs(allowed)
}
/** Attach files the host takes verbatim (a PDF, say). Kept out of addTextFiles:
* that one decodes to a string and drops anything the binary sniff rejects. */
export async function addBlobs(candidates: File[]) {
if (!chatHost.supportsMessageAttachments) return
if (candidates.length === 0) return
const oversized = candidates.filter((f) => f.size > MAX_BLOB_BYTES)
if (oversized.length > 0) {
const mb = Math.round(MAX_BLOB_BYTES / 1_000_000)
sendUserToast(`${oversized.length} file(s) over ${mb}MB were skipped.`, true)
}
const usable = candidates.filter((f) => f.size <= MAX_BLOB_BYTES)
if (usable.length === 0) return
const remaining = attachmentSlots(MAX_ATTACHED_BLOBS, draft.blobs.length + pendingBlobs)
if (remaining <= 0) {
sendUserToast(
chatHost.maxMessageAttachments !== undefined
? turnCapMessage()
: `You can attach up to ${MAX_ATTACHED_BLOBS} files.`,
true
)
return
}
const batch = usable.slice(0, remaining)
if (batch.length < usable.length) {
sendUserToast(skippedMessage(MAX_ATTACHED_BLOBS, 'files', usable.length - batch.length), true)
}
pendingBlobs += batch.length
try {
const added: AttachedBlob[] = []
for (const file of batch) {
try {
added.push(await fileToAttachedBlob(file))
} catch (e) {
sendUserToast(`Could not read ${file.name}`, true)
}
}
if (added.length > 0) draft.addBlobs(added)
} finally {
pendingBlobs -= batch.length
}
}
function removeBlob(index: number) {
draft.blobs = draft.blobs.filter((_, i) => i !== index)
}
// App mode @ mention state
let showAppContextTooltip = $state(false)
let appContextTooltipWord = $state('')
@@ -501,7 +638,8 @@
// Attachments still decoding/reading (or mid-drop-routing) count as
// occupancy too — they belong to a draft the user started even though
// their lane is still empty.
if (pendingImages > 0 || pendingFiles > 0 || ingestionHolds > 0) return false
if (pendingImages > 0 || pendingFiles > 0 || pendingBlobs > 0 || ingestionHolds > 0)
return false
if (
!draft.replaceIfEmpty({
text: value,
@@ -524,15 +662,17 @@
export function prependText(
text: string,
restoredImages: AttachedImage[] = [],
restoredFiles: AttachedTextFile[] = []
restoredFiles: AttachedTextFile[] = [],
restoredBlobs: AttachedBlob[] = []
): boolean {
// mergedIntoDraft: the restored text landed on top of a draft the user was
// already writing — both instructions now share one composer, so the caller
// must keep both their contexts rather than replacing one with the other.
const { mergedIntoDraft, droppedImages, droppedFiles } = draft.prepend({
const { mergedIntoDraft, droppedImages, droppedFiles, droppedBlobs } = draft.prepend({
text,
images: restoredImages,
files: restoredFiles
files: restoredFiles,
blobs: restoredBlobs
})
if (droppedImages > 0) {
sendUserToast(
@@ -546,6 +686,12 @@
true
)
}
if (droppedBlobs > 0) {
sendUserToast(
`You can attach up to ${MAX_ATTACHED_BLOBS} files; ${droppedBlobs} restored file(s) were dropped.`,
true
)
}
focusInput()
return mergedIntoDraft
}
@@ -717,7 +863,7 @@
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.
if (pendingImages > 0 || pendingFiles > 0 || ingestionHolds > 0) {
if (pendingImages > 0 || pendingFiles > 0 || pendingBlobs > 0 || ingestionHolds > 0) {
return
}
// A host whose consumer needs a message of its own refuses an attachment-only
@@ -761,7 +907,8 @@
expanded(chatDraft(sent.text, sent.pastes)),
sent.images,
[...selectedContext],
sent.files
sent.files,
sent.blobs
)
// Consumed at enqueue, not at flush: the entry above pinned them.
consumeMentionsIfGlobal()
@@ -789,11 +936,15 @@
// when given no override, and the consume below empties it.
const carried = chatHost.mode === AIMode.GLOBAL ? [...selectedContext] : undefined
consumeMentionsIfGlobal()
// A host that refuses the turn puts the draft back itself (see AIChatManager's
// restoreToInput and FlowChatViewHost's upload failure): restoring here too
// would double the text and every attachment.
chatHost.sendRequest({
instructions: sent.text,
pastes: sent.pastes,
images: sent.images,
files: sent.files,
blobs: sent.blobs,
contextOverride: carried,
contextOverrideOrigin: carried ? 'pinned' : undefined
})
@@ -1015,6 +1166,23 @@
updateAppTooltipPosition(appTooltipCurrentViewNumber)
}
})
/**
* Clipboard files on the plain composer, as ContextTextarea does for the rich one. Only
* when the clipboard has no text: a spreadsheet copy carries a bitmap next to the text,
* and pasting a cell range must paste the cells.
*/
function handlePlainPaste(e: ClipboardEvent) {
if (!chatHost.supportsMessageAttachments) return
if ((e.clipboardData?.getData('text/plain') ?? '').trim()) return
const pasted = Array.from(e.clipboardData?.files ?? [])
const images = pasted.filter((f) => f.type.startsWith('image/'))
const others = pasted.filter((f) => !f.type.startsWith('image/'))
if (images.length === 0 && others.length === 0) return
e.preventDefault()
if (images.length > 0) void addImages(images)
if (others.length > 0) void addNonImageFiles(others)
}
</script>
{#snippet sendStopButton()}
@@ -1035,6 +1203,7 @@
disabled ||
pendingImages > 0 ||
pendingFiles > 0 ||
pendingBlobs > 0 ||
ingestionHolds > 0 ||
needsText ||
(emptyDraft &&
@@ -1068,7 +1237,7 @@
thumbnails get their own row (different height). -->
{#snippet badgeRow()}
{@const contextChips = showContext ? selectedContext : domSelectorChips}
{#if contextChips.length > 0 || draft.files.length > 0 || pendingFiles > 0}
{#if contextChips.length > 0 || draft.files.length > 0 || pendingFiles > 0 || draft.blobs.length > 0 || pendingBlobs > 0}
<div class="flex flex-row flex-wrap items-center gap-1 px-2.5 pt-2">
{#each contextChips as element (contextKey(element))}
<ContextElementBadge
@@ -1087,7 +1256,19 @@
onDelete={() => removeFile(i)}
/>
{/each}
{#each { length: pendingFiles } as _, i (i)}
<!-- Blobs are shown by the same badge as text files. Their preview line stands
in for content the badge cannot render (a PDF has no text to show). -->
{#each draft.blobs as blob, i (i)}
<ContextElementBadge
contextElement={createAttachedFileContextElement(
blob.name,
`${blob.mediaType} · ${Math.max(1, Math.round(blob.size / 1024))} KB`
)}
deletable
onDelete={() => removeBlob(i)}
/>
{/each}
{#each { length: pendingFiles + pendingBlobs } as _, i (i)}
<div
class="h-6 w-24 rounded-md border bg-surface flex items-center justify-center"
title="Reading file..."
@@ -1152,6 +1333,7 @@
draft.isEmpty &&
pendingImages === 0 &&
pendingFiles === 0 &&
pendingBlobs === 0 &&
ingestionHolds === 0
) {
// Shell-style recall: ArrowUp in the empty main composer pulls the
@@ -1167,6 +1349,7 @@
chatHost.queuedMessage ||
chatHost.queuedImages.length > 0 ||
chatHost.queuedFiles.length > 0 ||
chatHost.queuedBlobs.length > 0 ||
(chatHost.queuedContext?.length ?? 0) > 0
) {
e.preventDefault()
@@ -1192,7 +1375,7 @@
? (pasted) => void addImages(pasted)
: undefined}
onTextFiles={chatHost.supportsMessageAttachments
? (pasted) => void addTextFiles(pasted)
? (pasted) => void addNonImageFiles(pasted)
: undefined}
{availableContext}
{selectedContext}
@@ -1299,6 +1482,7 @@
bind:this={instructionsTextareaComponent}
bind:value={draft.text}
use:autosize={{ maxHeight: '40vh' }}
onpaste={handlePlainPaste}
onkeydown={(e) => {
if (onKeyDown) {
onKeyDown(e)
@@ -1,3 +1,4 @@
import type { AttachedBlob } from './blobUtils'
import type { ChatViewHost } from './chatViewHost'
import type { ScriptLang } from '$lib/gen/types.gen'
import { JobService, type CompletedJob } from '$lib/gen'
@@ -94,7 +95,7 @@ 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'
import { prefersInstantReveal, TypewriterReveal } from './typewriterReveal'
import { chatState, DEFAULT_SIZE, triggerablesByAi } from './sharedChatState.svelte'
import {
createAppBackendRunnableContextElement,
@@ -154,11 +155,6 @@ import { appendAttachedFilesRoster } from './files/fileTools'
import { ENTER_PLAN_MODE_TOOL, EXIT_PLAN_MODE_TOOL } from './planMode'
import { PlanModeController, type PlanModeHost } from './planModeController.svelte'
// SSR and users who prefer reduced motion get no typewriter pacing.
function prefersInstantReveal(): boolean {
return !BROWSER || (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false)
}
// Compaction of the stored history: once the projected request size
// (contextTokens — the provider's report when current, a fresh chars/4
// estimate otherwise — plus the new user message) reaches the trigger ratio of
@@ -463,6 +459,10 @@ export class AIChatManager implements ChatViewHost {
get supportsLinkedFolders() {
return this.mode === AIMode.GLOBAL
}
// The copilot reads attachments in the browser, so non-image files decode to text.
attachmentsAsBlobs = false
// The copilot decodes its attachments, so nothing ever lands in the blob lane.
queuedBlobs: AttachedBlob[] = []
// Steers the OS file picker toward text + image formats (a soft hint; both attach to
// the message — text files after a content sniff).
attachmentAccept =
@@ -3954,6 +3954,9 @@ export class AIChatManager implements ChatViewHost {
{
role: 'assistant',
content: this.currentReply,
// Stamped as it lands. A chat restored from history predates this and
// simply shows no time rather than a made-up one.
createdAt: new Date().toISOString(),
...(this.currentReasoning
? { reasoning: this.currentReasoning, reasoningDurationMs }
: {}),
@@ -138,7 +138,9 @@
{:else}
<div class={twMerge('text-sm py-1 px-2', message.role === 'tool' && 'text-primary py-0')}>
{#if message.role === 'assistant'}
<div class="px-[1px]"><AssistantMessage {message} workspace={messageWorkspace} /></div>
<div class="px-[1px] group/answer"
><AssistantMessage {message} workspace={messageWorkspace} /></div
>
{:else if message.role === 'tool'}
<div class="px-[1px]"
><ToolExecutionDisplay message={message as ToolDisplayMessage} /></div
@@ -13,6 +13,10 @@
} from './workspaceItems.svelte'
import { markdownProse } from '$lib/components/markdownProse'
import DisplayResult from '$lib/components/DisplayResult.svelte'
import { Bot, ExternalLink } from 'lucide-svelte'
import CopyButton from '$lib/components/common/button/CopyButton.svelte'
import { base } from '$lib/base'
import { displayDate } from '$lib/utils'
interface Props {
message: DisplayMessage
@@ -23,6 +27,21 @@
let { message, workspace }: Props = $props()
// The run this answer came out of. Only a flow chat has one — a copilot turn runs in
// the browser — so the job link is absent rather than empty elsewhere.
const jobId = $derived(message.role === 'assistant' ? message.jobId : undefined)
const createdAt = $derived(message.role === 'assistant' ? message.createdAt : undefined)
const runHref = $derived(jobId ? `${base}/run/${jobId}?workspace=${workspace}` : undefined)
// Today's answers show the time alone; the day earns its place only on a conversation
// read back later. Resolved at render, so a chat left open across midnight keeps
// yesterday's format until it is reopened.
const timestamp = $derived.by(() => {
if (!createdAt) return undefined
const at = new Date(createdAt)
const today = new Date().toDateString() === at.toDateString()
return displayDate(at, false, !today)
})
const reasoning = $derived(
message.role === 'assistant' ? message.reasoning?.trim() || undefined : undefined
)
@@ -113,9 +132,16 @@
})
</script>
<!-- An agent step's answer is headed by the agent's own icon, hung in the margin so the
answer itself stays on the same left edge as the reader's messages. The icon sits in
the padding the message column already carries. -->
{#if stepName}
<div class="text-2xs text-tertiary font-medium mb-1 truncate" title="Answered by {stepName}">
{stepName}
<div
class="flex items-center gap-2 -ml-6 mb-1 text-2xs text-tertiary"
title="Answered by {stepName}"
>
<Bot size={16} class="shrink-0" />
<span class="font-mono truncate">{stepName}</span>
</div>
{/if}
@@ -140,3 +166,28 @@
<Markdown md={message.content} {plugins} />
</div>
{/if}
{#if message.content}
<!-- Kept in flow while invisible, so revealing it on hover does not nudge the message
below. A thinking-only row has no answer to copy, and the next row links its run. -->
<div
class="flex items-center gap-2 text-2xs text-tertiary opacity-0 transition-opacity duration-150 group-hover/answer:opacity-100 focus-within:opacity-100"
>
<CopyButton value={message.content} title="Copy answer" class="-ml-1" />
{#if timestamp}
<span>{timestamp}</span>
{/if}
{#if runHref}
<a
href={runHref}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 hover:text-primary hover:underline"
title="Open this run"
>
<span>job <span class="font-mono">{jobId?.slice(0, 8)}</span></span>
<ExternalLink size={11} class="shrink-0" />
</a>
{/if}
</div>
{/if}
@@ -20,13 +20,13 @@
here only for context-ONLY queues: text queues pin the same chips, but
those stay visible in the composer, and repeating them would read as two
selections. -->
{#if chatHost.queuedMessage || chatHost.queuedImages.length > 0 || chatHost.queuedFiles.length > 0 || (chatHost.queuedContext?.length ?? 0) > 0}
{#if chatHost.queuedMessage || chatHost.queuedImages.length > 0 || chatHost.queuedFiles.length > 0 || chatHost.queuedBlobs.length > 0 || (chatHost.queuedContext?.length ?? 0) > 0}
<!-- The body and the X are sibling buttons for the same action (an X inside a
clickable chip would be a nested interactive control, invalid ARIA). -->
<div
class="mb-1 flex flex-row items-start gap-1 rounded-md bg-surface-input px-3 py-2 opacity-60 hover:opacity-100"
>
{#if chatHost.queuedMessage || chatHost.queuedImages.length > 0 || chatHost.queuedFiles.length > 0}
{#if chatHost.queuedMessage || chatHost.queuedImages.length > 0 || chatHost.queuedFiles.length > 0 || chatHost.queuedBlobs.length > 0}
<button
type="button"
class="min-w-0 grow text-left cursor-pointer"
@@ -58,6 +58,21 @@
{/each}
</div>
{/if}
{#if chatHost.queuedBlobs.length > 0}
<!-- Blobs are the same chip as files: a host that forwards bytes verbatim
queues them here instead, and a queue of them alone must still show. -->
<div class="flex flex-row flex-wrap gap-1 {chatHost.queuedMessage ? 'mb-1' : ''}">
{#each chatHost.queuedBlobs as blob, i (i)}
<span
class="flex flex-row items-center gap-1 px-1.5 rounded border border-border-light text-2xs text-secondary max-w-36"
title={blob.name}
>
<FileText size={10} class="shrink-0" />
<span class="truncate min-w-0">{blob.name}</span>
</span>
{/each}
</div>
{/if}
{#if chatHost.queuedMessage}
<p class="text-xs text-secondary whitespace-pre-wrap line-clamp-2">
{chatHost.queuedMessage}
@@ -9,8 +9,10 @@
CircleMinus,
FileText,
PanelRight,
Lock
Lock,
ExternalLink
} from 'lucide-svelte'
import { base } from '$lib/base'
import {
EXIT_PLAN_MODE_TOOL,
isPlanCardTool,
@@ -249,6 +251,19 @@
{/if}
{/snippet}
{#snippet jobLink()}
<a
href="{base}/run/{message.jobId}?workspace={chatHost.operatingWorkspace}"
target="_blank"
rel="noopener noreferrer"
class="shrink-0 inline-flex items-center gap-1 font-main text-2xs text-tertiary hover:text-primary hover:underline"
title="Open this run"
>
<span>job <span class="font-mono">{message.jobId?.slice(0, 8)}</span></span>
<ExternalLink size={11} class="shrink-0" />
</a>
{/snippet}
<!-- Which system a call reaches is the first thing to know about it, so an MCP call
is marked before its label. Awaited rather than drawn immediately: the MCP logo
appearing first and being replaced would flicker on every row. -->
@@ -275,7 +290,7 @@
headerClass={message.needsConfirmation ? 'opacity-80' : ''}
labelClass={showPreviewChip ? 'truncate' : ''}
contentClass="space-y-3"
headerRight={showPreviewChip ? previewChip : undefined}
headerRight={showPreviewChip ? previewChip : message.jobId ? jobLink : undefined}
headerLeft={mcpServer?.workspace ? serverMark : undefined}
>
<!-- Image a tool produced (e.g. take_screenshot) — shown inline, not gated on expand. -->
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { matchesAccept } from './blobUtils'
function file(name: string, type: string): File {
return new File(['x'], name, { type })
}
describe('matchesAccept', () => {
it('matches an extension, a type wildcard and an exact media type', () => {
expect(matchesAccept(file('report.PDF', ''), '.pdf')).toBe(true)
expect(matchesAccept(file('shot.png', 'image/png'), 'image/*')).toBe(true)
expect(matchesAccept(file('shot.png', 'image/png'), 'image/png')).toBe(true)
})
it('refuses a file no pattern covers, and allows everything when the list is empty', () => {
expect(matchesAccept(file('notes.txt', 'text/plain'), '.pdf, image/*')).toBe(false)
expect(matchesAccept(file('notes.txt', 'text/plain'), '')).toBe(true)
})
})
@@ -0,0 +1,58 @@
/**
* Message attachments kept as their original bytes, such as a PDF, for a host that
* forwards them to object storage. Images are re-encoded and text files decoded instead,
* so neither lane can carry what the user picked unchanged.
*/
/** Blobs one message may carry — the same slot cap images and text files use. */
export const MAX_ATTACHED_BLOBS = 8
/**
* Per-blob byte cap. The data URL sits in composer state until send, so this
* bounds what one message can hold in memory; a host uploading elsewhere pays
* the same bytes again on the wire.
*/
export const MAX_BLOB_BYTES = 20_000_000
export type AttachedBlob = {
name: string
/** The file's own media type, verbatim — the upload's Content-Type depends on it. */
mediaType: string
/** `data:<mediaType>;base64,<...>` of the original bytes. */
dataUrl: string
size: number
}
/**
* Whether a file satisfies an `accept` list the same list the OS picker gets, applied
* again on drop, where the browser enforces nothing.
*/
export function matchesAccept(file: File, accept: string): boolean {
const patterns = accept
.split(',')
.map((p) => p.trim().toLowerCase())
.filter(Boolean)
if (patterns.length === 0) return true
const type = file.type.toLowerCase()
const name = file.name.toLowerCase()
return patterns.some((pattern) => {
if (pattern.startsWith('.')) return name.endsWith(pattern)
if (pattern.endsWith('/*')) return type.startsWith(pattern.slice(0, -1))
return type === pattern
})
}
export async function fileToAttachedBlob(file: File): Promise<AttachedBlob> {
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => reject(reader.error ?? new Error(`Could not read ${file.name}`))
reader.readAsDataURL(file)
})
return {
name: file.name,
mediaType: file.type || 'application/octet-stream',
dataUrl,
size: file.size
}
}
@@ -3,6 +3,7 @@ import type { AIMode, AIAutonomyMode } from './AIChatManager.svelte'
import { getAiChatManager } from './aiChatManagerContext'
import type { DisplayMessage, Tool } from './shared'
import type { ContextElement } from './context'
import type { AttachedBlob } from './blobUtils'
import type { AttachedImage } from './imageUtils'
import type { AttachedTextFile } from './textFileUtils'
import type { PasteAttachment } from './pasteTokens'
@@ -18,6 +19,7 @@ export type ChatSendRequestOptions = {
pastes?: PasteAttachment[]
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. */
@@ -73,11 +75,13 @@ export interface ChatViewHost {
queuedContext: ContextElement[] | undefined
readonly queuedImages: AttachedImage[]
readonly queuedFiles: AttachedTextFile[]
readonly queuedBlobs: AttachedBlob[]
queueMessage: (
text: string,
images?: AttachedImage[],
context?: ContextElement[],
files?: AttachedTextFile[]
files?: AttachedTextFile[],
blobs?: AttachedBlob[]
) => void
dequeueMessage: () => void
setComposerStaged: (key: string, editingIndex: number | null, bytes: number) => void
@@ -111,8 +115,16 @@ export interface ChatViewHost {
/** Click a user message to edit and resend it. Needs a host that can rewind
* its own transcript, which a host replaying a server-side run cannot. */
supportsMessageEditing: boolean
/** The `+` menu's file entry and drag-and-drop onto the panel. */
/** The `+` menu's file entry and drag-and-drop onto the panel. Attachments ride
* one message; where they go afterwards is the host's business (see sendRequest). */
supportsMessageAttachments: boolean
/**
* Why attaching is off right now, when the host would otherwise take attachments. Distinct
* from `supportsMessageAttachments` being false, which means this chat never takes them:
* here the composer keeps the control and says what is missing, because moving the input
* elsewhere would only offer an editor that cannot work either.
*/
attachmentsUnavailableReason?: string
/** The turn needs text: attachments alone cannot be sent. True where the consumer
* requires a message of its own an AI agent step refuses a run with neither a
* `user_message` nor manual memory. */
@@ -120,8 +132,17 @@ export interface ChatViewHost {
/** The `+` menu's folder entries, backed by `attachedFiles`. A linked folder is a
* live handle on the user's disk, so only a host reading files in the browser has one. */
supportsLinkedFolders: boolean
/** `accept` for the file picker. */
/** `accept` for the file picker, and the drop filter. A host whose consumer only
* understands some formats narrows it so the rest are refused rather than ignored. */
attachmentAccept: string
/** How many attachments one turn can carry, when the consumer holds a fixed number
* a flow input that is a single file, say. Undefined means no limit. Enforced at the
* picker and on drop, so what the composer shows is what the turn actually sends. */
maxMessageAttachments?: number
/** Take non-image attachments verbatim (`blobs`) instead of decoding them to text.
* True where the bytes are forwarded somewhere object storage rather than read
* in the browser. */
attachmentsAsBlobs: boolean
tools: Tool<any>[]
autonomyMode: AIAutonomyMode
setAutonomyMode: (mode: AIAutonomyMode) => void
@@ -1,6 +1,6 @@
/**
* A message draft: the four lanes that ship together with one send text,
* pastes, images, text files. Every place a draft accumulates or moves
* A message draft: the five lanes that ship together with one send text,
* pastes, images, text files, blobs. Every place a draft accumulates or moves
* (composer attach, queue append, dequeue restore, failure restore) goes
* through this type, so the draft rules file dedupe by source identity,
* courtesy rename, attachment slot caps, all-lanes-move-together live here
@@ -10,6 +10,7 @@
* manager-wide state enforced at the composer until it moves into the
* store) and @context/DOM picks (ContextManager owns their lifecycle).
*/
import { MAX_ATTACHED_BLOBS, type AttachedBlob } from './blobUtils'
import { MAX_ATTACHED_IMAGES, type AttachedImage } from './imageUtils'
import type { PasteAttachment } from './pasteTokens'
import {
@@ -19,12 +20,13 @@ import {
type AttachedTextFile
} from './textFileUtils'
/** A draft's four lanes as plain data — what moves between owners. */
/** A draft's five lanes as plain data — what moves between owners. */
export interface DraftSnapshot {
text: string
pastes: PasteAttachment[]
images: AttachedImage[]
files: AttachedTextFile[]
blobs: AttachedBlob[]
}
export class MessageDraft {
@@ -32,12 +34,14 @@ export class MessageDraft {
pastes = $state<PasteAttachment[]>([])
images = $state<AttachedImage[]>([])
files = $state<AttachedTextFile[]>([])
blobs = $state<AttachedBlob[]>([])
constructor(seed?: Partial<DraftSnapshot>) {
if (seed?.text) this.text = seed.text
if (seed?.pastes) this.pastes = [...seed.pastes]
if (seed?.images) this.images = [...seed.images]
if (seed?.files) this.files = [...seed.files]
if (seed?.blobs) this.blobs = [...seed.blobs]
}
get isEmpty(): boolean {
@@ -45,12 +49,13 @@ export class MessageDraft {
this.text.trim() === '' &&
this.pastes.length === 0 &&
this.images.length === 0 &&
this.files.length === 0
this.files.length === 0 &&
this.blobs.length === 0
)
}
get hasAttachments(): boolean {
return this.images.length > 0 || this.files.length > 0
return this.images.length > 0 || this.files.length > 0 || this.blobs.length > 0
}
/** Files joining a draft always fold (dedupe by source identity, courtesy
@@ -83,6 +88,14 @@ export class MessageDraft {
return dropped
}
/** Blobs join up to the slot cap. Returns the dropped count (caller toasts). */
addBlobs(blobs: AttachedBlob[]): number {
const merged = [...this.blobs, ...blobs]
const dropped = Math.max(0, merged.length - MAX_ATTACHED_BLOBS)
this.blobs = merged.slice(0, MAX_ATTACHED_BLOBS)
return dropped
}
/**
* Merge a restored draft on top of this one (queued-message delete, restore
* after a cancelled/errored turn): the restored draft was written FIRST, so
@@ -91,10 +104,16 @@ export class MessageDraft {
* Returns whether text merged onto a non-empty draft (the caller must then
* keep both drafts' context), plus dropped counts for toasts.
*/
prepend(restored: { text: string; images?: AttachedImage[]; files?: AttachedTextFile[] }): {
prepend(restored: {
text: string
images?: AttachedImage[]
files?: AttachedTextFile[]
blobs?: AttachedBlob[]
}): {
mergedIntoDraft: boolean
droppedImages: number
droppedFiles: number
droppedBlobs: number
} {
const mergedIntoDraft = !!restored.text && !!this.text.trim()
// An attachment-only restore has empty text; prepending would only add blank lines.
@@ -115,7 +134,13 @@ export class MessageDraft {
droppedFiles = Math.max(0, merged.length - MAX_ATTACHED_FILES)
this.files = merged.slice(0, MAX_ATTACHED_FILES)
}
return { mergedIntoDraft, droppedImages, droppedFiles }
let droppedBlobs = 0
if (restored.blobs?.length) {
const merged = [...restored.blobs, ...this.blobs]
droppedBlobs = Math.max(0, merged.length - MAX_ATTACHED_BLOBS)
this.blobs = merged.slice(0, MAX_ATTACHED_BLOBS)
}
return { mergedIntoDraft, droppedImages, droppedFiles, droppedBlobs }
}
/** Replace the draft with a snapshot, but only when it is empty an occupied
@@ -132,16 +157,18 @@ export class MessageDraft {
this.pastes = [...(snapshot.pastes ?? [])]
this.images = [...(snapshot.images ?? [])]
this.files = [...(snapshot.files ?? [])]
this.blobs = [...(snapshot.blobs ?? [])]
}
/** Snapshot and clear atomically the four lanes always move together, so no
/** Snapshot and clear atomically the five lanes always move together, so no
* call site can take one and forget another. */
take(): DraftSnapshot {
const snapshot: DraftSnapshot = {
text: this.text,
pastes: this.pastes,
images: this.images,
files: this.files
files: this.files,
blobs: this.blobs
}
this.clear()
return snapshot
@@ -152,5 +179,6 @@ export class MessageDraft {
this.pastes = []
this.images = []
this.files = []
this.blobs = []
}
}
@@ -5,22 +5,49 @@ import { MessageDraft } from './messageDraft.svelte'
// the draft-level guarantees: lanes move together, restores respect occupancy,
// aggregation always applies the rules.
function blob(name: string) {
return {
name,
mediaType: 'application/pdf',
dataUrl: 'data:application/pdf;base64,JVBERg==',
size: 4
}
}
describe('MessageDraft', () => {
it('take() snapshots and clears all four lanes atomically', () => {
it('take() snapshots and clears all five lanes atomically', () => {
const d = new MessageDraft({
text: 'hello',
pastes: [{ id: 'p1', content: 'x' } as any],
images: [{ dataUrl: 'i1' } as any],
files: [{ name: 'a.md', content: 'a' }]
files: [{ name: 'a.md', content: 'a' }],
blobs: [blob('a.pdf')]
})
const snap = d.take()
expect(snap.text).toBe('hello')
expect(snap.pastes).toHaveLength(1)
expect(snap.images).toHaveLength(1)
expect(snap.files).toHaveLength(1)
expect(snap.blobs).toHaveLength(1)
expect(d.isEmpty).toBe(true)
})
it('treats a blob-only draft as occupied and caps blobs, keeping restored ones first', () => {
const d = new MessageDraft({ blobs: [blob('only.pdf')] })
expect(d.isEmpty).toBe(false)
expect(d.hasAttachments).toBe(true)
expect(d.replaceIfEmpty({ text: 'restored' })).toBe(false)
const dropped = d.addBlobs(Array.from({ length: 8 }, (_, i) => blob(`new${i}.pdf`)))
expect(dropped).toBe(1)
expect(d.blobs).toHaveLength(8)
const res = d.prepend({ text: '', blobs: [blob('old.pdf')] })
expect(res.droppedBlobs).toBe(1)
expect(d.blobs[0].name).toBe('old.pdf')
expect(d.blobs).toHaveLength(8)
})
it('replaceIfEmpty declines when any lane is occupied', () => {
const d = new MessageDraft({ files: [{ name: 'a.md', content: 'a' }] })
expect(d.replaceIfEmpty({ text: 'restored' })).toBe(false)
@@ -634,6 +634,8 @@ export type ToolDisplayMessage = {
* workspace rides along: a chat is readable from any workspace, and the same path names
* a different server in each. */
mcpServer?: { workspace: string; path: string }
/** The run behind the call. Flow chats only: the card links to it. */
jobId?: string
showFade?: boolean
actions?: ToolDisplayAction[]
userQuestion?: UserQuestionDisplay
@@ -677,7 +679,9 @@ export type AssistantDisplayMessage = BaseDisplayMessage & {
/** The run behind this answer. Flow chats only: a copilot turn happens in the
* browser and has no job. */
jobId?: string
/** When the message was stored, as the server reports it. */
/** When the answer arrived: the server's time for a flow chat's stored row, the browser's
* for a copilot answer, which is stamped as it lands. Absent on a copilot chat restored
* from history, which predates the stamp. */
createdAt?: string
}
@@ -9,6 +9,13 @@
// state is the `onReveal` callback — so the pacing is unit-testable with an
// injected clock and scheduler.
import { BROWSER } from 'esm-env'
/** SSR and readers who prefer reduced motion get no pacing: text lands as it arrives. */
export function prefersInstantReveal(): boolean {
return !BROWSER || (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false)
}
type Schedule = (cb: () => void) => unknown
type Cancel = (handle: unknown) => void
@@ -38,7 +38,7 @@
import StepInputsGen from '$lib/components/copilot/StepInputsGen.svelte'
import InputTransformForm from '$lib/components/InputTransformForm.svelte'
import InputTransformPickers from '$lib/components/InputTransformPickers.svelte'
import { useS3StorageConfigured } from '$lib/components/inputTransformEnv.svelte'
import { useWorkspaceStorageConfigured } from '$lib/components/inputTransformEnv.svelte'
import type ItemPicker from '$lib/components/ItemPicker.svelte'
import type VariableEditor from '$lib/components/VariableEditor.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
@@ -170,7 +170,7 @@
let itemPicker: ItemPicker | undefined = $state(undefined)
let variableEditor: VariableEditor | undefined = $state(undefined)
const s3Storage = useS3StorageConfigured(() => ws)
const s3Storage = useWorkspaceStorageConfigured(() => ws)
// The per-field copilot only ever writes a JavaScript transform, so it belongs only where one can
// be stored. On a static-only field the write lands in a key the config drops on deploy, which
@@ -857,6 +857,7 @@
<FlowChat
onRunFlow={runFlowWithMessage}
path={$pathStore}
identity={$initialPathStore || fakeInitialPath}
conversationKind="test"
inputSchema={flowStore.val.schema}
flowModules={flowStore.val.value?.modules}
@@ -16,6 +16,7 @@
import { ApiError, type FlowModule } from '$lib/gen'
import { FlowChatPool, type FlowChatPoolState } from './flowChatPool'
import { FlowChatViewHost } from './flowChatViewHost.svelte'
import { FRAME_CLASS, type ChatFrame } from './flowChatProps'
interface Props {
/**
@@ -29,14 +30,24 @@
additionalInputs?: Record<string, any>
) => Promise<string | undefined>
deploymentInProgress?: boolean
/** The flow the chat runs and lists conversations for. Must be the path a run records,
* or a conversation is stored under one path and looked for under another. */
path: string
/**
* What the chat's stored inputs are filed under, when that is not the path. An unsaved
* flow's path changes as its author types, so the editor passes something that holds
* still for the flow it is editing.
*/
identity?: string
hideSidebar?: boolean
inputSchema?: Record<string, any>
/** The flow's modules, read for the provider wiring of its AI agent steps. */
/** The flow's modules, read for the AI agent inputs the composer drives: the provider wiring
* and the attachments input. */
flowModules?: FlowModule[]
/** The flow's description, shown under the empty transcript's prompt. */
description?: string
wideLayout?: boolean
frame?: ChatFrame
/**
* What this surface's own runs are: the editor runs previews and lists its test
* chats, the flow page runs the deployed flow and lists only its users' chats.
@@ -50,11 +61,13 @@
onRunFlow,
deploymentInProgress = false,
path,
identity = undefined,
hideSidebar = false,
inputSchema = undefined,
flowModules = undefined,
description = undefined,
wideLayout = false,
frame = 'top',
conversationKind = 'deployed'
}: Props = $props()
@@ -150,7 +163,7 @@
})
</script>
<div class="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden flex-1">
<div class="flex overflow-hidden flex-1 {FRAME_CLASS[frame]}">
{#if listChat && listState && pool && poolState && shown}
{#if !hideSidebar}
<FlowConversationsSidebar
@@ -162,20 +175,28 @@
canFilterKind={conversationKind !== 'deployed'}
/>
{/if}
{#key shown}
<FlowChatInterface
chat={shown.chat}
chatHost={shown.host}
isTest={shownIsTest}
{deploymentInProgress}
{additionalInputsSchema}
{flowModules}
{path}
{workspace}
{description}
{wideLayout}
{conversationKind}
/>
{/key}
<!-- pb-3 on the chat alone, not on the row: the transcript and composer stop short of
the panel edge the way the session chat does, while the sidebar and the border
dividing it from the chat still reach the bottom. -->
<div class="flex flex-1 min-w-0 min-h-0 pb-3">
<!-- One panel per conversation: the shown chat and its host come from the pool, and
moving to another conversation mounts a fresh panel rather than a stale host. -->
{#key shown}
<FlowChatInterface
chat={shown.chat}
chatHost={shown.host}
isTest={shownIsTest}
{deploymentInProgress}
{additionalInputsSchema}
{flowModules}
{path}
{identity}
{workspace}
{description}
{wideLayout}
{conversationKind}
/>
{/key}
</div>
{/if}
</div>
@@ -10,7 +10,14 @@
import { emptyString, type DynamicInput } from '$lib/utils'
import { tick, untrack } from 'svelte'
import type { Chat } from 'windmill-chat'
import { chatFlowKey } from './flowChatProps'
import type { FlowModule } from '$lib/gen'
import { useWorkspaceStorageConfigured } from '$lib/components/inputTransformEnv.svelte'
import {
attachmentsTargetFor,
PER_TURN_AGENT_CHAT_INPUT_KEY,
resolveAgentChatInputs
} from './agentAttachmentInput'
import { deepEqual } from 'fast-equals'
import FlowChatModelSettings from './FlowChatModelSettings.svelte'
import {
@@ -30,9 +37,12 @@
isTest?: boolean
deploymentInProgress?: boolean
additionalInputsSchema?: Record<string, any>
/** The flow's modules, read for the provider wiring of its AI agent steps. */
/** The flow's modules, read for the AI agent inputs the composer drives: the provider wiring
* and the attachments input. */
flowModules?: FlowModule[]
path: string
/** What the stored inputs are filed under when the path is not steady (see FlowChat). */
identity?: string
workspace?: string
/** The flow's description, shown under the empty transcript's prompt. */
description?: string
@@ -49,6 +59,7 @@
additionalInputsSchema,
flowModules,
path,
identity = undefined,
workspace = undefined,
description = undefined,
wideLayout = false,
@@ -65,9 +76,25 @@
return undefined
})
// The composer's attachments feed this input, and the paperclip is its whole editor.
const attachmentsTarget = $derived.by(() => {
const target = attachmentsTargetFor(
resolveAgentChatInputs(flowModules, additionalInputsSchema).find(
(input) => input.key === PER_TURN_AGENT_CHAT_INPUT_KEY
)
)
const required: unknown = additionalInputsSchema?.required
return target && Array.isArray(required) && required.includes(target.name)
? { ...target, required: true }
: target
})
// Uploading needs the workspace's object storage; without one the `+` is drawn disabled
// saying so, since the modal could not upload either.
const workspaceStorage = useWorkspaceStorageConfigured(() => workspace)
// The model gets its own button, shaped like the copilot's model settings, driven by
// whichever provider fields the flow exposes. Every other flow input is asked for in
// the Configure-inputs modal.
// whichever provider fields the flow exposes. Attachments are the paperclip's; every other
// flow input is asked for in the Configure-inputs modal.
const modelWiring = $derived(resolveAgentModelWiring(flowModules))
// An agent with nothing to call cannot answer, and the composer cannot fix it, so the
// chat says what to go and do instead of offering controls that write nowhere.
@@ -106,7 +133,7 @@
const runInputs = $derived(withoutRejectedEffort(modelWiring, effectiveInputs))
function getStorageKey(): string {
return `${STORAGE_KEY_PREFIX}${path}`
return `${STORAGE_KEY_PREFIX}${chatFlowKey({ path, identity })}`
}
function loadInputsFromStorage(): Record<string, any> | null {
@@ -153,11 +180,22 @@
showInputsModal = true
}
// The host belongs to the conversation, not to this panel: the pool keeps it alive so a
// message queued here still goes out once the reader has moved on. What it reads is this
// panel's, set on mount; FlowChat remounts the panel per conversation.
const chatHost = untrack(() => chatHostProp)
chatHost.setOptions({
additionalInputs: () => (additionalInputsSchema ? { ...runInputs } : undefined),
attachmentsTarget: () => attachmentsTarget,
attachmentsUnavailable: () =>
workspaceStorage.current
? undefined
: 'This workspace has no object storage, so files cannot be attached.',
workspace: () => workspace,
sendDisabled: () => deploymentInProgress || !!modelGap || !!wrongKindReason
sendDisabled: () => deploymentInProgress || !!modelGap || !!wrongKindReason,
// The model controls only: a retry changes model when the reader did, but replays
// the run's own attachments rather than whatever the composer holds now.
inputsShownInComposer: () => composerOwnedInputs(modelWiring, undefined)
})
setChatViewHost(chatHost)
@@ -174,7 +212,7 @@
// edit itself.
const modalSchema = $derived.by(() => {
if (!additionalInputsSchema) return undefined
const promoted = new Set(composerOwnedInputs(modelWiring, undefined))
const promoted = new Set(composerOwnedInputs(modelWiring, attachmentsTarget))
const properties = Object.fromEntries(
Object.entries(additionalInputsSchema.properties ?? {}).filter(([key]) => !promoted.has(key))
)
@@ -228,7 +228,7 @@
: 'w-[44px]'}"
>
<!-- Header -->
<div class="flex-shrink-0 border-b">
<div class="flex-shrink-0">
<div class="flex flex-col gap-2 p-1">
<Button
unifiedSize="md"
@@ -0,0 +1,179 @@
import { describe, expect, it } from 'vitest'
import {
agentSteps,
attachmentsTargetFor,
flowInputRef,
resolveAgentChatInputs
} from './agentAttachmentInput'
import type { FlowModule } from '$lib/gen'
/** An agent step with the given input transforms, as the editor stores them. */
function agentWith(input_transforms: Record<string, any>, id = 'a'): FlowModule {
return {
id,
value: { type: 'aiagent', tools: [], input_transforms }
} as unknown as FlowModule
}
describe('agentSteps', () => {
it('finds agents inside loops and branches', () => {
const modules = [
{ id: 'loop', value: { type: 'forloopflow', modules: [agentWith({}, 'in-loop')] } },
{
id: 'branch',
value: {
type: 'branchone',
default: [agentWith({}, 'in-default')],
branches: [{ modules: [agentWith({}, 'in-branch')] }]
}
},
{
id: 'all',
value: { type: 'branchall', branches: [{ modules: [agentWith({}, 'in-all')] }] }
}
] as unknown as FlowModule[]
expect(agentSteps(modules).map((m) => m.id)).toEqual([
'in-loop',
'in-default',
'in-branch',
'in-all'
])
})
// The graph walks an agent's tools as child steps; a tool agent's inputs belong to the
// agent that calls it, not to the chat.
it("ignores an agent carried as another agent's tool", () => {
const parent = agentWith({})
;(parent.value as any).tools = [
{ id: 'summarize', value: { tool_type: 'flowmodule', type: 'aiagent', tools: [] } }
]
expect(agentSteps([parent]).map((m) => m.id)).toEqual(['a'])
})
})
describe('flowInputRef', () => {
it('names the one input an expression reads, however it reshapes it', () => {
expect(
flowInputRef({
type: 'javascript',
expr: '(flow_input.files || []).map(f => ({ bucket: f.storage, key: f.s3 }))'
})
).toBe('files')
expect(flowInputRef({ type: 'javascript', expr: 'flow_input?.docs' })).toBe('docs')
})
// A loop step reads its iteration from `flow_input.iter`, which is not a flow input.
it('ignores names the flow does not declare, such as a loop iteration', () => {
const transform = {
type: 'javascript' as const,
expr: 'flow_input.files.filter((_, i) => i === flow_input.iter.index)'
}
expect(flowInputRef(transform)).toBeUndefined()
expect(flowInputRef(transform, { files: {} })).toBe('files')
})
it('counts real reads only, in any access form and in a statement body', () => {
const ref = (expr: string) =>
flowInputRef({ type: 'javascript', expr }, { files: {}, docs: {} })
expect(ref("flow_input['files']")).toBe('files')
expect(ref('/* flow_input.docs */ flow_input?.files')).toBe('files')
expect(ref("'flow_input.docs' + flow_input.files")).toBe('files')
expect(ref('results.a.flow_input.docs ?? flow_input.files')).toBe('files')
expect(ref('const f = flow_input.files\nreturn f')).toBe('files')
expect(ref('flow_input.files.concat(')).toBeUndefined()
})
it('names nothing for two inputs or a static value', () => {
expect(
flowInputRef({ type: 'javascript', expr: '[...flow_input.a, ...flow_input.b]' })
).toBeUndefined()
expect(flowInputRef({ type: 'static', value: [] })).toBeUndefined()
})
})
describe('resolveAgentChatInputs', () => {
const schema = { properties: { files: { type: 'array' } }, required: [] }
const reader = (name: string) =>
agentWith({ user_attachments: { type: 'javascript', expr: `flow_input.${name}` } })
// Every agent step carries a placeholder transform for each key of AI_AGENT_SCHEMA
// (loadSchemaFromModule writes them back onto the module), so an agent that reads
// nothing must not be mistaken for one reading a different input.
const seeded = () => agentWith({ user_attachments: { type: 'static', value: undefined } })
it('promotes the input one agent reads', () => {
expect(resolveAgentChatInputs([reader('files')], schema).map((i) => i.name)).toEqual(['files'])
})
it('still promotes it when another agent leaves the field unwired', () => {
expect(resolveAgentChatInputs([reader('files'), seeded()], schema).map((i) => i.name)).toEqual([
'files'
])
})
it('promotes nothing when two agents read different inputs', () => {
const twoInputs = {
properties: { files: { type: 'array' }, docs: { type: 'array' } },
required: []
}
expect(resolveAgentChatInputs([reader('files'), reader('docs')], twoInputs)).toEqual([])
})
it('promotes the input an agent inside a loop reads next to its iteration', () => {
const loop = {
id: 'loop',
value: {
type: 'forloopflow',
modules: [
agentWith({
user_attachments: {
type: 'javascript',
expr: 'flow_input.files.filter((_, i) => i === flow_input.iter.index)'
}
})
]
}
} as unknown as FlowModule
expect(resolveAgentChatInputs([loop], schema).map((i) => i.name)).toEqual(['files'])
})
it('gives no say to an agent that only mentions flow_input in a comment', () => {
const commented = agentWith({
user_attachments: { type: 'javascript', expr: '// flow_input.docs\nresults.a.files' }
})
expect(resolveAgentChatInputs([reader('files'), commented], schema).map((i) => i.name)).toEqual(
['files']
)
})
it('promotes nothing for an input the schema does not declare', () => {
expect(resolveAgentChatInputs([reader('missing')], schema)).toEqual([])
})
})
describe('attachmentsTargetFor', () => {
const input = (property: Record<string, any>) =>
({ name: 'files', key: 'user_attachments', property }) as any
it('takes a list of s3 files, and says it holds several', () => {
expect(
attachmentsTargetFor(input({ type: 'array', items: { resourceType: 's3object' } }))
).toEqual({ name: 'files', multiple: true })
})
it('takes a single s3 file', () => {
expect(attachmentsTargetFor(input({ format: 'resource-s3_object' }))).toEqual({
name: 'files',
multiple: false
})
})
// The transform can build the s3 object itself, promoting an input that holds a key
// rather than a file. Uploading into it would write an object where a string is declared.
it('offers no paperclip where the input cannot hold a file', () => {
expect(attachmentsTargetFor(input({ type: 'string' }))).toBeUndefined()
expect(
attachmentsTargetFor(input({ type: 'array', items: { type: 'string' } }))
).toBeUndefined()
expect(attachmentsTargetFor(undefined)).toBeUndefined()
})
})
@@ -0,0 +1,185 @@
import type { FlowModule, InputTransform } from '$lib/gen'
import { parse, parseExpressionAt } from 'acorn'
/**
* The flow's own AI agent steps, including those inside loops and branches. An agent carried
* as another agent's tool is left out, unlike in the graph (flowTree.ts): its inputs come from
* the agent calling it, not from the chat.
*/
export function agentSteps(modules: FlowModule[] | undefined): FlowModule[] {
const found: FlowModule[] = []
const walk = (mods: FlowModule[]) => {
for (const module of mods) {
const value = module.value as any
if (value?.type === 'aiagent') {
found.push(module)
continue
}
if (value?.type === 'forloopflow' || value?.type === 'whileloopflow') {
walk(value.modules ?? [])
} else if (value?.type === 'branchone') {
walk(value.default ?? [])
for (const branch of value.branches ?? []) walk(branch.modules ?? [])
} else if (value?.type === 'branchall') {
for (const branch of value.branches ?? []) walk(branch.modules ?? [])
}
}
}
walk(modules ?? [])
return found
}
/**
* AI agent inputs the chat composer drives, through the flow input the author wired to each:
* the composer writes run inputs, never the flow. Only what the person chatting owns turn to
* turn belongs here. `system_prompt`, `temperature` and the like shape the agent for everyone
* who runs the flow, so they stay in the Configure-inputs modal.
*/
export const AGENT_CHAT_INPUT_KEYS = ['user_attachments'] as const
export type AgentChatInputKey = (typeof AGENT_CHAT_INPUT_KEYS)[number]
/** The key that rides one message rather than the conversation, cleared on send. */
export const PER_TURN_AGENT_CHAT_INPUT_KEY: AgentChatInputKey = 'user_attachments'
export type AgentChatInput = {
/** Flow input property feeding the agent field. */
name: string
key: AgentChatInputKey
/** The flow input's own schema entry. */
property: Record<string, any>
}
/**
* The `flow_input` properties an expression reads, from its syntax tree rather than its text,
* so a mention in a comment, a string or a nested path (`results.a.flow_input.x`) is not a read.
* A transform may be a statement body with `return`. Undefined when it does not parse.
*/
export function flowInputReads(expr: string): Set<string> | undefined {
let root: unknown
try {
root = parseExpressionAt(`(\n${expr}\n)`, 0, { ecmaVersion: 'latest' })
} catch {
try {
root = parse(expr, {
ecmaVersion: 'latest',
allowReturnOutsideFunction: true,
allowAwaitOutsideFunction: true
})
} catch {
return undefined
}
}
const names = new Set<string>()
const visit = (node: any) => {
if (!node || typeof node !== 'object') return
if (Array.isArray(node)) return node.forEach(visit)
if (
node.type === 'MemberExpression' &&
node.object?.type === 'Identifier' &&
node.object.name === 'flow_input'
) {
if (!node.computed && node.property?.type === 'Identifier') names.add(node.property.name)
else if (node.computed && typeof node.property?.value === 'string')
names.add(node.property.value)
}
for (const key in node) if (key !== 'type') visit(node[key])
}
visit(root)
return names
}
/**
* The flow input a transform reads, when it reads exactly one. The expression may reshape it
* (`(flow_input.files || []).map(...)`) and still counts. With `declared`, only the flow's own
* inputs count, so a loop's `flow_input.iter` does not make a read ambiguous.
*/
export function flowInputRef(
transform: InputTransform | undefined,
declared?: Record<string, unknown>
): string | undefined {
if (transform?.type !== 'javascript') return undefined
const reads = flowInputReads(transform.expr)
if (!reads) return undefined
const names = declared ? [...reads].filter((name) => name in declared) : [...reads]
return names.length === 1 ? names[0] : undefined
}
/** Whether a schema entry holds an s3 file, as the flow input editor recognises one. */
function holdsS3File(property: Record<string, any> | undefined): boolean {
return (
property?.format === 'resource-s3_object' ||
property?.resourceType === 's3object' ||
property?.resourceType === 's3_object'
)
}
/** The flow input the composer's attachments feed, whether it holds a list, and whether the
* flow requires it (so a message without a file cannot run). */
export type AttachmentsTarget = { name: string; multiple: boolean; required?: boolean }
/**
* Where the composer's attachments go: the promoted input, only when its schema holds s3
* objects. A transform may build the s3 object from a plain string input, and writing
* `{ s3, filename }` into that input would fail at run time.
*/
export function attachmentsTargetFor(
input: AgentChatInput | undefined
): AttachmentsTarget | undefined {
if (!input) return undefined
if (holdsS3File(input.property)) return { name: input.name, multiple: false }
return input.property?.type === 'array' && holdsS3File(input.property.items)
? { name: input.name, multiple: true }
: undefined
}
/**
* The flow inputs that an AI agent step reads directly into one of its chat-relevant
* fields. Several agents may read the same flow input; it is promoted once.
*/
export function resolveAgentChatInputs(
modules: FlowModule[] | undefined,
additionalInputsSchema: Record<string, any> | undefined
): AgentChatInput[] {
const properties = additionalInputsSchema?.properties
if (!modules || !properties) return []
// One input per key, and only when every agent reading that key reads the same one:
// the composer writes a single flow input, so promoting one of two would feed one
// agent and leave the other with nothing — while hiding both from the modal, where
// the reader could at least have filled them in.
const namesPerKey = new Map<AgentChatInputKey, Set<string | undefined>>()
for (const module of agentSteps(modules)) {
const transforms = (module.value as any).input_transforms ?? {}
for (const key of AGENT_CHAT_INPUT_KEYS) {
const transform = transforms[key]
// An agent that feeds the key from anything but a flow input — a literal, another
// step's result, or the empty placeholder every agent step carries for the keys of
// AI_AGENT_SCHEMA — is not reading an input, so it has no say in which one the
// composer drives. An expression that does not parse is kept, as unreadable.
if (transform?.type !== 'javascript') continue
const reads = flowInputReads(transform.expr)
if (reads ? reads.size === 0 : !transform.expr.includes('flow_input')) continue
// Reading no declared input, or two of them, names none: this agent reads something
// the composer cannot drive, which is what disagreement means here.
const usable = flowInputRef(transform, properties)
const names = namesPerKey.get(key) ?? new Set<string | undefined>()
names.add(usable)
namesPerKey.set(key, names)
}
}
const keyOf = new Map<string, AgentChatInputKey>()
for (const [key, names] of namesPerKey) {
if (names.size !== 1) continue
const name = [...names][0]
if (name === undefined || keyOf.has(name)) continue
keyOf.set(name, key)
}
return [...keyOf.entries()].map(([name, key]) => ({
name,
key,
property: properties[name]
}))
}
@@ -422,6 +422,15 @@ describe('agents that do not read the message', () => {
expect(wiring?.fields.model).toBe('model')
})
// The worker runs a statement body too, so reading the message there is still reading it.
it('counts an agent that reads the message in a statement body', () => {
const wiring = resolveAgentModelWiring([
subAgent(wired, 'const m = flow_input.user_message\nreturn m'),
subAgent(fixed)
])
expect(wiring?.fields.model).toBe('model')
})
// Two agents both answering the reader still have to agree: either might be the one
// that replies, so a control moving one of them would be a lie about the other.
it('still needs agreement among the agents that do read the message', () => {
@@ -2,38 +2,7 @@ import type { AIProvider, FlowModule, InputTransform } from '$lib/gen'
import { explicitOffToken, getReasoningCapability } from '$lib/components/copilot/reasoningRegistry'
import { carriedReasoning } from '$lib/components/copilot/chatModelSettings'
import { parseExpressionAt } from 'acorn'
/**
* The flow's own AI agent steps, including those inside loops and branches but never one
* carried as another agent's tool.
*
* The graph walks an agent's tools as if they were child steps (flowTree.ts), which is
* right for the graph and wrong here: a tool agent's provider belongs to the agent that
* calls it, not to the chat. Counting it would let a nested agent's fixed model defeat the
* composer's model control on the step the reader is actually talking to.
*/
function agentSteps(modules: FlowModule[] | undefined): FlowModule[] {
const found: FlowModule[] = []
const walk = (mods: FlowModule[]) => {
for (const module of mods) {
const value = module.value as any
if (value?.type === 'aiagent') {
found.push(module)
continue
}
if (value?.type === 'forloopflow' || value?.type === 'whileloopflow') {
walk(value.modules ?? [])
} else if (value?.type === 'branchone') {
walk(value.default ?? [])
for (const branch of value.branches ?? []) walk(branch.modules ?? [])
} else if (value?.type === 'branchall') {
for (const branch of value.branches ?? []) walk(branch.modules ?? [])
}
}
}
walk(modules ?? [])
return found
}
import { agentSteps, flowInputReads } from './agentAttachmentInput'
/** Block and line comments removed, so what is left is only what affects the value. */
function withoutComments(source: string): string {
@@ -73,39 +42,9 @@ function chatFacingAgents(modules: FlowModule[] | undefined): FlowModule[] {
return facing.length > 0 ? facing : agents
}
/**
* Whether an expression reads `flow_input.<name>` anywhere in it.
*
* Parsed rather than matched: the author may write `flow_input['user_message']` as readily
* as the dot form the editor emits, and a mention inside a comment or a string is not a
* read. Reading two inputs is still a read of each, which is why this is not the question
* "which single input feeds a field" that the composer asks of a wired field.
*/
/** Whether an expression reads `flow_input.<name>`, as `flowInputReads` parses it. */
function readsFlowInput(expr: string, name: string): boolean {
let root: unknown
try {
root = parseExpressionAt(parenthesised(expr), 0, { ecmaVersion: 'latest' })
} catch {
return false
}
let found = false
const visit = (node: any) => {
if (found || !node || typeof node !== 'object') return
if (Array.isArray(node)) {
node.forEach(visit)
return
}
if (flowInputName(node) === name) {
found = true
return
}
for (const key of Object.keys(node)) {
if (key === 'type' || key === 'start' || key === 'end') continue
visit(node[key])
}
}
visit(root)
return found
return flowInputReads(expr)?.has(name) ?? false
}
/** A provider value as the agent stores it. */
@@ -0,0 +1,19 @@
/** What separates the chat from what sits above it. `boxed` is its own panel, corners
* clipped so the sidebar's edge follows them; `top` a dividing line under an enclosing
* header; `none` for a surface where the chat is the whole pane. */
export type ChatFrame = 'boxed' | 'top' | 'none'
export const FRAME_CLASS: Record<ChatFrame, string> = {
boxed: 'border rounded-md',
top: 'border-t',
none: ''
}
/**
* Which flow a chat is for, as something that holds still: `identity` where a surface has
* one, since `path` follows the path field as its author types. `||`, not `??`: an empty
* identity is no identity.
*/
export function chatFlowKey(props: { path: string; identity?: string }): string {
return props.identity || props.path
}
@@ -1,5 +1,6 @@
import {
TurnRunningError,
type AttachmentUpload,
type Chat,
type ChatMessage,
type ChatState,
@@ -15,18 +16,50 @@ import type { AIAutonomyMode } from '$lib/components/copilot/chat/AIChatManager.
import { isPlanCardTool } from '$lib/components/copilot/chat/planMode'
import { AttachedFilesStore } from '$lib/components/copilot/chat/files/attachedFiles.svelte'
import { SessionArtifactsStore } from '$lib/components/copilot/chat/artifacts/artifactsState.svelte'
import type { AttachedBlob } from '$lib/components/copilot/chat/blobUtils'
import type { AttachedImage } from '$lib/components/copilot/chat/imageUtils'
import type { AttachedTextFile } from '$lib/components/copilot/chat/textFileUtils'
import {
prefersInstantReveal,
TypewriterReveal,
type TypewriterRevealOptions
} from '$lib/components/copilot/chat/typewriterReveal'
import { JobService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { attachmentLanes } from './messageAttachments'
import type { AttachmentsTarget } from './agentAttachmentInput'
export type FlowChatViewHostOptions = {
/** The flow inputs sent next to `user_message` with every turn. */
additionalInputs?: () => Record<string, any> | undefined
/** The workspace the transcript's paths resolve against. */
/** The flow input the composer's attachments feed. Undefined where the flow has none:
* the chat then takes no attachments at all. */
attachmentsTarget?: () => AttachmentsTarget | undefined
/** Why attaching is off despite the flow taking attachments no object storage, say.
* Undefined while the workspace has not answered: an explanation must not be a guess. */
attachmentsUnavailable?: () => string | undefined
/** The workspace the transcript's paths resolve against, and a retry reads its run from. */
workspace?: () => string | undefined
/** Whether sending is refused right now (a deployment in progress, say). The composer
* is disabled on the same condition; this covers the sends the composer does not
* make itself: a queued message going out, a retry. */
sendDisabled?: () => boolean
/** Flow inputs edited by a control beside the composer, such as the model button. A retry
* takes their current value rather than the failed turn's. */
inputsShownInComposer?: () => string[]
/** Injectables for tests: the clock and scheduler behind the typewriter pacing. */
revealOptions?: Pick<TypewriterRevealOptions, 'instant' | 'now' | 'schedule' | 'cancel'>
}
/** The chat's own signal for a send stopped before it ran; nothing to tell the reader. */
function isAbort(e: unknown): boolean {
return e instanceof Error && e.name === 'AbortError'
}
type Queue = { text: string; images: AttachedImage[]; blobs: AttachedBlob[] }
function emptyQueue(): Queue {
return { text: '', images: [], blobs: [] }
}
/** A tool's arguments or result as the card shows them: parsed where the string is JSON. */
@@ -39,6 +72,27 @@ function parseToolPayload(raw: string | undefined): unknown {
}
}
/**
* The step name says which AI agent step wrote a message, so it only tells the reader
* anything once the conversation holds more than one. Counted over the transcript rather
* than over the flow's steps: a conversation outlives edits to the flow, so it can carry
* labels from a shape the flow no longer has.
*/
export function showsStepNames(messages: readonly ChatMessage[]): boolean {
return new Set(messages.map((m) => m.stepName).filter(Boolean)).size > 1
}
/** How much of a streaming message is on screen, per lane, in characters. */
export type Revealed = { content: number; reasoning: number }
/** What a display row can only learn beyond the message itself. Every lookup is optional. */
export type DisplayLookups = {
/** The workspace an attachment's download link points into. */
workspace?: string
/** How much of a pending assistant row the pacing has put on screen. */
revealed?: (message: ChatMessage) => Revealed | undefined
}
/**
* What a failed tool call returned, as the card's error: the card shows the error in place of
* the result. A job failure is stored as `{ message, name, stack }`; an MCP failure as plain text.
@@ -64,9 +118,11 @@ const STRUCTURED_OUTPUT_CALL = /^structured_output(_\d+)?$/
export function toDisplayMessages(
messages: readonly ChatMessage[],
busy = false,
stopped: ReadonlySet<string> = new Set()
stopped: ReadonlySet<string> = new Set(),
lookups: DisplayLookups = {}
): DisplayMessage[] {
let userIndex = 0
const stepNames = showsStepNames(messages)
let latestUser = -1
for (let i = messages.length - 1; i >= 0 && latestUser < 0; i--) {
if (messages[i].role === 'user') latestUser = i
@@ -79,17 +135,23 @@ export function toDisplayMessages(
!(busy && i === latestUser) &&
!stopped.has(message.id) &&
!(message.serverId && stopped.has(message.serverId))
const { images, contextElements } = attachmentLanes(lookups.workspace, message.attachments)
return [
{
role: 'user',
index,
content: message.content,
// Drives the shared Retry button.
error: (settled && turnFailed(messages, i)) || undefined
error: (settled && turnFailed(messages, i)) || undefined,
images: images.length > 0 ? images : undefined,
contextElements: contextElements.length > 0 ? contextElements : undefined
}
]
}
case 'tool': {
// The model's call and what the tool sent back, as the stream carried them or the
// worker stored them on the row. A row stored without them shows its name and job.
const toolName = message.tool?.name
const parameters = parseToolPayload(message.tool?.arguments)
const result = parseToolPayload(message.tool?.result)
const failed = message.success === false
@@ -108,17 +170,17 @@ export function toDisplayMessages(
tool_call_id: message.id,
// The card's header is the row's text, which the server only words once the
// tool has returned; until then the row says what is running.
content:
message.content || unfinished || (message.tool ? `Running ${message.tool.name}` : ''),
content: message.content || unfinished || (toolName ? `Running ${toolName}` : ''),
// Withheld for the copilot's two plan-mode names: `toolName` is what makes
// ToolExecutionDisplay render a plan card, and an agent tool that happened to
// share one would silently become one.
toolName: isPlanCardTool(message.tool?.name) ? undefined : message.tool?.name,
toolName: isPlanCardTool(toolName) ? undefined : toolName,
parameters,
result,
showDetails: parameters !== undefined || result !== undefined,
error: failed ? (toolErrorText(result) ?? message.content) : unfinished,
isLoading: message.pending && message.tool?.status === 'running'
isLoading: message.pending && message.tool?.status === 'running',
jobId: message.jobId
}
// The tool card has no thinking section: the thinking that led to the call reads
// as a card of its own, just before it.
@@ -128,7 +190,7 @@ export function toDisplayMessages(
role: 'assistant',
content: '',
reasoning: message.reasoning,
stepName: message.stepName,
stepName: stepNames ? message.stepName : undefined,
jobId: message.jobId,
createdAt: message.createdAt
},
@@ -136,24 +198,37 @@ export function toDisplayMessages(
]
: [call]
}
default:
default: {
// The pacing's prefix while the message streams; the whole text once it has
// settled, or the turn was stopped.
const revealed = message.pending ? lookups.revealed?.(message) : undefined
return [
{
role: 'assistant',
content: message.content,
content: revealed ? message.content.slice(0, revealed.content) : message.content,
// Only the message a turn is still writing: a finalized reasoning-only
// message must not look in progress.
streaming: message.pending || undefined,
reasoning: message.reasoning,
stepName: message.stepName,
reasoning: revealed
? message.reasoning?.slice(0, revealed.reasoning)
: message.reasoning,
stepName: stepNames ? message.stepName : undefined,
jobId: message.jobId,
createdAt: message.createdAt
}
]
}
}
})
}
/** The two paced lanes of one streaming message, and how much of each has been fed in. */
type RevealLanes = {
content: TypewriterReveal
reasoning: TypewriterReveal
fed: Revealed
}
/**
* Renders a flow run's conversation, as the `windmill-chat` SDK keeps it, through the
* copilot's chat components. The turn is a flow job rather than an LLM call this host
@@ -189,12 +264,14 @@ export class FlowChatViewHost implements ChatViewHost {
#disposed = false
/** Stops following the chat, and drops what was queued: a flush still waiting on the
* turn's release would otherwise start a run from a panel that is gone. The chat itself
* is the caller's to destroy. */
* turn's release would otherwise start a run from a panel that is gone. A send still
* uploading its attachments stops with the chat, which is the caller's to destroy; its
* draft is not handed back, since the composer it came from is gone too. */
dispose() {
this.#disposed = true
this.#queued = ''
this.#queue = emptyQueue()
this.#unsubscribe()
for (const id of Object.keys(this.#reveals)) this.#dropReveal(id)
}
/** The latest `ChatState`, for what the interface reads beyond the seam (paging, loading). */
@@ -205,6 +282,7 @@ export class FlowChatViewHost implements ChatViewHost {
#onState(state: ChatState) {
const previous = this.#state
this.#state = state
this.#paceReveals(state.messages)
const landed = state.messages.filter(
(m) => m.serverId && this.#stoppedTurns.has(m.id) && !this.#stoppedTurns.has(m.serverId)
)
@@ -219,6 +297,7 @@ export class FlowChatViewHost implements ChatViewHost {
this.dequeueMessage()
return
}
if (!isBusy(previous.status) && isBusy(state.status)) this.#turnsStarted++
if (isBusy(previous.status) && !isBusy(state.status)) {
// The turn settled. What was typed during it goes out once the turn is released,
// not now: the chat publishes `idle` from inside its own `sendMessage`, which still
@@ -232,9 +311,70 @@ export class FlowChatViewHost implements ChatViewHost {
}
}
// Smooth streaming. The chat appends each delta to the pending assistant message as it
// arrives, in the coarse bursts the provider sends; what is shown is a prefix that a
// typewriter advances per lane, so the bursts read as continuous typing. Plain fields
// for the pacers, reactive counts for what they have put on screen.
#reveals: Record<string, RevealLanes> = {}
#revealed = $state<Record<string, Revealed>>({})
#paceReveals(messages: readonly ChatMessage[]) {
const pending = new Set<string>()
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
if (message.role !== 'assistant' || !message.pending) continue
pending.add(message.id)
const lanes = (this.#reveals[message.id] ??= this.#newLanes(message.id))
const content = message.content.slice(lanes.fed.content)
const reasoning = (message.reasoning ?? '').slice(lanes.fed.reasoning)
lanes.fed = { content: message.content.length, reasoning: (message.reasoning ?? '').length }
lanes.content.push(content)
lanes.reasoning.push(reasoning)
// A row the turn has moved past — a tool card now follows it — is shown whole:
// what the pacing still holds belongs above that card, not trickling in under it.
if (i < messages.length - 1) {
lanes.content.flush()
lanes.reasoning.flush()
}
}
for (const id of Object.keys(this.#reveals)) {
if (!pending.has(id)) this.#dropReveal(id)
}
}
#newLanes(id: string): RevealLanes {
const options = this.#options.revealOptions ?? {}
const instant = options.instant ?? prefersInstantReveal()
const lane = (kind: keyof Revealed) =>
new TypewriterReveal({
...options,
instant,
onReveal: (chunk) => {
const current = this.#revealed[id] ?? { content: 0, reasoning: 0 }
this.#revealed[id] = { ...current, [kind]: current[kind] + chunk.length }
}
})
this.#revealed[id] = { content: 0, reasoning: 0 }
return {
content: lane('content'),
reasoning: lane('reasoning'),
fed: { content: 0, reasoning: 0 }
}
}
#dropReveal(id: string) {
this.#reveals[id]?.content.reset()
this.#reveals[id]?.reasoning.reset()
delete this.#reveals[id]
delete this.#revealed[id]
}
// Transcript
displayMessages = $derived.by(() =>
toDisplayMessages(this.#state.messages, isBusy(this.#state.status), this.#stoppedTurns)
toDisplayMessages(this.#state.messages, isBusy(this.#state.status), this.#stoppedTurns, {
workspace: this.#options.workspace?.(),
revealed: (message) => this.#revealed[message.id]
})
)
/** The user message of each turn stopped in this view, by message id and, once the chat has
* read its row, row id: a reopened conversation or an older page rebuilds messages from rows,
@@ -274,33 +414,96 @@ export class FlowChatViewHost implements ChatViewHost {
instructions = ''
// The user message lands in the transcript before `sendMessage` awaits anything.
sendInFlight = false
sendRequest = async (options: ChatSendRequestOptions = {}): Promise<boolean> => {
/**
* `replayInputs` are a failed turn's own run arguments, read back from its job. They
* stand in for the composer's current inputs, so a retry runs the turn that failed
* rather than a new one wearing its text.
*/
sendRequest = async (
options: ChatSendRequestOptions = {},
replayInputs?: Record<string, any>
): Promise<boolean> => {
const text = options.instructions?.trim() ?? ''
let images = options.images ?? []
let blobs = options.blobs ?? []
// The composer refuses an attachment-only send (requiresMessageText), so this is
// the same rule at the other end: nothing runs without a message.
if (!text) return false
if (this.loading) {
this.queueMessage(text)
this.queueMessage(text, images, undefined, undefined, blobs)
return true
}
if (this.#options.sendDisabled?.()) {
// Refused, not dropped: the text waits in the composer for sending to reopen.
this.#returnText(text)
// Refused, not dropped: the draft waits in the composer for sending to reopen.
this.#returnDraft(text, images, blobs)
return false
}
const target = this.#options.attachmentsTarget?.()
// The inputs modal does not ask for this input, so a required one is enforced here. A
// replay carries the files its run already has, in `replayInputs`, and attaches none.
if (!replayInputs && target?.required && images.length === 0 && blobs.length === 0) {
sendUserToast('This chat needs a file with each message. Attach one to send.', true)
this.#returnDraft(text, images, blobs)
return false
}
// A replay sends the arguments its run had, attachment references included.
const inputs = replayInputs ?? { ...(this.#options.additionalInputs?.() ?? {}) }
// The attachments are this input's only editor: a value stored for it in the inputs
// modal would otherwise ride along on every message.
if (target && !replayInputs) delete inputs[target.name]
// The composer caps files as they are attached, but a queue merged over several turns
// arrives here as one send, and the chat refuses more than a single-file input holds.
const cap = this.maxMessageAttachments
if (cap !== undefined && images.length + blobs.length > cap) {
const dropped = images.length + blobs.length - cap
images = images.slice(0, cap)
blobs = blobs.slice(0, Math.max(0, cap - images.length))
sendUserToast(
cap === 1
? `This chat sends one attachment per message; ${dropped} file(s) were not sent.`
: `This chat sends up to ${cap} attachments per message; ${dropped} file(s) were not sent.`,
true
)
}
const attachments: AttachmentUpload[] = target
? [...images, ...blobs].map((attachment, index) => ({
name: attachment.name ?? `attachment-${index + 1}`,
data: attachment.dataUrl,
mediaType: attachment.mediaType
}))
: []
this.#automaticScroll = true
// A run that fails is reported through the chat's `onError` and as a failed message;
// the promise itself only rejects when the chat refuses the turn outright, and the
// text is then handed back rather than dropped. Refused because the conversation is
// still answering a message sent elsewhere: that turn is followed here, and this one
// waits behind it as if it had been typed during it.
// the promise itself only rejects when the chat refuses the turn outright — a turn
// already running, an upload that failed, Stop pressed while it ran — and the draft
// is then handed back rather than dropped. The composer took it before calling, so
// nothing else would.
const turn = this.#chat
.sendMessage(text, { inputs: this.#options.additionalInputs?.() })
.sendMessage(text, {
inputs: replayInputs ?? (this.#options.additionalInputs?.() ? inputs : undefined),
attachments,
attachmentsInput: target
})
.catch((e) => {
if (e instanceof TurnRunningError && !this.#disposed) {
this.queueMessage(text)
if (this.#disposed) return
if (e instanceof TurnRunningError) {
// The conversation is still answering a message sent elsewhere: that turn is
// followed here, and this one waits behind it as if typed during it.
this.queueMessage(text, images, undefined, undefined, blobs)
this.resumeTurn(e.turn)
} else {
this.#returnText(text)
return
}
if (attachments.length > 0 && !isAbort(e)) {
sendUserToast(
`Could not upload the attachments: ${e instanceof Error ? e.message : String(e)}`,
true
)
}
// What was queued behind it comes back too, after it: the chat publishes `idle`
// when it withdraws the turn, and a queue left in place would be flushed as if
// the turn had run.
this.dequeueMessage()
this.#returnDraft(text, images, blobs)
})
this.#turnDone = turn
await turn
@@ -326,44 +529,77 @@ export class FlowChatViewHost implements ChatViewHost {
#aiChatInput: Parameters<ChatViewHost['setAiChatInput']>[0] = null
setAiChatInput: ChatViewHost['setAiChatInput'] = (aiChatInput) => {
this.#aiChatInput = aiChatInput
if (aiChatInput && this.#returned) {
aiChatInput.prependText(this.#returned)
this.#returned = ''
const { text, images, blobs } = this.#returned
if (aiChatInput && (text || images.length > 0 || blobs.length > 0)) {
this.#returned = emptyQueue()
aiChatInput.prependText(text, images, [], blobs)
}
}
/** Text handed back while no composer shows this conversation, for the next one that does. */
#returned = ''
#returnText(text: string) {
if (this.#aiChatInput) this.#aiChatInput.prependText(text)
else this.#returned = this.#returned ? `${this.#returned}\n${text}` : text
/**
* A draft handed back while no composer shows this conversation, for the next one that
* does. This host outlives the panel, so a turn that refuses its message after the reader
* has moved on has nowhere to put it back until then.
*/
#returned: Queue = emptyQueue()
#returnDraft(text: string, images: AttachedImage[] = [], blobs: AttachedBlob[] = []) {
if (this.#aiChatInput) {
this.#aiChatInput.prependText(text, images, [], blobs)
return
}
this.#returned = {
text: this.#returned.text ? `${this.#returned.text}\n${text}` : text,
images: [...this.#returned.images, ...images],
blobs: [...this.#returned.blobs, ...blobs]
}
}
// One message typed while the turn runs, sent whole once it settles. Enter again
// appends a line rather than replacing what waits.
#queued = $state('')
// One message typed while the turn runs, sent whole with its attachments once the turn
// settles. Enter again appends a line rather than replacing what waits.
#queue = $state<Queue>(emptyQueue())
get queuedMessage(): string {
return this.#queued
return this.#queue.text
}
queuedContext = undefined
queuedImages: AttachedImage[] = []
queuedFiles: AttachedTextFile[] = []
queueMessage = (text: string) => {
const trimmed = text.trim()
if (!trimmed) return
this.#queued = this.#queued ? `${this.#queued}\n${trimmed}` : trimmed
get queuedImages(): AttachedImage[] {
return this.#queue.images
}
/** Put the queued draft back in the composer. */
queuedFiles: AttachedTextFile[] = []
get queuedBlobs(): AttachedBlob[] {
return this.#queue.blobs
}
queueMessage = (
text: string,
images: AttachedImage[] = [],
_context?: unknown,
_files?: unknown,
blobs: AttachedBlob[] = []
) => {
const trimmed = text.trim()
if (!trimmed && images.length === 0 && blobs.length === 0) return
const queue = this.#queue
this.#queue = {
text: !trimmed ? queue.text : queue.text ? `${queue.text}\n${trimmed}` : trimmed,
images: [...queue.images, ...images],
blobs: [...queue.blobs, ...blobs]
}
}
/** Put the queued draft back in the composer, attachments included. */
dequeueMessage = () => {
const text = this.#queued
if (!text) return
this.#queued = ''
this.#returnText(text)
const { text, images, blobs } = this.#takeQueue()
if (!text && images.length === 0 && blobs.length === 0) return
this.#returnDraft(text, images, blobs)
}
flushQueuedMessage = () => {
const text = this.#queued
if (!text || this.#disposed) return
this.#queued = ''
void this.sendRequest({ instructions: text })
// Same rule as sendRequest, read before the queue is drained: a turn with no message
// cannot run, and taking the queue for it would drop the attachments on the floor.
if (!this.#queue.text || this.#disposed) return
const { text, images, blobs } = this.#takeQueue()
void this.sendRequest({ instructions: text, images, blobs })
}
#takeQueue(): Queue {
const taken = this.#queue
this.#queue = emptyQueue()
return taken
}
setComposerStaged = () => {}
clearComposerStaged = () => {}
@@ -371,12 +607,62 @@ export class FlowChatViewHost implements ChatViewHost {
// Per-message actions
storedImages = () => undefined
/** Send the user message at this transcript position again. The position is in
* `displayMessages`, which holds more entries than the chat's messages. */
retryRequest = (messageIndex: number) => {
const message = this.displayMessages[messageIndex]
if (!message || message.role !== 'user' || this.loading) return
void this.sendRequest({ instructions: message.content })
/** A retry reading back the turn it is about to replay. Deliberately not part of
* `loading`, which renders Stop: there is no run yet to stop. */
#readingReplayArgs = false
/** Turns this chat has started, so a retry can tell whether one ran while it read the
* arguments, whether or not it is still running. */
#turnsStarted = 0
/**
* Run the turn at this position again with the arguments its job ran with, not the
* composer's current inputs (`inputsShownInComposer` aside). The position is in
* `displayMessages`, which holds more entries than the chat's messages. A purged job falls
* back to the current inputs.
*/
retryRequest = async (messageIndex: number) => {
const shown = this.displayMessages[messageIndex]
if (!shown || shown.role !== 'user' || this.loading || this.#readingReplayArgs) return
const message = this.#state.messages.filter((m) => m.role === 'user')[shown.index]
if (!message) return
const conversationId = this.#state.conversationId
const turnsStarted = this.#turnsStarted
const workspace = this.#options.workspace?.()
let replayInputs: Record<string, any> | undefined
if (message.jobId && workspace) {
this.#readingReplayArgs = true
try {
const original = (await JobService.getJobArgs({ workspace, id: message.jobId })) as
| Record<string, any>
| undefined
// `user_message` is the message itself, passed as the instructions below.
const { user_message: _sent, ...rest } = original ?? {}
const current = this.#options.additionalInputs?.() ?? {}
for (const name of this.#options.inputsShownInComposer?.() ?? []) {
if (name in current) rest[name] = current[name]
else delete rest[name]
}
replayInputs = rest
} catch (error) {
// Only a job that is gone justifies running with other inputs; after a blip or
// a 500 that would silently run a different turn.
if ((error as { status?: number })?.status !== 404) {
sendUserToast('Could not read what that turn ran with. Try again.', true)
return
}
} finally {
this.#readingReplayArgs = false
}
}
// The reader may have moved on while the arguments were read: to another conversation,
// where this turn does not belong, or by sending. Any turn started since counts, settled
// or not: they wrote the chat's latest message, and running this one now would answer
// something they have moved past.
if (this.#disposed || this.#state.conversationId !== conversationId) return
if (this.#turnsStarted !== turnsStarted) {
sendUserToast('That chat started another turn. Retry once it finishes.', true)
return
}
void this.sendRequest({ instructions: message.content }, replayInputs)
}
restartGeneration = () => {}
handleUserQuestionAnswer = () => false
@@ -389,11 +675,31 @@ export class FlowChatViewHost implements ChatViewHost {
isSessionChat = false
supportsModelSettings = false
supportsMessageEditing = false
supportsMessageAttachments = false
// The input's shape alone: whether this chat takes attachments at all is a fact about the
// flow, not about the workspace. Object storage decides whether it can right now, which is
// `attachmentsUnavailableReason` — a state on the control rather than a reason to move the
// input to the modal, where a file picker would be just as unable to upload.
get supportsMessageAttachments(): boolean {
return !!this.#options.attachmentsTarget?.()
}
get attachmentsUnavailableReason(): string | undefined {
return this.#options.attachmentsUnavailable?.()
}
// An AI agent step refuses a run with no `user_message`.
requiresMessageText = true
// Attachments go to object storage for the worker to read, so a linked folder — a live
// handle on the user's own disk — has no meaning here.
supportsLinkedFolders = false
attachmentAccept = ''
attachmentsAsBlobs = true
// A single-file flow input takes one attachment per message.
get maxMessageAttachments(): number | undefined {
return this.#options.attachmentsTarget?.()?.multiple === false ? 1 : undefined
}
// What a provider actually takes. Anthropic's document block accepts base64
// `application/pdf` and nothing else, so the wider set `is_document_mime`
// (windmill-ai/src/ai_types.rs) claims — csv, html, plain, docx, xlsx — is rejected with a
// 400 rather than read. Widen this only alongside a worker that inlines text as text.
attachmentAccept = 'image/*,application/pdf,.pdf'
tools = []
// The enum's value, written out so this module never imports the copilot manager at
// runtime: its unit test would otherwise load the manager and the editor it pulls in.
@@ -1,7 +1,23 @@
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TurnRunningError, type Chat, type ChatMessage, type ChatState } from 'windmill-chat'
import { FlowChatViewHost, toDisplayMessages } from './flowChatViewHost.svelte'
vi.mock('$lib/gen', () => ({
JobService: { getJobArgs: vi.fn() }
}))
vi.mock('$lib/toast', () => ({ sendUserToast: vi.fn() }))
import { JobService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
const getJobArgs = vi.mocked(JobService.getJobArgs)
const toast = vi.mocked(sendUserToast)
beforeEach(() => {
getJobArgs.mockReset()
toast.mockReset()
})
function message(partial: Partial<ChatMessage> & Pick<ChatMessage, 'role'>): ChatMessage {
return {
id: partial.id ?? `${partial.role}-${Math.random()}`,
@@ -31,6 +47,10 @@ function idleState(partial: Partial<ChatState> = {}): ChatState {
function fakeChat(initial: ChatState = idleState()) {
let state = initial
const listeners = new Set<(s: ChatState) => void>()
const set = (patch: Partial<ChatState>) => {
state = { ...state, ...patch }
for (const listener of listeners) listener(state)
}
const chat = {
getState: () => state,
subscribe: (listener: (s: ChatState) => void) => {
@@ -50,13 +70,11 @@ function fakeChat(initial: ChatState = idleState()) {
refreshMessages: vi.fn(async () => {}),
destroy: vi.fn()
} satisfies Chat
const set = (patch: Partial<ChatState>) => {
state = { ...state, ...patch }
for (const listener of listeners) listener(state)
}
return { chat, set }
}
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
describe('toDisplayMessages', () => {
it('maps user, assistant and tool rows, marking the user message of a failed turn', () => {
const rows = [
@@ -80,17 +98,23 @@ describe('toDisplayMessages', () => {
message({ role: 'assistant', content: 'boom', success: false })
]
const display = toDisplayMessages(rows)
expect(display[0]).toEqual({ role: 'user', index: 0, content: 'hi', error: undefined })
expect(display[0]).toEqual({
role: 'user',
index: 0,
content: 'hi',
error: undefined,
images: undefined,
contextElements: undefined
})
expect(display[1]).toMatchObject({
role: 'assistant',
content: 'hello',
reasoning: 'thinking',
stepName: 'agent',
jobId: 'job-1',
createdAt: '2026-09-16T10:00:01Z',
streaming: undefined
})
expect(display[2]).toEqual({ role: 'user', index: 1, content: 'again', error: true })
expect(display[2]).toMatchObject({ role: 'user', index: 1, content: 'again', error: true })
expect(display[3]).toMatchObject({
role: 'tool',
tool_call_id: 'tool-row',
@@ -232,6 +256,81 @@ describe('toDisplayMessages', () => {
isLoading: false
})
})
// A row stored before the worker kept the call has only its sentence: the card names the
// tool and links its job, and has no details to open.
it('builds a tool card from the row alone, with its job link', () => {
const display = toDisplayMessages([
message({
role: 'tool',
content: 'Used lookup tool',
jobId: 'tool-job',
tool: { name: 'lookup', status: 'success', arguments: '{"id":1}', result: '"ok"' }
}),
message({
role: 'tool',
content: 'Used search_docs tool',
jobId: 'older-job',
tool: { name: 'search_docs', status: 'success' }
})
])
expect(display[0]).toMatchObject({
toolName: 'lookup',
parameters: { id: 1 },
result: 'ok',
showDetails: true,
jobId: 'tool-job'
})
expect(display[1]).toMatchObject({
toolName: 'search_docs',
parameters: undefined,
result: undefined,
showDetails: false,
jobId: 'older-job'
})
})
it('labels answers with their step only once the transcript names more than one', () => {
const one = toDisplayMessages([
message({ role: 'assistant', content: 'a', stepName: 'agent' }),
message({ role: 'assistant', content: 'b', stepName: 'agent' })
])
expect(one.map((m) => (m.role === 'assistant' ? m.stepName : null))).toEqual([
undefined,
undefined
])
const two = toDisplayMessages([
message({ role: 'assistant', content: 'a', stepName: 'agent' }),
message({ role: 'assistant', content: 'b', stepName: 'reviewer' })
])
expect(two.map((m) => (m.role === 'assistant' ? m.stepName : null))).toEqual([
'agent',
'reviewer'
])
})
it('shows the files a user message carried, read from the message itself', () => {
const display = toDisplayMessages(
[
message({
role: 'user',
content: 'go',
attachments: [
{ input: 'user_attachments', s3: 'chat/u1/shot.png' },
{ input: 'user_attachments', s3: 'chat/u1/notes.pdf' }
]
})
],
false,
new Set(),
{ workspace: 'ws' }
)
expect(display[0]).toMatchObject({
role: 'user',
images: [{ name: 'shot.png' }],
contextElements: [{ title: 'notes.pdf' }]
})
})
})
describe('FlowChatViewHost', () => {
@@ -240,7 +339,11 @@ describe('FlowChatViewHost', () => {
const host = new FlowChatViewHost(chat, { additionalInputs: () => ({ tone: 'brief' }) })
expect(host.loading).toBe(false)
expect(await host.sendRequest({ instructions: ' hello ' })).toBe(true)
expect(chat.sendMessage).toHaveBeenCalledWith('hello', { inputs: { tone: 'brief' } })
expect(chat.sendMessage).toHaveBeenCalledWith('hello', {
inputs: { tone: 'brief' },
attachments: [],
attachmentsInput: undefined
})
expect(await host.sendRequest({ instructions: ' ' })).toBe(false)
set({ status: 'streaming' })
expect(host.loading).toBe(true)
@@ -264,12 +367,16 @@ describe('FlowChatViewHost', () => {
host.queueMessage('second')
expect(host.queuedMessage).toBe('first\nsecond')
set({ status: 'idle' })
await new Promise((resolve) => setTimeout(resolve, 0))
await flush()
expect(chat.sendMessage).toHaveBeenCalledTimes(1)
releaseTurn()
await new Promise((resolve) => setTimeout(resolve, 0))
await flush()
expect(host.queuedMessage).toBe('')
expect(chat.sendMessage).toHaveBeenLastCalledWith('first\nsecond', { inputs: undefined })
expect(chat.sendMessage).toHaveBeenLastCalledWith('first\nsecond', {
inputs: undefined,
attachments: [],
attachmentsInput: undefined
})
host.dispose()
})
@@ -289,7 +396,7 @@ describe('FlowChatViewHost', () => {
message({ role: 'assistant', content: 'boom', success: false })
]
})
expect(prependText).toHaveBeenCalledWith('later')
expect(prependText).toHaveBeenCalledWith('later', [], [], [])
expect(chat.sendMessage).not.toHaveBeenCalled()
host.dispose()
})
@@ -304,9 +411,9 @@ describe('FlowChatViewHost', () => {
// A deployment starts while the turn is still running; the composer is disabled.
deploying = true
set({ status: 'idle' })
await new Promise((resolve) => setTimeout(resolve, 0))
await flush()
expect(chat.sendMessage).not.toHaveBeenCalled()
expect(prependText).toHaveBeenCalledWith('after deploy')
expect(prependText).toHaveBeenCalledWith('after deploy', [], [], [])
expect(host.queuedMessage).toBe('')
host.dispose()
})
@@ -324,7 +431,7 @@ describe('FlowChatViewHost', () => {
set({ status: 'idle' })
host.dispose()
releaseTurn()
await new Promise((resolve) => setTimeout(resolve, 0))
await flush()
expect(chat.sendMessage).toHaveBeenCalledTimes(1)
})
@@ -335,7 +442,7 @@ describe('FlowChatViewHost', () => {
const prependText = vi.fn()
host.setAiChatInput({ prependText } as any)
await host.sendRequest({ instructions: 'kept' })
expect(prependText).toHaveBeenCalledWith('kept')
expect(prependText).toHaveBeenCalledWith('kept', [], [], [])
host.dispose()
})
@@ -355,7 +462,11 @@ describe('FlowChatViewHost', () => {
set({ status: 'idle' })
releaseResumed()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(chat.sendMessage).toHaveBeenLastCalledWith('after it', { inputs: undefined })
expect(chat.sendMessage).toHaveBeenLastCalledWith('after it', {
inputs: undefined,
attachments: [],
attachmentsInput: undefined
})
host.dispose()
})
@@ -366,7 +477,7 @@ describe('FlowChatViewHost', () => {
host.cancel()
const prependText = vi.fn()
host.setAiChatInput({ prependText } as any)
expect(prependText).toHaveBeenCalledWith('typed before leaving')
expect(prependText).toHaveBeenCalledWith('typed before leaving', [], [], [])
host.dispose()
})
@@ -378,16 +489,166 @@ describe('FlowChatViewHost', () => {
host.queueMessage('later')
host.cancel()
expect(chat.stop).toHaveBeenCalled()
expect(prependText).toHaveBeenCalledWith('later')
expect(prependText).toHaveBeenCalledWith('later', [], [], [])
expect(host.queuedMessage).toBe('')
host.queueMessage('after error')
set({ status: 'error' })
expect(prependText).toHaveBeenLastCalledWith('after error')
expect(prependText).toHaveBeenLastCalledWith('after error', [], [], [])
expect(chat.sendMessage).not.toHaveBeenCalled()
host.dispose()
})
const PNG = `data:image/png;base64,${btoa('\x89PNG')}`
const image = { name: 'shot.webp', dataUrl: PNG, mediaType: 'image/png' } as any
const pdf = {
name: 'contract.pdf',
dataUrl: `data:application/pdf;base64,${btoa('%PDF')}`,
mediaType: 'application/pdf',
size: 4
}
const listInput = { name: 'files', multiple: true }
it('takes attachments only where the flow has an input for them', () => {
const { chat } = fakeChat()
const none = new FlowChatViewHost(chat)
expect(none.supportsMessageAttachments).toBe(false)
const list = new FlowChatViewHost(chat, { attachmentsTarget: () => listInput })
expect(list.supportsMessageAttachments).toBe(true)
expect(list.maxMessageAttachments).toBeUndefined()
const single = new FlowChatViewHost(chat, {
attachmentsTarget: () => ({ name: 'file', multiple: false }),
attachmentsUnavailable: () => 'no storage'
})
expect(single.maxMessageAttachments).toBe(1)
expect(single.attachmentsUnavailableReason).toBe('no storage')
})
it('hands the attachments to the chat, and drops a stored value for their input', async () => {
const { chat } = fakeChat()
const host = new FlowChatViewHost(chat, {
additionalInputs: () => ({ tone: 'brief', files: [{ s3: 'stale' }] }),
attachmentsTarget: () => listInput
})
await host.sendRequest({ instructions: 'read', images: [image], blobs: [pdf] })
const [, options] = chat.sendMessage.mock.calls[0] as any
expect(options.inputs).toEqual({ tone: 'brief' })
expect(options.attachmentsInput).toBe(listInput)
expect(options.attachments).toEqual([
{ name: 'shot.webp', data: PNG, mediaType: 'image/png' },
{ name: 'contract.pdf', data: pdf.dataUrl, mediaType: 'application/pdf' }
])
})
// A queue merged over several turns reaches the host as one send.
it('re-applies a single-file cap to a merged queue', async () => {
const { chat } = fakeChat()
const host = new FlowChatViewHost(chat, {
attachmentsTarget: () => ({ name: 'file', multiple: false })
})
await host.sendRequest({ instructions: 'read', images: [image], blobs: [pdf] })
const [, options] = chat.sendMessage.mock.calls[0] as any
expect(options.attachments.map((a: any) => a.name)).toEqual(['shot.webp'])
})
it('hands the draft back with its attachments when the upload is refused or stopped', async () => {
const { chat } = fakeChat()
const host = new FlowChatViewHost(chat, { attachmentsTarget: () => listInput })
const prependText = vi.fn()
host.setAiChatInput({ prependText } as any)
chat.sendMessage.mockRejectedValueOnce(new Error('POST upload failed (500)'))
await host.sendRequest({ instructions: 'read', blobs: [pdf] })
expect(prependText).toHaveBeenCalledWith('read', [], [], [pdf])
chat.sendMessage.mockRejectedValueOnce(new DOMException('aborted', 'AbortError'))
await host.sendRequest({ instructions: 'again', images: [image] })
expect(prependText).toHaveBeenLastCalledWith('again', [image], [], [])
host.dispose()
})
// The chat settles a withdrawn turn as `idle`, which reads like a turn that ran; a queue
// left waiting would then go out with the failed draft merged in front of it.
it('does not send what was queued behind an upload that failed', async () => {
const { chat, set } = fakeChat(
idleState({ messages: [message({ role: 'user', content: 'earlier' })] })
)
let refuse = (_e: Error) => {}
chat.sendMessage.mockImplementationOnce(
() => new Promise<void>((_, reject) => (refuse = reject))
)
const host = new FlowChatViewHost(chat, { attachmentsTarget: () => listInput })
const prependText = vi.fn()
host.setAiChatInput({ prependText } as any)
void host.sendRequest({ instructions: 'A', blobs: [pdf] })
set({ status: 'submitted' })
host.queueMessage('B')
set({ status: 'idle' })
refuse(new Error('upload failed (500)'))
await new Promise((resolve) => setTimeout(resolve, 0))
expect(chat.sendMessage).toHaveBeenCalledTimes(1)
expect(host.queuedMessage).toBe('')
expect(prependText.mock.calls.map((c) => c[0])).toEqual(['B', 'A'])
expect(prependText).toHaveBeenLastCalledWith('A', [], [], [pdf])
host.dispose()
})
it('retries a failed turn with the files its run had, uploading nothing', async () => {
const { chat } = fakeChat(
idleState({
messages: [
message({ role: 'user', content: 'read', jobId: 'flow-job' }),
message({ role: 'assistant', content: 'boom', success: false })
]
})
)
getJobArgs.mockResolvedValueOnce({
user_message: 'read',
user_attachments: [{ s3: 'chat/u1/contract.pdf', filename: 'contract.pdf' }]
} as any)
const host = new FlowChatViewHost(chat, {
workspace: () => 'ws',
attachmentsTarget: () => listInput
})
await host.retryRequest(0)
const [text, options] = chat.sendMessage.mock.calls[0] as any
expect(text).toBe('read')
// The references the run already has, so the files are not uploaded a second time.
expect(options.inputs).toEqual({
user_attachments: [{ s3: 'chat/u1/contract.pdf', filename: 'contract.pdf' }]
})
expect(options.attachments).toEqual([])
host.dispose()
})
it('refuses a message without a file when the flow requires one', async () => {
const { chat } = fakeChat()
const host = new FlowChatViewHost(chat, {
attachmentsTarget: () => ({ ...listInput, required: true })
})
const prependText = vi.fn()
host.setAiChatInput({ prependText } as any)
expect(await host.sendRequest({ instructions: 'no file' })).toBe(false)
expect(chat.sendMessage).not.toHaveBeenCalled()
expect(prependText).toHaveBeenCalledWith('no file', [], [], [])
expect(await host.sendRequest({ instructions: 'with file', blobs: [pdf] })).toBe(true)
expect(chat.sendMessage).toHaveBeenCalledTimes(1)
host.dispose()
})
it('queues attachments with the text and sends them together', async () => {
const { chat, set } = fakeChat(idleState({ status: 'streaming' }))
const host = new FlowChatViewHost(chat, { attachmentsTarget: () => listInput })
host.queueMessage('look', [image], undefined, undefined, [pdf])
expect(host.queuedImages).toEqual([image])
expect(host.queuedBlobs).toEqual([pdf])
set({ status: 'idle' })
await new Promise((resolve) => setTimeout(resolve, 0))
const [text, options] = chat.sendMessage.mock.calls[0] as any
expect(text).toBe('look')
expect(options.attachments.map((a: any) => a.name)).toEqual(['shot.webp', 'contract.pdf'])
expect(host.queuedBlobs).toEqual([])
host.dispose()
})
it('offers no retry on a turn the reader stopped', () => {
const failedTool = message({
role: 'tool',
@@ -448,4 +709,163 @@ describe('FlowChatViewHost', () => {
set({ status: 'streaming' })
expect(host.loading).toBe(false)
})
describe('retry', () => {
const failedTurn = () =>
idleState({
messages: [
message({ role: 'user', content: 'go', jobId: 'flow-job' }),
message({ role: 'assistant', content: 'boom', success: false })
]
})
it("replays the turn with the inputs its run had, not the composer's", async () => {
const { chat } = fakeChat(failedTurn())
getJobArgs.mockResolvedValueOnce({ user_message: 'go', tone: 'terse', model: 'old' } as any)
const host = new FlowChatViewHost(chat, {
workspace: () => 'ws',
additionalInputs: () => ({ tone: 'brief', model: 'new' }),
inputsShownInComposer: () => ['model']
})
await host.retryRequest(0)
expect(getJobArgs).toHaveBeenCalledWith({ workspace: 'ws', id: 'flow-job' })
// The composer's own control wins for what it shows; everything else replays.
expect(chat.sendMessage).toHaveBeenCalledWith(
'go',
expect.objectContaining({ inputs: { tone: 'terse', model: 'new' } })
)
host.dispose()
})
it('falls back to a plain resend once the job is purged', async () => {
const { chat } = fakeChat(failedTurn())
getJobArgs.mockRejectedValueOnce(Object.assign(new Error('gone'), { status: 404 }))
const host = new FlowChatViewHost(chat, {
workspace: () => 'ws',
additionalInputs: () => ({ tone: 'brief' })
})
await host.retryRequest(0)
expect(chat.sendMessage).toHaveBeenCalledWith(
'go',
expect.objectContaining({ inputs: { tone: 'brief' } })
)
expect(toast).not.toHaveBeenCalled()
host.dispose()
})
it('does nothing but say so when the run cannot be read', async () => {
const { chat } = fakeChat(failedTurn())
getJobArgs.mockRejectedValueOnce(Object.assign(new Error('down'), { status: 500 }))
const host = new FlowChatViewHost(chat, { workspace: () => 'ws' })
await host.retryRequest(0)
expect(chat.sendMessage).not.toHaveBeenCalled()
expect(toast).toHaveBeenCalledWith('Could not read what that turn ran with. Try again.', true)
host.dispose()
})
it('refuses once a turn started while the run was being read', async () => {
const { chat, set } = fakeChat(failedTurn())
let answer = (_: unknown) => {}
getJobArgs.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)) as any)
const host = new FlowChatViewHost(chat, { workspace: () => 'ws' })
const retried = host.retryRequest(0)
set({ status: 'streaming' })
answer({ user_message: 'go' })
await retried
expect(chat.sendMessage).not.toHaveBeenCalled()
expect(toast).toHaveBeenCalledWith(
'That chat started another turn. Retry once it finishes.',
true
)
host.dispose()
})
// A quick turn can start and settle inside that read: the chat is idle again, but its
// latest message is not the one this retry was clicked on.
it('refuses once a turn ran and settled while the run was being read', async () => {
const { chat, set } = fakeChat(failedTurn())
let answer = (_: unknown) => {}
getJobArgs.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)) as any)
const host = new FlowChatViewHost(chat, { workspace: () => 'ws' })
const retried = host.retryRequest(0)
set({ status: 'streaming' })
set({ status: 'idle' })
answer({ user_message: 'go' })
await retried
expect(chat.sendMessage).not.toHaveBeenCalled()
expect(toast).toHaveBeenCalledWith(
'That chat started another turn. Retry once it finishes.',
true
)
host.dispose()
})
})
describe('streaming reveal', () => {
/** A scheduler the test steps by hand, and a clock it advances. */
function manualReveal() {
let time = 0
const queue: (() => void)[] = []
return {
options: {
instant: false,
now: () => time,
schedule: (cb: () => void) => (queue.push(cb), cb),
cancel: (handle: unknown) => {
const i = queue.indexOf(handle as () => void)
if (i >= 0) queue.splice(i, 1)
}
},
tick: (ms: number) => {
time += ms
const due = queue.splice(0)
for (const cb of due) cb()
}
}
}
it('paces a streaming answer and shows it whole once it settles', () => {
const { chat, set } = fakeChat(idleState({ status: 'streaming' }))
const reveal = manualReveal()
const host = new FlowChatViewHost(chat, { revealOptions: reveal.options })
const streaming = message({ role: 'assistant', id: 'a1', content: '', pending: true })
set({ messages: [{ ...streaming, content: 'The answer, in one burst of text.' }] })
const shown = () => (host.displayMessages[0] as { content: string }).content
// Nothing is revealed until the pacer's first frame, and that frame shows a slice.
expect(shown()).toBe('')
reveal.tick(16)
expect(shown().length).toBeGreaterThan(0)
expect(shown().length).toBeLessThan('The answer, in one burst of text.'.length)
expect('The answer, in one burst of text.'.startsWith(shown())).toBe(true)
// Settled: the whole text, whatever the pacer had got to.
set({
status: 'idle',
messages: [{ ...streaming, content: 'The answer, in one burst of text.', pending: false }]
})
expect(shown()).toBe('The answer, in one burst of text.')
host.dispose()
})
it('shows a paced row whole once a tool card follows it', () => {
const { chat, set } = fakeChat(idleState({ status: 'streaming' }))
const reveal = manualReveal()
const host = new FlowChatViewHost(chat, { revealOptions: reveal.options })
const answer = message({
role: 'assistant',
id: 'a1',
content: 'Let me look.',
pending: true
})
set({ messages: [answer] })
expect((host.displayMessages[0] as { content: string }).content).toBe('')
set({
messages: [
answer,
message({ role: 'tool', pending: true, tool: { name: 'lookup', status: 'running' } })
]
})
expect((host.displayMessages[0] as { content: string }).content).toBe('Let me look.')
host.dispose()
})
})
})
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { attachmentLanes } from './messageAttachments'
describe('attachmentLanes', () => {
it('splits images into thumbnails and everything else into named file chips', () => {
const { images, contextElements } = attachmentLanes('ws', [
{ input: 'user_attachments', s3: 'chat/u1/shot.PNG', storage: 'secondary' },
{ input: 'user_attachments', s3: 'chat/u1/0/notes.pdf', filename: 'Q3 notes.pdf' },
{ input: 'report', s3: 'chat/u1/data.csv' }
])
expect(images.map((i) => i.name)).toEqual(['shot.PNG'])
expect(images[0].dataUrl).toContain('/api/w/ws/job_helpers/download_s3_file?')
expect(images[0].dataUrl).toContain('file_key=chat%2Fu1%2Fshot.PNG')
expect(images[0].dataUrl).toContain('storage=secondary')
expect(contextElements.map((c) => c.title)).toEqual(['Q3 notes.pdf', 'data.csv'])
})
it('shows an image as a file chip when there is no workspace to link it in', () => {
const { images, contextElements } = attachmentLanes(undefined, [
{ input: 'user_attachments', s3: 'chat/u1/shot.png' }
])
expect(images).toEqual([])
expect(contextElements.map((c) => c.title)).toEqual(['shot.png'])
})
})
@@ -0,0 +1,48 @@
/** The files a user message carried, as the lanes its bubble renders: image thumbnails and file chips. */
import type { ChatAttachment } from 'windmill-chat'
import { base } from '$lib/base'
import {
createAttachedFileContextElement,
type ContextElement
} from '$lib/components/copilot/chat/context'
import type { AttachedImage } from '$lib/components/copilot/chat/imageUtils'
const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.avif']
export type MessageAttachments = { images: AttachedImage[]; contextElements: ContextElement[] }
function displayName(attachment: ChatAttachment): string {
return attachment.filename || attachment.s3.split('/').pop() || attachment.s3
}
function looksLikeImage(attachment: ChatAttachment): boolean {
const name = displayName(attachment).toLowerCase()
return IMAGE_EXTENSIONS.some((ext) => name.endsWith(ext))
}
/** Same-origin, cookie-authed GET, usable directly as an <img src>. */
function downloadUrl(workspace: string, attachment: ChatAttachment): string {
const params = new URLSearchParams({ file_key: attachment.s3 })
if (attachment.storage) params.set('storage', attachment.storage)
return `${base}/api/w/${workspace}/job_helpers/download_s3_file?${params.toString()}`
}
/** Without a workspace there is no link to build, so an image falls back to a file chip. */
export function attachmentLanes(
workspace: string | undefined,
attachments: readonly ChatAttachment[] | undefined
): MessageAttachments {
const images: AttachedImage[] = []
const contextElements: ContextElement[] = []
for (const attachment of attachments ?? []) {
const name = displayName(attachment)
if (workspace && looksLikeImage(attachment)) {
images.push({ dataUrl: downloadUrl(workspace, attachment), mediaType: 'image/png', name })
} else {
contextElements.push(
createAttachedFileContextElement(name, `Attached file · ${attachment.s3}`)
)
}
}
return { images, contextElements }
}
@@ -1,11 +1,28 @@
import { CancelError, WorkspaceService } from '$lib/gen'
import { CancelError, WorkspaceService, type LargeFileStorage } from '$lib/gen'
import { resource } from 'runed'
/**
* Whether the workspace has S3 storage configured, for the fields that warn without it. Call during
* component initialisation and read `.current` where the answer is used.
* Whether the workspace has large-file storage the upload endpoints can resolve. Every
* kind counts, not only S3: Azure Blob, Azure Workload Identity, S3 via AWS OIDC and GCS
* all go through the same object-store abstraction, so reading `s3_resource_path` alone
* calls a perfectly good workspace unconfigured.
*/
export function useS3StorageConfigured(ws: () => string | undefined): {
function storageConfigured(storage: LargeFileStorage | undefined): boolean {
if (!storage) return false
return (
storage.type !== undefined ||
storage.s3_resource_path !== undefined ||
storage.azure_blob_resource_path !== undefined ||
storage.gcs_resource_path !== undefined
)
}
/**
* Whether the workspace can store uploaded files; read `.current`. Assumed configured until
* this workspace's own answer lands, so "no storage" never flashes on navigation or shows
* merely because the fetch failed.
*/
export function useWorkspaceStorageConfigured(ws: () => string | undefined): {
readonly current: boolean
} {
const settings = resource(ws, async (ws, _previousWs, { onCleanup }) => {
@@ -24,12 +41,10 @@ export function useS3StorageConfigured(ws: () => string | undefined): {
}
})
// Assume configured until this workspace's own answer lands: the warning must not
// linger from the previous workspace, nor appear merely because the fetch failed.
const configured = $derived.by(() => {
const loaded = settings.current
return loaded && loaded.ws === ws()
? loaded.settings.large_file_storage?.s3_resource_path !== undefined
? storageConfigured(loaded.settings.large_file_storage)
: true
})
@@ -639,63 +639,74 @@
<div
class={twMerge(
'w-full flex flex-col',
chatInputEnabled ? 'p-3 h-full' : 'max-w-3xl p-6 min-h-[300px] justify-center',
chatInputEnabled ? 'h-full min-h-0' : 'max-w-3xl p-6 min-h-[300px] justify-center',
'mx-auto'
)}
>
{#if flow?.path}
<CiTestResults path={flow.path} kind="flow" />
{/if}
<!-- The chat reaches the edges of the pane, so the notices above it carry their
own padding. `contents` leaves the form layout exactly as it was. -->
<!-- Top spacing hangs off the first notice, not the wrapper: `{#if}` leaves a
comment anchor behind, so an empty wrapper is not `:empty` and its own
padding would show as a gap above a chat with nothing to announce. -->
<div
class={chatInputEnabled ? 'flex flex-col px-3 [&>*:first-child]:mt-3' : 'contents'}
>
{#if flow?.path}
<CiTestResults path={flow.path} kind="flow" />
{/if}
{#if flow?.archived}
<Alert type="error" title="Archived">This flow was archived</Alert>
<div class="h-4"></div>
{/if}
{#if flow?.archived}
<Alert type="error" title="Archived">This flow was archived</Alert>
<div class="h-4"></div>
{/if}
{#if pinnedVersion !== undefined}
<Alert type="info" title="Viewing pinned version {pinnedVersion}">
This is a historical version of the flow, not the latest.
<a class="underline" href="/flows/get/{path}?workspace={$workspaceStore}">
View latest
</a>
</Alert>
<div class="h-4"></div>
{/if}
{#if pinnedVersion !== undefined}
<Alert type="info" title="Viewing pinned version {pinnedVersion}">
This is a historical version of the flow, not the latest.
<a class="underline" href="/flows/get/{path}?workspace={$workspaceStore}">
View latest
</a>
</Alert>
<div class="h-4"></div>
{/if}
{#if !emptyString(flow?.description)}
<div class="p-4 rounded-md bg-surface-secondary">
<GfmMarkdown
md={defaultIfEmptyString(flow?.description, 'No description')}
noPadding
/>
</div>
<div class="h-4"></div>
{/if}
<!-- In chat mode the description belongs to the chat, which shows it under the
empty transcript. -->
{#if !chatInputEnabled && !emptyString(flow?.description)}
<div class="p-4 rounded-md bg-surface-secondary">
<GfmMarkdown
md={defaultIfEmptyString(flow?.description, 'No description')}
noPadding
/>
</div>
<div class="h-4"></div>
{/if}
{#if deploymentInProgress}
<div class="pb-4" transition:slide={{ duration: 150 }}>
<HeaderBadge color="yellow">
<Loader2 size={12} class="inline animate-spin mr-1" />
Deployment in progress
{#if deploymentJobId}
<a
href="/run/{deploymentJobId}?workspace={$workspaceStore}"
class="underline"
target="_blank">view job</a
>
{/if}
</HeaderBadge>
</div>
{/if}
{#if flow.lock_error_logs && flow.lock_error_logs != ''}
<Alert type="error" title="Deployment failed">
<p>
This flow has not been deployed successfully because of the following errors:
</p>
<LogViewer content={flow.lock_error_logs} isLoading={false} tag={undefined} />
</Alert>
<div class="h-4"></div>
{/if}
{#if deploymentInProgress}
<div class="pb-4" transition:slide={{ duration: 150 }}>
<HeaderBadge color="yellow">
<Loader2 size={12} class="inline animate-spin mr-1" />
Deployment in progress
{#if deploymentJobId}
<a
href="/run/{deploymentJobId}?workspace={$workspaceStore}"
class="underline"
target="_blank">view job</a
>
{/if}
</HeaderBadge>
</div>
{/if}
{#if flow.lock_error_logs && flow.lock_error_logs != ''}
<Alert type="error" title="Deployment failed">
<p>
This flow has not been deployed successfully because of the following errors:
</p>
<LogViewer content={flow.lock_error_logs} isLoading={false} tag={undefined} />
</Alert>
<div class="h-4"></div>
{/if}
</div>
{#if chatInputEnabled}
<!-- Chat Layout with Sidebar -->
@@ -707,6 +718,7 @@
inputSchema={flow?.schema}
flowModules={flow?.value?.modules}
wideLayout
frame="none"
/>
{:else}
{@const hasSchema =
+1 -1
View File
@@ -4,7 +4,7 @@ verify_ssl = true
name = "pypi"
[packages]
wmill = ">=1.813.0"
wmill = ">=1.814.0"
sendgrid = "*"
mysql-connector-python = "*"
pymongo = "*"
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: '3.0.3'
info:
version: 1.813.0
version: 1.814.0
title: OpenFlow Spec
contact:
name: Ruben Fiszel
@@ -12,7 +12,7 @@
RootModule = 'WindmillClient.psm1'
# Version number of this module.
ModuleVersion = '1.813.0'
ModuleVersion = '1.814.0'
# Supported PSEditions
# CompatiblePSEditions = @()
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "wmill"
version = "1.813.0"
version = "1.814.0"
description = "A client library for accessing Windmill server wrapping the Windmill client API"
license = "Apache-2.0"
homepage = "https://windmill.dev"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill/windmill",
"version": "1.813.0",
"version": "1.814.0",
"exports": "./src/index.ts",
"publish": {
"exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts", "./wacError.ts"]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "windmill-client",
"description": "Windmill SDK client for browsers and Node.js",
"version": "1.813.0",
"version": "1.814.0",
"author": "Ruben Fiszel",
"license": "Apache 2.0",
"homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme",
+1 -1
View File
@@ -1 +1 @@
1.813.0
1.814.0
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-yaml-validator",
"version": "1.813.0",
"version": "1.814.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-yaml-validator",
"version": "1.813.0",
"version": "1.814.0",
"license": "Apache 2.0",
"dependencies": {
"@stoplight/yaml": "^4.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "windmill-yaml-validator",
"version": "1.813.0",
"version": "1.814.0",
"description": "YAML validator for Windmill flow, schedule, and trigger files",
"main": "dist/index.js",
"types": "dist/index.d.ts",