mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 08:02:38 +00:00
feat(ai-chat): render flow chat mode through the AI session chat components
The flow chat ran on its own components; this points it at the ones the AI session chat already uses, so the two surfaces share a transcript, a composer and a sidebar instead of keeping two of each. The seam is `ChatViewHost` (copilot/chat/chatViewHost.ts): the view components read the host rather than `AIChatManager` directly, and `getChatViewHost()` falls back to the session manager, so the copilot's call sites are unchanged. `FlowChatViewHost` is the second adapter, over `FlowChatManager`. What the flow chat gains from the move: - turns running in several conversations at once, with a status, a queue and a Stop per chat, and an unread count on the rail - attachments, uploaded to the workspace's object storage for the worker to read - a composer that speaks for the agent steps a message is fed to: the model, the thinking effort, and the flow inputs the agent reads straight out of `flow_input`; everything else is asked for in a Configure-inputs modal - tool cards with the call and the result, the model's reasoning, and a step name per answer once a conversation holds more than one agent - Retry, which replays the failed turn's own run arguments read back from its job - named and renamable conversations, and test chats kept out of the deployed flow's list Backend: conversation rows carry an MCP tool's call and result and the model's reasoning, which live nowhere else; every row gets a job so retention can empty it; and the providers parse a non-streaming answer's reasoning. Stacked on #11134, which keeps the windmill-chat SDK for external frontends and raw apps while the in-app flow chat runs on these components. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3895e1d579
commit
0e20a5d751
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM flow_conversation c\n WHERE c.id = ANY($1)\n AND c.workspace_id = $2\n AND NOT EXISTS (\n SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"UuidArray",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "07a005f0f9e80a156cd2a5a0ae39a1fabeaa167818206a25abfe31d5582f942a"
|
||||
}
|
||||
+21
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, conversation_id, message_type as \"message_type: MessageType\", content, job_id, created_at, created_seq, step_name, success\n FROM (\n SELECT id, conversation_id, message_type, content, job_id, created_at, created_seq, step_name, success\n FROM flow_conversation_message\n WHERE conversation_id = $1\n ORDER BY created_seq DESC\n LIMIT $2 OFFSET $3\n ) AS messages\n ORDER BY created_seq ASC\n ",
|
||||
"query": "SELECT id, conversation_id, message_type as \"message_type: MessageType\", content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning\n FROM flow_conversation_message\n WHERE conversation_id = $1\n AND created_seq > $2\n ORDER BY created_seq ASC\n LIMIT $3\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -58,6 +58,21 @@
|
||||
"ordinal": 8,
|
||||
"name": "success",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "tool_arguments",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "tool_result",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "reasoning",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -76,8 +91,11 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "1c3473a0f9f6b6148b2c975f9f05bdefedf8a51c4e6ddf0eca367b9cc778d051"
|
||||
"hash": "318ed7a45d8326e3ecbc9727ee3812adaeeead8b39b007a32badadf67ec5ca17"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM flow_conversation_message m\n USING flow_conversation c\n WHERE m.conversation_id = c.id AND c.workspace_id = $1 AND m.job_id = ANY($2)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"UuidArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "462d2b2822b185a6f51fafcfa957cb3b31ee6b69abae79a214dddba0dee4425c"
|
||||
}
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by\n FROM flow_conversation\n WHERE id = $1 AND workspace_id = $2",
|
||||
"query": "SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test\n FROM flow_conversation\n WHERE id = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -37,6 +37,11 @@
|
||||
"ordinal": 6,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "is_test",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -52,8 +57,9 @@
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c383cc023714b361d10c10e8fef1fc148ab1da942951ee9ffdddaecee76a6be9"
|
||||
"hash": "48c8522a4fed219c5011f4ba63c81cfe028a8b2a32bd790840cef65c452a8c31"
|
||||
}
|
||||
+6
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id, step_name, success)\n VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
"query": "INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id, step_name, success, tool_arguments, tool_result, reasoning)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -21,10 +21,13 @@
|
||||
"Text",
|
||||
"Uuid",
|
||||
"Varchar",
|
||||
"Bool"
|
||||
"Bool",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b1a9a433e577133869c067b2ce383fc6ce4e9df307feb5fd3edc0d1276d61ff1"
|
||||
"hash": "55dbe12954489532644b91e67b2a9df7ede61e050d3e634b795aaf7f60b3b05d"
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE flow_conversation SET title = $1, updated_at = updated_at\n WHERE id = $2 AND workspace_id = $3\n RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5b9c9eb64051f291fed4be9bc0b0cc0aef2e7bde732899976eddac36a2da7658"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM flow_conversation_message m\n USING flow_conversation c\n WHERE m.conversation_id = c.id AND c.workspace_id = $1 AND m.job_id = ANY($2)\n RETURNING m.conversation_id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "conversation_id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"UuidArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "69bfbe9b39414b724488532cc3b3659915d9fcb3d58f16532aaffe06c44ec976"
|
||||
}
|
||||
+10
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by, title)\n VALUES ($1, $2, $3, $4, $5)\n RETURNING id, workspace_id, flow_path, title, created_at, updated_at, created_by",
|
||||
"query": "INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by, title, is_test)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -37,6 +37,11 @@
|
||||
"ordinal": 6,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "is_test",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -45,7 +50,8 @@
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
"Varchar",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
@@ -55,8 +61,9 @@
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6bd23a98838e3eec309e6b696edc776bd56fc9dae1238b3272557d1562400dbe"
|
||||
"hash": "79776c1a15e41edecb1a3cf749ff7e6c10efc85dc73473470078ad28f4c7cee1"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM ai_agent_memory a\n USING flow_conversation c\n WHERE c.id = ANY($1)\n AND c.workspace_id = $2\n AND a.conversation_id = c.id\n AND a.workspace_id = c.workspace_id\n AND NOT EXISTS (\n SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"UuidArray",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "89ea81b765550cf665e30533efc9672f8c98d2579fc72c07752361cc5fd683dc"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM flow_conversation c\n WHERE c.id = ANY($1)\n AND NOT EXISTS (\n SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"UuidArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "90b910da8d00a7c7bcf29c167e38e44eb1c0062a8241dc8fe0ed3dd95b65f89a"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM flow_conversation_message WHERE job_id = ANY($1) RETURNING conversation_id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "conversation_id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"UuidArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "967f52005f4a044b3a2e9f02ceadf90dad5246681bde6caaa633b85a5e8b2352"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM ai_agent_memory a\n USING flow_conversation c\n WHERE c.id = ANY($1)\n AND a.conversation_id = c.id\n AND a.workspace_id = c.workspace_id\n AND NOT EXISTS (\n SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"UuidArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a1b23f3e62c6433d95cdac58215741a51ca0ce66bf1c674097705cf2e1b72eff"
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM flow_conversation_message WHERE job_id = ANY($1)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"UuidArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "bfdd60b42e32bd81e2d20b327462893147b4e5ff078531de36147d908132d636"
|
||||
}
|
||||
+21
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, conversation_id, message_type as \"message_type: MessageType\", content, job_id, created_at, created_seq, step_name, success\n FROM flow_conversation_message\n WHERE conversation_id = $1\n AND created_seq > $2\n ORDER BY created_seq ASC\n LIMIT $3\n ",
|
||||
"query": "SELECT id, conversation_id, message_type as \"message_type: MessageType\", content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning\n FROM (\n SELECT id, conversation_id, message_type, content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning\n FROM flow_conversation_message\n WHERE conversation_id = $1\n ORDER BY created_seq DESC\n LIMIT $2 OFFSET $3\n ) AS messages\n ORDER BY created_seq ASC\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -58,6 +58,21 @@
|
||||
"ordinal": 8,
|
||||
"name": "success",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "tool_arguments",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "tool_result",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "reasoning",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -76,8 +91,11 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e8802be9203c1e88a06e337260ccca029380139f89a01a89033e36a6ed9ac082"
|
||||
"hash": "d6c65ce1d443d1e1783818d36c7eabd9633bbe5219bfd9683f336de19763cb58"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE flow_conversation DROP COLUMN is_test;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- A chat run from the flow editor's test panel is stored exactly like one from the
|
||||
-- deployed flow, so the two were indistinguishable once written. Marking them lets the
|
||||
-- lists tell a trial apart from a real conversation.
|
||||
ALTER TABLE flow_conversation ADD COLUMN is_test BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- Existing rows: a conversation whose messages came from a flowpreview run was a test.
|
||||
-- Derived once here because the job is purged on retention, after which the origin of an
|
||||
-- old conversation is unknowable.
|
||||
--
|
||||
-- Walked to the root job rather than matched directly: an existing message row never holds
|
||||
-- the flow job itself. Only this migration's release starts storing it on the user row, and
|
||||
-- the rows written before it point at the step that produced them — the AI agent's job for
|
||||
-- an answer, the tool's own job for a tool call — whose kind is never 'flowpreview'.
|
||||
--
|
||||
-- `root_job` first, matching `get_root_job_id` (windmill-worker/src/common.rs): only it
|
||||
-- reaches the top of the run. `flow_innermost_root_job` stops at the closest flow scope by
|
||||
-- design, so an agent inside a subflow would land on that subflow's 'flow' row and the
|
||||
-- conversation would read as deployed.
|
||||
UPDATE flow_conversation c
|
||||
SET is_test = true
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM flow_conversation_message m
|
||||
JOIN v2_job j ON j.id = m.job_id
|
||||
JOIN v2_job root
|
||||
ON root.id = coalesce(j.root_job, j.flow_innermost_root_job, j.parent_job, j.id)
|
||||
WHERE m.conversation_id = c.id AND root.kind = 'flowpreview'
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE flow_conversation_message DROP COLUMN tool_arguments;
|
||||
ALTER TABLE flow_conversation_message DROP COLUMN tool_result;
|
||||
ALTER TABLE flow_conversation_message DROP COLUMN reasoning;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- A tool row's call and result are read back from the tool's own job, which an MCP tool
|
||||
-- and a provider-native tool never have: they run inside the agent's job. For those the
|
||||
-- row is the only record, so it carries the call itself.
|
||||
ALTER TABLE flow_conversation_message ADD COLUMN tool_arguments TEXT;
|
||||
ALTER TABLE flow_conversation_message ADD COLUMN tool_result TEXT;
|
||||
|
||||
-- The thinking that produced an answer is streamed, never returned in the response body,
|
||||
-- so it exists nowhere once the stream is over.
|
||||
ALTER TABLE flow_conversation_message ADD COLUMN reasoning TEXT;
|
||||
@@ -97,10 +97,10 @@ email_trigger: path(char), local_part(char), workspaced_local_part(bool), script
|
||||
favorite: usr(char), workspace_id(char), path(char), favorite_kind(favorite_kind)
|
||||
flow: workspace_id(char), path(char), summary(text), description(text), value(jsonb), edited_by(char), edited_at(ts), archived(bool), schema(json), extra_perms(jsonb), dependency_job(uuid), draft_only(bool), tag(char), ws_error_handler_muted(bool), dedicated_worker(bool), timeout(int), visible_to_runner_only(bool), concurrency_key(char), versions(bigint[]), on_behalf_of(varchar), on_behalf_of_email(text), lock_error_logs(text), labels(text[])
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
flow_conversation: id(uuid), workspace_id(char), flow_path(char), title(char), created_at(ts), updated_at(ts), created_by(char)
|
||||
flow_conversation: id(uuid), workspace_id(char), flow_path(char), title(char), created_at(ts), updated_at(ts), created_by(char), is_test(bool)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
flow_conversation_message: id(uuid), conversation_id(uuid), message_type(message_type), content(text), job_id(uuid), created_at(ts), created_seq(int8), step_name(char), success(bool)
|
||||
FK: (conversation_id) -> flow_conversation(id) | (job_id) -> v2_job(id)
|
||||
flow_conversation_message: id(uuid), conversation_id(uuid), message_type(message_type), content(text), job_id(uuid), created_at(ts), created_seq(int8), step_name(char), success(bool), tool_arguments(text), tool_result(text), reasoning(text)
|
||||
FK: (conversation_id) -> flow_conversation(id)
|
||||
flow_iterator_data: job_id(uuid), itered(jsonb)
|
||||
flow_node: id(bigint), workspace_id(char), hash(bigint), path(char), lock(text), code(text), flow(jsonb), hash_v2(char(64))
|
||||
FK: (path, workspace_id) -> flow(path, workspace_id) | (workspace_id) -> workspace(id)
|
||||
|
||||
@@ -37,7 +37,7 @@ async fn seed_side_rows(db: &Pool<Postgres>, ws: &str, job_id: Uuid) -> anyhow::
|
||||
.bind(ws)
|
||||
.execute(db)
|
||||
.await?;
|
||||
// created_seq is assigned by a trigger; inserting a value is rejected.
|
||||
// created_seq is an identity column; supplying a value is rejected.
|
||||
sqlx::query(
|
||||
"INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id)
|
||||
VALUES ($1, 'assistant', 'hi', $2)",
|
||||
@@ -121,6 +121,90 @@ async fn test_delete_jobs_removes_side_rows(db: Pool<Postgres>) -> anyhow::Resul
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// (conversation rows, agent-memory rows) for one conversation.
|
||||
async fn conversation_and_memory_counts(
|
||||
db: &Pool<Postgres>,
|
||||
conversation_id: Uuid,
|
||||
) -> anyhow::Result<(i64, i64)> {
|
||||
Ok((
|
||||
count(
|
||||
db,
|
||||
"SELECT count(*) FROM flow_conversation WHERE id = $1",
|
||||
conversation_id,
|
||||
)
|
||||
.await?,
|
||||
count(
|
||||
db,
|
||||
"SELECT count(*) FROM ai_agent_memory WHERE conversation_id = $1",
|
||||
conversation_id,
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
/// A conversation outlives the jobs behind its messages until the last one goes: only then
|
||||
/// are the row and the agent's memory for it left with nothing, and only then are they
|
||||
/// deleted. Both halves matter — the surviving half is what a single data-modifying CTE
|
||||
/// would break, since its emptiness check would read the snapshot from before the delete.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_delete_jobs_removes_a_conversation_once_its_last_message_goes(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let first_job = Uuid::new_v4();
|
||||
let second_job = Uuid::new_v4();
|
||||
insert_job(&db, WS, first_job).await?;
|
||||
insert_job(&db, WS, second_job).await?;
|
||||
|
||||
let conv_id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by)
|
||||
VALUES ($1, $2, 'f/flow', 'test-user')",
|
||||
)
|
||||
.bind(conv_id)
|
||||
.bind(WS)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
for job_id in [first_job, second_job] {
|
||||
sqlx::query(
|
||||
"INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id)
|
||||
VALUES ($1, 'assistant', 'hi', $2)",
|
||||
)
|
||||
.bind(conv_id)
|
||||
.bind(job_id)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO ai_agent_memory (workspace_id, conversation_id, step_id, messages)
|
||||
VALUES ($1, $2, 'a', '[]'::jsonb)",
|
||||
)
|
||||
.bind(WS)
|
||||
.bind(conv_id)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let mut conn = db.acquire().await?;
|
||||
windmill_common::jobs::delete_jobs(&mut conn, &[first_job]).await?;
|
||||
drop(conn);
|
||||
assert_eq!(
|
||||
conversation_and_memory_counts(&db, conv_id).await?,
|
||||
(1, 1),
|
||||
"a conversation with a message left must survive, memory included"
|
||||
);
|
||||
|
||||
let mut conn = db.acquire().await?;
|
||||
windmill_common::jobs::delete_jobs(&mut conn, &[second_job]).await?;
|
||||
drop(conn);
|
||||
assert_eq!(
|
||||
conversation_and_memory_counts(&db, conv_id).await?,
|
||||
(0, 0),
|
||||
"the last message going should take the conversation and its memory"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_clear_schedule_removes_side_rows(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
@@ -192,6 +276,74 @@ async fn test_workspace_delete_removes_side_rows(db: Pool<Postgres>) -> anyhow::
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The purge endpoint carries its own copy of the emptied-conversation rule, so it gets the
|
||||
/// same guard: the conversation and its memory go with the last message, and not before.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_jobs_export_delete_removes_a_conversation_once_its_last_message_goes(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let first_job = Uuid::new_v4();
|
||||
let second_job = Uuid::new_v4();
|
||||
insert_job(&db, WS, first_job).await?;
|
||||
insert_job(&db, WS, second_job).await?;
|
||||
|
||||
let conv_id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by)
|
||||
VALUES ($1, $2, 'f/flow', 'test-user')",
|
||||
)
|
||||
.bind(conv_id)
|
||||
.bind(WS)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
for job_id in [first_job, second_job] {
|
||||
sqlx::query(
|
||||
"INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id)
|
||||
VALUES ($1, 'assistant', 'hi', $2)",
|
||||
)
|
||||
.bind(conv_id)
|
||||
.bind(job_id)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO ai_agent_memory (workspace_id, conversation_id, step_id, messages)
|
||||
VALUES ($1, $2, 'a', '[]'::jsonb)",
|
||||
)
|
||||
.bind(WS)
|
||||
.bind(conv_id)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let purge = |job_id: Uuid| async move {
|
||||
reqwest::Client::new()
|
||||
.post(format!("http://localhost:{port}/api/w/{WS}/jobs/delete"))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&[job_id])
|
||||
.send()
|
||||
.await
|
||||
};
|
||||
|
||||
assert!(purge(first_job).await?.status().is_success());
|
||||
assert_eq!(
|
||||
conversation_and_memory_counts(&db, conv_id).await?,
|
||||
(1, 1),
|
||||
"a conversation with a message left must survive the purge endpoint too"
|
||||
);
|
||||
|
||||
assert!(purge(second_job).await?.status().is_success());
|
||||
assert_eq!(
|
||||
conversation_and_memory_counts(&db, conv_id).await?,
|
||||
(0, 0),
|
||||
"the last message going should take the conversation and its memory"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The `/jobs/delete` purge endpoint must scope every side-table delete to the path
|
||||
/// workspace. A `test-workspace` admin passing a job id from another workspace must not be
|
||||
/// able to delete that workspace's job or side rows (the side tables no longer cascade, so
|
||||
|
||||
@@ -767,6 +767,7 @@ impl QueryBuilder for AnthropicQueryBuilder {
|
||||
|
||||
let AnthropicSSEParser {
|
||||
accumulated_content,
|
||||
accumulated_reasoning,
|
||||
accumulated_tool_calls,
|
||||
events_str,
|
||||
annotations,
|
||||
@@ -790,6 +791,7 @@ impl QueryBuilder for AnthropicQueryBuilder {
|
||||
} else {
|
||||
Some(accumulated_content)
|
||||
},
|
||||
reasoning: (!accumulated_reasoning.is_empty()).then_some(accumulated_reasoning),
|
||||
tool_calls: accumulated_tool_calls.into_values().collect(),
|
||||
events_str: Some(events_str),
|
||||
annotations,
|
||||
|
||||
@@ -1188,6 +1188,13 @@ impl BedrockQueryBuilder {
|
||||
Some(accumulated_text)
|
||||
};
|
||||
|
||||
// The block folded for replay is also what the reader sees as thinking. Read out
|
||||
// before the block itself moves into the tool calls below.
|
||||
let reasoning_text = reasoning
|
||||
.as_ref()
|
||||
.and_then(|r| r.reasoning_text.clone())
|
||||
.filter(|t| !t.is_empty());
|
||||
|
||||
let tool_calls = streaming_tool_calls_to_openai(
|
||||
accumulated_tool_calls.into_values().collect(),
|
||||
reasoning,
|
||||
@@ -1195,6 +1202,7 @@ impl BedrockQueryBuilder {
|
||||
|
||||
Ok(ParsedResponse::Text {
|
||||
content,
|
||||
reasoning: reasoning_text,
|
||||
tool_calls,
|
||||
events_str: if events_str.is_empty() {
|
||||
None
|
||||
|
||||
@@ -666,6 +666,7 @@ impl QueryBuilder for GoogleAIQueryBuilder {
|
||||
|
||||
let GeminiSSEParser {
|
||||
accumulated_content,
|
||||
accumulated_reasoning,
|
||||
accumulated_tool_calls,
|
||||
mut events_str,
|
||||
stream_event_processor,
|
||||
@@ -698,6 +699,7 @@ impl QueryBuilder for GoogleAIQueryBuilder {
|
||||
} else {
|
||||
Some(accumulated_content)
|
||||
},
|
||||
reasoning: (!accumulated_reasoning.is_empty()).then_some(accumulated_reasoning),
|
||||
tool_calls: accumulated_tool_calls.into_values().collect(),
|
||||
events_str: Some(events_str),
|
||||
annotations,
|
||||
|
||||
@@ -538,6 +538,9 @@ impl QueryBuilder for OpenAIQueryBuilder {
|
||||
} else {
|
||||
Some(parser.accumulated_content)
|
||||
},
|
||||
// The Responses stream has no reasoning-summary event in
|
||||
// `OpenAIResponsesSSEEvent`, so nothing thinks out loud on this path yet.
|
||||
reasoning: None,
|
||||
tool_calls: parser.accumulated_tool_calls.into_values().collect(),
|
||||
events_str: Some(parser.events_str),
|
||||
annotations: parser.annotations,
|
||||
|
||||
@@ -251,6 +251,7 @@ impl QueryBuilder for OtherQueryBuilder {
|
||||
|
||||
let OpenAISSEParser {
|
||||
accumulated_content,
|
||||
accumulated_reasoning,
|
||||
accumulated_tool_calls,
|
||||
mut events_str,
|
||||
stream_event_processor,
|
||||
@@ -277,6 +278,7 @@ impl QueryBuilder for OtherQueryBuilder {
|
||||
} else {
|
||||
Some(accumulated_content)
|
||||
},
|
||||
reasoning: (!accumulated_reasoning.is_empty()).then_some(accumulated_reasoning),
|
||||
tool_calls: accumulated_tool_calls.into_values().collect(),
|
||||
events_str: Some(events_str),
|
||||
annotations: Vec::new(),
|
||||
|
||||
@@ -33,6 +33,8 @@ pub struct BuildRequestArgs<'a> {
|
||||
pub enum ParsedResponse {
|
||||
Text {
|
||||
content: Option<String>,
|
||||
/// The thinking the model streamed before the answer, when it emitted any.
|
||||
reasoning: Option<String>,
|
||||
tool_calls: Vec<OpenAIToolCall>,
|
||||
events_str: Option<String>,
|
||||
annotations: Vec<UrlCitation>,
|
||||
|
||||
@@ -135,6 +135,8 @@ pub trait SSEParser {
|
||||
|
||||
pub struct OpenAISSEParser {
|
||||
pub accumulated_content: String,
|
||||
/// The thinking streamed before the answer, kept so it can be stored with it.
|
||||
pub accumulated_reasoning: String,
|
||||
pub accumulated_tool_calls: HashMap<i64, OpenAIToolCall>,
|
||||
pub events_str: String,
|
||||
pub stream_event_processor: Box<dyn StreamEventSink>,
|
||||
@@ -146,6 +148,7 @@ impl OpenAISSEParser {
|
||||
pub fn new(stream_event_processor: Box<dyn StreamEventSink>) -> Self {
|
||||
Self {
|
||||
accumulated_content: String::new(),
|
||||
accumulated_reasoning: String::new(),
|
||||
accumulated_tool_calls: HashMap::new(),
|
||||
events_str: String::new(),
|
||||
stream_event_processor,
|
||||
@@ -175,6 +178,7 @@ impl SSEParser for OpenAISSEParser {
|
||||
if let Some(mut choices) = event.choices.filter(|s| !s.is_empty()) {
|
||||
if let Some(delta) = choices.remove(0).delta {
|
||||
if let Some(reasoning) = delta.reasoning_content.filter(|s| !s.is_empty()) {
|
||||
self.accumulated_reasoning.push_str(&reasoning);
|
||||
let event = StreamingEvent::ReasoningTokenDelta { content: reasoning };
|
||||
self.stream_event_processor
|
||||
.send(event, &mut self.events_str)
|
||||
@@ -353,6 +357,8 @@ enum ContentBlockState {
|
||||
/// Anthropic SSE Parser for streaming responses
|
||||
pub struct AnthropicSSEParser {
|
||||
pub accumulated_content: String,
|
||||
/// The thinking streamed before the answer, kept so it can be stored with it.
|
||||
pub accumulated_reasoning: String,
|
||||
pub accumulated_tool_calls: HashMap<i64, OpenAIToolCall>,
|
||||
pub events_str: String,
|
||||
pub stream_event_processor: Box<dyn StreamEventSink>,
|
||||
@@ -375,6 +381,7 @@ impl AnthropicSSEParser {
|
||||
pub fn new(stream_event_processor: Box<dyn StreamEventSink>) -> Self {
|
||||
Self {
|
||||
accumulated_content: String::new(),
|
||||
accumulated_reasoning: String::new(),
|
||||
accumulated_tool_calls: HashMap::new(),
|
||||
events_str: String::new(),
|
||||
stream_event_processor,
|
||||
@@ -455,6 +462,7 @@ impl SSEParser for AnthropicSSEParser {
|
||||
.thinking
|
||||
.get_or_insert_with(String::new)
|
||||
.push_str(&thinking);
|
||||
self.accumulated_reasoning.push_str(&thinking);
|
||||
self.stream_event_processor
|
||||
.send(
|
||||
StreamingEvent::ReasoningTokenDelta { content: thinking },
|
||||
@@ -523,6 +531,7 @@ impl SSEParser for AnthropicSSEParser {
|
||||
.thinking
|
||||
.get_or_insert_with(String::new)
|
||||
.push_str(&thinking);
|
||||
self.accumulated_reasoning.push_str(&thinking);
|
||||
self.stream_event_processor
|
||||
.send(
|
||||
StreamingEvent::ReasoningTokenDelta { content: thinking },
|
||||
@@ -590,6 +599,8 @@ impl SSEParser for AnthropicSSEParser {
|
||||
/// `windmill_ai::ai_google` so the logic can be shared with the API proxy.
|
||||
pub struct GeminiSSEParser {
|
||||
pub accumulated_content: String,
|
||||
/// The thinking streamed before the answer, kept so it can be stored with it.
|
||||
pub accumulated_reasoning: String,
|
||||
pub accumulated_tool_calls: HashMap<i64, OpenAIToolCall>,
|
||||
pub events_str: String,
|
||||
pub stream_event_processor: Box<dyn StreamEventSink>,
|
||||
@@ -603,6 +614,7 @@ impl GeminiSSEParser {
|
||||
pub fn new(stream_event_processor: Box<dyn StreamEventSink>) -> Self {
|
||||
Self {
|
||||
accumulated_content: String::new(),
|
||||
accumulated_reasoning: String::new(),
|
||||
accumulated_tool_calls: HashMap::new(),
|
||||
events_str: String::new(),
|
||||
stream_event_processor,
|
||||
@@ -621,6 +633,7 @@ impl SSEParser for GeminiSSEParser {
|
||||
};
|
||||
|
||||
if let Some(reasoning) = parsed.reasoning.filter(|s| !s.is_empty()) {
|
||||
self.accumulated_reasoning.push_str(&reasoning);
|
||||
self.stream_event_processor
|
||||
.send(
|
||||
StreamingEvent::ReasoningTokenDelta { content: reasoning },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
extract::{Path, Query},
|
||||
routing::{delete, get},
|
||||
routing::{delete, get, post},
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -15,13 +15,14 @@ use windmill_common::{
|
||||
db::{UserDB, DB},
|
||||
error::{JsonResult, Result},
|
||||
flow_conversations::MessageType,
|
||||
utils::{not_found_if_none, paginate, Pagination},
|
||||
utils::{not_found_if_none, paginate, truncate_with_ellipsis, Pagination},
|
||||
};
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/list", get(list_conversations))
|
||||
.route("/delete/{conversation_id}", delete(delete_conversation))
|
||||
.route("/update/{conversation_id}", post(update_conversation))
|
||||
.route("/{conversation_id}/messages", get(list_messages))
|
||||
}
|
||||
|
||||
@@ -36,11 +37,31 @@ pub struct FlowConversationMessage {
|
||||
pub created_seq: i64,
|
||||
pub step_name: Option<String>,
|
||||
pub success: bool,
|
||||
/// The call behind a tool row whose tool has no job of its own — an MCP tool, or a
|
||||
/// provider-native one. Read back from the job otherwise, and null here.
|
||||
pub tool_arguments: Option<String>,
|
||||
pub tool_result: Option<String>,
|
||||
/// The thinking that produced an answer, streamed by the provider and stored here
|
||||
/// because nothing else keeps it.
|
||||
pub reasoning: Option<String>,
|
||||
}
|
||||
|
||||
/// Which conversations a listing holds. A test chat was started from the editor's test
|
||||
/// panel; a deployed one from the flow itself.
|
||||
#[derive(Deserialize, Default, Clone, Copy)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ConversationKind {
|
||||
Test,
|
||||
/// The default: a deployed flow's chat should not surface someone's trial runs.
|
||||
#[default]
|
||||
Deployed,
|
||||
All,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListConversationsQuery {
|
||||
pub flow_path: Option<String>,
|
||||
pub kind: Option<ConversationKind>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -67,6 +88,7 @@ async fn list_conversations(
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"is_test",
|
||||
])
|
||||
.and_where_eq("workspace_id", "?".bind(&w_id));
|
||||
|
||||
@@ -74,6 +96,16 @@ async fn list_conversations(
|
||||
sqlb.and_where_eq("flow_path", "?".bind(flow_path));
|
||||
}
|
||||
|
||||
match query.kind.unwrap_or_default() {
|
||||
ConversationKind::Test => {
|
||||
sqlb.and_where_eq("is_test", "true");
|
||||
}
|
||||
ConversationKind::Deployed => {
|
||||
sqlb.and_where_eq("is_test", "false");
|
||||
}
|
||||
ConversationKind::All => {}
|
||||
}
|
||||
|
||||
sqlb.order_by("updated_at", true)
|
||||
.limit(per_page as i64)
|
||||
.offset(offset as i64);
|
||||
@@ -101,7 +133,7 @@ async fn delete_conversation(
|
||||
// Verify the conversation exists and belongs to the user
|
||||
let conversation = sqlx::query_as!(
|
||||
FlowConversation,
|
||||
"SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by
|
||||
"SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test
|
||||
FROM flow_conversation
|
||||
WHERE id = $1 AND workspace_id = $2",
|
||||
conversation_id,
|
||||
@@ -148,6 +180,44 @@ async fn delete_conversation(
|
||||
Ok(format!("Conversation {} deleted", conversation_id))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateConversation {
|
||||
/// The chat's name. Set from the first message when the chat is created, and left
|
||||
/// alone afterwards, so a typed one stays typed.
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
async fn update_conversation(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, conversation_id)): Path<(String, Uuid)>,
|
||||
Json(update): Json<UpdateConversation>,
|
||||
) -> Result<String> {
|
||||
// The column is VARCHAR(255) and the helper appends an ellipsis to what it cuts, so the
|
||||
// bound it takes is three short of the column's. A longer title would otherwise reach
|
||||
// Postgres as a 22001 and come back a 500.
|
||||
let title = truncate_with_ellipsis(update.title.trim(), 252);
|
||||
|
||||
let mut tx = user_db.clone().begin(&authed).await?;
|
||||
|
||||
let updated = sqlx::query_scalar!(
|
||||
"UPDATE flow_conversation SET title = $1, updated_at = updated_at
|
||||
WHERE id = $2 AND workspace_id = $3
|
||||
RETURNING id",
|
||||
title,
|
||||
conversation_id,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
not_found_if_none(updated, "Conversation", conversation_id.to_string())?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(format!("Conversation {} updated", conversation_id))
|
||||
}
|
||||
|
||||
async fn list_messages(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
@@ -178,7 +248,7 @@ async fn list_messages(
|
||||
let messages = if let Some(after_seq) = query.after_seq {
|
||||
sqlx::query_as!(
|
||||
FlowConversationMessage,
|
||||
r#"SELECT id, conversation_id, message_type as "message_type: MessageType", content, job_id, created_at, created_seq, step_name, success
|
||||
r#"SELECT id, conversation_id, message_type as "message_type: MessageType", content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning
|
||||
FROM flow_conversation_message
|
||||
WHERE conversation_id = $1
|
||||
AND created_seq > $2
|
||||
@@ -195,9 +265,9 @@ async fn list_messages(
|
||||
// Fetch messages for this conversation, oldest first, but reverse the order of the messages for easy rendering on the frontend
|
||||
sqlx::query_as!(
|
||||
FlowConversationMessage,
|
||||
r#"SELECT id, conversation_id, message_type as "message_type: MessageType", content, job_id, created_at, created_seq, step_name, success
|
||||
r#"SELECT id, conversation_id, message_type as "message_type: MessageType", content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning
|
||||
FROM (
|
||||
SELECT id, conversation_id, message_type, content, job_id, created_at, created_seq, step_name, success
|
||||
SELECT id, conversation_id, message_type, content, job_id, created_at, created_seq, step_name, success, tool_arguments, tool_result, reasoning
|
||||
FROM flow_conversation_message
|
||||
WHERE conversation_id = $1
|
||||
ORDER BY created_seq DESC
|
||||
|
||||
@@ -668,10 +668,17 @@ pub async fn handle_chat_conversation_messages(
|
||||
flow_path: &str,
|
||||
run_query: &RunJobQuery,
|
||||
user_message_raw: Option<&Box<serde_json::value::RawValue>>,
|
||||
job_id: Uuid,
|
||||
is_test: bool,
|
||||
) -> error::Result<()> {
|
||||
// Names the query parameter rather than the field: it is not a flow argument, and
|
||||
// supplying it as one is the first thing tried on reading `memory_id is required`.
|
||||
let memory_id = run_query.memory_id.ok_or_else(|| {
|
||||
windmill_common::error::Error::BadRequest(
|
||||
"memory_id is required for chat-enabled flows".to_string(),
|
||||
"memory_id is required for chat-enabled flows. Pass it as the `memory_id` query \
|
||||
parameter, not as a flow argument: it names the conversation the turn belongs to, \
|
||||
so a fresh UUID starts one and reusing a UUID continues it."
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -695,17 +702,22 @@ pub async fn handle_chat_conversation_messages(
|
||||
&authed.username,
|
||||
&user_message,
|
||||
memory_id,
|
||||
is_test,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// The run this message started. Its args are the only record of what the message
|
||||
// carried besides its text — attachments and every other flow input — and nothing
|
||||
// written later points at them: an assistant row holds the AI agent step's job.
|
||||
add_message_to_conversation_tx(
|
||||
tx,
|
||||
memory_id,
|
||||
None,
|
||||
Some(job_id),
|
||||
&user_message,
|
||||
MessageType::User,
|
||||
None,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -826,6 +838,8 @@ pub async fn run_flow<'c>(
|
||||
&flow_path.to_string(),
|
||||
&run_query,
|
||||
args.args.get("user_message"),
|
||||
uuid,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -692,16 +692,53 @@ pub async fn delete_jobs(
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
let conversation_message_deleted = sqlx::query!(
|
||||
// One row per message deleted, so the conversation of a chat losing several appears
|
||||
// several times: the count is taken before the dedup below.
|
||||
let mut conversation_ids: Vec<Uuid> = sqlx::query_scalar!(
|
||||
"DELETE FROM flow_conversation_message m
|
||||
USING flow_conversation c
|
||||
WHERE m.conversation_id = c.id AND c.workspace_id = $1 AND m.job_id = ANY($2)",
|
||||
WHERE m.conversation_id = c.id AND c.workspace_id = $1 AND m.job_id = ANY($2)
|
||||
RETURNING m.conversation_id",
|
||||
&w_id,
|
||||
&job_ids
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.rows_affected();
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
let conversation_message_deleted = conversation_ids.len() as u64;
|
||||
|
||||
// Same rule as retention (windmill_common::jobs::delete_jobs): a conversation with no
|
||||
// messages left goes, and the agent's memory for it with it.
|
||||
conversation_ids.sort_unstable();
|
||||
conversation_ids.dedup();
|
||||
if !conversation_ids.is_empty() {
|
||||
sqlx::query!(
|
||||
"DELETE FROM ai_agent_memory a
|
||||
USING flow_conversation c
|
||||
WHERE c.id = ANY($1)
|
||||
AND c.workspace_id = $2
|
||||
AND a.conversation_id = c.id
|
||||
AND a.workspace_id = c.workspace_id
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id
|
||||
)",
|
||||
&conversation_ids,
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"DELETE FROM flow_conversation c
|
||||
WHERE c.id = ANY($1)
|
||||
AND c.workspace_id = $2
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id
|
||||
)",
|
||||
&conversation_ids,
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Resolutions are not exported, so a delete-then-reimport of the same UUID would
|
||||
// otherwise resurrect the old annotation on a job that never carried one.
|
||||
|
||||
@@ -12403,6 +12403,15 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: kind
|
||||
description: which conversations to list - the flow editor's test chats, the deployed flow's own, or both
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- test
|
||||
- deployed
|
||||
- all
|
||||
responses:
|
||||
"200":
|
||||
description: flow conversations list
|
||||
@@ -12413,6 +12422,40 @@ paths:
|
||||
items:
|
||||
$ref: "#/components/schemas/FlowConversation"
|
||||
|
||||
/w/{workspace}/flow_conversations/update/{conversation_id}:
|
||||
post:
|
||||
summary: rename flow conversation
|
||||
operationId: updateFlowConversation
|
||||
tags:
|
||||
- flow_conversations
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: conversation_id
|
||||
description: conversation id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [title]
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
description: the chat's name
|
||||
responses:
|
||||
"200":
|
||||
description: flow conversation updated
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/flow_conversations/delete/{conversation_id}:
|
||||
delete:
|
||||
summary: delete flow conversation
|
||||
@@ -27958,7 +28001,7 @@ components:
|
||||
FlowConversation:
|
||||
type: object
|
||||
required:
|
||||
[id, workspace_id, flow_path, created_at, updated_at, created_by]
|
||||
[id, workspace_id, flow_path, created_at, updated_at, created_by, is_test]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
@@ -27985,6 +28028,9 @@ components:
|
||||
created_by:
|
||||
type: string
|
||||
description: Username who created the conversation
|
||||
is_test:
|
||||
type: boolean
|
||||
description: Started from the flow editor's test panel rather than a deployed run
|
||||
|
||||
FlowConversationMessage:
|
||||
type: object
|
||||
@@ -28024,6 +28070,25 @@ components:
|
||||
success:
|
||||
type: boolean
|
||||
description: Whether the message is a success
|
||||
tool_arguments:
|
||||
type: string
|
||||
nullable: true
|
||||
description: >-
|
||||
The call, for a tool that runs inside the agent's job and so has none of its
|
||||
own: an MCP tool, or a provider-native one such as web search. A Windmill tool
|
||||
is a script or flow with its own job, and its call is read from there instead.
|
||||
tool_result:
|
||||
type: string
|
||||
nullable: true
|
||||
description: >-
|
||||
What that same call returned, including the citations of a provider-native web
|
||||
search. Null on a call that failed, which `success` reports.
|
||||
reasoning:
|
||||
type: string
|
||||
nullable: true
|
||||
description: >-
|
||||
The thinking behind this answer. Stored because it reaches the job result only
|
||||
as `wm_stream`, which a step with `streaming: false` never accumulates.
|
||||
|
||||
EndpointTool:
|
||||
type: object
|
||||
|
||||
@@ -9553,6 +9553,9 @@ async fn run_preview_flow_job(
|
||||
&flow_path,
|
||||
&run_query,
|
||||
user_message.as_ref(),
|
||||
uuid,
|
||||
// Run from the editor's test panel: a trial, not a real conversation.
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ pub struct FlowConversation {
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub created_by: String,
|
||||
/// Started from the flow editor's test panel rather than a deployed run.
|
||||
pub is_test: bool,
|
||||
}
|
||||
|
||||
pub async fn get_or_create_conversation_with_id(
|
||||
@@ -35,11 +37,12 @@ pub async fn get_or_create_conversation_with_id(
|
||||
username: &str,
|
||||
title: &str,
|
||||
conversation_id: Uuid,
|
||||
is_test: bool,
|
||||
) -> Result<FlowConversation> {
|
||||
// Check if conversation already exists
|
||||
let existing_conversation = sqlx::query_as!(
|
||||
FlowConversation,
|
||||
"SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by
|
||||
"SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test
|
||||
FROM flow_conversation
|
||||
WHERE id = $1 AND workspace_id = $2",
|
||||
conversation_id,
|
||||
@@ -58,14 +61,15 @@ pub async fn get_or_create_conversation_with_id(
|
||||
// Create new conversation with provided ID
|
||||
let conversation = sqlx::query_as!(
|
||||
FlowConversation,
|
||||
"INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by, title)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, workspace_id, flow_path, title, created_at, updated_at, created_by",
|
||||
"INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by, title, is_test)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test",
|
||||
conversation_id,
|
||||
w_id,
|
||||
flow_path,
|
||||
username,
|
||||
title
|
||||
title,
|
||||
is_test
|
||||
)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
@@ -73,6 +77,17 @@ pub async fn get_or_create_conversation_with_id(
|
||||
Ok(conversation)
|
||||
}
|
||||
|
||||
/// What a row carries beyond its text, for the parts of a turn no job can be asked for.
|
||||
/// An MCP or provider-native tool runs inside the agent's job, which holds every call of
|
||||
/// the turn and nothing tying one to a row. Thinking reaches that job's result only as
|
||||
/// `wm_stream`, which a step with `streaming: false` never accumulates.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MessageExtras {
|
||||
pub tool_arguments: Option<String>,
|
||||
pub tool_result: Option<String>,
|
||||
pub reasoning: Option<String>,
|
||||
}
|
||||
|
||||
/// Add a message to a conversation using an existing transaction
|
||||
/// If the conversation doesn't exist, logs a warning and returns Ok (no error thrown)
|
||||
/// This allows memory_id to be used for agent memory without requiring a conversation
|
||||
@@ -84,6 +99,7 @@ pub async fn add_message_to_conversation_tx(
|
||||
message_type: MessageType,
|
||||
step_name: Option<&str>,
|
||||
success: bool,
|
||||
extras: Option<&MessageExtras>,
|
||||
) -> Result<()> {
|
||||
// Check if conversation exists first
|
||||
let conversation_exists = sqlx::query!(
|
||||
@@ -104,14 +120,17 @@ pub async fn add_message_to_conversation_tx(
|
||||
|
||||
// Insert the message
|
||||
sqlx::query!(
|
||||
"INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id, step_name, success)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
"INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id, step_name, success, tool_arguments, tool_result, reasoning)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
||||
conversation_id,
|
||||
message_type as MessageType,
|
||||
content,
|
||||
job_id,
|
||||
step_name,
|
||||
success
|
||||
success,
|
||||
extras.and_then(|e| e.tool_arguments.as_deref()),
|
||||
extras.and_then(|e| e.tool_result.as_deref()),
|
||||
extras.and_then(|e| e.reasoning.as_deref())
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
@@ -485,12 +485,45 @@ pub async fn delete_jobs(conn: &mut sqlx::PgConnection, ids: &[uuid::Uuid]) -> e
|
||||
)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"DELETE FROM flow_conversation_message WHERE job_id = ANY($1)",
|
||||
let mut conversation_ids: Vec<uuid::Uuid> = sqlx::query_scalar!(
|
||||
"DELETE FROM flow_conversation_message WHERE job_id = ANY($1) RETURNING conversation_id",
|
||||
ids
|
||||
)
|
||||
.execute(&mut *conn)
|
||||
.fetch_all(&mut *conn)
|
||||
.await?;
|
||||
conversation_ids.sort_unstable();
|
||||
conversation_ids.dedup();
|
||||
if !conversation_ids.is_empty() {
|
||||
// A conversation is a view over its messages: once the last one goes with its job,
|
||||
// the row and the agent's memory for it are all that is left, and nothing else
|
||||
// collects them — `ai_agent_memory` carries no job id for retention to match on.
|
||||
// Two statements rather than one CTE: a data-modifying CTE reads the snapshot from
|
||||
// before the delete above, so every conversation would still look non-empty.
|
||||
// Memory first, since it reads the conversation row for its workspace.
|
||||
sqlx::query!(
|
||||
"DELETE FROM ai_agent_memory a
|
||||
USING flow_conversation c
|
||||
WHERE c.id = ANY($1)
|
||||
AND a.conversation_id = c.id
|
||||
AND a.workspace_id = c.workspace_id
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id
|
||||
)",
|
||||
&conversation_ids
|
||||
)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"DELETE FROM flow_conversation c
|
||||
WHERE c.id = ANY($1)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM flow_conversation_message m WHERE m.conversation_id = c.id
|
||||
)",
|
||||
&conversation_ids
|
||||
)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
sqlx::query!("DELETE FROM zombie_job_counter WHERE job_id = ANY($1)", ids)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
@@ -33,7 +33,7 @@ use windmill_common::{
|
||||
client::AuthedClient,
|
||||
db::DB,
|
||||
error::Error,
|
||||
flow_conversations::MessageType,
|
||||
flow_conversations::{MessageExtras, MessageType},
|
||||
flow_status::AgentAction,
|
||||
flows::FlowModuleValue,
|
||||
worker::{to_raw_value, Connection},
|
||||
@@ -235,9 +235,24 @@ async fn execute_mcp_tool_call(
|
||||
update_flow_status_module_with_actions_success(ctx.db, parent_job, true).await?;
|
||||
}
|
||||
|
||||
// Add tool message to conversation if chat_input_enabled
|
||||
// An MCP tool runs inside the agent's job, whose result holds every call of the
|
||||
// turn and nothing tying one of them to this row: same job id for all of them,
|
||||
// no call id on the row. Kept here so the card shows this call — and the row
|
||||
// names that job, so retention sweeps it with every other row of the turn.
|
||||
let content = format!("Used {} tool", tool_call.function.name);
|
||||
add_tool_message_to_chat(ctx, None, &content, true).await;
|
||||
let agent_job_id = ctx.job.id;
|
||||
add_tool_message_to_chat(
|
||||
ctx,
|
||||
Some(agent_job_id),
|
||||
&content,
|
||||
true,
|
||||
Some(MessageExtras {
|
||||
tool_arguments: Some(tool_call.function.arguments.clone()),
|
||||
tool_result: Some(result_str),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
let error_msg = format!("MCP tool error: {}", e);
|
||||
@@ -272,7 +287,18 @@ async fn execute_mcp_tool_call(
|
||||
}
|
||||
|
||||
// Add tool message to conversation if chat_input_enabled
|
||||
add_tool_message_to_chat(ctx, None, &error_msg, false).await;
|
||||
let agent_job_id = ctx.job.id;
|
||||
add_tool_message_to_chat(
|
||||
ctx,
|
||||
Some(agent_job_id),
|
||||
&error_msg,
|
||||
false,
|
||||
Some(MessageExtras {
|
||||
tool_arguments: Some(tool_call.function.arguments.clone()),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -681,7 +707,7 @@ async fn handle_tool_execution_error(
|
||||
}
|
||||
|
||||
// Add tool message to conversation if chat_input_enabled (error case)
|
||||
add_tool_message_to_chat(ctx, Some(job_id), &error_message, false).await;
|
||||
add_tool_message_to_chat(ctx, Some(job_id), &error_message, false, None).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -806,7 +832,7 @@ async fn handle_tool_execution_success(
|
||||
format!("Error executing {}", tool_call.function.name)
|
||||
};
|
||||
|
||||
add_tool_message_to_chat(ctx, Some(job_id), &content, success).await;
|
||||
add_tool_message_to_chat(ctx, Some(job_id), &content, success, None).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -814,9 +840,16 @@ async fn handle_tool_execution_success(
|
||||
/// Add tool message to conversation if chat is enabled
|
||||
async fn add_tool_message_to_chat(
|
||||
ctx: &mut ToolExecutionContext<'_>,
|
||||
// The job this row belongs to: the tool's own where it has one, else the agent's, which
|
||||
// is the job it ran inside. Every row names one so that retention collects the whole
|
||||
// turn — `delete_jobs` removes messages by `job_id = ANY(..)` (there is no FK on the
|
||||
// column; `drop_v2_job_side_table_cascades` dropped it), and a row naming no job would
|
||||
// survive every purge and leave a conversation that can never become empty.
|
||||
tool_job_id: Option<Uuid>,
|
||||
content: &str,
|
||||
success: bool,
|
||||
// Only for a tool with no job of its own; a Windmill tool's call is read from its job.
|
||||
extras: Option<MessageExtras>,
|
||||
) {
|
||||
if ctx.omit_output_from_conversation {
|
||||
return;
|
||||
@@ -852,6 +885,7 @@ async fn add_tool_message_to_chat(
|
||||
MessageType::Tool,
|
||||
&step_name,
|
||||
success,
|
||||
extras.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ use windmill_common::flows::FlowModuleValue;
|
||||
use windmill_common::{
|
||||
db::DB,
|
||||
error::Error,
|
||||
flow_conversations::{add_message_to_conversation_tx, MessageType},
|
||||
flow_conversations::{add_message_to_conversation_tx, MessageExtras, MessageType},
|
||||
flow_status::AgentAction,
|
||||
flows::{InputTransform, Step},
|
||||
jobs::JobKind,
|
||||
@@ -209,6 +209,7 @@ pub async fn add_message_to_conversation(
|
||||
message_type: MessageType,
|
||||
step_name: &Option<String>,
|
||||
success: bool,
|
||||
extras: Option<&MessageExtras>,
|
||||
) -> Result<(), Error> {
|
||||
let mut tx = db.begin().await?;
|
||||
add_message_to_conversation_tx(
|
||||
@@ -219,6 +220,7 @@ pub async fn add_message_to_conversation(
|
||||
message_type,
|
||||
step_name.as_deref(),
|
||||
success,
|
||||
extras,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
@@ -40,7 +40,7 @@ use windmill_common::{
|
||||
client::AuthedClient,
|
||||
db::DB,
|
||||
error::{self, Error},
|
||||
flow_conversations::MessageType,
|
||||
flow_conversations::{MessageExtras, MessageType},
|
||||
flow_status::AgentAction,
|
||||
flows::{AgentTool, FlowModule, FlowModuleValue, InputTransform, ToolValue},
|
||||
get_latest_hash_for_path,
|
||||
@@ -1355,6 +1355,7 @@ pub async fn run_agent(
|
||||
match parsed {
|
||||
ParsedResponse::Text {
|
||||
content: response_content,
|
||||
reasoning: response_reasoning,
|
||||
tool_calls,
|
||||
events_str,
|
||||
annotations,
|
||||
@@ -1394,6 +1395,13 @@ pub async fn run_agent(
|
||||
let db_clone = db.clone();
|
||||
let message_content = "Used websearch tool successfully".to_string();
|
||||
let step_name = step_name.clone();
|
||||
// The search ran inside the provider's call, so this job's args
|
||||
// describe the agent, not the search: its sources reach the row
|
||||
// only if they are written here.
|
||||
let extras = (!annotations.is_empty()).then(|| MessageExtras {
|
||||
tool_result: serde_json::to_string(&annotations).ok(),
|
||||
..Default::default()
|
||||
});
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = add_message_to_conversation(
|
||||
&db_clone,
|
||||
@@ -1403,6 +1411,7 @@ pub async fn run_agent(
|
||||
MessageType::Tool,
|
||||
&step_name,
|
||||
true,
|
||||
extras.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1446,6 +1455,11 @@ pub async fn run_agent(
|
||||
let db_clone = db.clone();
|
||||
let message_content = response_content.clone();
|
||||
let step_name = step_name.clone();
|
||||
// The thinking is streamed and never returned in a response
|
||||
// body, so the answer's row is the only place it can be kept.
|
||||
let extras = response_reasoning.clone().map(|reasoning| {
|
||||
MessageExtras { reasoning: Some(reasoning), ..Default::default() }
|
||||
});
|
||||
|
||||
// Spawn task because we do not need to wait for the result
|
||||
tokio::spawn(async move {
|
||||
@@ -1457,6 +1471,7 @@ pub async fn run_agent(
|
||||
MessageType::Assistant,
|
||||
&step_name,
|
||||
true,
|
||||
extras.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1471,6 +1486,44 @@ pub async fn run_agent(
|
||||
}
|
||||
}
|
||||
|
||||
// An iteration that answered with tool calls has no message row to carry its
|
||||
// thinking, and the next iteration's row holds only its own. Stored on a row
|
||||
// of its own so a reader sees what led to the call.
|
||||
if persist_output_to_conversation
|
||||
&& response_content.as_deref().unwrap_or("").is_empty()
|
||||
{
|
||||
if let (Some(memory_id), Some(reasoning)) =
|
||||
(memory_id, response_reasoning.clone())
|
||||
{
|
||||
let agent_job_id = job.id;
|
||||
let db_clone = db.clone();
|
||||
let step_name = step_name.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = add_message_to_conversation(
|
||||
&db_clone,
|
||||
&memory_id,
|
||||
Some(agent_job_id),
|
||||
"",
|
||||
MessageType::Assistant,
|
||||
&step_name,
|
||||
true,
|
||||
Some(&MessageExtras {
|
||||
reasoning: Some(reasoning),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to add reasoning message to conversation {}: {}",
|
||||
memory_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if tool_calls.is_empty() {
|
||||
break;
|
||||
} else if i == max_iterations - 1 {
|
||||
@@ -1584,6 +1637,7 @@ pub async fn run_agent(
|
||||
MessageType::Assistant,
|
||||
&step_name,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -2235,6 +2235,7 @@ async fn add_tool_message_to_conversation(
|
||||
MessageType::Assistant,
|
||||
None,
|
||||
success,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Generated
+52
-2
@@ -5268,9 +5268,59 @@ tool, \`websearch\` for web search.
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
- \`provider\` is a static object, not a bare resource string: \`{ "kind": <provider kind>,
|
||||
- \`provider\` is an object, not a bare resource string: \`{ "kind": <provider kind>,
|
||||
"resource": "$res:<path>", "model": <model id> }\`. Required unless the module links to a saved
|
||||
agent through \`value.agent\`
|
||||
agent through \`value.agent\`. Static is right for a flow run from a form; a chat flow wires its
|
||||
fields to flow inputs instead — see below
|
||||
|
||||
### Chat-Mode Flows
|
||||
|
||||
A flow with \`value.chat_input_enabled: true\` is run from a chat instead of a form: the composer
|
||||
sends one message per turn and renders the conversation. It needs a required \`user_message\` string
|
||||
input, read by the agent.
|
||||
|
||||
**A static \`provider\` gives a chat that cannot change its model.** Feed it from flow inputs
|
||||
instead, either way round: one input carrying the whole object (\`"expr": "flow_input.model_config"\`)
|
||||
makes every field editable, or wire it field by field to fix some and expose others. A field the
|
||||
chat can write becomes a control in the composer — a provider picker, a model list, a thinking
|
||||
slider — and a field left static is fixed, with no control drawn for it. Two fields are
|
||||
exceptions: \`kind\`, which the button writes only together with \`resource\` since a provider is
|
||||
picked as a pair, and \`reasoning_effort\`, which gets a slider only for a model Windmill has
|
||||
thinking levels for. Either way the field stays askable under Configure inputs, so nothing
|
||||
the run needs becomes unreachable.
|
||||
\`user_attachments\` works the same way: point it at an s3-object input and the composer gets a
|
||||
paperclip.
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"id": "chat_agent",
|
||||
"value": {
|
||||
"type": "aiagent",
|
||||
"input_transforms": {
|
||||
"provider": {
|
||||
"type": "javascript",
|
||||
"expr": "({ kind: 'anthropic', resource: '$res:f/ai/claude', model: flow_input.model, reasoning_effort: flow_input.thinking })"
|
||||
},
|
||||
"user_message": { "type": "javascript", "expr": "flow_input.user_message" },
|
||||
"user_attachments": { "type": "javascript", "expr": "flow_input.files" },
|
||||
"memory": { "type": "static", "value": { "kind": "auto", "context_length": 10 } },
|
||||
"streaming": { "type": "static", "value": true },
|
||||
"output_type": { "type": "static", "value": "text" }
|
||||
},
|
||||
"tools": []
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
- \`memory\` is what lets the agent see earlier turns; without it every message starts from nothing
|
||||
- \`streaming\` on makes the answer and its thinking appear token by token instead of all at once
|
||||
- Running one needs a \`memory_id\` **query parameter** — not a flow argument — naming the
|
||||
conversation the turn belongs to: a fresh UUID starts one, reusing a UUID continues it. The chat
|
||||
supplies it itself; a run driven any other way has to pass it or the server refuses the job
|
||||
- Wiring field by field means one object literal whose values are literals or bare \`flow_input.x\`
|
||||
references. A spread, a call or a computed key leaves the composer unable to tell which input
|
||||
feeds which field, so it offers no control at all — a bare \`flow_input.x\` for the whole object
|
||||
is read instead as that one input carrying every field
|
||||
|
||||
### Tool Naming Rules
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface SchemaProperty {
|
||||
required?: string[]
|
||||
showExpr?: string
|
||||
hideWhenChatEnabled?: boolean
|
||||
/** Why the oneOf variant is chat mode's to pick. Set = selector disabled, reason shown. */
|
||||
lockOneOfWhenChatEnabled?: string
|
||||
password?: boolean
|
||||
order?: string[]
|
||||
nullable?: boolean
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
let capability = $derived(
|
||||
provider && model
|
||||
? getReasoningCapability(provider, model)
|
||||
: { supported: false, levels: [], canDisable: false }
|
||||
: { supported: false, levels: [], canDisable: false, known: false }
|
||||
)
|
||||
|
||||
// The token that turns reasoning off on a model that reasons by default
|
||||
|
||||
@@ -123,6 +123,8 @@
|
||||
workspace?: string | undefined
|
||||
s3StorageConfigured?: boolean
|
||||
chatInputEnabled?: boolean
|
||||
/** Why the oneOf variant is fixed. Set = the selector is disabled and says so. */
|
||||
oneOfLockedReason?: string
|
||||
actions?: import('svelte').Snippet
|
||||
innerBottomSnippet?: import('svelte').Snippet
|
||||
fieldHeaderActions?: import('svelte').Snippet
|
||||
@@ -184,6 +186,7 @@
|
||||
workspace = undefined,
|
||||
s3StorageConfigured = true,
|
||||
chatInputEnabled = false,
|
||||
oneOfLockedReason = undefined,
|
||||
actions,
|
||||
innerBottomSnippet,
|
||||
fieldHeaderActions,
|
||||
@@ -1104,11 +1107,15 @@
|
||||
{:else if inputCat == 'object' || inputCat == 'resource-object' || isListJson}
|
||||
{#if oneOf && oneOf.length >= 2}
|
||||
<div class="flex flex-col gap-2 w-full border rounded-md p-4">
|
||||
{#if oneOfLockedReason !== undefined}
|
||||
<div class="text-2xs text-tertiary">{oneOfLockedReason}</div>
|
||||
{/if}
|
||||
{#if oneOf && oneOf.length >= 2}
|
||||
<ToggleButtonGroup
|
||||
selected={oneOfSelected}
|
||||
wrap
|
||||
class="mb-4"
|
||||
disabled={disabled || oneOfLockedReason !== undefined}
|
||||
on:selected={({ detail }) => {
|
||||
oneOfSelected = detail
|
||||
const selectedObjProperties =
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
interface Props {
|
||||
schema: Schema | any
|
||||
hiddenArgs?: string[]
|
||||
/** Fields another part of the app owns: shown, but not renameable, deletable or retypeable. */
|
||||
lockedArgs?: string[]
|
||||
args?: Record<string, any>
|
||||
shouldHideNoInputs?: boolean
|
||||
noVariablePicker?: boolean
|
||||
@@ -89,6 +91,7 @@
|
||||
let {
|
||||
schema = $bindable(),
|
||||
hiddenArgs = [],
|
||||
lockedArgs = [],
|
||||
args = $bindable(undefined),
|
||||
shouldHideNoInputs = false,
|
||||
noVariablePicker = false,
|
||||
@@ -587,6 +590,7 @@
|
||||
>
|
||||
{#if keys.length > 0}
|
||||
{#each keys as argName, i (argName)}
|
||||
{@const locked = lockedArgs.includes(argName)}
|
||||
<div>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
@@ -605,7 +609,7 @@
|
||||
>
|
||||
<div class="flex flex-row gap-2 text-sm">
|
||||
{argName}
|
||||
{#if !uiOnly}
|
||||
{#if !uiOnly && !locked}
|
||||
<div onclick={stopPropagation(preventDefault(bubble('click')))}>
|
||||
<Popover placement="bottom-end" closeButton>
|
||||
{#snippet trigger()}
|
||||
@@ -654,7 +658,7 @@
|
||||
<span class="text-red-500 text-xs"> Required </span>
|
||||
{/if}
|
||||
|
||||
{#if !uiOnly}
|
||||
{#if !uiOnly && !locked}
|
||||
<button
|
||||
class="delete-schema-field-button
|
||||
rounded-full p-1 text-gray-500 bg-white
|
||||
@@ -701,6 +705,7 @@
|
||||
<ToggleButtonGroup
|
||||
tabListClass="flex-wrap"
|
||||
class="h-auto"
|
||||
disabled={lockedArgs.includes(opened ?? '')}
|
||||
bind:selected={
|
||||
() => computeSelected(schema.properties[opened ?? '']),
|
||||
(v) => {
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
import FlowRestartButton from './FlowRestartButton.svelte'
|
||||
import { useNestedRestartState } from './useNestedRestartState.svelte'
|
||||
import { buildFlowRecording, downloadRecordingJson } from './recording/runRecording'
|
||||
import { agentStreamingEnabled } from './flows/agentFormFields'
|
||||
|
||||
interface Props {
|
||||
previewMode: 'upTo' | 'whole'
|
||||
@@ -160,6 +161,13 @@
|
||||
|
||||
let loadingHistory = $state(false)
|
||||
|
||||
let shouldUseStreaming = $derived.by(() => {
|
||||
const modules = flowStore.val.value?.modules
|
||||
const lastModule = modules && modules.length > 0 ? modules[modules.length - 1] : undefined
|
||||
if (lastModule?.value?.type !== 'aiagent') return false
|
||||
return agentStreamingEnabled(lastModule.value)
|
||||
})
|
||||
|
||||
function extractFlow(previewMode: 'upTo' | 'whole'): OpenFlow {
|
||||
if (previewMode === 'whole') {
|
||||
return flowStore.val
|
||||
@@ -462,6 +470,7 @@
|
||||
{#if flowStore.val.value?.chat_input_enabled}
|
||||
<div class="flex flex-row justify-center w-full mb-6">
|
||||
<FlowChat
|
||||
useStreaming={shouldUseStreaming}
|
||||
onRunFlow={async (userMessage, conversationId, additionalInputs) => {
|
||||
await runPreview(
|
||||
{ user_message: userMessage, ...(additionalInputs ?? {}) },
|
||||
@@ -470,9 +479,11 @@
|
||||
)
|
||||
return jobId ?? ''
|
||||
}}
|
||||
hideSidebar={true}
|
||||
conversationKind="test"
|
||||
frame="boxed"
|
||||
path={$pathStore}
|
||||
inputSchema={flowStore.val.schema}
|
||||
flowModules={flowStore.val.value?.modules}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -557,7 +568,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
|
||||
|
||||
@@ -999,6 +999,11 @@
|
||||
{helperScript}
|
||||
{s3StorageConfigured}
|
||||
{chatInputEnabled}
|
||||
oneOfLockedReason={chatInputEnabled &&
|
||||
arg?.type === 'static' &&
|
||||
(arg.value as any)?.kind !== 'off'
|
||||
? schema.properties[argName]?.lockOneOfWhenChatEnabled
|
||||
: undefined}
|
||||
otherArgs={Object.fromEntries(
|
||||
Object.entries(otherArgs).map(([key, transform]) => [
|
||||
key,
|
||||
|
||||
@@ -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>
|
||||
@@ -1,30 +1,89 @@
|
||||
/**
|
||||
* The AI agent's streamed events, as the worker writes them.
|
||||
*
|
||||
* One SSE chunk can carry several lines, so parsing returns a list: a chunk holding a
|
||||
* tool call and its result must not collapse to whichever came last. Mirrors
|
||||
* `StreamingEvent` in backend/windmill-ai/src/types.rs (tagged `type`, snake_case).
|
||||
*/
|
||||
export type StreamEvent =
|
||||
| { kind: 'token'; content: string }
|
||||
| { kind: 'reasoning'; content: string }
|
||||
| { kind: 'tool_call'; callId: string; name: string }
|
||||
| { kind: 'tool_arguments'; callId: string; name: string; arguments: string }
|
||||
| { kind: 'tool_execution'; callId: string; name: string }
|
||||
| { kind: 'tool_result'; callId: string; name: string; result: string; success: boolean }
|
||||
|
||||
export function parseStreamEvents(streamData: string): StreamEvent[] {
|
||||
const events: StreamEvent[] = []
|
||||
for (const line of streamData.trim().split('\n')) {
|
||||
if (!line.trim()) continue
|
||||
let parsed: any
|
||||
try {
|
||||
parsed = JSON.parse(line)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse stream line:', line, e)
|
||||
continue
|
||||
}
|
||||
switch (parsed?.type) {
|
||||
case 'token_delta':
|
||||
if (parsed.content) events.push({ kind: 'token', content: parsed.content })
|
||||
break
|
||||
case 'reasoning_token_delta':
|
||||
if (parsed.content) events.push({ kind: 'reasoning', content: parsed.content })
|
||||
break
|
||||
case 'tool_call':
|
||||
events.push({ kind: 'tool_call', callId: parsed.call_id, name: parsed.function_name })
|
||||
break
|
||||
case 'tool_call_arguments':
|
||||
events.push({
|
||||
kind: 'tool_arguments',
|
||||
callId: parsed.call_id,
|
||||
name: parsed.function_name,
|
||||
arguments: parsed.arguments ?? ''
|
||||
})
|
||||
break
|
||||
case 'tool_execution':
|
||||
events.push({ kind: 'tool_execution', callId: parsed.call_id, name: parsed.function_name })
|
||||
break
|
||||
case 'tool_result':
|
||||
events.push({
|
||||
kind: 'tool_result',
|
||||
callId: parsed.call_id,
|
||||
name: parsed.function_name,
|
||||
result: parsed.result ?? '',
|
||||
success: parsed.success !== false
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
/** One-line summary of a tool call, for a surface with no room for the call itself. */
|
||||
export function toolSummary(name: string, success: boolean): string {
|
||||
return success ? `Used ${name} tool` : `Failed to use ${name} tool`
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattened view of a chunk, for callers that render a single running string.
|
||||
* Keeps the shape AppChat has always consumed.
|
||||
*/
|
||||
export function parseStreamDeltas(streamData: string): {
|
||||
content: string
|
||||
type?: string
|
||||
success?: boolean
|
||||
} {
|
||||
const lines = streamData.trim().split('\n')
|
||||
let content = ''
|
||||
let type = 'message'
|
||||
let success = true
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
try {
|
||||
const parsed = JSON.parse(line)
|
||||
if (parsed.type === 'tool_result') {
|
||||
type = 'tool_result'
|
||||
success = parsed.success
|
||||
const toolName = parsed.function_name
|
||||
content = success ? `Used ${toolName} tool` : `Failed to use ${toolName} tool`
|
||||
}
|
||||
if (parsed.type === 'token_delta' && parsed.content) {
|
||||
content += parsed.content
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse stream line:', line, e)
|
||||
for (const event of parseStreamEvents(streamData)) {
|
||||
if (event.kind === 'token') {
|
||||
content += event.content
|
||||
} else if (event.kind === 'tool_result') {
|
||||
type = 'tool_result'
|
||||
success = event.success
|
||||
content = toolSummary(event.name, event.success)
|
||||
}
|
||||
}
|
||||
|
||||
return { content, type, success }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* How many messages have arrived somewhere since it was last read.
|
||||
*
|
||||
* Sits in the row's own flow by default, which is where a list uses it; `class` pins it
|
||||
* to a corner for a caller that has one icon standing for the whole list. Nothing renders
|
||||
* at zero — an absent badge is what "nothing new" looks like.
|
||||
*/
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
interface Props {
|
||||
count: number
|
||||
/** What the count is of, for the label a screen reader reads. */
|
||||
noun?: string
|
||||
/** Positioning for a caller that pins it to a corner rather than letting it sit in
|
||||
* the row — the collapsed rail, where the count belongs to an icon button. */
|
||||
class?: string
|
||||
/** The 12px form, for a corner where the row-sized badge would crowd the icon. */
|
||||
small?: boolean
|
||||
}
|
||||
|
||||
let { count, noun = 'message', class: className = '', small = false }: Props = $props()
|
||||
</script>
|
||||
|
||||
{#if count > 0}
|
||||
<span
|
||||
class={twMerge(
|
||||
'unread-badge inline-flex items-center justify-center rounded-full bg-surface-accent-primary text-white font-medium',
|
||||
small ? 'min-w-3 h-3 px-0.5 text-[8px]' : 'min-w-3.5 h-3.5 px-1 text-[9px]',
|
||||
// After the size, not before: tailwind-merge counts a text size as resetting
|
||||
// line-height, so a `leading-*` ahead of one is dropped from the result.
|
||||
'leading-none',
|
||||
className
|
||||
)}
|
||||
aria-label="{count} unread {noun}{count === 1 ? '' : 's'}"
|
||||
>
|
||||
{count > 9 ? '9+' : count}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/*
|
||||
* Centring a digit by flex centres its *line box*, which is the font's em box — and a
|
||||
* digit's ink does not sit in the middle of that. Inter reserves descender space a
|
||||
* figure never uses, so the glyph lands fractionally low; at these sizes that reads as
|
||||
* the badge being a pixel off. Trimming the box to cap-height and baseline makes the
|
||||
* ink itself what gets centred. Dropped silently where it is unsupported, which leaves
|
||||
* the same near-miss as before rather than anything worse.
|
||||
*/
|
||||
.unread-badge {
|
||||
text-box: trim-both cap alphabetic;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,293 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The model button every chat puts in the bottom-right of its composer: the trigger
|
||||
* names the model and its reasoning effort, and the menu holds the choices behind
|
||||
* both. Driven entirely by ChatModelSettingsConfig, so the session chat and the flow
|
||||
* chat render the same control from different data — see chatModelSettings.ts.
|
||||
*/
|
||||
import { ChevronDown, Check, Loader2 } from 'lucide-svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import DropdownSubmenuItem from '$lib/components/DropdownSubmenuItem.svelte'
|
||||
import MenuItem from '$lib/components/meltComponents/MenuItem.svelte'
|
||||
import MenuItemWrapper from '$lib/components/meltComponents/MenuItemWrapper.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import ReasoningEffortSlider from './ReasoningEffortSlider.svelte'
|
||||
import { getReasoningCapability, resolveEffectiveReasoning } from './reasoningRegistry'
|
||||
import {
|
||||
fixedReasoningReason,
|
||||
reasoningControlState,
|
||||
reasoningDisplay,
|
||||
type ChatModelSettingsConfig,
|
||||
type ChoiceSection
|
||||
} from './chatModelSettings'
|
||||
import type { Item } from '$lib/utils'
|
||||
import type { MenubarMenuElements, createDropdownMenu } from '@melt-ui/svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
type MeltItem = MenubarMenuElements['item']
|
||||
type MeltBuilders = ReturnType<typeof createDropdownMenu>['builders']
|
||||
|
||||
let { config }: { config: ChatModelSettingsConfig } = $props()
|
||||
|
||||
const reasoning = $derived(config.reasoning)
|
||||
const capability = $derived(
|
||||
reasoning?.provider && reasoning.model
|
||||
? getReasoningCapability(reasoning.provider, reasoning.model)
|
||||
: { supported: false, levels: [] as string[], canDisable: false, known: false }
|
||||
)
|
||||
const controlState = $derived(reasoningControlState(reasoning, capability))
|
||||
const fixedReason = $derived(fixedReasoningReason(reasoning, capability))
|
||||
// Effective effort accounts for the default-on level on capable models.
|
||||
const effective = $derived(
|
||||
reasoning?.provider && reasoning.model
|
||||
? resolveEffectiveReasoning({
|
||||
provider: reasoning.provider,
|
||||
model: reasoning.model,
|
||||
reasoning: reasoning.value
|
||||
})
|
||||
: undefined
|
||||
)
|
||||
// The stops, the one in use and the trigger's suffix are decided together, in one
|
||||
// tested place: they have to agree, and three rounds of review found them disagreeing.
|
||||
const display = $derived(reasoningDisplay(reasoning, capability, effective))
|
||||
const stops = $derived(display.stops)
|
||||
const currentStop = $derived(display.currentStop)
|
||||
const effortLabel = $derived(display.label)
|
||||
|
||||
let effortSlider: ReasoningEffortSlider | undefined = $state(undefined)
|
||||
|
||||
// The trigger label resizes when the effort changes (dragging the slider while the menu
|
||||
// is open). With a `bottom-end` popover anchored to the trigger's right edge, that resize
|
||||
// would shift the popover, so freeze the trigger to its width at open time and release it
|
||||
// on close — no movement while open, natural sizing the rest of the time.
|
||||
let menuOpen = $state(false)
|
||||
let triggerEl: HTMLElement | undefined = $state(undefined)
|
||||
let lockedWidth = $state<number | undefined>(undefined)
|
||||
$effect(() => {
|
||||
if (menuOpen) {
|
||||
if (lockedWidth === undefined && triggerEl) {
|
||||
lockedWidth = triggerEl.getBoundingClientRect().width
|
||||
}
|
||||
} else {
|
||||
lockedWidth = undefined
|
||||
}
|
||||
})
|
||||
|
||||
// Blocks are separated, not prefixed: a rule belongs between two of them, so the first
|
||||
// one rendered must not draw one above itself whichever block that turns out to be.
|
||||
const BLOCK_CLASS =
|
||||
'border-border-light [&:not(:first-child)]:border-t [&:not(:first-child)]:mt-1 [&:not(:first-child)]:pt-1'
|
||||
|
||||
const ROW_CLASS =
|
||||
'w-full flex items-center gap-2 px-3 py-1.5 text-left font-normal hover:bg-surface-hover data-[highlighted]:bg-surface-hover rounded-sm transition-colors cursor-pointer'
|
||||
</script>
|
||||
|
||||
{#snippet trigger()}
|
||||
<div
|
||||
bind:this={triggerEl}
|
||||
style={lockedWidth !== undefined ? `width: ${lockedWidth}px` : undefined}
|
||||
>
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
unifiedSize="2xs"
|
||||
variant="subtle"
|
||||
disabled={config.readOnly}
|
||||
endIcon={config.readOnly ? undefined : { icon: ChevronDown }}
|
||||
btnClasses="w-full max-w-[200px] text-secondary font-normal"
|
||||
title={config.readOnly ? config.readOnlyReason : config.title}
|
||||
>
|
||||
<span class="flex items-center gap-1 min-w-0">
|
||||
<span class="truncate">{config.label}</span>
|
||||
{#if effortLabel}
|
||||
<span class="shrink-0 text-tertiary">· {effortLabel}</span>
|
||||
{/if}
|
||||
{#if config.badge}
|
||||
<span
|
||||
class={twMerge(
|
||||
'shrink-0 rounded-full px-1.5 text-2xs',
|
||||
config.badge.warn
|
||||
? 'bg-yellow-100 text-yellow-600 dark:bg-yellow-900/40'
|
||||
: 'bg-surface-secondary text-tertiary'
|
||||
)}>{config.badge.text}</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet section(sec: ChoiceSection, item: MeltItem)}
|
||||
<div class="px-3 pt-1.5 pb-1 text-2xs uppercase tracking-wide text-secondary">{sec.label}</div>
|
||||
{#if sec.loading}
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 text-tertiary">
|
||||
<Loader2 size={14} class="animate-spin" /> Loading...
|
||||
</div>
|
||||
{:else if sec.options.length === 0}
|
||||
<div class="px-3 py-1.5 text-tertiary">{sec.emptyMessage ?? 'Nothing to choose from'}</div>
|
||||
{:else}
|
||||
<div class={twMerge('overflow-y-auto', sec.maxHeight ?? 'max-h-48')}>
|
||||
{#each sec.options as option (option.key)}
|
||||
<MenuItem {item} class={ROW_CLASS} onClick={() => option.onSelect()}>
|
||||
<span class="truncate grow min-w-0">{option.label}</span>
|
||||
{#if option.hint}
|
||||
<span class="shrink-0 text-tertiary truncate max-w-[70px]">{option.hint}</span>
|
||||
{/if}
|
||||
{#if option.selected}
|
||||
<Check size={14} class="shrink-0 text-primary" />
|
||||
{/if}
|
||||
</MenuItem>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet rows(items: Item[], item: MeltItem, builders: MeltBuilders)}
|
||||
{#each items.filter((row) => !row.hide) as row (row.displayName)}
|
||||
{#if row.separatorTop}
|
||||
<div class="my-1 border-t border-border-light"></div>
|
||||
{/if}
|
||||
{#if row.submenuItems}
|
||||
<!-- Melt submenu: hover-opens and is floating-positioned (flips on screen edges). -->
|
||||
<DropdownSubmenuItem item={row} {builders} meltItem={item} />
|
||||
{:else}
|
||||
<MenuItem {item} class={ROW_CLASS} onClick={(e) => row.action?.(e)}>
|
||||
{#if row.icon}
|
||||
<row.icon size={14} class="shrink-0" />
|
||||
{/if}
|
||||
<span class="truncate grow min-w-0 text-2xs text-secondary">{row.displayName}</span>
|
||||
{#if row.selected}
|
||||
<Check size={14} class="shrink-0 text-primary" />
|
||||
{/if}
|
||||
</MenuItem>
|
||||
{/if}
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
{#if config.readOnly}
|
||||
{@render trigger()}
|
||||
{:else}
|
||||
<DropdownV2
|
||||
customMenu
|
||||
placement="bottom-end"
|
||||
fixedHeight={false}
|
||||
closeOnItemClick={false}
|
||||
bind:open={menuOpen}
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
{@render trigger()}
|
||||
{/snippet}
|
||||
{#snippet menu({ item, builders, close })}
|
||||
<div
|
||||
class="bg-surface-tertiary dark:border w-64 origin-top-right rounded-lg shadow-lg focus:outline-none py-1 text-xs"
|
||||
>
|
||||
{#if config.topItems}
|
||||
<div class={BLOCK_CLASS}>
|
||||
{@render rows(config.topItems(close), item, builders)}
|
||||
</div>
|
||||
{/if}
|
||||
{#each config.sections ?? [] as sec (sec.label)}
|
||||
<div class={BLOCK_CLASS}>
|
||||
{@render section(sec, item)}
|
||||
</div>
|
||||
{/each}
|
||||
{#if reasoning}
|
||||
<div class={BLOCK_CLASS}>
|
||||
{#if controlState === 'fixed'}
|
||||
<ReasoningEffortSlider
|
||||
stops={[]}
|
||||
current=""
|
||||
onSelect={() => {}}
|
||||
unsupportedReason={fixedReason}
|
||||
/>
|
||||
{:else if controlState === 'awaiting-model'}
|
||||
<ReasoningEffortSlider
|
||||
stops={[]}
|
||||
current=""
|
||||
onSelect={() => {}}
|
||||
unsupportedReason="Pick a model first"
|
||||
/>
|
||||
{:else if controlState === 'unknown'}
|
||||
<!-- No rules for this provider, so no ladder to offer. The flow still takes a
|
||||
token, so it is typed rather than picked: claiming the model cannot think
|
||||
would be a guess, and offering nothing would leave it settable nowhere. -->
|
||||
<div class="px-3 pt-1 pb-1.5">
|
||||
<div class="text-2xs uppercase tracking-wide text-secondary mb-1">Thinking</div>
|
||||
{#key reasoning.value}
|
||||
<TextInput
|
||||
size="sm"
|
||||
value={reasoning.value ?? ''}
|
||||
inputProps={{
|
||||
placeholder: 'none',
|
||||
onchange: (e) => reasoning?.onSelect(e.currentTarget.value.trim()),
|
||||
// Capture, not bubble: Svelte delegates `keydown` to the root, which sits
|
||||
// above the menu — so a bubble handler here would run only after melt's own
|
||||
// listener had read the key as typeahead and moved focus. A capture key is
|
||||
// not delegatable, so this becomes a real listener on the input and sees the
|
||||
// event first.
|
||||
onkeydowncapture: (e) => {
|
||||
// Escape cancels: let it reach the menu with the value untouched.
|
||||
if (e.key === 'Escape') return
|
||||
// Tab closes the menu, unmounting this field before focus moves, so no
|
||||
// change event would ever fire. Commit on the way past.
|
||||
if (e.key === 'Tab') {
|
||||
reasoning?.onSelect(e.currentTarget.value.trim())
|
||||
return
|
||||
}
|
||||
// Enter means done: commit and close, rather than leaving the menu open
|
||||
// around a field the commit is about to rebuild.
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
reasoning?.onSelect(e.currentTarget.value.trim())
|
||||
close()
|
||||
return
|
||||
}
|
||||
// Everything else is typing; the menu reads loose keys as typeahead.
|
||||
e.stopPropagation()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
<div class="text-2xs text-tertiary mt-1">
|
||||
Windmill has no thinking levels for this provider — type what it accepts.
|
||||
</div>
|
||||
</div>
|
||||
{:else if controlState === 'ladder'}
|
||||
<!-- Registered as a melt item so it joins the roving focus/highlight (and arrow
|
||||
up/down navigation), and so hovering it takes the highlight off the row
|
||||
above. Left/right adjust the effort; the slider's input handler also drives it. -->
|
||||
<MenuItemWrapper
|
||||
{item}
|
||||
onKeydown={(e) => effortSlider?.adjust(e)}
|
||||
class="block group"
|
||||
>
|
||||
<ReasoningEffortSlider
|
||||
bind:this={effortSlider}
|
||||
{stops}
|
||||
current={currentStop}
|
||||
onSelect={reasoning.onSelect}
|
||||
format={(stop) => (stop === reasoning?.offToken ? 'off' : stop)}
|
||||
overrideLabel={stops.includes(currentStop) ? undefined : effortLabel}
|
||||
/>
|
||||
</MenuItemWrapper>
|
||||
{:else}
|
||||
<!-- Kept in place rather than dropped: the row saying the model cannot think
|
||||
is the answer to why there is no slider. -->
|
||||
<ReasoningEffortSlider
|
||||
stops={[]}
|
||||
current=""
|
||||
onSelect={() => {}}
|
||||
unsupportedReason="Not supported by this model"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if config.bottomItems}
|
||||
<div class={BLOCK_CLASS}>
|
||||
{@render rows(config.bottomItems(close), item, builders)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
{/if}
|
||||
@@ -0,0 +1,172 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The reasoning-effort control: a thin slider over a model's ordered effort stops.
|
||||
*
|
||||
* Presentational on purpose. Callers keep their own value convention — the copilot's
|
||||
* REASONING_OFF sentinel and an agent's `reasoning_effort` token mean off in
|
||||
* different ways — and hand this component a resolved list of stops plus the current
|
||||
* one, so the two never have to agree on anything but the ordering.
|
||||
*/
|
||||
interface Props {
|
||||
/** Ordered stops, least effort first. Fewer than two renders no slider. */
|
||||
stops: string[]
|
||||
current: string
|
||||
onSelect: (stop: string) => void
|
||||
/** When set, the section renders disabled with this as the explanation. */
|
||||
unsupportedReason?: string
|
||||
/** Display name for a stop whose value is a provider sentinel rather than a word. */
|
||||
format?: (stop: string) => string
|
||||
/** Shown in place of the current stop — a state the slider has no position for. */
|
||||
overrideLabel?: string
|
||||
}
|
||||
|
||||
let {
|
||||
stops,
|
||||
current,
|
||||
onSelect,
|
||||
unsupportedReason,
|
||||
format = (stop: string) => stop,
|
||||
overrideLabel
|
||||
}: Props = $props()
|
||||
|
||||
/**
|
||||
* A `current` naming no stop is a real state, not a missing one: an agent that leaves the
|
||||
* effort unset sends nothing and the provider decides. Three things follow, and each has
|
||||
* been got wrong on its own — keep them together.
|
||||
*
|
||||
* The thumb rests at the start, because a range input always has one somewhere, and
|
||||
* `overrideLabel` is what tells the reader this is not the lowest stop. The track is
|
||||
* unfilled there, which index 0 gives for free. And since the input's value already reads
|
||||
* 0, picking the lowest stop by pointer fires no `input` event — so a click has to be
|
||||
* committed explicitly, or that stop is reachable only by keyboard.
|
||||
*/
|
||||
const hasPosition = $derived(stops.indexOf(current) >= 0)
|
||||
const stopIndex = $derived(Math.max(0, stops.indexOf(current)))
|
||||
// Percentage filled (accent) up to the thumb; the rest of the track stays surface-secondary.
|
||||
const fillPct = $derived(
|
||||
stops.length > 1 ? Math.round((stopIndex / (stops.length - 1)) * 100) : 0
|
||||
)
|
||||
|
||||
/** Left/right stepping, for a caller that owns the keyboard (a melt menu item). */
|
||||
export function adjust(e: KeyboardEvent) {
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
||||
e.preventDefault()
|
||||
const next = Math.min(
|
||||
stops.length - 1,
|
||||
Math.max(0, stopIndex + (e.key === 'ArrowRight' ? 1 : -1))
|
||||
)
|
||||
onSelect(stops[next])
|
||||
}
|
||||
|
||||
// Melt's roving focus blurs the focused element on pointermove, which aborts a native
|
||||
// thumb drag. Direct (non-delegated) listeners so they run before melt's item listener.
|
||||
function isolatePointer(node: HTMLElement) {
|
||||
const stop = (e: Event) => e.stopPropagation()
|
||||
node.addEventListener('pointerdown', stop)
|
||||
node.addEventListener('pointermove', stop)
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('pointerdown', stop)
|
||||
node.removeEventListener('pointermove', stop)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if unsupportedReason}
|
||||
<!-- Kept visible rather than hidden: the absence of the control is itself the answer,
|
||||
but only if it says why. -->
|
||||
<div class="px-3 pt-1 pb-1.5 opacity-60 cursor-default" aria-disabled="true">
|
||||
<div class="text-2xs uppercase tracking-wide text-secondary">Thinking</div>
|
||||
<div class="text-2xs text-tertiary mt-0.5">{unsupportedReason}</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="px-3 pt-1 pb-0.5 flex items-center justify-between">
|
||||
<span class="text-2xs uppercase tracking-wide text-secondary">Thinking</span>
|
||||
<span class="text-2xs text-secondary tabular-nums">{overrideLabel ?? format(current)}</span>
|
||||
</div>
|
||||
{#if stops.length > 1}
|
||||
<!-- Only the slider area reflects an enclosing menu item's highlight, not the header. -->
|
||||
<div class="px-3 py-1.5 rounded-sm transition-colors group-data-[highlighted]:bg-surface-hover">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={stops.length - 1}
|
||||
step="1"
|
||||
value={stopIndex}
|
||||
style="--fill: {fillPct}%"
|
||||
oninput={(e) => onSelect(stops[+e.currentTarget.value])}
|
||||
onclick={(e) => {
|
||||
// `click`, not `pointerup`: it is the event that means pressed and released on
|
||||
// the track, so a press that began on the row above cannot commit an effort
|
||||
// nobody chose. Only the click that moved nothing — any other stop has already
|
||||
// committed through `oninput`, and doing it again would write it twice.
|
||||
if (!hasPosition && +e.currentTarget.value === stopIndex) {
|
||||
onSelect(stops[stopIndex])
|
||||
}
|
||||
}}
|
||||
use:isolatePointer
|
||||
class="lean-range no-default-style w-full"
|
||||
aria-label="Reasoning effort"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Lean reasoning slider: a thin track and a small, borderless accent thumb. Native range
|
||||
thumbs can't be styled with Tailwind, and Svelte prunes scoped vendor pseudo-element
|
||||
rules — so they are wrapped in :global (the class is unique to this component). */
|
||||
.lean-range {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
/* override the global `input { background-color: ... !important }` so only the
|
||||
thin track shows, not a full-height band behind it */
|
||||
background-color: transparent !important;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
.lean-range:focus,
|
||||
.lean-range:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
:global(.lean-range::-webkit-slider-runnable-track) {
|
||||
height: 3px;
|
||||
border-radius: 9999px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
rgb(var(--color-surface-accent-primary)) var(--fill, 0%),
|
||||
rgb(var(--color-surface-secondary)) var(--fill, 0%)
|
||||
);
|
||||
}
|
||||
:global(.lean-range::-webkit-slider-thumb) {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
margin-top: -3.5px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--color-surface-accent-primary));
|
||||
}
|
||||
:global(.lean-range::-moz-range-track) {
|
||||
height: 3px;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--color-surface-secondary));
|
||||
}
|
||||
:global(.lean-range::-moz-range-progress) {
|
||||
height: 3px;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--color-surface-accent-primary));
|
||||
}
|
||||
:global(.lean-range::-moz-range-thumb) {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--color-surface-accent-primary));
|
||||
}
|
||||
</style>
|
||||
@@ -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'
|
||||
@@ -44,6 +45,7 @@
|
||||
import Markdown from 'svelte-exmarkdown'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { AIAutonomyMode, AIMode } from './AIChatManager.svelte'
|
||||
import { getChatViewHost } from './chatViewHost'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import ChatTypingIndicator from './ChatTypingIndicator.svelte'
|
||||
import AIChatInput from './AIChatInput.svelte'
|
||||
@@ -68,6 +70,9 @@
|
||||
import { base } from '$lib/base'
|
||||
|
||||
const MAX_YOLO_TOOLTIP_TOOLS = 8
|
||||
const chatHost = getChatViewHost()
|
||||
// The skill and MCP menus take an AIChatManager itself, which the seam deliberately
|
||||
// doesn't carry. They render only under GLOBAL, which a non-copilot host never sets.
|
||||
const aiChatManager = getAiChatManager()
|
||||
|
||||
// The user spent their one-time free Windmill AI grant: there is no model left to send
|
||||
@@ -174,8 +179,12 @@
|
||||
wideLayout = false,
|
||||
emptyHint,
|
||||
inputPreface,
|
||||
footerSettings,
|
||||
initialInstructions = undefined,
|
||||
onDraftChange = undefined
|
||||
onDraftChange = undefined,
|
||||
placeholder = undefined,
|
||||
scrollElement = $bindable(),
|
||||
onTranscriptScroll = undefined
|
||||
}: {
|
||||
messages: DisplayMessage[]
|
||||
pastChats: { id: string; title: string }[]
|
||||
@@ -202,9 +211,18 @@
|
||||
wideLayout?: boolean
|
||||
emptyHint?: Snippet
|
||||
inputPreface?: Snippet
|
||||
/** The settings control at the footer's right edge, where the copilot puts its
|
||||
* model picker. A host that configures its turn elsewhere replaces it here. */
|
||||
footerSettings?: Snippet
|
||||
// Seed / observe the main composer's draft text (see AIChatInput).
|
||||
initialInstructions?: string
|
||||
onDraftChange?: (text: string) => void
|
||||
/** Composer placeholder. Falls back to the per-AI-mode wording. */
|
||||
placeholder?: string
|
||||
/** The transcript's scroll container. A host that paginates older messages
|
||||
* needs it to measure and restore the scroll position. */
|
||||
scrollElement?: HTMLDivElement | undefined
|
||||
onTranscriptScroll?: () => void
|
||||
} = $props()
|
||||
|
||||
let aiChatInput: AIChatInput | undefined = $state()
|
||||
@@ -223,7 +241,7 @@
|
||||
let panelEl: HTMLDivElement | undefined = $state()
|
||||
$effect(() => {
|
||||
function onWindowKeydownCapture(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape' || !aiChatManager.loading) return
|
||||
if (e.key !== 'Escape' || !chatHost.loading) return
|
||||
const active = document.activeElement
|
||||
const focusOnChat =
|
||||
!active || active === document.body || (panelEl?.contains(active) ?? false)
|
||||
@@ -231,22 +249,21 @@
|
||||
// row alone stops the turn — wherever it is mounted, since the preview panel holds the
|
||||
// form outside `panelEl`. Matched by call: two chats can be loading at once, and one's
|
||||
// row must not answer for the other.
|
||||
if (aiChatManager.hasPendingRunForm) {
|
||||
if (chatHost.hasPendingRunForm) {
|
||||
const row = active?.closest('[data-run-form-actions]')
|
||||
const toolCallId = row?.getAttribute('data-run-form-actions')
|
||||
if (!toolCallId || !aiChatManager.isRunFormPending(toolCallId)) return
|
||||
if (!toolCallId || !chatHost.isRunFormPending(toolCallId)) return
|
||||
} else if (!focusOnChat) return
|
||||
e.preventDefault()
|
||||
// Immediate form: other chat panels' identical listeners must not
|
||||
// also cancel on body focus, nor a drawer/modal close on this press.
|
||||
e.stopImmediatePropagation()
|
||||
aiChatManager.cancel()
|
||||
chatHost.cancel()
|
||||
}
|
||||
window.addEventListener('keydown', onWindowKeydownCapture, true)
|
||||
return () => window.removeEventListener('keydown', onWindowKeydownCapture, true)
|
||||
})
|
||||
|
||||
let scrollEl: HTMLDivElement | undefined = $state()
|
||||
// Programmatic-scroll guard. `scrollDown()` triggers an async `scroll`
|
||||
// event; if a token-append between the scrollTo and the dispatch makes
|
||||
// scrollHeight grow, the gap can briefly exceed STICK_TO_BOTTOM_PX and
|
||||
@@ -259,22 +276,23 @@
|
||||
// Instant scroll — smooth would animate every token append, racing with
|
||||
// the next scrollDown and confusing the onscroll bottom-detection below.
|
||||
function scrollDown() {
|
||||
if (!scrollEl) return
|
||||
if (!scrollElement) return
|
||||
programmaticScrollAt = Date.now()
|
||||
scrollEl.scrollTo({ top: scrollEl.scrollHeight, behavior: 'auto' })
|
||||
scrollElement.scrollTo({ top: scrollElement.scrollHeight, behavior: 'auto' })
|
||||
}
|
||||
|
||||
let height = $state(0)
|
||||
$effect(() => {
|
||||
if (aiChatManager.automaticScroll && height) {
|
||||
if (chatHost.automaticScroll && height) {
|
||||
scrollDown()
|
||||
}
|
||||
// Recompute the scroll-to-latest visibility on every content-height
|
||||
// change. `onScroll` only fires for actual scroll events, so without
|
||||
// this the arrow can go stale when content grows past the threshold
|
||||
// while auto-scroll is disabled (user scrolled up mid-stream).
|
||||
if (scrollEl && height) {
|
||||
const distance = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight
|
||||
if (scrollElement && height) {
|
||||
const distance =
|
||||
scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight
|
||||
showScrollToLatest = distance > SCROLL_TO_LATEST_THRESHOLD_PX
|
||||
}
|
||||
})
|
||||
@@ -289,8 +307,9 @@
|
||||
const SCROLL_TO_LATEST_THRESHOLD_PX = 200
|
||||
let showScrollToLatest = $state(false)
|
||||
function onScroll() {
|
||||
if (!scrollEl) return
|
||||
const distance = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight
|
||||
if (!scrollElement) return
|
||||
const distance =
|
||||
scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight
|
||||
// Always refresh the arrow visibility — even during the cooldown,
|
||||
// because clicking the arrow itself triggers a programmatic scroll
|
||||
// whose only event would otherwise be swallowed, leaving the arrow
|
||||
@@ -303,14 +322,15 @@
|
||||
return
|
||||
}
|
||||
if (distance <= STICK_TO_BOTTOM_PX) {
|
||||
aiChatManager.enableAutomaticScroll()
|
||||
chatHost.enableAutomaticScroll()
|
||||
} else {
|
||||
aiChatManager.disableAutomaticScroll()
|
||||
chatHost.disableAutomaticScroll()
|
||||
}
|
||||
onTranscriptScroll?.()
|
||||
}
|
||||
|
||||
function submitSuggestion(suggestion: string) {
|
||||
aiChatManager.sendRequest({ instructions: suggestion })
|
||||
chatHost.sendRequest({ instructions: suggestion })
|
||||
}
|
||||
|
||||
export function focusInput() {
|
||||
@@ -319,35 +339,39 @@
|
||||
|
||||
$effect(() => {
|
||||
if (aiChatInput) {
|
||||
aiChatManager.setAiChatInput(aiChatInput)
|
||||
chatHost.setAiChatInput(aiChatInput)
|
||||
}
|
||||
|
||||
return () => {
|
||||
aiChatManager.setAiChatInput(null)
|
||||
chatHost.setAiChatInput(null)
|
||||
}
|
||||
})
|
||||
|
||||
// Also shown for a run held by another tab, labeled with where it is: the
|
||||
// dots say a turn is in flight even before the reader reaches the footer
|
||||
// note. Remote runs pause nothing and offer no Stop — this tab can't cancel.
|
||||
const showTypingIndicator = $derived(aiChatManager.loading || aiChatManager.runHeldElsewhere)
|
||||
const showTypingIndicator = $derived(chatHost.loading || chatHost.runHeldElsewhere)
|
||||
|
||||
// The manual `@` context-picker button. Shown in SCRIPT/FLOW (workspace items +
|
||||
// code blocks) and APP (datatables, frontend files). Hidden in GLOBAL — there
|
||||
// `@`-context is still invoked inline by typing `@` in the input, so the button
|
||||
// is redundant. NAVIGATOR/ASK/API don't take @-context at all.
|
||||
const showContextPicker = $derived(
|
||||
aiChatManager.mode === AIMode.SCRIPT ||
|
||||
aiChatManager.mode === AIMode.FLOW ||
|
||||
aiChatManager.mode === AIMode.APP
|
||||
chatHost.mode === AIMode.SCRIPT || chatHost.mode === AIMode.FLOW || chatHost.mode === AIMode.APP
|
||||
)
|
||||
|
||||
// File attachment is GLOBAL-mode only.
|
||||
const canAttachFiles = $derived(aiChatManager.mode === AIMode.GLOBAL && !disabled)
|
||||
// Steers the OS file picker toward text + image formats (soft hint; both attach
|
||||
// to the message — text files after a content sniff).
|
||||
const TEXT_FILE_ACCEPT =
|
||||
'image/*,text/*,.txt,.csv,.tsv,.json,.jsonl,.ndjson,.md,.markdown,.log,.yaml,.yml,.toml,.ini,.cfg,.conf,.env,.xml,.html,.htm,.css,.js,.mjs,.cjs,.ts,.tsx,.jsx,.py,.rb,.rs,.go,.java,.kt,.c,.h,.cpp,.cc,.cs,.php,.sh,.bash,.zsh,.sql,.svelte,.vue,.dockerfile'
|
||||
// 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)
|
||||
let fileInputEl = $state<HTMLInputElement | null>(null)
|
||||
let folderInputEl = $state<HTMLInputElement | null>(null)
|
||||
let dragDepth = $state(0)
|
||||
@@ -373,12 +397,12 @@
|
||||
}
|
||||
|
||||
async function handleAddFiles(files: FileList | FileToAttach[]) {
|
||||
const { added, rejected } = await aiChatManager.attachedFiles.addFiles(files)
|
||||
const { added, rejected } = await chatHost.attachedFiles.addFiles(files)
|
||||
reportAddResult(added, rejected)
|
||||
}
|
||||
|
||||
async function addDirHandle(dir: FileSystemDirectoryHandle) {
|
||||
const { added, rejected } = await aiChatManager.attachedFiles.addFolder(dir)
|
||||
const { added, rejected } = await chatHost.attachedFiles.addFolder(dir)
|
||||
reportAddResult(added, rejected)
|
||||
}
|
||||
|
||||
@@ -467,12 +491,16 @@
|
||||
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.
|
||||
for (const h of handles.filter(isDirectoryHandle)) {
|
||||
await addDirHandle(h)
|
||||
const dirs = handles.filter(isDirectoryHandle)
|
||||
if (dirs.length > 0 && !canLinkFolders) {
|
||||
sendUserToast('Folders cannot be attached in this chat — drop individual files.', true)
|
||||
} else {
|
||||
for (const h of dirs) {
|
||||
await addDirHandle(h)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback (no File System Access API): snapshot dropped files AND folders by walking
|
||||
@@ -497,25 +525,35 @@
|
||||
topLevelText.push(file)
|
||||
}
|
||||
}
|
||||
if (folderEntries.length > 0) await handleAddFiles(folderEntries)
|
||||
if (topLevelText.length > 0) await aiChatInput?.addTextFiles(topLevelText)
|
||||
if (folderEntries.length > 0) {
|
||||
if (canLinkFolders) await handleAddFiles(folderEntries)
|
||||
else sendUserToast('Folders cannot be attached in this chat — drop individual files.', true)
|
||||
}
|
||||
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
|
||||
@@ -524,9 +562,9 @@
|
||||
input.value = ''
|
||||
}
|
||||
const autonomyAvailability = $derived({
|
||||
autoAcceptEditsAvailable: aiChatManager.autoAcceptEditsAvailable,
|
||||
autoAcceptToolConfirmationsAvailable: aiChatManager.autoAcceptToolConfirmationsAvailable,
|
||||
planModeAvailable: aiChatManager.planModeAvailable
|
||||
autoAcceptEditsAvailable: chatHost.autoAcceptEditsAvailable,
|
||||
autoAcceptToolConfirmationsAvailable: chatHost.autoAcceptToolConfirmationsAvailable,
|
||||
planModeAvailable: chatHost.planModeAvailable
|
||||
})
|
||||
const availableAutonomyModeOptions = $derived(
|
||||
autonomyModeOptions.filter((option) => option.isAvailable(autonomyAvailability))
|
||||
@@ -534,8 +572,8 @@
|
||||
// Fall back to ask-permission when the persisted mode isn't applicable in the
|
||||
// current AI mode (e.g. auto-accept edits while in a mode without edits).
|
||||
const effectiveAutonomyMode = $derived(
|
||||
availableAutonomyModeOptions.some((option) => option.mode === aiChatManager.autonomyMode)
|
||||
? aiChatManager.autonomyMode
|
||||
availableAutonomyModeOptions.some((option) => option.mode === chatHost.autonomyMode)
|
||||
? chatHost.autonomyMode
|
||||
: AIAutonomyMode.DEFAULT
|
||||
)
|
||||
const showAutonomyModeSelector = $derived(!disabled && availableAutonomyModeOptions.length > 1)
|
||||
@@ -544,13 +582,25 @@
|
||||
// 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.
|
||||
const waitingForUserAction = $derived(aiChatManager.loading && !!pendingUserAction(messages))
|
||||
/**
|
||||
* An agent step's answer hangs its icon in the margin beside the text, so the column has
|
||||
* to carry enough padding for it to land in. Widened on both sides, not just the left:
|
||||
* the column is centred, and padding one side alone would shift the text off centre.
|
||||
*/
|
||||
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:
|
||||
// its resolver left with the old page, so the composer must not advertise an
|
||||
// answer it cannot deliver.
|
||||
const pendingQuestionToolCallId = $derived.by(() => {
|
||||
if (!aiChatManager.loading) {
|
||||
if (!chatHost.loading) {
|
||||
return undefined
|
||||
}
|
||||
const pending = pendingUserActionDetail(messages)
|
||||
@@ -559,14 +609,14 @@
|
||||
|
||||
// Get app context for display when in APP mode
|
||||
const appContext = $derived.by((): SelectedContext | undefined => {
|
||||
if (aiChatManager.mode !== AIMode.APP || !aiChatManager.appAiChatHelpers) {
|
||||
if (chatHost.mode !== AIMode.APP || !chatHost.appAiChatHelpers) {
|
||||
return undefined
|
||||
}
|
||||
return aiChatManager.appAiChatHelpers.getSelectedContext()
|
||||
return chatHost.appAiChatHelpers.getSelectedContext()
|
||||
})
|
||||
|
||||
const yoloBypassedTools = $derived.by(() => {
|
||||
return aiChatManager.tools
|
||||
return chatHost.tools
|
||||
.filter((tool) => tool.requiresConfirmation === true || tool.bypassedByAutoAccept === true)
|
||||
.map((tool) => ({
|
||||
name: tool.def.function.name,
|
||||
@@ -583,8 +633,7 @@
|
||||
Math.max(0, yoloBypassedTools.length - visibleYoloBypassedTools.length)
|
||||
)
|
||||
const showFlowPendingActionControls = $derived(
|
||||
(aiChatManager.flowAiChatHelpers?.hasPendingChanges() ?? false) &&
|
||||
!aiChatManager.autoAcceptEditsActive
|
||||
(chatHost.flowAiChatHelpers?.hasPendingChanges() ?? false) && !chatHost.autoAcceptEditsActive
|
||||
)
|
||||
// A disabled state with no message (a remote hold, a spent free grant) keeps
|
||||
// the footer toolbar in place — swapping it for an empty strip would make
|
||||
@@ -592,11 +641,16 @@
|
||||
// a real message (archived, AI off) still shows it, hold or not, matching
|
||||
// the precedence disabledMessage itself encodes.
|
||||
const footerMessageShown = $derived(disabled && disabledMessage !== '')
|
||||
// `canAttachFiles` belongs in the group too: in GLOBAL mode the `+` always has the
|
||||
// context picker or the autonomy selector beside it, but a host with attachments and
|
||||
// nothing else would lose the group and the `+` with it.
|
||||
const showFooterLeftControls = $derived(
|
||||
!footerMessageShown &&
|
||||
(showContextPicker ||
|
||||
(canAttachFiles ||
|
||||
attachmentsOffReason !== undefined ||
|
||||
showContextPicker ||
|
||||
showAutonomyModeSelector ||
|
||||
(aiChatManager.mode === AIMode.SCRIPT && hasDiff))
|
||||
(chatHost.mode === AIMode.SCRIPT && hasDiff))
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -694,12 +748,12 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
{#each pastChats as chat (chat.id)}
|
||||
<button
|
||||
class="text-left flex flex-row items-center gap-2 justify-between hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md p-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent dark:disabled:hover:bg-transparent"
|
||||
disabled={aiChatManager.loading ||
|
||||
aiChatManager.sendInFlight ||
|
||||
aiChatManager.runHeldElsewhere}
|
||||
title={aiChatManager.runHeldElsewhere
|
||||
disabled={chatHost.loading ||
|
||||
chatHost.sendInFlight ||
|
||||
chatHost.runHeldElsewhere}
|
||||
title={chatHost.runHeldElsewhere
|
||||
? 'Wait for the turn in the other tab to switch conversation'
|
||||
: aiChatManager.loading || aiChatManager.sendInFlight
|
||||
: chatHost.loading || chatHost.sendInFlight
|
||||
? 'Stop the current answer to switch conversation'
|
||||
: undefined}
|
||||
onclick={() => {
|
||||
@@ -731,10 +785,10 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
{/snippet}
|
||||
</Popover>
|
||||
<Button
|
||||
title={aiChatManager.runHeldElsewhere
|
||||
title={chatHost.runHeldElsewhere
|
||||
? 'Wait for the turn in the other tab to start a new chat'
|
||||
: 'New chat'}
|
||||
disabled={aiChatManager.runHeldElsewhere}
|
||||
disabled={chatHost.runHeldElsewhere}
|
||||
on:click={() => {
|
||||
saveAndClear()
|
||||
}}
|
||||
@@ -769,15 +823,10 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<div
|
||||
class="absolute inset-0 overflow-y-scroll pt-2 scrollbar-subtle"
|
||||
bind:this={scrollEl}
|
||||
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}
|
||||
@@ -800,22 +849,24 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
<ChatTypingIndicator
|
||||
loading={showTypingIndicator}
|
||||
paused={waitingForUserAction}
|
||||
label={aiChatManager.runHeldElsewhere
|
||||
label={chatHost.runHeldElsewhere
|
||||
? 'Running in another tab'
|
||||
: aiChatManager.loadingLabel
|
||||
? aiChatManager.loadingLabel
|
||||
: aiChatManager.compacting
|
||||
: chatHost.loadingLabel
|
||||
? chatHost.loadingLabel
|
||||
: chatHost.compacting
|
||||
? 'Compacting conversation'
|
||||
: aiChatManager.currentReasoningActive &&
|
||||
!aiChatManager.currentReply &&
|
||||
!aiChatManager.currentReasoning
|
||||
? (aiChatManager.reasoningHiddenIndicatorLabel ?? 'Thinking')
|
||||
: chatHost.currentReasoningActive &&
|
||||
!chatHost.currentReply &&
|
||||
!chatHost.currentReasoning
|
||||
? (chatHost.reasoningHiddenIndicatorLabel ?? 'Thinking')
|
||||
: undefined}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sits below the scroll-to-latest button, which carries z-10. -->
|
||||
<ScrollFade scroller={scrollElement} />
|
||||
{#if showScrollToLatest}
|
||||
<div
|
||||
transition:fade={{ duration: 120 }}
|
||||
@@ -832,7 +883,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
aria-label="Scroll to latest message"
|
||||
startIcon={{ icon: ArrowDown }}
|
||||
on:click={() => {
|
||||
aiChatManager.enableAutomaticScroll()
|
||||
chatHost.enableAutomaticScroll()
|
||||
scrollDown()
|
||||
}}
|
||||
/>
|
||||
@@ -841,11 +892,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
|
||||
@@ -854,7 +903,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
variant="default"
|
||||
btnClasses="bg-green-500 hover:bg-green-600 text-white hover:text-white"
|
||||
onclick={() => {
|
||||
aiChatManager.flowAiChatHelpers?.acceptAllModuleActions()
|
||||
chatHost.flowAiChatHelpers?.acceptAllModuleActions()
|
||||
}}
|
||||
>
|
||||
Accept all
|
||||
@@ -866,7 +915,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
variant="default"
|
||||
btnClasses="dark:opacity-50 opacity-60 hover:opacity-100"
|
||||
onclick={() => {
|
||||
aiChatManager.flowAiChatHelpers?.rejectAllModuleActions()
|
||||
chatHost.flowAiChatHelpers?.rejectAllModuleActions()
|
||||
}}
|
||||
>
|
||||
Reject all
|
||||
@@ -876,7 +925,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
{/if}
|
||||
<div>
|
||||
<QueuedMessageChip />
|
||||
{#if aiChatManager.mode === AIMode.GLOBAL && !aiChatManager.isSessionChat}
|
||||
{#if chatHost.mode === AIMode.GLOBAL && !chatHost.isSessionChat}
|
||||
<!-- Standalone Jobs bar for the global side-panel chat. In /sessions the
|
||||
Jobs segment lives inside the session bar (SessionChangesBar). -->
|
||||
<div class="mb-1">
|
||||
@@ -898,9 +947,10 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
bind:this={aiChatInput}
|
||||
bind:selectedContext
|
||||
{availableContext}
|
||||
{placeholder}
|
||||
{initialInstructions}
|
||||
{onDraftChange}
|
||||
showContext={aiChatManager.mode !== AIMode.GLOBAL}
|
||||
showContext={chatHost.mode !== AIMode.GLOBAL}
|
||||
{disabled}
|
||||
{pendingQuestionToolCallId}
|
||||
isFirstMessage={messages.length === 0}
|
||||
@@ -925,7 +975,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet content({ close })}
|
||||
{#if aiChatManager.mode === AIMode.APP}
|
||||
{#if chatHost.mode === AIMode.APP}
|
||||
<AppAvailableContextList
|
||||
{availableContext}
|
||||
{selectedContext}
|
||||
@@ -962,14 +1012,28 @@ 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
|
||||
// together: awaited inline they queue, and the whole menu —
|
||||
// attachments included — waits out two round trips.
|
||||
const closeMenu = () => (plusMenuOpen = false)
|
||||
const inGlobal = aiChatManager.mode === AIMode.GLOBAL
|
||||
const inGlobal = chatHost.mode === AIMode.GLOBAL
|
||||
const [skillItems, mcpItems] = await Promise.all([
|
||||
inGlobal ? skillsMenu.items(closeMenu) : undefined,
|
||||
inGlobal ? mcpMenu.items(closeMenu) : undefined
|
||||
@@ -983,19 +1047,23 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
linkFiles()
|
||||
}
|
||||
},
|
||||
{
|
||||
// A real (live) link needs the File System Access API; without it the
|
||||
// folder is only snapshotted, so call it "Add folder", not "Link folder".
|
||||
displayName: canUseFsAccess ? 'Link folder' : 'Add folder',
|
||||
icon: Folder,
|
||||
tooltip: canUseFsAccess
|
||||
? 'Linked live — the assistant reads the folder’s current files from disk and refreshes each turn.'
|
||||
: 'Loaded as a snapshot — the folder’s files are copied into your browser (they won’t auto-update). For a live link that refreshes from disk, use a Chromium-based browser (Chrome, Edge).',
|
||||
action: () => {
|
||||
plusMenuOpen = false
|
||||
linkFolder()
|
||||
}
|
||||
},
|
||||
...(canLinkFolders
|
||||
? [
|
||||
{
|
||||
// A real (live) link needs the File System Access API; without it the
|
||||
// folder is only snapshotted, so call it "Add folder", not "Link folder".
|
||||
displayName: canUseFsAccess ? 'Link folder' : 'Add folder',
|
||||
icon: Folder,
|
||||
tooltip: canUseFsAccess
|
||||
? 'Linked live — the assistant reads the folder’s current files from disk and refreshes each turn.'
|
||||
: 'Loaded as a snapshot — the folder’s files are copied into your browser (they won’t auto-update). For a live link that refreshes from disk, use a Chromium-based browser (Chrome, Edge).',
|
||||
action: () => {
|
||||
plusMenuOpen = false
|
||||
linkFolder()
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(skillItems
|
||||
? [
|
||||
{
|
||||
@@ -1054,7 +1122,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
bind:this={fileInputEl}
|
||||
type="file"
|
||||
multiple
|
||||
accept={TEXT_FILE_ACCEPT}
|
||||
accept={chatHost.attachmentAccept}
|
||||
class="hidden no-default-style"
|
||||
onchange={onFileInputChange}
|
||||
/>
|
||||
@@ -1075,7 +1143,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
availableAutonomyModeOptions.map((option) => ({
|
||||
displayName: option.label,
|
||||
selected: effectiveAutonomyMode === option.mode,
|
||||
action: () => aiChatManager.setAutonomyMode(option.mode)
|
||||
action: () => chatHost.setAutonomyMode(option.mode)
|
||||
}))}
|
||||
placement="bottom-start"
|
||||
fixedHeight={false}
|
||||
@@ -1102,18 +1170,18 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
{#if effectiveAutonomyMode === AIAutonomyMode.PLAN}
|
||||
<span class="text-2xs text-secondary">{PLAN_MODE_MESSAGES.modeNote}</span>
|
||||
{/if}
|
||||
{#if effectiveAutonomyMode === AIAutonomyMode.YOLO && aiChatManager.autoAcceptToolConfirmationsAvailable}
|
||||
{#if effectiveAutonomyMode === AIAutonomyMode.YOLO && chatHost.autoAcceptToolConfirmationsAvailable}
|
||||
<Tooltip small placement="top">
|
||||
<AlertTriangle class="w-3 h-3 text-red-500" />
|
||||
{#snippet text()}
|
||||
<div class="max-w-64 text-xs">
|
||||
<p class="font-semibold">
|
||||
{aiChatManager.autoAcceptEditsAvailable
|
||||
{chatHost.autoAcceptEditsAvailable
|
||||
? 'Bypass permissions auto-accepts edits and tool usage.'
|
||||
: 'Bypass permissions auto-accepts tool usage.'}
|
||||
</p>
|
||||
<p class="mt-1">
|
||||
{aiChatManager.autoAcceptEditsAvailable
|
||||
{chatHost.autoAcceptEditsAvailable
|
||||
? 'This can result in edits being applied or tools being called without user confirmation.'
|
||||
: 'This can result in tools being called without user confirmation.'}
|
||||
</p>
|
||||
@@ -1134,7 +1202,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
{/if}
|
||||
{#if aiChatManager.mode === AIMode.SCRIPT && hasDiff && !disabled}
|
||||
{#if chatHost.mode === AIMode.SCRIPT && hasDiff && !disabled}
|
||||
<ChatQuickActions {askAi} {diffMode} />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1145,25 +1213,27 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-row gap-x-1.5 min-w-0 flex-wrap items-center">
|
||||
{#if aiChatManager.mode === AIMode.GLOBAL}
|
||||
{#if chatHost.mode === AIMode.GLOBAL}
|
||||
<AttachedFilesBar />
|
||||
{/if}
|
||||
{#if !hideModeSelector}
|
||||
<ChatMode />
|
||||
{/if}
|
||||
{#if aiChatManager.mode === AIMode.APP}
|
||||
{#if chatHost.mode === AIMode.APP}
|
||||
<DatatableCreationPolicy />
|
||||
{/if}
|
||||
<ContextUsageIndicator />
|
||||
<!-- Unconditional: this composer mounts only via `AIChat` ← `SessionWrapper`,
|
||||
and `sessionRuntime` locks a session to GLOBAL, where the settings
|
||||
modal's Instructions section owns the prompt entries. -->
|
||||
<AIChatModelSettings promptSettings={false} />
|
||||
{#if aiChatManager.mode === AIMode.GLOBAL}
|
||||
{#if chatHost.supportsModelSettings}
|
||||
<!-- `promptSettings={false}`: in a session, GLOBAL, the settings modal's
|
||||
Instructions section owns the prompt entries. -->
|
||||
<AIChatModelSettings promptSettings={false} />
|
||||
{/if}
|
||||
{@render footerSettings?.()}
|
||||
{#if chatHost.mode === AIMode.GLOBAL}
|
||||
<AssistantSettingsModal bind:this={assistantSettings} />
|
||||
{/if}
|
||||
|
||||
{#if aiChatManager.mode === AIMode.APP && appContext && (appContext.inspectorElement || appContext.codeSelection)}
|
||||
{#if chatHost.mode === AIMode.APP && appContext && (appContext.inspectorElement || appContext.codeSelection)}
|
||||
{#if appContext.inspectorElement}
|
||||
<div
|
||||
class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 text-2xs"
|
||||
@@ -1210,7 +1280,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if (aiChatManager.mode === AIMode.NAVIGATOR || aiChatManager.mode === AIMode.ASK) && suggestions.length > 0 && messages.filter((m) => m.role === 'user').length === 0 && !disabled}
|
||||
{#if (chatHost.mode === AIMode.NAVIGATOR || chatHost.mode === AIMode.ASK) && suggestions.length > 0 && messages.filter((m) => m.role === 'user').length === 0 && !disabled}
|
||||
<div class="px-2 mt-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each suggestions as suggestion (suggestion)}
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
type ContextElement
|
||||
} from './context'
|
||||
import { AIMode } from './AIChatManager.svelte'
|
||||
import { CHAT_INPUT_PADDING, getAiChatManager } from './aiChatManagerContext'
|
||||
import { CHAT_INPUT_PADDING } from './aiChatManagerContext'
|
||||
import { getChatViewHost } from './chatViewHost'
|
||||
import { composerBoxClass, COMPOSER_FIELD_RESET } from './composerBox'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import { formatMention } from './mention'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { tick, untrack, type Snippet } from 'svelte'
|
||||
@@ -40,12 +43,22 @@
|
||||
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
|
||||
} from '$lib/components/common/image/ExpandableImage.svelte'
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
const chatHost = getChatViewHost()
|
||||
// Resolved here, not where it is used: getContext is only legal during component
|
||||
// initialisation, and the mention consumer below runs inside the send gesture.
|
||||
const chatManager = getAiChatManager()
|
||||
|
||||
interface Props {
|
||||
availableContext: ContextElement[]
|
||||
@@ -65,15 +78,15 @@
|
||||
showContext?: boolean
|
||||
bottomRightSnippet?: Snippet
|
||||
onKeyDown?: (e: KeyboardEvent) => void
|
||||
// When provided, overrides `aiChatManager.loading` for the send/stop
|
||||
// When provided, overrides `chatHost.loading` for the send/stop
|
||||
// button — useful for callers driving their own request lifecycle
|
||||
// (e.g. the inline ⌘K widget runs requests outside the global
|
||||
// `aiChatManager.loading` flag).
|
||||
// `chatHost.loading` flag).
|
||||
loading?: boolean
|
||||
// Called when the user clicks Stop. Defaults to `aiChatManager.cancel()`.
|
||||
// Called when the user clicks Stop. Defaults to `chatHost.cancel()`.
|
||||
onCancel?: () => void
|
||||
// Observe the composer draft as it changes (the text is local state —
|
||||
// `aiChatManager.instructions` only carries programmatic prompts). Used by
|
||||
// `chatHost.instructions` only carries programmatic prompts). Used by
|
||||
// sessions to persist the typed-but-unsent prompt with the session draft.
|
||||
onDraftChange?: (text: string) => void
|
||||
// tool_call_id of the askUserQuestion the turn is parked on, when it is. A
|
||||
@@ -132,7 +145,7 @@
|
||||
// The composer unlocks by itself when the other tab's turn ends, so the
|
||||
// placeholder names what it is waiting on (the typing indicator says
|
||||
// where the run is).
|
||||
if (aiChatManager.runHeldElsewhere) {
|
||||
if (chatHost.runHeldElsewhere) {
|
||||
return 'Waiting for the turn in the other tab to finish'
|
||||
}
|
||||
if (pendingQuestionToolCallId !== undefined) {
|
||||
@@ -147,7 +160,7 @@
|
||||
return placeholder
|
||||
}
|
||||
|
||||
switch (aiChatManager.mode) {
|
||||
switch (chatHost.mode) {
|
||||
case AIMode.SCRIPT:
|
||||
return 'Modify this script...'
|
||||
case AIMode.FLOW:
|
||||
@@ -208,31 +221,88 @@
|
||||
: 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.
|
||||
let pendingImages = $state(0)
|
||||
|
||||
/** Attach dropped/pasted image files (downscaled + bounded). GLOBAL mode only. */
|
||||
/** Attach dropped/pasted image files (downscaled + bounded). */
|
||||
export async function addImages(files: (File | Blob)[]) {
|
||||
if (aiChatManager.mode !== AIMode.GLOBAL) return
|
||||
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
|
||||
// tryGetCurrentModel returns undefined instead of throwing: this runs from a
|
||||
// drop/paste handler that can't surface a rejection.
|
||||
const model = tryGetCurrentModel()
|
||||
// Only known text-only models fail this, so attaching would certainly 400 the
|
||||
// next turn — refuse rather than warn and send it anyway.
|
||||
if (model && !modelSupportsVision(model.provider, model.model)) {
|
||||
sendUserToast(`${model.model} can't read images. Switch to a vision model first.`, true)
|
||||
return
|
||||
// The vision check is about the model this composer's own turn will hit, so it
|
||||
// only applies to a host that picks that model. Elsewhere the model is chosen
|
||||
// in the flow and tryGetCurrentModel would answer for the wrong one.
|
||||
if (chatHost.supportsModelSettings) {
|
||||
// tryGetCurrentModel returns undefined instead of throwing: this runs from a
|
||||
// drop/paste handler that can't surface a rejection.
|
||||
const model = tryGetCurrentModel()
|
||||
// Only known text-only models fail this, so attaching would certainly 400 the
|
||||
// next turn — refuse rather than warn and send it anyway.
|
||||
if (model && !modelSupportsVision(model.provider, model.model)) {
|
||||
sendUserToast(`${model.model} can't read images. Switch to a vision model first.`, true)
|
||||
return
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
@@ -245,7 +315,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
|
||||
)
|
||||
}
|
||||
@@ -308,17 +378,22 @@
|
||||
draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) + pendingFileBytes
|
||||
)
|
||||
$effect(() => {
|
||||
aiChatManager.setComposerStaged(composerKey, editingMessageIndex, stagedBytes)
|
||||
chatHost.setComposerStaged(composerKey, editingMessageIndex, stagedBytes)
|
||||
})
|
||||
$effect(() => () => aiChatManager.clearComposerStaged(composerKey))
|
||||
$effect(() => () => chatHost.clearComposerStaged(composerKey))
|
||||
|
||||
/** Attach dropped/picked text files (sniffed + bounded). GLOBAL mode only. */
|
||||
/** Attach dropped/picked text files (sniffed + bounded). */
|
||||
export async function addTextFiles(candidates: File[]) {
|
||||
if (aiChatManager.mode !== AIMode.GLOBAL) return
|
||||
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)
|
||||
@@ -333,10 +408,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
|
||||
@@ -346,7 +418,7 @@
|
||||
// stage stands in for it, so counting both would charge those bytes twice.
|
||||
let budget =
|
||||
MAX_CONVERSATION_FILE_BYTES -
|
||||
aiChatManager.attachmentBytesExcluding(composerKey) -
|
||||
chatHost.attachmentBytesExcluding(composerKey) -
|
||||
draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) -
|
||||
pendingFileBytes
|
||||
const withinBudget: File[] = []
|
||||
@@ -388,7 +460,7 @@
|
||||
// from the budget — the decoded sizes replace it.
|
||||
const liveBudget =
|
||||
MAX_CONVERSATION_FILE_BYTES -
|
||||
aiChatManager.attachmentBytesExcluding(composerKey) -
|
||||
chatHost.attachmentBytesExcluding(composerKey) -
|
||||
draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) -
|
||||
(pendingFileBytes - reservedBytes)
|
||||
const { droppedAtBudget } = draft.addFiles(reads, liveBudget)
|
||||
@@ -410,6 +482,86 @@
|
||||
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 actually reads: a host that decodes
|
||||
* them takes text, one that forwards them verbatim (to object storage) takes blobs, and
|
||||
* its narrower `accept` is re-applied because a drop and a paste both bypass the picker's
|
||||
* own filtering. Every way of attaching goes through here, so no route can take the lane
|
||||
* the host ignores and drop the file at send.
|
||||
*/
|
||||
export async function addNonImageFiles(files: File[]) {
|
||||
if (files.length === 0) 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
|
||||
}
|
||||
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('')
|
||||
@@ -420,9 +572,9 @@
|
||||
// Modes that show the rich textarea with @-context support (workspace
|
||||
// scripts, workspace flows, code blocks, DBs, etc.).
|
||||
const isContextEnabledMode = $derived(
|
||||
aiChatManager.mode === AIMode.SCRIPT ||
|
||||
aiChatManager.mode === AIMode.FLOW ||
|
||||
aiChatManager.mode === AIMode.GLOBAL
|
||||
chatHost.mode === AIMode.SCRIPT ||
|
||||
chatHost.mode === AIMode.FLOW ||
|
||||
chatHost.mode === AIMode.GLOBAL
|
||||
)
|
||||
|
||||
const domSelectorChips = $derived(
|
||||
@@ -491,7 +643,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,
|
||||
@@ -514,15 +667,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(
|
||||
@@ -536,6 +691,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
|
||||
}
|
||||
@@ -551,14 +712,14 @@
|
||||
* the composer. The conversation is left untouched — resending creates a new
|
||||
* message, unlike the bubble's edit pencil which rewinds the conversation. */
|
||||
function recallLastSentMessage(): boolean {
|
||||
const messages = aiChatManager.displayMessages
|
||||
const messages = chatHost.displayMessages
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i]
|
||||
if (message.role !== 'user' || message.synthetic) continue
|
||||
// Images come from the stored turn, never the bubble: a provider
|
||||
// rejection strips them from history while the bubble keeps its copy,
|
||||
// and recalling that copy would re-attach the refused image.
|
||||
const images = aiChatManager.storedImages(i) ?? []
|
||||
const images = chatHost.storedImages(i) ?? []
|
||||
// Eligibility looks at the bubble, though: the last thing the user
|
||||
// actually sent is the recall boundary, so a context-only turn (GLOBAL
|
||||
// allows text-free sends with chips) recalls its chips, and a turn
|
||||
@@ -582,8 +743,7 @@
|
||||
// count against the conversation budget — re-admit them instead of
|
||||
// copying, or resending would blow past MAX_CONVERSATION_FILE_BYTES.
|
||||
if (message.files?.length) {
|
||||
const budget =
|
||||
MAX_CONVERSATION_FILE_BYTES - aiChatManager.attachmentBytesExcluding(composerKey)
|
||||
const budget = MAX_CONVERSATION_FILE_BYTES - chatHost.attachmentBytesExcluding(composerKey)
|
||||
const { droppedAtBudget } = draft.addFiles(message.files, budget)
|
||||
if (droppedAtBudget > 0) {
|
||||
const mb = Math.round(MAX_CONVERSATION_FILE_BYTES / 1_000_000)
|
||||
@@ -654,10 +814,10 @@
|
||||
|
||||
if (
|
||||
contextElement.type === 'app_datatable' &&
|
||||
aiChatManager.mode === AIMode.APP &&
|
||||
aiChatManager.appAiChatHelpers
|
||||
chatHost.mode === AIMode.APP &&
|
||||
chatHost.appAiChatHelpers
|
||||
) {
|
||||
const appAiChatHelpers = aiChatManager.appAiChatHelpers
|
||||
const appAiChatHelpers = chatHost.appAiChatHelpers
|
||||
appAiChatHelpers.addTableToWhitelist(
|
||||
contextElement.datatableName,
|
||||
contextElement.schemaName,
|
||||
@@ -699,14 +859,21 @@
|
||||
* consuming past them would hand this message a mention the user picked for
|
||||
* the next one. */
|
||||
function consumeMentionsIfGlobal() {
|
||||
if (aiChatManager.mode !== AIMode.GLOBAL) return
|
||||
aiChatManager.contextManager?.consumeMentionContext()
|
||||
if (chatHost.mode !== AIMode.GLOBAL) return
|
||||
// The mention context belongs to the copilot's own ContextManager, which only
|
||||
// the manager has — the GLOBAL guard above means this host is always it.
|
||||
chatManager.contextManager?.consumeMentionContext()
|
||||
}
|
||||
|
||||
function sendRequest() {
|
||||
// The send button is disabled while decoding, but Enter reaches here directly.
|
||||
// Sending now would drop the in-flight attachments onto the following message.
|
||||
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
|
||||
// turn. Returning before `take()` keeps the chips where the user put them.
|
||||
if (chatHost.requiresMessageText && draft.text.trim() === '') {
|
||||
return
|
||||
}
|
||||
// Read before `take()` empties the draft the id derives from, and only take
|
||||
@@ -715,7 +882,7 @@
|
||||
const answeredQuestionId = questionAnsweredBySend
|
||||
if (
|
||||
answeredQuestionId &&
|
||||
aiChatManager.handleUserQuestionAnswer(answeredQuestionId, [
|
||||
chatHost.handleUserQuestionAnswer(answeredQuestionId, [
|
||||
expanded(chatDraft(draft.text.trim(), draft.pastes))
|
||||
])
|
||||
) {
|
||||
@@ -727,7 +894,7 @@
|
||||
contextTextareaComponent?.clearForSend()
|
||||
return
|
||||
}
|
||||
if (aiChatManager.loading) {
|
||||
if (chatHost.loading) {
|
||||
// Queue the message instead of silently discarding it — it is
|
||||
// auto-sent when the streaming turn completes successfully.
|
||||
// Editing-while-loading keeps the old discard behavior. Paste
|
||||
@@ -738,14 +905,15 @@
|
||||
// chips picked at press time.
|
||||
if (
|
||||
editingMessageIndex === null &&
|
||||
(!draft.isEmpty || (aiChatManager.mode === AIMode.GLOBAL && selectedContext.length > 0))
|
||||
(!draft.isEmpty || (chatHost.mode === AIMode.GLOBAL && selectedContext.length > 0))
|
||||
) {
|
||||
const sent = draft.take()
|
||||
aiChatManager.queueMessage(
|
||||
chatHost.queueMessage(
|
||||
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()
|
||||
@@ -758,7 +926,7 @@
|
||||
// message's original chips), so send exactly what's shown — the user may
|
||||
// have added or removed chips.
|
||||
const sent = draft.take()
|
||||
aiChatManager.restartGeneration(
|
||||
chatHost.restartGeneration(
|
||||
editingMessageIndex,
|
||||
sent.text,
|
||||
sent.pastes,
|
||||
@@ -771,13 +939,17 @@
|
||||
const sent = draft.take()
|
||||
// Pin before consuming: the manager falls back to the live selection only
|
||||
// when given no override, and the consume below empties it.
|
||||
const carried = aiChatManager.mode === AIMode.GLOBAL ? [...selectedContext] : undefined
|
||||
const carried = chatHost.mode === AIMode.GLOBAL ? [...selectedContext] : undefined
|
||||
consumeMentionsIfGlobal()
|
||||
aiChatManager.sendRequest({
|
||||
// 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
|
||||
})
|
||||
@@ -999,37 +1171,67 @@
|
||||
updateAppTooltipPosition(appTooltipCurrentViewNumber)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Clipboard files on the plain composer. ContextTextarea does this for the rich one; a
|
||||
* host that attaches but renders the plain field would otherwise take files from the `+`
|
||||
* and from a drop and silently ignore the same file pasted.
|
||||
*
|
||||
* Only when the clipboard carries no text, as there: a spreadsheet or browser copy puts a
|
||||
* bitmap alongside 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()}
|
||||
<!-- The turn stays `loading` while parked on a question, but a drafted answer
|
||||
is what the button should ship then — otherwise the only pointer action on
|
||||
a typed answer would be Stop. Anything else keeps Stop. -->
|
||||
{@const isLoading = (loading ?? aiChatManager.loading) && !questionAnsweredBySend}
|
||||
{@const isLoading = (loading ?? chatHost.loading) && !questionAnsweredBySend}
|
||||
{@const emptyDraft = draft.isEmpty}
|
||||
<!-- A text-free GLOBAL draft with context chips is a valid turn (Enter
|
||||
already sends it), so the button stays enabled there for pointer/touch
|
||||
parity — mirrors the sendRequest guard. Custom onSendRequest consumers
|
||||
(inline ⌘K) and editor copilots need content. -->
|
||||
{@const needsText = chatHost.requiresMessageText && draft.text.trim() === ''}
|
||||
<!-- The wording is about the attachment, so it earns its place only once there is one:
|
||||
an empty composer is the idle state, not a refusal. -->
|
||||
{@const needsTextForAttachment = needsText && !emptyDraft}
|
||||
{@const sendDisabled =
|
||||
disabled ||
|
||||
pendingImages > 0 ||
|
||||
pendingFiles > 0 ||
|
||||
pendingBlobs > 0 ||
|
||||
ingestionHolds > 0 ||
|
||||
needsText ||
|
||||
(emptyDraft &&
|
||||
(onSendRequest !== undefined ||
|
||||
aiChatManager.mode !== AIMode.GLOBAL ||
|
||||
chatHost.mode !== AIMode.GLOBAL ||
|
||||
selectedContext.length === 0))}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="md"
|
||||
iconOnly
|
||||
title={isLoading ? 'Stop' : 'Send'}
|
||||
title={isLoading
|
||||
? 'Stop'
|
||||
: needsTextForAttachment
|
||||
? 'Write a message to send with the attachment'
|
||||
: 'Send'}
|
||||
startIcon={{ icon: isLoading ? Square : ArrowUp }}
|
||||
disabled={!isLoading && sendDisabled}
|
||||
on:click={() => {
|
||||
if (isLoading) {
|
||||
onCancel ? onCancel() : aiChatManager.cancel()
|
||||
onCancel ? onCancel() : chatHost.cancel()
|
||||
} else if (!sendDisabled) {
|
||||
submitRequest()
|
||||
}
|
||||
@@ -1043,7 +1245,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
|
||||
@@ -1062,7 +1264,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..."
|
||||
@@ -1115,9 +1329,9 @@
|
||||
class="relative mt-1"
|
||||
role="presentation"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape' && aiChatManager.loading) {
|
||||
if (e.key === 'Escape' && chatHost.loading) {
|
||||
e.preventDefault()
|
||||
aiChatManager.cancel()
|
||||
chatHost.cancel()
|
||||
} else if (
|
||||
e.key === 'ArrowUp' &&
|
||||
!e.defaultPrevented &&
|
||||
@@ -1127,6 +1341,7 @@
|
||||
draft.isEmpty &&
|
||||
pendingImages === 0 &&
|
||||
pendingFiles === 0 &&
|
||||
pendingBlobs === 0 &&
|
||||
ingestionHolds === 0
|
||||
) {
|
||||
// Shell-style recall: ArrowUp in the empty main composer pulls the
|
||||
@@ -1139,14 +1354,15 @@
|
||||
// custom-send consumers (inline widget) have their own history
|
||||
// semantics.
|
||||
if (
|
||||
aiChatManager.queuedMessage ||
|
||||
aiChatManager.queuedImages.length > 0 ||
|
||||
aiChatManager.queuedFiles.length > 0 ||
|
||||
(aiChatManager.queuedContext?.length ?? 0) > 0
|
||||
chatHost.queuedMessage ||
|
||||
chatHost.queuedImages.length > 0 ||
|
||||
chatHost.queuedFiles.length > 0 ||
|
||||
chatHost.queuedBlobs.length > 0 ||
|
||||
(chatHost.queuedContext?.length ?? 0) > 0
|
||||
) {
|
||||
e.preventDefault()
|
||||
aiChatManager.dequeueMessage()
|
||||
} else if (!aiChatManager.sendInFlight && recallLastSentMessage()) {
|
||||
chatHost.dequeueMessage()
|
||||
} else if (!chatHost.sendInFlight && recallLastSentMessage()) {
|
||||
// History recall waits for the in-flight turn: from the moment the
|
||||
// composer clears, the turn's bubble, stored images and context land
|
||||
// across several awaits, so recalling now would return an incomplete
|
||||
@@ -1163,10 +1379,10 @@
|
||||
bind:this={contextTextareaComponent}
|
||||
bind:value={draft.text}
|
||||
bind:pastes={draft.pastes}
|
||||
onImageFiles={aiChatManager.mode === AIMode.GLOBAL
|
||||
onImageFiles={chatHost.supportsMessageAttachments
|
||||
? (pasted) => void addImages(pasted)
|
||||
: undefined}
|
||||
onTextFiles={aiChatManager.mode === AIMode.GLOBAL
|
||||
onTextFiles={chatHost.supportsMessageAttachments
|
||||
? (pasted) => void addTextFiles(pasted)
|
||||
: undefined}
|
||||
{availableContext}
|
||||
@@ -1196,7 +1412,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if aiChatManager.mode === AIMode.APP}
|
||||
{:else if chatHost.mode === AIMode.APP}
|
||||
{#if showContext}
|
||||
{@render badgeRow()}
|
||||
{/if}
|
||||
@@ -1264,30 +1480,37 @@
|
||||
</Portal>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class={twMerge('relative w-full scroll-pb-2 pt-2', className)}>
|
||||
<textarea
|
||||
bind:this={instructionsTextareaComponent}
|
||||
bind:value={draft.text}
|
||||
use:autosize={{ maxHeight: '40vh' }}
|
||||
onkeydown={(e) => {
|
||||
if (onKeyDown) {
|
||||
onKeyDown(e)
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
sendRequest()
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
placeholder={modePlaceholder}
|
||||
class={twMerge('resize-none', CHAT_INPUT_PADDING)}
|
||||
{disabled}
|
||||
></textarea>
|
||||
{#if !bottomRightSnippet}
|
||||
<div class="absolute bottom-1 right-1">
|
||||
{@render sendStopButton()}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Same box as the rich composer above, so a host on the plain textarea shows
|
||||
the identical chip rows inside the identical field. -->
|
||||
<div class={composerBoxClass(disabled)}>
|
||||
{@render badgeRow()}
|
||||
{@render imageChipsRow()}
|
||||
<div class={twMerge('relative w-full', className)}>
|
||||
<textarea
|
||||
bind:this={instructionsTextareaComponent}
|
||||
bind:value={draft.text}
|
||||
use:autosize={{ maxHeight: '40vh' }}
|
||||
onpaste={handlePlainPaste}
|
||||
onkeydown={(e) => {
|
||||
if (onKeyDown) {
|
||||
onKeyDown(e)
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
sendRequest()
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
placeholder={modePlaceholder}
|
||||
class={twMerge('resize-none', COMPOSER_FIELD_RESET, CHAT_INPUT_PADDING)}
|
||||
{disabled}
|
||||
></textarea>
|
||||
{#if !bottomRightSnippet}
|
||||
<div class="absolute bottom-1 right-1">
|
||||
{@render sendStopButton()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if bottomRightSnippet}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
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'
|
||||
import type { FlowOptions, ScriptOptions } from './ContextManager.svelte'
|
||||
@@ -93,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,
|
||||
@@ -153,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
|
||||
@@ -445,9 +442,31 @@ function planModeHostFor(m: AIChatManager): PlanModeHost {
|
||||
}
|
||||
}
|
||||
|
||||
export class AIChatManager {
|
||||
export class AIChatManager implements ChatViewHost {
|
||||
contextManager = new ContextManager()
|
||||
historyManager = new HistoryManager()
|
||||
// The copilot owns its model choice and its own transcript, so both chat
|
||||
// affordances apply here. See ChatViewHost for hosts where they don't.
|
||||
supportsModelSettings = true
|
||||
supportsMessageEditing = true
|
||||
// The copilot turn is the attachments themselves when there is no text.
|
||||
requiresMessageText = false
|
||||
// Attachments and linked folders are GLOBAL-mode affordances. Declared as
|
||||
// getters because `mode` changes under a mounted composer.
|
||||
get supportsMessageAttachments() {
|
||||
return this.mode === AIMode.GLOBAL
|
||||
}
|
||||
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 =
|
||||
'image/*,text/*,.txt,.csv,.tsv,.json,.jsonl,.ndjson,.md,.markdown,.log,.yaml,.yml,.toml,.ini,.cfg,.conf,.env,.xml,.html,.htm,.css,.js,.mjs,.cjs,.ts,.tsx,.jsx,.py,.rb,.rs,.go,.java,.kt,.c,.h,.cpp,.cc,.cs,.php,.sh,.bash,.zsh,.sql,.svelte,.vue,.dockerfile'
|
||||
/** Files the user attached to the current GLOBAL-mode conversation. */
|
||||
attachedFiles = new AttachedFilesStore()
|
||||
/** Markdown artifacts the copilot created for the current session. */
|
||||
@@ -2421,8 +2440,11 @@ export class AIChatManager {
|
||||
openArtifact: this.openArtifact
|
||||
}
|
||||
: {}),
|
||||
testActiveFlow: async (storagePath: string, args?: Record<string, any>) =>
|
||||
this.flowEditorFor(storagePath)?.testFlow(args),
|
||||
testActiveFlow: async (
|
||||
storagePath: string,
|
||||
args?: Record<string, any>,
|
||||
conversationId?: string
|
||||
) => this.flowEditorFor(storagePath)?.testFlow(args, conversationId),
|
||||
getModifiedItems: () => (this.modifiedItems ? [...this.modifiedItems] : undefined),
|
||||
attachedFiles: this.attachedFiles,
|
||||
getUserInstructions: () => getUserCustomPrompts()[AIMode.GLOBAL] ?? '',
|
||||
@@ -3935,6 +3957,9 @@ export class AIChatManager {
|
||||
{
|
||||
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 }
|
||||
: {}),
|
||||
|
||||
@@ -858,7 +858,9 @@ describe('AIChatManager autonomy mode', () => {
|
||||
const jobId = await manager.helpers.testActiveFlow('u/admin/live_flow', { name: 'Ada' })
|
||||
|
||||
expect(jobId).toBe('job-flow-preview')
|
||||
expect(testFlow).toHaveBeenCalledWith({ name: 'Ada' })
|
||||
// Second argument is the chat-mode conversation id, which only `test_run_flow`'s
|
||||
// own `conversation_id` supplies — never the session id.
|
||||
expect(testFlow).toHaveBeenCalledWith({ name: 'Ada' }, undefined)
|
||||
// A session chat resolves an editor by its storage path, so it never names one.
|
||||
expect(manager.flowAiChatHelpers).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { DisplayMessage, ToolDisplayMessage } from './shared'
|
||||
import ContextElementBadge from './ContextElementBadge.svelte'
|
||||
import AssistantMessage from './AssistantMessage.svelte'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import { getChatViewHost } from './chatViewHost'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { RefreshCwIcon, Undo2Icon } from 'lucide-svelte'
|
||||
import AIChatInput from './AIChatInput.svelte'
|
||||
@@ -15,7 +15,7 @@
|
||||
import ExpandableImage from '$lib/components/common/image/ExpandableImage.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
const chatHost = getChatViewHost()
|
||||
|
||||
// Paths in a message name items the chat's tools reach, so they resolve against the
|
||||
// operating workspace, never `workspaceStore`: a fork session leaves the store on the
|
||||
@@ -25,7 +25,7 @@
|
||||
// Registers the dependency that `operatingWorkspace`'s own untracked
|
||||
// `get(workspaceStore)` cannot.
|
||||
void $workspaceStore
|
||||
return aiChatManager.operatingWorkspace
|
||||
return chatHost.operatingWorkspace
|
||||
})
|
||||
|
||||
// Per-message expand/collapse state for paste chips shown in the bubble.
|
||||
@@ -62,7 +62,12 @@
|
||||
let editContext = $state<ContextElement[]>([])
|
||||
|
||||
function editMessage() {
|
||||
if (message.role !== 'user' || editingMessageIndex !== null || aiChatManager.loading) {
|
||||
if (
|
||||
!chatHost.supportsMessageEditing ||
|
||||
message.role !== 'user' ||
|
||||
editingMessageIndex !== null ||
|
||||
chatHost.loading
|
||||
) {
|
||||
return
|
||||
}
|
||||
editContext = [...(message.contextElements ?? [])]
|
||||
@@ -79,7 +84,9 @@
|
||||
message.role === 'tool' && 'mb-1',
|
||||
message.role === 'user' && messageIndex > 0 && 'mt-4 mb-6',
|
||||
isLast && '!mb-12',
|
||||
message.role !== 'user' ? 'cursor-default' : 'cursor-pointer'
|
||||
message.role !== 'user' || !chatHost.supportsMessageEditing
|
||||
? 'cursor-default'
|
||||
: 'cursor-pointer'
|
||||
)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@@ -116,7 +123,7 @@
|
||||
bind:selectedContext={editContext}
|
||||
initialInstructions={message.content}
|
||||
initialPastes={message.pastes}
|
||||
initialImages={aiChatManager.storedImages(messageIndex)}
|
||||
initialImages={chatHost.storedImages(messageIndex)}
|
||||
initialFiles={message.files}
|
||||
{editingMessageIndex}
|
||||
onClickOutside={() => (editingMessageIndex = null)}
|
||||
@@ -131,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
|
||||
@@ -185,9 +194,9 @@
|
||||
on:click={() => {
|
||||
if (message.snapshot) {
|
||||
if (message.snapshot.type === 'flow') {
|
||||
aiChatManager.flowAiChatHelpers?.revertToSnapshot(message.snapshot.value)
|
||||
chatHost.flowAiChatHelpers?.revertToSnapshot(message.snapshot.value)
|
||||
} else if (message.snapshot.type === 'app') {
|
||||
aiChatManager.appAiChatHelpers?.revertToSnapshot(message.snapshot.value)
|
||||
chatHost.appAiChatHelpers?.revertToSnapshot(message.snapshot.value)
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -206,7 +215,7 @@
|
||||
variant="default"
|
||||
title="Retry generation"
|
||||
startIcon={{ icon: RefreshCwIcon }}
|
||||
onclick={() => aiChatManager.retryRequest(messageIndex)}
|
||||
onclick={() => chatHost.retryRequest(messageIndex)}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, Check, User, Building2, Settings, ExternalLink } from 'lucide-svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import DropdownSubmenuItem from '$lib/components/DropdownSubmenuItem.svelte'
|
||||
import MenuItem from '$lib/components/meltComponents/MenuItem.svelte'
|
||||
import MenuItemWrapper from '$lib/components/meltComponents/MenuItemWrapper.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
/**
|
||||
* The session chat's model button: a fixed ChatModelSettings config over the copilot's
|
||||
* own state — the workspace's configured models, the session's model/effort selection
|
||||
* and its localStorage pins, the custom-prompt editors, and the free-tier grant.
|
||||
*/
|
||||
import { User, Building2, Settings, ExternalLink } from 'lucide-svelte'
|
||||
import ChatModelSettings from '../ChatModelSettings.svelte'
|
||||
import { carriedReasoning, type ChatModelSettingsConfig } from '../chatModelSettings'
|
||||
import {
|
||||
COPILOT_SESSION_MODEL_SETTING_NAME,
|
||||
COPILOT_SESSION_PROVIDER_SETTING_NAME,
|
||||
@@ -28,7 +30,6 @@
|
||||
import { thinkingPreferences } from './thinkingPreferences.svelte'
|
||||
import {
|
||||
getReasoningCapability,
|
||||
resolveEffectiveReasoning,
|
||||
REASONING_OFF,
|
||||
type ReasoningProviderModel
|
||||
} from '../reasoningRegistry'
|
||||
@@ -60,57 +61,16 @@
|
||||
let freeUsedPct = $derived(Math.min(100, Math.round((freeTier?.used_ratio ?? 0) * 100)))
|
||||
let freeRunningLow = $derived(!!freeTier && !freeTier.exhausted && freeUsedPct >= 80)
|
||||
|
||||
let capability = $derived(
|
||||
getReasoningCapability(providerModel.provider as AIProvider, providerModel.model)
|
||||
)
|
||||
// Effective effort accounts for the default-on level on capable models.
|
||||
let currentEffort = $derived(resolveEffectiveReasoning(providerModel))
|
||||
// Slider stops: an off position only where the model can truly disable (else the
|
||||
// provider would coerce it to the lowest level), then the provider-native levels.
|
||||
let stops = $derived([...(capability.canDisable ? [REASONING_OFF] : []), ...capability.levels])
|
||||
let currentStop = $derived(
|
||||
providerModel.reasoning === REASONING_OFF
|
||||
? REASONING_OFF
|
||||
: (currentEffort ?? stops[stops.length - 1])
|
||||
)
|
||||
let stopIndex = $derived(Math.max(0, stops.indexOf(currentStop)))
|
||||
// Percentage filled (accent) up to the thumb; the rest of the track stays surface-secondary.
|
||||
let fillPct = $derived(stops.length > 1 ? Math.round((stopIndex / (stops.length - 1)) * 100) : 0)
|
||||
// Button suffix: the effort token, or 'off' when explicitly disabled. Omitted entirely
|
||||
// for models with no reasoning support.
|
||||
let effortLabel = $derived(capability.supported ? (currentEffort ?? REASONING_OFF) : undefined)
|
||||
|
||||
// The trigger label resizes when the effort changes (e.g. dragging the slider while the menu
|
||||
// is open). With a `bottom-end` popover anchored to the trigger's right edge, that resize would
|
||||
// shift the popover. So we freeze the trigger to its width at open time and release it on close —
|
||||
// no movement while open, and natural sizing (no reserved padding) the rest of the time.
|
||||
let menuOpen = $state(false)
|
||||
let triggerEl: HTMLElement | undefined = $state(undefined)
|
||||
let lockedWidth = $state<number | undefined>(undefined)
|
||||
$effect(() => {
|
||||
if (menuOpen) {
|
||||
if (lockedWidth === undefined && triggerEl) {
|
||||
lockedWidth = triggerEl.getBoundingClientRect().width
|
||||
}
|
||||
} else {
|
||||
lockedWidth = undefined
|
||||
}
|
||||
})
|
||||
|
||||
function selectModel(m: AIProviderModel) {
|
||||
// Carry the effort onto the new model only if it supports that level ('off'
|
||||
// only where the model can truly disable); otherwise drop it so the model's
|
||||
// default applies.
|
||||
const carried = providerModel.reasoning
|
||||
const cap = getReasoningCapability(m.provider, m.model)
|
||||
const keep =
|
||||
carried === REASONING_OFF
|
||||
? cap.canDisable
|
||||
: carried !== undefined && cap.levels.includes(carried)
|
||||
$copilotSessionModel = { ...m, ...(keep ? { reasoning: carried } : {}) }
|
||||
const keep = carriedReasoning(
|
||||
providerModel.reasoning,
|
||||
REASONING_OFF,
|
||||
getReasoningCapability(m.provider, m.model)
|
||||
)
|
||||
$copilotSessionModel = { ...m, ...(keep !== undefined ? { reasoning: keep } : {}) }
|
||||
storeLocalSetting(COPILOT_SESSION_MODEL_SETTING_NAME, m.model)
|
||||
storeLocalSetting(COPILOT_SESSION_PROVIDER_SETTING_NAME, m.provider)
|
||||
storeLocalSetting(COPILOT_SESSION_REASONING_SETTING_NAME, keep ? carried : undefined)
|
||||
storeLocalSetting(COPILOT_SESSION_REASONING_SETTING_NAME, keep)
|
||||
}
|
||||
|
||||
function selectReasoning(value: string) {
|
||||
@@ -233,9 +193,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Prompt parameters, surfaced as a melt submenu (hover-opens and is floating-positioned,
|
||||
// so it flips on screen edges instead of overflowing). The menu keeps itself open on
|
||||
// item click (closeOnItemClick=false), so these actions close it explicitly via `close`.
|
||||
// Prompt parameters, surfaced as a melt submenu. The menu keeps itself open on item
|
||||
// click, so these actions close it explicitly before opening a modal.
|
||||
function paramItems(close: () => void): Item {
|
||||
return {
|
||||
displayName: 'Parameters',
|
||||
@@ -270,155 +229,55 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the slider's pointer events from bubbling to the enclosing melt item: melt's
|
||||
// roving focus blurs the focused element on pointermove, which would abort the native
|
||||
// thumb drag. Direct (non-delegated) listeners so they run before melt's item listener.
|
||||
function isolatePointer(node: HTMLElement) {
|
||||
const stop = (e: Event) => e.stopPropagation()
|
||||
node.addEventListener('pointerdown', stop)
|
||||
node.addEventListener('pointermove', stop)
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('pointerdown', stop)
|
||||
node.removeEventListener('pointermove', stop)
|
||||
const config = $derived<ChatModelSettingsConfig>({
|
||||
label: providerModel.model,
|
||||
title: 'Model & reasoning settings',
|
||||
badge: freeTier && !freeTier.exhausted ? { text: 'Free', warn: freeRunningLow } : undefined,
|
||||
// Off in a session: the assistant settings modal's Instructions section owns the
|
||||
// prompt entries there, so the menu would offer the same thing twice.
|
||||
topItems: promptSettings ? (close) => [paramItems(close)] : undefined,
|
||||
sections: [
|
||||
{
|
||||
label: 'Model',
|
||||
options: models.map((m) => ({
|
||||
key: `${m.provider}/${m.model}`,
|
||||
label: m.model,
|
||||
selected: m.model === providerModel.model && m.provider === providerModel.provider,
|
||||
onSelect: () => selectModel(m)
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust the reasoning effort with the arrow keys while the Thinking item is focused.
|
||||
function adjustEffort(e: KeyboardEvent) {
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
||||
e.preventDefault()
|
||||
const next = Math.min(
|
||||
stops.length - 1,
|
||||
Math.max(0, stopIndex + (e.key === 'ArrowRight' ? 1 : -1))
|
||||
)
|
||||
selectReasoning(stops[next])
|
||||
}
|
||||
],
|
||||
reasoning: {
|
||||
provider: providerModel.provider as AIProvider,
|
||||
model: providerModel.model,
|
||||
value: providerModel.reasoning,
|
||||
offToken: REASONING_OFF,
|
||||
// The copilot fills an unset effort in before it calls the provider, so unset
|
||||
// really does run at the default level and the button may name it.
|
||||
sendsDefaultWhenUnset: true,
|
||||
// The session chat's model is always its own to change.
|
||||
writable: true,
|
||||
onSelect: selectReasoning
|
||||
},
|
||||
// A reading preference rather than a model parameter: it applies to every chat in
|
||||
// this browser, including thinking already in the transcript. No close(): flipping
|
||||
// it should not dismiss the menu.
|
||||
bottomItems: () => [
|
||||
{
|
||||
displayName: 'Always expand thinking',
|
||||
selected: thinkingPreferences.expandByDefault,
|
||||
action: () => (thinkingPreferences.expandByDefault = !thinkingPreferences.expandByDefault)
|
||||
}
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
{#snippet externalLinkIcon()}
|
||||
<ExternalLink size={14} class="shrink-0 text-secondary" />
|
||||
{/snippet}
|
||||
|
||||
<DropdownV2
|
||||
customMenu
|
||||
placement="bottom-end"
|
||||
fixedHeight={false}
|
||||
closeOnItemClick={false}
|
||||
bind:open={menuOpen}
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<div
|
||||
bind:this={triggerEl}
|
||||
style={lockedWidth !== undefined ? `width: ${lockedWidth}px` : undefined}
|
||||
>
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
unifiedSize="2xs"
|
||||
variant="subtle"
|
||||
endIcon={{ icon: ChevronDown }}
|
||||
btnClasses="w-full max-w-[200px] text-secondary font-normal"
|
||||
title="Model & reasoning settings"
|
||||
>
|
||||
<span class="flex items-center gap-1 min-w-0">
|
||||
<span class="truncate">{providerModel.model}</span>
|
||||
{#if effortLabel}
|
||||
<span class="shrink-0 text-tertiary">· {effortLabel}</span>
|
||||
{/if}
|
||||
{#if freeTier && !freeTier.exhausted}
|
||||
<span
|
||||
class="shrink-0 rounded-full px-1.5 text-2xs {freeRunningLow
|
||||
? 'bg-yellow-100 text-yellow-600 dark:bg-yellow-900/40'
|
||||
: 'bg-surface-secondary text-tertiary'}">Free</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet menu({ item, builders, close })}
|
||||
<div
|
||||
class="bg-surface-tertiary dark:border w-64 origin-top-right rounded-lg shadow-lg focus:outline-none py-1 text-xs"
|
||||
>
|
||||
<!-- Melt submenu: hover-opens and is floating-positioned (flips on screen edges). -->
|
||||
{#if promptSettings}
|
||||
<DropdownSubmenuItem item={paramItems(close)} {builders} meltItem={item} />
|
||||
{/if}
|
||||
<ChatModelSettings {config} />
|
||||
|
||||
<div class="my-1 border-t border-border-light"></div>
|
||||
<div class="px-3 pt-1.5 pb-1 text-2xs uppercase tracking-wide text-secondary">Model</div>
|
||||
<div class="max-h-48 overflow-y-auto">
|
||||
{#each models as m (m.provider + m.model)}
|
||||
<MenuItem
|
||||
{item}
|
||||
class="w-full flex items-center gap-2 px-3 py-1.5 text-left font-normal hover:bg-surface-hover data-[highlighted]:bg-surface-hover rounded-sm transition-colors cursor-pointer"
|
||||
onClick={() => selectModel(m)}
|
||||
>
|
||||
<span class="truncate grow min-w-0">{m.model}</span>
|
||||
{#if m.model === providerModel.model && m.provider === providerModel.provider}
|
||||
<Check size={14} class="shrink-0 text-primary" />
|
||||
{/if}
|
||||
</MenuItem>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="my-1 border-t border-border-light"></div>
|
||||
{#if capability.supported}
|
||||
<!-- Registered as a melt item so it joins the roving focus/highlight (and arrow
|
||||
up/down navigation), and so hovering it takes the highlight off the Parameters
|
||||
trigger. Left/right adjust the effort; the slider's input handler also drives it. -->
|
||||
<MenuItemWrapper {item} onKeydown={adjustEffort} class="block group">
|
||||
<div class="px-3 pt-1 pb-0.5 flex items-center justify-between">
|
||||
<span class="text-2xs uppercase tracking-wide text-secondary">Thinking</span>
|
||||
<span class="text-2xs text-secondary tabular-nums">{currentStop}</span>
|
||||
</div>
|
||||
{#if stops.length > 1}
|
||||
<!-- Only the slider area reflects the item's highlight, not the header. -->
|
||||
<div
|
||||
class="px-3 py-1.5 rounded-sm transition-colors group-data-[highlighted]:bg-surface-hover"
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={stops.length - 1}
|
||||
step="1"
|
||||
value={stopIndex}
|
||||
style="--fill: {fillPct}%"
|
||||
oninput={(e) => selectReasoning(stops[+e.currentTarget.value])}
|
||||
use:isolatePointer
|
||||
class="lean-range no-default-style w-full"
|
||||
aria-label="Reasoning effort"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</MenuItemWrapper>
|
||||
{:else}
|
||||
<!-- Reasoning unsupported: keep the section but show it disabled with a reason,
|
||||
rather than hiding it. Not a melt item, so it's skipped by keyboard navigation. -->
|
||||
<div class="px-3 pt-1 pb-1.5 opacity-60 cursor-default" aria-disabled="true">
|
||||
<div class="text-2xs uppercase tracking-wide text-secondary">Thinking</div>
|
||||
<div class="text-2xs text-tertiary mt-0.5">Not supported by this model</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- A reading preference rather than a model parameter: it applies to every
|
||||
chat in this browser, including thinking already in the transcript. -->
|
||||
<MenuItem
|
||||
{item}
|
||||
class="w-full flex items-center gap-2 px-3 py-1.5 text-left font-normal hover:bg-surface-hover data-[highlighted]:bg-surface-hover rounded-sm transition-colors cursor-pointer"
|
||||
onClick={() => (thinkingPreferences.expandByDefault = !thinkingPreferences.expandByDefault)}
|
||||
>
|
||||
<span class="truncate grow min-w-0 text-2xs text-secondary">Always expand thinking</span>
|
||||
{#if thinkingPreferences.expandByDefault}
|
||||
<Check size={14} class="shrink-0 text-primary" />
|
||||
{/if}
|
||||
</MenuItem>
|
||||
</div>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
|
||||
<!-- Only where the entries that open it are rendered. -->
|
||||
{#if promptSettings}
|
||||
<AIPromptsModal
|
||||
bind:open={modalOpen}
|
||||
@@ -436,61 +295,3 @@
|
||||
settingsHref={isAdmin ? AI_SETTINGS_HREF : undefined}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Lean reasoning slider: a thin track and a small, borderless accent thumb. Native range
|
||||
thumbs can't be styled with Tailwind, and Svelte prunes scoped vendor pseudo-element
|
||||
rules — so they are wrapped in :global (the class is unique to this component). */
|
||||
.lean-range {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
/* override the global `input { background-color: ... !important }` so only the
|
||||
thin track shows, not a full-height band behind it */
|
||||
background-color: transparent !important;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
.lean-range:focus,
|
||||
.lean-range:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
:global(.lean-range::-webkit-slider-runnable-track) {
|
||||
height: 3px;
|
||||
border-radius: 9999px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
rgb(var(--color-surface-accent-primary)) var(--fill, 0%),
|
||||
rgb(var(--color-surface-secondary)) var(--fill, 0%)
|
||||
);
|
||||
}
|
||||
:global(.lean-range::-webkit-slider-thumb) {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
margin-top: -3.5px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--color-surface-accent-primary));
|
||||
}
|
||||
:global(.lean-range::-moz-range-track) {
|
||||
height: 3px;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--color-surface-secondary));
|
||||
}
|
||||
:global(.lean-range::-moz-range-progress) {
|
||||
height: 3px;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--color-surface-accent-primary));
|
||||
}
|
||||
:global(.lean-range::-moz-range-thumb) {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--color-surface-accent-primary));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { CircleHelp, ArrowUp, Plus, Square, SquareCheck } from 'lucide-svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import { getChatViewHost } from './chatViewHost'
|
||||
import type { UserQuestionDisplay } from './shared'
|
||||
|
||||
// Sessions inject a per-pane `AIChatManager` via context; outside of
|
||||
@@ -12,7 +12,7 @@
|
||||
// this, answers clicked inside a session would dispatch to the singleton's
|
||||
// pending callbacks map (which doesn't have the session manager's question
|
||||
// callback), and the AI loop would stall.
|
||||
const aiChatManager = getAiChatManager()
|
||||
const chatHost = getChatViewHost()
|
||||
|
||||
interface Props {
|
||||
toolCallId: string
|
||||
@@ -93,14 +93,14 @@
|
||||
}
|
||||
return
|
||||
}
|
||||
aiChatManager.handleUserQuestionAnswer(toolCallId, [choice])
|
||||
chatHost.handleUserQuestionAnswer(toolCallId, [choice])
|
||||
}
|
||||
|
||||
function submitPicked() {
|
||||
if (!multiSelect || picked.size === 0) {
|
||||
return
|
||||
}
|
||||
aiChatManager.handleUserQuestionAnswer(toolCallId, [...picked])
|
||||
chatHost.handleUserQuestionAnswer(toolCallId, [...picked])
|
||||
}
|
||||
|
||||
function submitCustomAnswer() {
|
||||
@@ -119,7 +119,7 @@
|
||||
return
|
||||
}
|
||||
|
||||
aiChatManager.handleUserQuestionAnswer(toolCallId, [answer])
|
||||
chatHost.handleUserQuestionAnswer(toolCallId, [answer])
|
||||
}
|
||||
|
||||
function handleChoiceKeydown(event: KeyboardEvent, choice: string, index: number) {
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
workspaceItemRegistry
|
||||
} 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
|
||||
@@ -22,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 footer 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
|
||||
)
|
||||
@@ -60,6 +80,20 @@
|
||||
return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`
|
||||
}
|
||||
|
||||
const stepName = $derived(message.role === 'assistant' ? message.stepName : undefined)
|
||||
|
||||
// A flow step can return a file rather than text; the raw JSON would be
|
||||
// unreadable, so hand it to the result viewer instead of the markdown renderer.
|
||||
const s3Object = $derived.by(() => {
|
||||
if (!message.content.startsWith('{')) return undefined
|
||||
try {
|
||||
const parsed = JSON.parse(message.content)
|
||||
return parsed?.type === 'windmill_s3_object' && parsed?.s3 ? parsed : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
|
||||
const candidatePaths = $derived(extractCandidatePaths(message.content))
|
||||
const rendererPlugin = {
|
||||
renderer: {
|
||||
@@ -98,6 +132,19 @@
|
||||
})
|
||||
</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="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}
|
||||
|
||||
{#if reasoning}
|
||||
<ChatCollapsibleCard
|
||||
label={reasoningLabel}
|
||||
@@ -112,8 +159,38 @@
|
||||
</ChatCollapsibleCard>
|
||||
{/if}
|
||||
|
||||
{#if message.content}
|
||||
{#if s3Object}
|
||||
<DisplayResult result={s3Object} workspaceId={workspace} noControls={true} />
|
||||
{:else if message.content}
|
||||
<div class="w-full space-y-2 {markdownProse.sm}">
|
||||
<Markdown md={message.content} {plugins} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if message.content}
|
||||
<!-- Present but invisible until the answer is hovered: kept in flow so revealing it
|
||||
does not nudge the message below, and with no margin of its own so it sits in the
|
||||
gap the transcript already leaves between messages. A row carrying only thinking
|
||||
has no answer to copy or date, and the run behind it is the one the next row
|
||||
already links. -->
|
||||
<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}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { composerBoxClass, COMPOSER_FIELD_RESET } from './composerBox'
|
||||
import autosize from '$lib/autosize'
|
||||
import { tick, type Snippet } from 'svelte'
|
||||
import type { ContextElement } from './context'
|
||||
@@ -767,21 +768,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- The composer box: border + rounded live HERE (on the wrapper), not on the
|
||||
textarea, so context chips can sit INSIDE the box, above the text. The
|
||||
textarea's own @tailwindcss/forms border/ring is neutralized below. -->
|
||||
<!-- The disabled treatment lives on the wrapper for the same reason the box
|
||||
does: `disabled` on the textarea alone leaves the field looking exactly
|
||||
like a usable one, so the only cue that typing is refused is placeholder
|
||||
text the eye reads as an invitation. -->
|
||||
<div
|
||||
class={twMerge(
|
||||
'w-full scroll-pb-2 rounded-md border border-border-light transition-colors',
|
||||
disabled
|
||||
? 'bg-surface-disabled cursor-not-allowed'
|
||||
: 'bg-surface-input focus-within:border-border-selected'
|
||||
)}
|
||||
>
|
||||
<div class={composerBoxClass(disabled)}>
|
||||
<!-- Context chips live inside the input box, above the textarea. The snippet
|
||||
self-guards (renders nothing when empty) so no blank row appears. -->
|
||||
{@render leading?.()}
|
||||
@@ -830,11 +817,7 @@
|
||||
{placeholder}
|
||||
class={twMerge(
|
||||
'textarea-input resize-none caret-black dark:caret-white overflow-clip',
|
||||
// The box (border/ring) lives on the wrapper; kill the textarea's own
|
||||
// @tailwindcss/forms border, focus ring, and background so only the
|
||||
// wrapper reads as the field.
|
||||
'!border-transparent !bg-transparent !shadow-none focus:!border-transparent focus:!ring-0',
|
||||
'disabled:cursor-not-allowed disabled:placeholder:text-disabled',
|
||||
COMPOSER_FIELD_RESET,
|
||||
CHAT_INPUT_PADDING,
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { copilotInfo, copilotSessionModel } from '$lib/aiStore'
|
||||
import { getKnownModelContextWindow, getModelContextWindow } from '../modelConfig'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import { getChatViewHost } from './chatViewHost'
|
||||
import { AIMode } from './AIChatManager.svelte'
|
||||
import UsageMeter from './UsageMeter.svelte'
|
||||
import { formatTokenCount } from './tokenUsage'
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
const chatHost = getChatViewHost()
|
||||
|
||||
// The `/compact` slash command is only wired up in session-chat GLOBAL mode,
|
||||
// so only advertise it where it actually works.
|
||||
let canCompact = $derived(aiChatManager.isSessionChat && aiChatManager.mode === AIMode.GLOBAL)
|
||||
let canCompact = $derived(chatHost.isSessionChat && chatHost.mode === AIMode.GLOBAL)
|
||||
|
||||
let providerModel = $derived(
|
||||
$copilotSessionModel ?? $copilotInfo.defaultModel ?? $copilotInfo.aiModels[0]
|
||||
@@ -27,10 +27,10 @@
|
||||
// The same number the compaction trigger uses: the provider's report when
|
||||
// one describes the current history (one turn stale by nature), otherwise
|
||||
// a live chars/4 estimate of the stored context.
|
||||
let usedTokens = $derived(Math.round(aiChatManager.contextTokens))
|
||||
let usedTokens = $derived(Math.round(chatHost.contextTokens))
|
||||
// Always surface usage once a conversation has started, at any fill level, so
|
||||
// the user can watch context grow toward the compaction threshold.
|
||||
let visible = $derived(usedTokens > 0 && aiChatManager.messages.length > 0)
|
||||
let visible = $derived(usedTokens > 0 && chatHost.messages.length > 0)
|
||||
|
||||
// Compaction triggers at 80% of the window (COMPACTION_TRIGGER_RATIO); the
|
||||
// gauge fills toward that point and turns red once it is reached.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { FileText, X } from 'lucide-svelte'
|
||||
import ContextElementBadge from './ContextElementBadge.svelte'
|
||||
import { contextElementKey } from './context'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import { getChatViewHost } from './chatViewHost'
|
||||
|
||||
// The single message typed while a turn was streaming, waiting to be
|
||||
// auto-sent when the turn finishes. Rendered above the whole input stack
|
||||
@@ -11,7 +11,7 @@
|
||||
// conversation". Pressing Enter again appends another line to it; clicking
|
||||
// the chip body (its X, or ArrowUp in the empty input) removes it and
|
||||
// restores its content into the input so nothing is lost.
|
||||
const aiChatManager = getAiChatManager()
|
||||
const chatHost = getChatViewHost()
|
||||
</script>
|
||||
|
||||
<!-- Attachment-only and context-only queues have empty text; without their
|
||||
@@ -20,23 +20,23 @@
|
||||
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 aiChatManager.queuedMessage || aiChatManager.queuedImages.length > 0 || aiChatManager.queuedFiles.length > 0 || (aiChatManager.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 aiChatManager.queuedMessage || aiChatManager.queuedImages.length > 0 || aiChatManager.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"
|
||||
title={aiChatManager.queuedMessage}
|
||||
title={chatHost.queuedMessage}
|
||||
aria-label="Remove queued message and put it back in the input"
|
||||
onclick={() => aiChatManager.dequeueMessage()}
|
||||
onclick={() => chatHost.dequeueMessage()}
|
||||
>
|
||||
{#if aiChatManager.queuedImages.length > 0}
|
||||
<div class="flex flex-row flex-wrap gap-1 {aiChatManager.queuedMessage ? 'mb-1' : ''}">
|
||||
{#each aiChatManager.queuedImages as image, i (i)}
|
||||
{#if chatHost.queuedImages.length > 0}
|
||||
<div class="flex flex-row flex-wrap gap-1 {chatHost.queuedMessage ? 'mb-1' : ''}">
|
||||
{#each chatHost.queuedImages as image, i (i)}
|
||||
<img
|
||||
src={image.dataUrl}
|
||||
alt={image.name ?? 'queued image'}
|
||||
@@ -45,9 +45,9 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if aiChatManager.queuedFiles.length > 0}
|
||||
<div class="flex flex-row flex-wrap gap-1 {aiChatManager.queuedMessage ? 'mb-1' : ''}">
|
||||
{#each aiChatManager.queuedFiles as file, i (i)}
|
||||
{#if chatHost.queuedFiles.length > 0}
|
||||
<div class="flex flex-row flex-wrap gap-1 {chatHost.queuedMessage ? 'mb-1' : ''}">
|
||||
{#each chatHost.queuedFiles as file, 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={file.name}
|
||||
@@ -58,20 +58,35 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if aiChatManager.queuedMessage}
|
||||
{#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">
|
||||
{aiChatManager.queuedMessage}
|
||||
{chatHost.queuedMessage}
|
||||
</p>
|
||||
{/if}
|
||||
</button>
|
||||
{:else if aiChatManager.queuedContext?.length}
|
||||
{:else if chatHost.queuedContext?.length}
|
||||
<!-- Context badges are interactive themselves (popover preview), so a
|
||||
context-only queue gets a plain row instead of the clickable body —
|
||||
nesting the badges in it would be invalid ARIA and a badge click
|
||||
would dequeue out from under the opening popover. The X (and
|
||||
ArrowUp in the empty input) still restores the queue. -->
|
||||
<div class="min-w-0 grow flex flex-row flex-wrap gap-1">
|
||||
{#each aiChatManager.queuedContext as element (contextElementKey(element))}
|
||||
{#each chatHost.queuedContext as element (contextElementKey(element))}
|
||||
<ContextElementBadge contextElement={element} compact />
|
||||
{/each}
|
||||
</div>
|
||||
@@ -82,7 +97,7 @@
|
||||
iconOnly
|
||||
title="Remove queued message and put it back in the input"
|
||||
startIcon={{ icon: X }}
|
||||
on:click={() => aiChatManager.dequeueMessage()}
|
||||
on:click={() => chatHost.dequeueMessage()}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import { getChatViewHost } from './chatViewHost'
|
||||
|
||||
interface Props {
|
||||
toolCallId: string | undefined
|
||||
@@ -25,11 +25,11 @@
|
||||
class: className
|
||||
}: Props = $props()
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
const chatHost = getChatViewHost()
|
||||
|
||||
function respond(confirmed: boolean) {
|
||||
if (toolCallId) {
|
||||
aiChatManager.handleToolConfirmation(toolCallId, confirmed)
|
||||
chatHost.handleToolConfirmation(toolCallId, confirmed)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
} from './planMode'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { markdownProse } from '$lib/components/markdownProse'
|
||||
import { getAiChatManager } from './aiChatManagerContext'
|
||||
import { getChatViewHost } from './chatViewHost'
|
||||
|
||||
const aiChatManager = getAiChatManager()
|
||||
const chatHost = getChatViewHost()
|
||||
import { isActiveUserQuestion, type ToolDisplayMessage } from './shared'
|
||||
import ChatCollapsibleCard from './ChatCollapsibleCard.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
@@ -69,7 +69,7 @@
|
||||
const planLabel = $derived((planState && planCopy?.[planState]) ?? '')
|
||||
const planDoc = $derived(
|
||||
message.planArtifactId
|
||||
? aiChatManager.artifacts.artifacts.find((a) => a.id === message.planArtifactId)
|
||||
? chatHost.artifacts.artifacts.find((a) => a.id === message.planArtifactId)
|
||||
: undefined
|
||||
)
|
||||
// The version this card wrote, not the document's current one, since later proposals move it on.
|
||||
@@ -201,7 +201,7 @@
|
||||
title="Open this plan in the side panel: {planDoc.name}"
|
||||
startIcon={{ icon: FileText, classes: PLAN_MODE_TEXT_COLOR }}
|
||||
endIcon={{ icon: PanelRight }}
|
||||
on:click={() => aiChatManager.openArtifact?.(planDoc.id, planDoc.name, planCardVersion)}
|
||||
on:click={() => chatHost.openArtifact?.(planDoc.id, planDoc.name, planCardVersion)}
|
||||
>
|
||||
<span class="font-main">Plan</span>
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { dataUrlToBlob, matchesAccept } from './blobUtils'
|
||||
|
||||
function file(name: string, type: string): File {
|
||||
return new File(['x'], name, { type })
|
||||
}
|
||||
|
||||
describe('dataUrlToBlob', () => {
|
||||
// The bytes are re-uploaded verbatim, so a decode that drops or shifts one is a
|
||||
// corrupted file the reader only discovers downstream.
|
||||
it('decodes base64 back to the exact bytes', async () => {
|
||||
const bytes = new Uint8Array([0x00, 0xff, 0x10, 0x89, 0x50])
|
||||
const b64 = btoa(String.fromCharCode(...bytes))
|
||||
const blob = dataUrlToBlob(`data:application/pdf;base64,${b64}`)
|
||||
expect(blob.type).toBe('application/pdf')
|
||||
expect(new Uint8Array(await blob.arrayBuffer())).toEqual(bytes)
|
||||
})
|
||||
|
||||
it('percent-decodes a url that is not base64', async () => {
|
||||
const blob = dataUrlToBlob('data:text/plain,hello%20world')
|
||||
expect(blob.type).toBe('text/plain')
|
||||
expect(await blob.text()).toBe('hello world')
|
||||
})
|
||||
|
||||
it('falls back to a media type when the url names none', async () => {
|
||||
expect(dataUrlToBlob('data:;base64,QQ==').type).toBe('application/octet-stream')
|
||||
expect(dataUrlToBlob('data:;base64,QQ==', 'image/png').type).toBe('image/png')
|
||||
})
|
||||
})
|
||||
|
||||
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,79 @@
|
||||
/**
|
||||
* Message-scoped attachments that are neither an image nor readable text — a PDF
|
||||
* being the case that matters. They ride the composer next to images and files,
|
||||
* as chips cleared on send, and reach the host through `ChatSendRequestOptions`.
|
||||
*
|
||||
* The bytes are kept verbatim, unlike an image (which normalises to a bounded
|
||||
* PNG/JPEG for the model) and unlike a text file (which is decoded to a string):
|
||||
* a host that forwards these to object storage has to upload what the user
|
||||
* picked, not a re-encoding of it.
|
||||
*/
|
||||
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
|
||||
/** The bytes behind a `data:` URL, for a host that has to re-upload them. */
|
||||
export function dataUrlToBlob(dataUrl: string, fallbackType = 'application/octet-stream'): Blob {
|
||||
const comma = dataUrl.indexOf(',')
|
||||
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 })
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { getContext, setContext } from 'svelte'
|
||||
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'
|
||||
import type { AttachedFilesStore } from './files/attachedFiles.svelte'
|
||||
import type { SessionArtifactsStore } from './artifacts/artifactsState.svelte'
|
||||
import type { ArtifactVersionTarget } from '$lib/components/sessions/previewRouter'
|
||||
import type { FlowAIChatHelpers } from './flow/core'
|
||||
import type { AppAIChatHelpers } from './app/core'
|
||||
import type AIChatInput from './AIChatInput.svelte'
|
||||
|
||||
export type ChatSendRequestOptions = {
|
||||
instructions?: string
|
||||
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. */
|
||||
contextOverride?: ContextElement[]
|
||||
/** Where `contextOverride` came from. 'pinned': chips picked for THIS message, so
|
||||
* they are consumed from the live selection on send. 'replay': an edit or retry
|
||||
* resending an older message's context, already consumed long ago. */
|
||||
contextOverrideOrigin?: 'pinned' | 'replay'
|
||||
/** The conversation this turn belongs to, where that is not the one on screen — a
|
||||
* queued message going out after its own chat's run finished. Hosts with a single
|
||||
* conversation ignore it. */
|
||||
conversationId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* What the chat view components (AIChatDisplay and everything it renders) need
|
||||
* from whatever is driving the conversation. AIChatManager implements it for the
|
||||
* copilot's own LLM loop; FlowChatViewHost implements it over a flow run's
|
||||
* conversation so both chats render through the same components.
|
||||
*
|
||||
* Each affordance is gated by the field that answers for it — attachments by
|
||||
* `supportsMessageAttachments`, the model button by `supportsModelSettings`, and so on —
|
||||
* so a host turns on exactly what it can serve, and a new one is a matter of answering
|
||||
* these fields rather than of being a copilot. `mode` is the exception, still read
|
||||
* directly for chrome that only the copilot has.
|
||||
*/
|
||||
export interface ChatViewHost {
|
||||
// Transcript
|
||||
displayMessages: DisplayMessage[]
|
||||
/** API-level messages. Only the count is read (context usage visibility). */
|
||||
messages: readonly unknown[]
|
||||
contextTokens: number
|
||||
/** The workspace a message's paths and jobs resolve against, which a fork session
|
||||
* pins away from the navigated one. */
|
||||
readonly operatingWorkspace: string | undefined
|
||||
loading: boolean
|
||||
/** A turn this tab can neither follow nor stop, held by another tab on the same chat. */
|
||||
readonly runHeldElsewhere: boolean
|
||||
loadingLabel: string | undefined
|
||||
compacting: boolean
|
||||
currentReply: string
|
||||
currentReasoning: string
|
||||
currentReasoningActive: boolean
|
||||
readonly reasoningHiddenIndicatorLabel: string | undefined
|
||||
readonly automaticScroll: boolean
|
||||
enableAutomaticScroll: () => void
|
||||
disableAutomaticScroll: () => void
|
||||
|
||||
// Composer
|
||||
instructions: string
|
||||
readonly sendInFlight: boolean
|
||||
/** Resolves to whether the draft was consumed as a turn. */
|
||||
sendRequest: (options?: ChatSendRequestOptions) => Promise<boolean | undefined>
|
||||
cancel: (reason?: string) => void
|
||||
setAiChatInput: (aiChatInput: AIChatInput | null) => void
|
||||
readonly queuedMessage: string
|
||||
queuedContext: ContextElement[] | undefined
|
||||
readonly queuedImages: AttachedImage[]
|
||||
readonly queuedFiles: AttachedTextFile[]
|
||||
readonly queuedBlobs: AttachedBlob[]
|
||||
queueMessage: (
|
||||
text: string,
|
||||
images?: AttachedImage[],
|
||||
context?: ContextElement[],
|
||||
files?: AttachedTextFile[],
|
||||
blobs?: AttachedBlob[]
|
||||
) => void
|
||||
dequeueMessage: () => void
|
||||
setComposerStaged: (key: string, editingIndex: number | null, bytes: number) => void
|
||||
clearComposerStaged: (key: string) => void
|
||||
attachmentBytesExcluding: (selfKey: string) => number
|
||||
|
||||
// Per-message actions
|
||||
storedImages: (displayMessageIndex: number) => AttachedImage[] | undefined
|
||||
retryRequest: (messageIndex: number) => void
|
||||
restartGeneration: (
|
||||
displayMessageIndex: number,
|
||||
newContent?: string,
|
||||
pastes?: PasteAttachment[],
|
||||
images?: AttachedImage[],
|
||||
editedContext?: ContextElement[],
|
||||
files?: AttachedTextFile[]
|
||||
) => void | Promise<void>
|
||||
handleUserQuestionAnswer: (toolId: string, choices: string[]) => boolean
|
||||
handleToolConfirmation: (toolId: string, confirmed: boolean) => void
|
||||
/** A tool is waiting on a run form the user is filling in. Escape belongs to that form
|
||||
* then, not to the turn — see AIChatDisplay's window handler. */
|
||||
readonly hasPendingRunForm: boolean
|
||||
isRunFormPending: (toolCallId: string) => boolean
|
||||
|
||||
// Copilot-only surfaces. Left undefined/false by hosts that have no LLM loop
|
||||
// of their own; the chrome they drive hides itself.
|
||||
mode?: AIMode
|
||||
isSessionChat: boolean
|
||||
/** Model + reasoning picker. Off where the model is configured elsewhere. */
|
||||
supportsModelSettings: boolean
|
||||
/** 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. 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. */
|
||||
requiresMessageText: boolean
|
||||
/** 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, 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
|
||||
readonly autoAcceptEditsActive: boolean
|
||||
readonly autoAcceptEditsAvailable: boolean
|
||||
readonly autoAcceptToolConfirmationsAvailable: boolean
|
||||
readonly planModeAvailable: boolean
|
||||
attachedFiles: AttachedFilesStore
|
||||
artifacts: SessionArtifactsStore
|
||||
openArtifact?: (artifactId: string, name: string, version?: ArtifactVersionTarget) => void
|
||||
flowAiChatHelpers?: FlowAIChatHelpers
|
||||
appAiChatHelpers?: AppAIChatHelpers
|
||||
}
|
||||
|
||||
const CHAT_VIEW_HOST_CONTEXT_KEY = 'chatViewHost'
|
||||
|
||||
export function setChatViewHost(host: ChatViewHost) {
|
||||
setContext(CHAT_VIEW_HOST_CONTEXT_KEY, host)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the host driving the chat in this subtree. Falls back to the
|
||||
* AIChatManager (scoped instance or app-wide singleton) so every existing
|
||||
* copilot chat keeps working without setting anything.
|
||||
*/
|
||||
export function getChatViewHost(): ChatViewHost {
|
||||
return getContext<ChatViewHost>(CHAT_VIEW_HOST_CONTEXT_KEY) ?? getAiChatManager()
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* The composer's box, shared by both of AIChatInput's branches — the rich
|
||||
* ContextTextarea and the plain textarea a host without @-context gets.
|
||||
*
|
||||
* Border and rounding live on the WRAPPER, never on the field, so the chip rows
|
||||
* (context, files, images) sit inside the box above the text. The field's own
|
||||
* @tailwindcss/forms border, ring and background are neutralised so only the
|
||||
* wrapper reads as the input.
|
||||
*
|
||||
* The disabled treatment is on the wrapper for the same reason: `disabled` on the
|
||||
* field alone leaves it looking exactly like a usable one, so the only cue that
|
||||
* typing is refused is placeholder text the eye reads as an invitation.
|
||||
*/
|
||||
|
||||
const BOX_BASE = 'w-full scroll-pb-2 rounded-md border border-border-light transition-colors'
|
||||
|
||||
export function composerBoxClass(disabled: boolean = false): string {
|
||||
return `${BOX_BASE} ${
|
||||
disabled
|
||||
? 'bg-surface-disabled cursor-not-allowed'
|
||||
: 'bg-surface-input focus-within:border-border-selected'
|
||||
}`
|
||||
}
|
||||
|
||||
/** Applied to the field inside the box; without it the field draws a second border. */
|
||||
export const COMPOSER_FIELD_RESET =
|
||||
'!border-transparent !bg-transparent !shadow-none focus:!border-transparent focus:!ring-0 disabled:cursor-not-allowed disabled:placeholder:text-disabled'
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { randomUUID } from '$lib/utils/uuid'
|
||||
import FlowModuleSchemaMap from '$lib/components/flows/map/FlowModuleSchemaMap.svelte'
|
||||
import { getContext, tick, untrack } from 'svelte'
|
||||
import type { ExtendedOpenFlow, FlowEditorContext } from '$lib/components/flows/types'
|
||||
@@ -172,8 +173,14 @@
|
||||
if (args) {
|
||||
previewArgs.val = args
|
||||
}
|
||||
// A chat-enabled flow is refused without a conversation to run the turn in, and
|
||||
// that id is a query parameter no caller can reach through `args`. A test run has
|
||||
// no conversation open, so it gets one of its own rather than appending a turn to
|
||||
// a chat someone is reading.
|
||||
const memoryId =
|
||||
conversationId ?? (flowStore.val.value.chat_input_enabled ? randomUUID() : undefined)
|
||||
// Call the UI test function which opens preview panel
|
||||
return await onTestFlow?.(conversationId)
|
||||
return await onTestFlow?.(memoryId)
|
||||
},
|
||||
|
||||
getLintErrors: async (moduleId: string): Promise<ScriptLintResult> => {
|
||||
|
||||
@@ -4925,13 +4925,64 @@ describe('global AI tools', () => {
|
||||
)
|
||||
|
||||
// What the form submitted, not what the model proposed: the editor runs the flow, but
|
||||
// the arguments are the user's.
|
||||
expect(testActiveFlow).toHaveBeenCalledWith('u/admin/live_flow_storage', { name: 'Grace' })
|
||||
// the arguments are the user's. The third argument is the chat-mode conversation id,
|
||||
// which only `test_run_flow`'s own `conversation_id` supplies.
|
||||
expect(testActiveFlow).toHaveBeenCalledWith(
|
||||
'u/admin/live_flow_storage',
|
||||
{ name: 'Grace' },
|
||||
undefined
|
||||
)
|
||||
expect(FlowService.getFlowByPath).not.toHaveBeenCalled()
|
||||
expect(JobService.runFlowPreview).not.toHaveBeenCalled()
|
||||
expect(result).toContain('Result (SUCCESS)')
|
||||
})
|
||||
|
||||
// A chat flow only shows its memory across turns, so the model has to be able to name
|
||||
// the conversation it is continuing rather than getting a fresh one every call.
|
||||
it('test_run_flow passes the conversation id it was given to the live editor hook', async () => {
|
||||
seedBackendDraft(
|
||||
'flow',
|
||||
'',
|
||||
{
|
||||
path: 'u/admin/live_chat_flow',
|
||||
summary: 'Live chat flow',
|
||||
value: { modules: [{ id: 'live_step', value: { type: 'identity' } }] },
|
||||
schema: { type: 'object', properties: { user_message: { type: 'string' } } },
|
||||
edited_by: '',
|
||||
edited_at: '',
|
||||
archived: false,
|
||||
extra_perms: {}
|
||||
},
|
||||
{ workspace: WORKSPACE }
|
||||
)
|
||||
UserDraft.setLiveEditorDraft({
|
||||
workspace: WORKSPACE,
|
||||
itemKind: 'flow',
|
||||
storagePath: '',
|
||||
effectivePath: 'u/admin/live_chat_flow'
|
||||
})
|
||||
const testActiveFlow = vi.fn(async () => 'job-live-chat')
|
||||
|
||||
await withCompletedTestJob(() =>
|
||||
callGlobalTool(
|
||||
'test_run_flow',
|
||||
{
|
||||
path: 'u/admin/live_chat_flow',
|
||||
args: { user_message: 'hi' },
|
||||
conversation_id: '550e8400-e29b-41d4-a716-446655440000'
|
||||
},
|
||||
toolCallbacks,
|
||||
{ testActiveFlow }
|
||||
)
|
||||
)
|
||||
|
||||
expect(testActiveFlow).toHaveBeenCalledWith(
|
||||
'',
|
||||
{ user_message: 'hi' },
|
||||
'550e8400-e29b-41d4-a716-446655440000'
|
||||
)
|
||||
})
|
||||
|
||||
it('test_run_flow falls back to preview when the live flow editor test hook returns undefined', async () => {
|
||||
seedBackendDraft(
|
||||
'flow',
|
||||
@@ -4968,7 +5019,11 @@ describe('global AI tools', () => {
|
||||
)
|
||||
)
|
||||
|
||||
expect(testActiveFlow).toHaveBeenCalledWith('u/admin/live_flow_fallback', { name: 'Ada' })
|
||||
expect(testActiveFlow).toHaveBeenCalledWith(
|
||||
'u/admin/live_flow_fallback',
|
||||
{ name: 'Ada' },
|
||||
undefined
|
||||
)
|
||||
expect(FlowService.getFlowByPath).not.toHaveBeenCalled()
|
||||
expect(JobService.runFlowPreview).toHaveBeenCalledWith({
|
||||
workspace: WORKSPACE,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from '$lib/utils/uuid'
|
||||
import {
|
||||
AppService,
|
||||
AzureTriggerService,
|
||||
@@ -926,6 +927,12 @@ const runScriptToolDef = createToolDef(
|
||||
const testRunFlowSchema = z.object({
|
||||
path: z.string().describe('Workspace path of the flow to test.'),
|
||||
args: testRunArgsSchema,
|
||||
conversation_id: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Chat-mode flows only. A UUID naming the conversation this turn belongs to: reuse the same one across calls to test memory and follow-ups, and omit it for a one-off turn in a conversation of its own. Generate the UUID yourself so you can pass it again.'
|
||||
),
|
||||
background: backgroundArgSchema,
|
||||
wait_seconds: waitSecondsArgSchema
|
||||
})
|
||||
@@ -4402,8 +4409,14 @@ type WriteDraftCtx = {
|
||||
export type SessionToolHelpers = { sessionId?: string }
|
||||
|
||||
export type GlobalToolHelpers = SessionToolHelpers & {
|
||||
/** Runs the flow editor mounted on `storagePath`, if one is. */
|
||||
testActiveFlow?: (storagePath: string, args?: Record<string, any>) => Promise<string | undefined>
|
||||
/** Runs the flow editor mounted on `storagePath`, if one is. `conversationId` names the
|
||||
* chat-mode conversation the turn belongs to; the editor mints one when it is omitted and
|
||||
* the flow is chat-enabled. */
|
||||
testActiveFlow?: (
|
||||
storagePath: string,
|
||||
args?: Record<string, any>,
|
||||
conversationId?: string
|
||||
) => Promise<string | undefined>
|
||||
attachedFiles?: AttachedFilesStore
|
||||
// Read/write the user-level Global instructions. `setUserInstructions` persists the
|
||||
// value and rebuilds the system message so the change applies on the next chat-loop
|
||||
@@ -4440,13 +4453,18 @@ function operatingWorkspaceFromHelpers(helpers: unknown): string | undefined {
|
||||
function liveFlowTestHookFromCtx(
|
||||
ctx: { workspace: string; helpers?: unknown },
|
||||
path: string
|
||||
): ((args?: Record<string, any>) => Promise<string | undefined>) | undefined {
|
||||
):
|
||||
| ((args?: Record<string, any>, conversationId?: string) => Promise<string | undefined>)
|
||||
| undefined {
|
||||
const activeEditor = getActiveGlobalEditorContext(ctx.workspace)
|
||||
if (activeEditor?.type !== 'flow' || activeEditor.path !== path) {
|
||||
return undefined
|
||||
}
|
||||
const testActiveFlow = (ctx.helpers as GlobalToolHelpers | undefined)?.testActiveFlow
|
||||
return testActiveFlow && ((args) => testActiveFlow(activeEditor.storagePath, args))
|
||||
return (
|
||||
testActiveFlow &&
|
||||
((args, conversationId) => testActiveFlow(activeEditor.storagePath, args, conversationId))
|
||||
)
|
||||
}
|
||||
|
||||
export type OpenPreviewHandler = (req: {
|
||||
@@ -5461,6 +5479,16 @@ function flowDraftValueForPreview(flowDraft: FlowDraftValue): FlowValue {
|
||||
return flowDraftAsEditableInput(flowDraft).value
|
||||
}
|
||||
|
||||
/**
|
||||
* The conversation a test run of a chat-enabled flow belongs to. The server refuses such a
|
||||
* run without one, and it is a query parameter rather than a flow argument, so there is no
|
||||
* way for the caller to supply it through `args`. A fresh id each time is the right default:
|
||||
* a test run is its own conversation, not a turn appended to one someone is reading.
|
||||
*/
|
||||
function chatMemoryId(value: FlowValue): string | undefined {
|
||||
return value.chat_input_enabled ? randomUUID() : undefined
|
||||
}
|
||||
|
||||
async function loadScriptForFlowStep(
|
||||
moduleValue: { path: string; hash?: string },
|
||||
workspace: string
|
||||
@@ -5926,15 +5954,20 @@ async function testRunFlowByPath(
|
||||
// An open editor runs its own in-memory flow and paints the run in its graph.
|
||||
// Resolved here rather than before the form: the form waits as long as the user
|
||||
// does, and the editor on screen when they press Run is the one it belongs in.
|
||||
const jobId = await liveFlowTestHookFromCtx(ctx, args.path)?.(submitted)
|
||||
const jobId = await liveFlowTestHookFromCtx(ctx, args.path)?.(
|
||||
submitted,
|
||||
args.conversation_id
|
||||
)
|
||||
if (jobId) {
|
||||
return jobId
|
||||
}
|
||||
const value = flowDraftValueForPreview(flow.flow)
|
||||
return JobService.runFlowPreview({
|
||||
workspace,
|
||||
memoryId: args.conversation_id ?? chatMemoryId(value),
|
||||
requestBody: {
|
||||
path: args.path,
|
||||
value: flowDraftValueForPreview(flow.flow),
|
||||
value,
|
||||
args: submitted
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 = []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -671,6 +671,14 @@ export type AssistantDisplayMessage = BaseDisplayMessage & {
|
||||
* would look like it is still streaming forever.
|
||||
*/
|
||||
streaming?: boolean
|
||||
/** Flow step that produced this message, when the conversation is a flow run
|
||||
* rather than a copilot turn. Rendered as a label above the content. */
|
||||
stepName?: string
|
||||
/** The run behind this answer, linked under it. Flow chats only: a copilot turn
|
||||
* happens in the browser and has no job to open. */
|
||||
jobId?: string
|
||||
/** When the message was stored, shown beside the run link. */
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
carriedReasoning,
|
||||
fixedReasoningReason,
|
||||
reasoningControlState,
|
||||
reasoningDisplay,
|
||||
REASONING_PROVIDER_DEFAULT,
|
||||
type ChatModelSettingsReasoning
|
||||
} from './chatModelSettings'
|
||||
import {
|
||||
getReasoningCapability,
|
||||
REASONING_OFF,
|
||||
resolveEffectiveReasoning
|
||||
} from './reasoningRegistry'
|
||||
|
||||
/**
|
||||
* The trigger's suffix and the slider's stops are read side by side, so they have to agree
|
||||
* about one value — the provider-native off token must not read as `none` on one and `off`
|
||||
* on the other, and an effort the run does not send must not be named at all.
|
||||
*/
|
||||
function display(
|
||||
reasoning: Partial<ChatModelSettingsReasoning> & { provider: any; model: string }
|
||||
) {
|
||||
const full = {
|
||||
value: undefined,
|
||||
offToken: undefined,
|
||||
sendsDefaultWhenUnset: false,
|
||||
onSelect: () => {},
|
||||
...reasoning
|
||||
} as ChatModelSettingsReasoning
|
||||
const capability = getReasoningCapability(full.provider, full.model)
|
||||
// Composed exactly as the component composes it, so the test exercises the real pair.
|
||||
const effective = resolveEffectiveReasoning({
|
||||
provider: full.provider,
|
||||
model: full.model,
|
||||
reasoning: full.value
|
||||
})
|
||||
return reasoningDisplay(full, capability, effective)
|
||||
}
|
||||
|
||||
describe('reasoningDisplay', () => {
|
||||
it('says nothing for a model that cannot reason', () => {
|
||||
const shown = display({ provider: 'openai', model: 'gpt-4o' })
|
||||
expect(shown.label).toBeUndefined()
|
||||
expect(shown.stops).toEqual([])
|
||||
})
|
||||
|
||||
// The session chat's own sentinel: what it stores is already the word the reader sees.
|
||||
it('reads the session chat off sentinel as off', () => {
|
||||
const shown = display({
|
||||
provider: 'openai',
|
||||
model: 'gpt-5.1',
|
||||
offToken: REASONING_OFF,
|
||||
value: REASONING_OFF,
|
||||
sendsDefaultWhenUnset: true
|
||||
})
|
||||
expect(shown.label).toBe(REASONING_OFF)
|
||||
expect(shown.currentStop).toBe(REASONING_OFF)
|
||||
})
|
||||
|
||||
// An agent writes the provider's own token, which can read as anything.
|
||||
it('reads a provider-native off token as off too', () => {
|
||||
const shown = display({
|
||||
provider: 'openai',
|
||||
model: 'gpt-5.1',
|
||||
offToken: 'none',
|
||||
value: 'none'
|
||||
})
|
||||
expect(shown.label).toBe(REASONING_OFF)
|
||||
expect(shown.currentStop).toBe('none')
|
||||
expect(shown.stops[0]).toBe('none')
|
||||
})
|
||||
|
||||
it('names the level a chat that fills one in will send', () => {
|
||||
const shown = display({
|
||||
provider: 'openai',
|
||||
model: 'gpt-5.1',
|
||||
offToken: REASONING_OFF,
|
||||
value: undefined,
|
||||
sendsDefaultWhenUnset: true
|
||||
})
|
||||
expect(shown.label).toBe('high')
|
||||
})
|
||||
|
||||
// An agent step omits the field, so naming a level would claim something untrue.
|
||||
it('names no level where an unset effort is simply not sent', () => {
|
||||
const shown = display({
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-5',
|
||||
offToken: 'none',
|
||||
value: undefined
|
||||
})
|
||||
expect(shown.label).toBe(REASONING_PROVIDER_DEFAULT)
|
||||
expect(shown.currentStop).toBe('')
|
||||
})
|
||||
|
||||
// Claude 4.x only thinks when asked, so an absent effort is already off — and the flow
|
||||
// chat must be able to get back to it after a level has been picked.
|
||||
it('offers omission as the off stop where that is how the model disables', () => {
|
||||
const unset = display({ provider: 'anthropic', model: 'claude-opus-4-6', offToken: '' })
|
||||
expect(unset.label).toBe(REASONING_OFF)
|
||||
expect(unset.stops[0]).toBe('')
|
||||
const picked = display({
|
||||
provider: 'anthropic',
|
||||
model: 'claude-opus-4-6',
|
||||
offToken: '',
|
||||
value: 'high'
|
||||
})
|
||||
expect(picked.currentStop).toBe('high')
|
||||
expect(picked.stops).toContain('')
|
||||
})
|
||||
|
||||
// gpt-5 reasons at medium with no effort sent, so an empty off token buys no off stop.
|
||||
it('offers no off where the model cannot stop thinking', () => {
|
||||
const shown = display({ provider: 'openai', model: 'gpt-5', offToken: '' })
|
||||
expect(shown.stops).not.toContain('')
|
||||
expect(shown.label).toBe(REASONING_PROVIDER_DEFAULT)
|
||||
})
|
||||
|
||||
// The run sends an explicitly set effort whatever the model, so a token typed against a
|
||||
// provider we have no rules for has to reach the trigger — silence would hide it.
|
||||
it('names a set effort even where it can offer no ladder', () => {
|
||||
const shown = display({ provider: 'customai', model: 'deepseek-r1', value: 'high' })
|
||||
expect(shown.label).toBe('high')
|
||||
expect(shown.stops).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('carriedReasoning', () => {
|
||||
const cap = (model: string) => getReasoningCapability('openai', model)
|
||||
|
||||
// The bug this exists for: picking a model that cannot think left the old level in the
|
||||
// flow input, and the run sent it anyway.
|
||||
it('drops a level the new model does not have', () => {
|
||||
expect(carriedReasoning('high', '', cap('gpt-4o'))).toBeUndefined()
|
||||
expect(carriedReasoning('xhigh', '', cap('gpt-5.1'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a level the new model does have', () => {
|
||||
expect(carriedReasoning('high', '', cap('gpt-5.1'))).toBe('high')
|
||||
})
|
||||
|
||||
it('carries off only onto a model that can truly stop thinking', () => {
|
||||
expect(carriedReasoning(REASONING_OFF, REASONING_OFF, cap('gpt-5.1'))).toBe(REASONING_OFF)
|
||||
expect(carriedReasoning(REASONING_OFF, REASONING_OFF, cap('gpt-5'))).toBeUndefined()
|
||||
})
|
||||
|
||||
// A provider the registry has no rules for draws no thinking control, so a carried level
|
||||
// would be invisible and unclearable — and still sent, since an explicitly set effort
|
||||
// goes out whatever the model.
|
||||
it('drops the effort where it has no rules for the provider', () => {
|
||||
expect(
|
||||
carriedReasoning('high', REASONING_OFF, getReasoningCapability('customai', 'deepseek-r1'))
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('has nothing to carry when no effort is set', () => {
|
||||
expect(carriedReasoning(undefined, '', cap('gpt-5.1'))).toBeUndefined()
|
||||
expect(carriedReasoning('', '', cap('gpt-5.1'))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
const asReasoning = (over: Partial<ChatModelSettingsReasoning>): ChatModelSettingsReasoning =>
|
||||
({
|
||||
provider: 'openai',
|
||||
model: 'gpt-5.1',
|
||||
value: undefined,
|
||||
offToken: REASONING_OFF,
|
||||
sendsDefaultWhenUnset: false,
|
||||
writable: true,
|
||||
onSelect: () => {},
|
||||
...over
|
||||
}) as ChatModelSettingsReasoning
|
||||
|
||||
/** The control is always drawn; this is the only thing that decides what it draws. */
|
||||
describe('reasoningControlState', () => {
|
||||
const cap = (model: string) => getReasoningCapability('openai', model)
|
||||
|
||||
it('shows the ladder for a model with levels', () => {
|
||||
expect(reasoningControlState(asReasoning({}), cap('gpt-5.1'))).toBe('ladder')
|
||||
})
|
||||
|
||||
it('says a model cannot think when the registry knows it cannot', () => {
|
||||
expect(reasoningControlState(asReasoning({ model: 'gpt-4o' }), cap('gpt-4o'))).toBe(
|
||||
'unsupported'
|
||||
)
|
||||
})
|
||||
|
||||
// Not the same as "cannot think": we have no rules for the provider, so the flow's own
|
||||
// token is typed rather than picked.
|
||||
it('asks for a typed token where it has no rules for the provider', () => {
|
||||
expect(
|
||||
reasoningControlState(
|
||||
asReasoning({ provider: 'customai', model: 'deepseek-r1' }),
|
||||
getReasoningCapability('customai', 'deepseek-r1')
|
||||
)
|
||||
).toBe('unknown')
|
||||
})
|
||||
|
||||
// A provider with a full ladder must not be described as unreadable just because no
|
||||
// model has been picked yet — which is the state right after choosing a resource.
|
||||
it('waits for a model rather than blaming the provider', () => {
|
||||
expect(
|
||||
reasoningControlState(asReasoning({ model: undefined }), { supported: false, known: false })
|
||||
).toBe('awaiting-model')
|
||||
})
|
||||
|
||||
it('shows what the flow fixed when this chat cannot write it', () => {
|
||||
expect(reasoningControlState(asReasoning({ writable: false }), cap('gpt-5.1'))).toBe('fixed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fixedReasoningReason', () => {
|
||||
it('names the level the run will use', () => {
|
||||
expect(
|
||||
fixedReasoningReason(asReasoning({ value: 'high' }), { supported: true, known: true })
|
||||
).toBe('high · set in the flow')
|
||||
})
|
||||
|
||||
// The step naming no effort at all is the common shape; saying it was "set in the flow"
|
||||
// would describe a line the flow does not contain.
|
||||
it('does not claim a level the step never set', () => {
|
||||
expect(
|
||||
fixedReasoningReason(asReasoning({ value: undefined }), { supported: true, known: true })
|
||||
).toBe('Not set in the flow, so the provider decides')
|
||||
})
|
||||
|
||||
it('surfaces a level fixed on a model that cannot use it', () => {
|
||||
expect(
|
||||
fixedReasoningReason(asReasoning({ value: 'high', model: 'gpt-4o' }), {
|
||||
supported: false,
|
||||
known: true
|
||||
})
|
||||
).toContain('cannot think')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { AIProvider } from '$lib/gen'
|
||||
import type { Item } from '$lib/utils'
|
||||
import { REASONING_OFF } from './reasoningRegistry'
|
||||
|
||||
/**
|
||||
* The contract between a chat and its model button.
|
||||
*
|
||||
* One component renders this menu for every chat — the copilot's own session chat and
|
||||
* the flow chat — so the component knows only about rows, choices and a reasoning
|
||||
* ladder. What a row means (a workspace AI resource, a prompt to edit, a reading
|
||||
* preference) is the caller's business, and each caller derives its own config: a fixed
|
||||
* one for the session chat, one derived from the flow's exposed inputs for flow chat.
|
||||
*/
|
||||
|
||||
export type ModelChoice = {
|
||||
/** Stable across rebuilds of the config; used as the `{#each}` key. */
|
||||
key: string
|
||||
label: string
|
||||
/** Muted trailing text, e.g. the provider a resource speaks. */
|
||||
hint?: string
|
||||
selected: boolean
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
export type ChoiceSection = {
|
||||
/** Section heading, e.g. 'Provider' or 'Model'. */
|
||||
label: string
|
||||
options: ModelChoice[]
|
||||
/** Fetched lists render one consistent loading line instead of the options. */
|
||||
loading?: boolean
|
||||
/** Shown when the list is empty and settled. */
|
||||
emptyMessage?: string
|
||||
maxHeight?: string
|
||||
}
|
||||
|
||||
export type ChatModelSettingsConfig = {
|
||||
/** The trigger's main text: the chosen model, or an invitation to choose one. */
|
||||
label: string
|
||||
title?: string
|
||||
/** Trailing pill on the trigger, e.g. the free-tier grant this chat is spending. */
|
||||
badge?: { text: string; warn?: boolean }
|
||||
/** Nothing here is editable — the trigger still names the model, but no menu opens. */
|
||||
readOnly?: boolean
|
||||
readOnlyReason?: string
|
||||
/**
|
||||
* Rows above and below the choice sections. Given the menu's own `close` because a
|
||||
* row that opens a modal must close the menu first, while a row that toggles a
|
||||
* preference must not.
|
||||
*/
|
||||
topItems?: (close: () => void) => Item[]
|
||||
sections?: ChoiceSection[]
|
||||
bottomItems?: (close: () => void) => Item[]
|
||||
/**
|
||||
* The thinking slider. The ladder is derived from the provider and model here rather
|
||||
* than by each caller, so a new provider's effort levels reach every chat at once.
|
||||
* `value` is the raw stored effort (undefined meaning the model's default), and
|
||||
* `offToken` the token this caller stores for "off" — the copilot keeps its own
|
||||
* sentinel and translates when it calls the provider, an agent writes the
|
||||
* provider-native token straight into its step.
|
||||
*/
|
||||
reasoning?: ChatModelSettingsReasoning
|
||||
}
|
||||
|
||||
export type ChatModelSettingsReasoning = {
|
||||
/** Absent until the chat knows what it will run; the ladder then has nothing to stand on. */
|
||||
provider: AIProvider | undefined
|
||||
model: string | undefined
|
||||
value: string | undefined
|
||||
offToken: string | undefined
|
||||
/**
|
||||
* What an unset value means on the wire. The copilot fills one in before calling the
|
||||
* provider, so unset really runs at the default effort and the button says so. An agent
|
||||
* step omits the field entirely, so unset means whatever the provider does by itself —
|
||||
* naming a level there would state something the run does not do.
|
||||
*/
|
||||
sendsDefaultWhenUnset: boolean
|
||||
/**
|
||||
* Whether this chat can write the effort back. False where the flow fixes it in the step:
|
||||
* the run still uses it, so the button shows it and refuses to pretend otherwise — only
|
||||
* the flow editor can change it.
|
||||
*/
|
||||
writable: boolean
|
||||
onSelect: (token: string) => void
|
||||
}
|
||||
|
||||
/** What an unset agent effort reads as: the provider decides, and we do not know what. */
|
||||
export const REASONING_PROVIDER_DEFAULT = 'default'
|
||||
|
||||
/**
|
||||
* The effort to keep when the model changes, or nothing where the new model has no such
|
||||
* level. Dropped rather than carried because a model that cannot think at that level either
|
||||
* rejects the request or quietly runs at another one, and the button would name a level the
|
||||
* run never used. Off survives only onto a model that can truly disable.
|
||||
*
|
||||
* A model the registry has no rules for drops it too, for the same reason: the button draws
|
||||
* no thinking control there, so a carried level would be invisible and unclearable while
|
||||
* still going out on the wire — `resolveEffectiveReasoning` sends an explicitly set effort
|
||||
* whatever the model, and a provider that rejects the field would then fail every turn with
|
||||
* nothing on screen to explain it.
|
||||
*/
|
||||
export function carriedReasoning(
|
||||
current: string | undefined,
|
||||
offToken: string | undefined,
|
||||
capability: { levels: string[]; canDisable: boolean }
|
||||
): string | undefined {
|
||||
if (current === undefined || current === '') return undefined
|
||||
if (offToken !== undefined && current === offToken) {
|
||||
return capability.canDisable ? current : undefined
|
||||
}
|
||||
return capability.levels.includes(current) ? current : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* What the menu shows for the reasoning ladder: the stops the slider offers, the one it
|
||||
* sits on, and the suffix on the trigger.
|
||||
*
|
||||
* Pure and here rather than in the component because these three have to agree — a stop
|
||||
* the slider renders as `off` must not read as the provider's own `none` on the button —
|
||||
* and because the rules are provider-shaped enough to be worth testing directly.
|
||||
*/
|
||||
export function reasoningDisplay(
|
||||
reasoning: ChatModelSettingsReasoning | undefined,
|
||||
capability: { supported: boolean; levels: string[]; canDisable: boolean },
|
||||
effective: string | undefined
|
||||
): {
|
||||
stops: string[]
|
||||
currentStop: string
|
||||
/** Trigger suffix, or undefined when there is nothing truthful to say. */
|
||||
label: string | undefined
|
||||
} {
|
||||
if (!reasoning) return { stops: [], currentStop: '', label: undefined }
|
||||
if (!capability.supported) {
|
||||
// No ladder to place it on, but an effort that is explicitly set still goes out —
|
||||
// `resolveEffectiveReasoning` sends one whatever the model — so the button names it.
|
||||
// Saying nothing would hide from the reader what the run is about to do.
|
||||
const set = reasoning.value ? reasoning.value : undefined
|
||||
return { stops: [], currentStop: '', label: set }
|
||||
}
|
||||
// An off position only where the model can truly disable, else the provider would
|
||||
// coerce it to the lowest level; then the provider-native levels.
|
||||
const offToken = capability.canDisable ? reasoning.offToken : undefined
|
||||
const stops = [...(offToken !== undefined ? [offToken] : []), ...capability.levels]
|
||||
// An agent whose model disables by omission stores the empty string, which the run
|
||||
// treats as no effort at all — so an unset value already sits on that stop.
|
||||
const isOff = offToken !== undefined && (reasoning.value ?? '') === offToken
|
||||
if (isOff) {
|
||||
// The off token is provider-native and can read as anything ('none', 'disabled');
|
||||
// on the button and on the slider it always reads as off.
|
||||
return { stops, currentStop: offToken as string, label: REASONING_OFF }
|
||||
}
|
||||
if (reasoning.value === undefined || reasoning.value === '') {
|
||||
// Where the provider takes an explicit disable, unset is a third state — the
|
||||
// provider's own level, above off — that the ladder has no position for. The
|
||||
// button still names it, and every stop the ladder does offer stays reachable.
|
||||
return reasoning.sendsDefaultWhenUnset
|
||||
? { stops, currentStop: effective ?? '', label: effective ?? REASONING_OFF }
|
||||
: { stops, currentStop: '', label: REASONING_PROVIDER_DEFAULT }
|
||||
}
|
||||
return { stops, currentStop: reasoning.value, label: reasoning.value }
|
||||
}
|
||||
|
||||
/** Which thinking control a chat should draw. */
|
||||
export type ReasoningControlState =
|
||||
/** The flow sets the effort itself; show what the run will use. */
|
||||
| 'fixed'
|
||||
/** No model chosen yet, so nothing can be said about its levels. */
|
||||
| 'awaiting-model'
|
||||
/** No rules for this provider: the flow takes a token, so let one be typed. */
|
||||
| 'unknown'
|
||||
/** Known levels — the ladder. */
|
||||
| 'ladder'
|
||||
/** Known to have none. */
|
||||
| 'unsupported'
|
||||
|
||||
/**
|
||||
* The control is always drawn; only its state varies. Deciding that here rather than in the
|
||||
* markup keeps the states in one readable place — and testable, which the two predicates
|
||||
* this replaced were not.
|
||||
*/
|
||||
export function reasoningControlState(
|
||||
reasoning: ChatModelSettingsReasoning | undefined,
|
||||
capability: { supported: boolean; known: boolean }
|
||||
): ReasoningControlState {
|
||||
if (!reasoning || !reasoning.writable) return 'fixed'
|
||||
if (!reasoning.model) return 'awaiting-model'
|
||||
if (!capability.known) return 'unknown'
|
||||
return capability.supported ? 'ladder' : 'unsupported'
|
||||
}
|
||||
|
||||
/**
|
||||
* What the row says when the chat cannot write the effort. Naming the level is the point: a
|
||||
* button that names the model a run will use should name its thinking too, and a level fixed
|
||||
* on a model that cannot use one is a broken flow worth seeing rather than a silent row.
|
||||
*/
|
||||
export function fixedReasoningReason(
|
||||
reasoning: ChatModelSettingsReasoning | undefined,
|
||||
capability: { supported: boolean; known: boolean }
|
||||
): string {
|
||||
const cannotThink = capability.known && !capability.supported
|
||||
const model = reasoning?.model ?? 'this model'
|
||||
if (!reasoning?.value) {
|
||||
// The step names no effort, so the provider decides — saying it was "set in the flow"
|
||||
// would describe a line the flow does not contain.
|
||||
return cannotThink ? `${model} cannot think` : 'Not set in the flow, so the provider decides'
|
||||
}
|
||||
return cannotThink
|
||||
? `${reasoning.value} · set in the flow, but ${model} cannot think`
|
||||
: `${reasoning.value} · set in the flow`
|
||||
}
|
||||
@@ -77,9 +77,9 @@ describe('supportsReasoning (static registry)', () => {
|
||||
}
|
||||
// Bedrock translates the same sentinel on its Converse path, but only for
|
||||
// Opus 5 — AWS documents Bedrock's Sonnet 5 as always thinking.
|
||||
expect(
|
||||
getReasoningCapability('aws_bedrock', 'global.anthropic.claude-opus-5').canDisable
|
||||
).toBe(true)
|
||||
expect(getReasoningCapability('aws_bedrock', 'global.anthropic.claude-opus-5').canDisable).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
resolveRequestReasoning({
|
||||
provider: 'aws_bedrock',
|
||||
@@ -189,13 +189,29 @@ describe('supportsReasoning (static registry)', () => {
|
||||
expect(supportsReasoning('mistral', 'mistral-medium-3.5')).toBe(true)
|
||||
expect(getReasoningCapability('mistral', 'mistral-medium-3-5').canDisable).toBe(true)
|
||||
})
|
||||
it('returns no levels for providers without a registry entry', () => {
|
||||
it('returns no levels for a model its provider family has no entry for', () => {
|
||||
// The family is known, so the `false` is an answer: codestral does not reason.
|
||||
expect(getReasoningCapability('mistral', 'codestral-latest')).toEqual({
|
||||
supported: false,
|
||||
levels: [],
|
||||
canDisable: false
|
||||
canDisable: false,
|
||||
known: true
|
||||
})
|
||||
})
|
||||
|
||||
// `customai` fronts any OpenAI-compatible endpoint, so `supported: false` there is an
|
||||
// absence of rules rather than a fact about the model. A caller that shows the reader
|
||||
// "this model cannot think" has to tell the two apart.
|
||||
it('admits when it has no rules for the provider at all', () => {
|
||||
expect(getReasoningCapability('customai', 'deepseek-r1')).toEqual({
|
||||
supported: false,
|
||||
levels: [],
|
||||
canDisable: false,
|
||||
known: false
|
||||
})
|
||||
expect(getReasoningCapability('openai', 'gpt-4o').known).toBe(true)
|
||||
expect(getReasoningCapability('anthropic', 'claude-sonnet-5').known).toBe(true)
|
||||
})
|
||||
it('only offers off where the model can truly disable thinking', () => {
|
||||
// Gemini Pro enforces a thinking floor — no off option.
|
||||
expect(getReasoningCapability('googleai', 'gemini-2.5-pro').canDisable).toBe(false)
|
||||
|
||||
@@ -231,14 +231,35 @@ export type ReasoningCapability = {
|
||||
* level, making the switch a lie.
|
||||
*/
|
||||
canDisable: boolean
|
||||
/**
|
||||
* Whether `supported` is an answer or an absence of one. The registry has rules per
|
||||
* provider family and falls through to `false` for the rest — `customai` above all,
|
||||
* which fronts any OpenAI-compatible endpoint and may well serve a thinking model. A
|
||||
* caller that presents `supported: false` as a fact must check this first, or it tells
|
||||
* the reader a model cannot think when all we know is that we have never heard of it.
|
||||
*/
|
||||
known: boolean
|
||||
}
|
||||
|
||||
/** Provider families the registry has real rules for; everything else is a shrug. */
|
||||
const KNOWN_REASONING_FAMILIES: ReadonlySet<string> = new Set([
|
||||
'anthropic',
|
||||
'aws_bedrock',
|
||||
'openai',
|
||||
'azure_openai',
|
||||
'openrouter',
|
||||
'googleai',
|
||||
'deepseek',
|
||||
'mistral'
|
||||
])
|
||||
|
||||
/** Resolve the reasoning capability of a model from the static registry. */
|
||||
export function getReasoningCapability(provider: AIProvider, model: string): ReasoningCapability {
|
||||
const bareModel = stripLegacyThinkingSuffix(model)
|
||||
const known = KNOWN_REASONING_FAMILIES.has(reasoningProviderFamily(provider, bareModel))
|
||||
const supported = supportsReasoningStatic(provider, bareModel)
|
||||
if (!supported) {
|
||||
return { supported: false, levels: [], canDisable: false }
|
||||
return { supported: false, levels: [], canDisable: false, known }
|
||||
}
|
||||
const family = reasoningProviderFamily(provider, bareModel)
|
||||
const levels =
|
||||
@@ -251,7 +272,7 @@ export function getReasoningCapability(provider: AIProvider, model: string): Rea
|
||||
: family === 'openrouter'
|
||||
? openrouterReasoningLevels(bareModel)
|
||||
: (PROVIDER_REASONING_LEVELS[family] ?? ['low', 'medium', 'high'])
|
||||
return { supported, levels, canDisable: canDisableReasoning(provider, bareModel) }
|
||||
return { supported, levels, canDisable: canDisableReasoning(provider, bareModel), known }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -362,15 +383,11 @@ export function explicitOffToken(provider: AIProvider, model: string): Reasoning
|
||||
// real off there and stays the wire form. Only the 5 family, which
|
||||
// thinks when the field is absent, needs the explicit disable —
|
||||
// Fable and Mythos reject it outright and get no off token at all.
|
||||
return /claude-(opus|sonnet)-5/.test(model.toLowerCase())
|
||||
? ANTHROPIC_OFF_SENTINEL
|
||||
: undefined
|
||||
return /claude-(opus|sonnet)-5/.test(model.toLowerCase()) ? ANTHROPIC_OFF_SENTINEL : undefined
|
||||
case 'aws_bedrock':
|
||||
// Bedrock's Sonnet 5 cannot be disabled at all, so only Opus 5 gets
|
||||
// the sentinel; the rest keep omission.
|
||||
return model.toLowerCase().includes('claude-opus-5')
|
||||
? ANTHROPIC_OFF_SENTINEL
|
||||
: undefined
|
||||
return model.toLowerCase().includes('claude-opus-5') ? ANTHROPIC_OFF_SENTINEL : undefined
|
||||
case 'googleai':
|
||||
// Gemini 2.5/3 think by default (dynamic budget / level). The backend
|
||||
// proxy maps 'none' to off on Flash, or the floor on Pro (only
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
AGENT_FIELD_BY_KEY,
|
||||
AGENT_FIELDS,
|
||||
agentFieldIsSet,
|
||||
agentStreamingEnabled,
|
||||
initialVisibleAgentFields
|
||||
} from './agentFormFields'
|
||||
|
||||
@@ -80,3 +81,46 @@ describe('initialVisibleAgentFields', () => {
|
||||
expect(Object.keys(schemaProperties).filter((k) => !registered.has(k))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// Three chat surfaces decide whether to consume a stream from this, and the worker decides whether
|
||||
// to send one from `streaming.unwrap_or(true)`. They agree only while absent means on here.
|
||||
describe('agentStreamingEnabled', () => {
|
||||
const step = (input_transforms: Record<string, any>, rest: Record<string, any> = {}) => ({
|
||||
type: 'aiagent',
|
||||
input_transforms,
|
||||
...rest
|
||||
})
|
||||
|
||||
it('reads an unwritten field as streaming', () => {
|
||||
expect(agentStreamingEnabled(step({}))).toBe(true)
|
||||
// What the API returns for the `{"type":"static"}` placeholder the schema backfill seeds.
|
||||
expect(agentStreamingEnabled(step({ streaming: { type: 'static', value: null } }))).toBe(true)
|
||||
expect(agentStreamingEnabled(step({ streaming: { type: 'static', value: true } }))).toBe(true)
|
||||
})
|
||||
|
||||
it('only an explicit false holds the answer back', () => {
|
||||
expect(agentStreamingEnabled(step({ streaming: { type: 'static', value: false } }))).toBe(false)
|
||||
})
|
||||
|
||||
it('reads off what the step cannot answer for', () => {
|
||||
// An image answer never streams, whatever `streaming` says.
|
||||
expect(
|
||||
agentStreamingEnabled(
|
||||
step({
|
||||
streaming: { type: 'static', value: true },
|
||||
output_type: { type: 'static', value: 'image' }
|
||||
})
|
||||
)
|
||||
).toBe(false)
|
||||
// A linked step carries no brain: the agent's own `streaming: false` is invisible here.
|
||||
expect(agentStreamingEnabled(step({}, { agent: 'u/admin/a' }))).toBe(false)
|
||||
// An expression has no value until the run it would decide is already under way, on either
|
||||
// of the two fields the answer depends on.
|
||||
expect(
|
||||
agentStreamingEnabled(step({ streaming: { type: 'javascript', expr: 'flow_input.s' } }))
|
||||
).toBe(false)
|
||||
expect(
|
||||
agentStreamingEnabled(step({ output_type: { type: 'javascript', expr: 'flow_input.o' } }))
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -177,6 +177,31 @@ export function agentFieldIsSet(
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a run of this step would stream its answer, mirroring the worker's
|
||||
* `has_stream = user_wants_streaming && is_text_output`. Absence means on
|
||||
* (`args.streaming.unwrap_or(true)`), so an unwritten field streams.
|
||||
*
|
||||
* A caller that reads this wrong does not merely mislabel the run: a chat surface that opens a
|
||||
* stream for an answer the worker sends in one piece re-runs the flow when its connection times
|
||||
* out. So the rule is that anything this cannot settle from the step alone reads as off, the cost
|
||||
* of being wrong that way being a live answer arriving at the end instead of as it is written.
|
||||
* Unsettled means either of the two fields holding an expression, whose value exists only once the
|
||||
* run it decides is already under way, or a linked agent, whose brain lives in the resource where
|
||||
* this has no sight of it at all.
|
||||
*/
|
||||
export function agentStreamingEnabled(value: Record<string, any> | undefined): boolean {
|
||||
if (value?.agent) return false
|
||||
const transforms = value?.input_transforms as Record<string, InputTransform | any> | undefined
|
||||
const settled = (t: InputTransform | any | undefined) => t == undefined || t.type === 'static'
|
||||
const outputType = transforms?.output_type
|
||||
const streaming = transforms?.streaming
|
||||
if (!settled(outputType) || !settled(streaming)) return false
|
||||
// An image answer never streams, whatever `streaming` says.
|
||||
if (outputType?.value === 'image') return false
|
||||
return streaming?.value !== false
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current schema carries this field at all. A linked step's schema is reduced to the
|
||||
* flow-local inputs, which is what collapses its form to the Messages group on its own.
|
||||
|
||||
@@ -28,7 +28,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'
|
||||
@@ -143,7 +143,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
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
Save,
|
||||
X,
|
||||
Check,
|
||||
Settings2
|
||||
Settings2,
|
||||
MessageSquare
|
||||
} from 'lucide-svelte'
|
||||
import CaptureIcon from '$lib/components/triggers/CaptureIcon.svelte'
|
||||
import FlowInputEditor from './FlowInputEditor.svelte'
|
||||
@@ -47,10 +48,13 @@
|
||||
import type { AiAgent, InputTransform, ScriptLang } from '$lib/gen'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { AI_AGENT_SCHEMA } from '../flowInfers'
|
||||
import { agentStreamingEnabled } from '../agentFormFields'
|
||||
import { nextId } from '../flowModuleNextId'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import FlowChat from '../conversations/FlowChat.svelte'
|
||||
import { isEmptyAgentChatInputValue } from '../conversations/agentChatInputs'
|
||||
import { SPECIAL_MODULE_IDS } from '$lib/components/copilot/chat/shared'
|
||||
|
||||
interface Props {
|
||||
@@ -96,9 +100,16 @@
|
||||
)
|
||||
|
||||
let chatInputEnabled = $state(Boolean(flowStore.val.value?.chat_input_enabled))
|
||||
let showChatModeWarning = $state(false)
|
||||
let showAdditionalInputs = $state(false)
|
||||
let shouldUseStreaming = $derived.by(() => {
|
||||
const modules = flowStore.val.value?.modules
|
||||
const lastModule = modules && modules.length > 0 ? modules[modules.length - 1] : undefined
|
||||
if (lastModule?.value?.type !== 'aiagent') return false
|
||||
return agentStreamingEnabled(lastModule.value)
|
||||
})
|
||||
// Chat mode shows one of the two at a time: the conversation, or the inputs it sends.
|
||||
let chatPanelTab = $state<'chat' | 'inputs'>('chat')
|
||||
let chatInputsEditTab = $state(false)
|
||||
let chatEditableSchemaForm: EditableSchemaForm | undefined = $state(undefined)
|
||||
let chatInputsAddPropertyV2: AddPropertyV2 | undefined = $state(undefined)
|
||||
|
||||
let addPropertyV2: AddPropertyV2 | undefined = $state(undefined)
|
||||
@@ -511,23 +522,9 @@
|
||||
return jobId
|
||||
}
|
||||
|
||||
function hasOtherInputs(): boolean {
|
||||
const properties = flowStore.val.schema?.properties
|
||||
return Boolean(
|
||||
properties &&
|
||||
Object.keys(properties).length > 0 &&
|
||||
!(Object.keys(properties).length === 1 && Object.keys(properties).includes('user_message'))
|
||||
)
|
||||
}
|
||||
|
||||
function handleToggleChatMode() {
|
||||
if (!flowStore.val.value?.chat_input_enabled) {
|
||||
// Check if there are existing inputs
|
||||
if (hasOtherInputs()) {
|
||||
showChatModeWarning = true
|
||||
} else {
|
||||
enableChatMode()
|
||||
}
|
||||
enableChatMode()
|
||||
} else {
|
||||
// Disable chat input - remove from flow.value
|
||||
if (flowStore.val.value) {
|
||||
@@ -536,21 +533,46 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the flow input the agent's `user_attachments` reads, and return its name. Chat
|
||||
* mode means files dropped in the composer, and that only works through a flow input —
|
||||
* so it is set up with the message and the memory rather than left to be discovered.
|
||||
*/
|
||||
function addAttachmentsInput(): string {
|
||||
const schema = (flowStore.val.schema ?? {}) as Record<string, any>
|
||||
const properties: Record<string, any> = (schema.properties ??= {})
|
||||
let name = 'files'
|
||||
for (let i = 2; name in properties; i++) name = `files_${i}`
|
||||
properties[name] = {
|
||||
type: 'array',
|
||||
items: { type: 'object', resourceType: 's3object' },
|
||||
description: 'Images or PDFs for the agent to read'
|
||||
}
|
||||
flowStore.val.schema = schema
|
||||
return name
|
||||
}
|
||||
|
||||
function enableChatMode() {
|
||||
// Enable chat input - set in flow.value
|
||||
flowStore.val.value.chat_input_enabled = true
|
||||
|
||||
// Set up the schema for chat input
|
||||
// The chat fills the flow's form rather than standing in for it: all it needs is a
|
||||
// `user_message` string, which the server requires under that exact argument name.
|
||||
// Every other input stays — the composer drives the ones an agent field reads, and
|
||||
// the rest are asked for in the Configure-inputs modal.
|
||||
const schema = flowStore.val.schema ?? {}
|
||||
const properties = { ...(schema.properties ?? {}) }
|
||||
// Only a string can carry the message; anything else here cannot be what chat sends.
|
||||
if (properties['user_message']?.type !== 'string') {
|
||||
properties['user_message'] = { type: 'string', description: 'Message from user' }
|
||||
}
|
||||
const required: string[] = Array.isArray(schema.required) ? schema.required : []
|
||||
flowStore.val.schema = {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
...schema,
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_message: {
|
||||
type: 'string',
|
||||
description: 'Message from user'
|
||||
}
|
||||
},
|
||||
required: ['user_message']
|
||||
properties,
|
||||
required: required.includes('user_message') ? required : [...required, 'user_message']
|
||||
}
|
||||
|
||||
// Find all AI agent modules
|
||||
@@ -570,6 +592,8 @@
|
||||
(accu, key) => {
|
||||
if (key === 'user_message') {
|
||||
accu[key] = { type: 'javascript', expr: 'flow_input.user_message' }
|
||||
} else if (key === 'user_attachments') {
|
||||
accu[key] = { type: 'javascript', expr: `flow_input.${addAttachmentsInput()}` }
|
||||
} else if (key === 'memory') {
|
||||
accu[key] = { type: 'static', value: { kind: 'auto', context_length: 10 } }
|
||||
} else {
|
||||
@@ -586,25 +610,24 @@
|
||||
}
|
||||
]
|
||||
sendUserToast(
|
||||
'Chat mode enabled. AI agent created with user message input and context memory set to 10.',
|
||||
'Chat mode enabled. AI agent created with user message and attachments inputs, and context memory set to 10.',
|
||||
false
|
||||
)
|
||||
} else if (aiAgentModules.length === 1) {
|
||||
// Exactly one AI agent exists: fill in defaults only for inputs the
|
||||
// user hasn't configured, so re-enabling chat mode on an already
|
||||
// configured agent doesn't clobber a custom user_message expression
|
||||
// or a deliberate memory choice (e.g. off).
|
||||
// configured agent doesn't clobber a custom user_message expression.
|
||||
const aiAgent = aiAgentModules[0]
|
||||
const value = aiAgent.value as AiAgent
|
||||
|
||||
// Degenerate shapes the input form can produce without deliberate
|
||||
// configuration count as unconfigured: empty static value (undefined
|
||||
// persists as null through JSON round-trips), blank JS expression
|
||||
// (the JS toggle seeds a bare backtick pair), or an AI transform
|
||||
// (meaningless for the chat input).
|
||||
// persists as null through JSON round-trips, and the step panel seeds an
|
||||
// array-typed field with []), blank JS expression (the JS toggle seeds a
|
||||
// bare backtick pair), or an AI transform (meaningless for the chat input).
|
||||
const isUnconfigured = (transform: InputTransform | undefined) =>
|
||||
transform === undefined ||
|
||||
(transform.type === 'static' && (transform.value == null || transform.value === '')) ||
|
||||
(transform.type === 'static' && isEmptyAgentChatInputValue(transform.value)) ||
|
||||
(transform.type === 'javascript' && transform.expr.replaceAll('`', '').trim() === '') ||
|
||||
transform.type === 'ai'
|
||||
|
||||
@@ -617,7 +640,25 @@
|
||||
applied.push('user message input')
|
||||
}
|
||||
|
||||
if (isUnconfigured(value.input_transforms['memory'])) {
|
||||
if (isUnconfigured(value.input_transforms['user_attachments'])) {
|
||||
value.input_transforms['user_attachments'] = {
|
||||
type: 'javascript',
|
||||
expr: `flow_input.${addAttachmentsInput()}`
|
||||
}
|
||||
applied.push('attachments input')
|
||||
}
|
||||
|
||||
// `off` is the first oneOf variant of the memory field, so a step added by hand
|
||||
// carries it without anyone choosing it — and an agent that forgets every turn
|
||||
// makes the chat a series of unrelated questions. Overwritten rather than left
|
||||
// alone; the toast below says it happened.
|
||||
const memoryIsOff = (transform: InputTransform | undefined) =>
|
||||
transform?.type === 'static' && (transform.value as any)?.kind === 'off'
|
||||
|
||||
if (
|
||||
isUnconfigured(value.input_transforms['memory']) ||
|
||||
memoryIsOff(value.input_transforms['memory'])
|
||||
) {
|
||||
value.input_transforms['memory'] = {
|
||||
type: 'static',
|
||||
value: { kind: 'auto', context_length: 10 }
|
||||
@@ -625,6 +666,17 @@
|
||||
applied.push('context memory set to 10')
|
||||
}
|
||||
|
||||
// Without streaming the chat has no SSE to read, so a turn shows nothing —
|
||||
// no thinking, no answer — until the run ends and its rows are written.
|
||||
if (
|
||||
isUnconfigured(value.input_transforms['streaming']) ||
|
||||
(value.input_transforms['streaming']?.type === 'static' &&
|
||||
value.input_transforms['streaming'].value === false)
|
||||
) {
|
||||
value.input_transforms['streaming'] = { type: 'static', value: true }
|
||||
applied.push('streaming turned on')
|
||||
}
|
||||
|
||||
sendUserToast(
|
||||
applied.length > 0
|
||||
? `Chat mode enabled. AI agent configured with ${applied.join(' and ')}.`
|
||||
@@ -633,40 +685,48 @@
|
||||
)
|
||||
}
|
||||
// If there are multiple AI agents, don't auto-configure (ambiguous which one to configure)
|
||||
|
||||
showChatModeWarning = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add svelte:window to listen for keyboard events -->
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<ConfirmationModal
|
||||
open={showChatModeWarning}
|
||||
title="Enable Chat Mode?"
|
||||
confirmationText="Continue"
|
||||
onConfirmed={enableChatMode}
|
||||
onCanceled={() => {
|
||||
showChatModeWarning = false
|
||||
chatInputEnabled = false
|
||||
}}
|
||||
>
|
||||
<p class="text-sm text-secondary">
|
||||
Enabling Chat Mode will replace all existing flow inputs with a single
|
||||
<span class="font-mono text-xs bg-surface-secondary px-1 rounded">user_message</span>
|
||||
parameter.
|
||||
</p>
|
||||
<p class="text-sm text-secondary mt-2">
|
||||
Your current input configuration will be lost. Are you sure you want to continue?
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
<!-- The edit toggle and the add-input target, shared by both panels below: chat mode
|
||||
carries a smaller set of side tabs, but the controls themselves must not differ. -->
|
||||
{#snippet inputsEditButton(open: boolean, toggle: () => void)}
|
||||
<Button
|
||||
onClick={toggle}
|
||||
{...open
|
||||
? {
|
||||
title: 'Close input editor',
|
||||
startIcon: { icon: ChevronRight },
|
||||
btnClasses: 'rounded-none rounded-tl-md'
|
||||
}
|
||||
: {
|
||||
title: 'Open input editor',
|
||||
startIcon: { icon: Pen }
|
||||
}}
|
||||
variant="accent"
|
||||
iconOnly
|
||||
wrapperClasses="h-full"
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet inputsAddTrigger()}
|
||||
<div
|
||||
class="w-full py-2 flex justify-center items-center border border-dashed rounded-md hover:bg-surface-hover"
|
||||
id="add-flow-input-btn"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<FlowCard {noEditor} title="Flow Input">
|
||||
{#snippet action()}
|
||||
{#if !disabled}
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle
|
||||
size="sm"
|
||||
size="xs"
|
||||
bind:checked={chatInputEnabled}
|
||||
on:change={() => {
|
||||
handleToggleChatMode()
|
||||
@@ -674,20 +734,25 @@
|
||||
options={{
|
||||
right: 'Chat Mode',
|
||||
rightTooltip:
|
||||
'When enabled, the flow execution page will show a chat interface where each message sent runs the flow with the message as "user_message" input parameter. The flow schema will be automatically set to accept only a user_message string input.'
|
||||
'Turns this flow\'s page into a chat. Each message runs the flow with the message as its "user_message" input, and is kept as a chat — one conversation per chat, each with its own AI agent memory. Chats started in the editor are marked as tests and stay out of the deployed flow\'s list.',
|
||||
rightDocumentationLink:
|
||||
'https://www.windmill.dev/docs/core_concepts/ai_agents#chat-mode'
|
||||
}}
|
||||
/>
|
||||
{#if flowStore.val.value?.chat_input_enabled}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
color={showAdditionalInputs ? 'blue' : 'light'}
|
||||
startIcon={{ icon: Settings2 }}
|
||||
title="Manage inputs"
|
||||
on:click={() => (showAdditionalInputs = !showAdditionalInputs)}
|
||||
>
|
||||
Manage inputs
|
||||
</Button>
|
||||
<ToggleButtonGroup bind:selected={chatPanelTab} noWFull>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton size="sm" value="chat" label="Chat" icon={MessageSquare} {item} />
|
||||
<ToggleButton
|
||||
size="sm"
|
||||
value="inputs"
|
||||
label="Inputs"
|
||||
icon={Settings2}
|
||||
tooltip="Edit the flow inputs the chat sends alongside each message"
|
||||
{item}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -696,11 +761,15 @@
|
||||
<div class="flex flex-col h-full">
|
||||
{#if flowStore.val.value?.chat_input_enabled}
|
||||
<div class="flex flex-col h-full">
|
||||
{#if showAdditionalInputs}
|
||||
<div class="border-b p-2">
|
||||
{#if chatPanelTab === 'inputs'}
|
||||
<!-- EditableSchemaForm scrolls internally against `h-full`, so the wrapper has
|
||||
to be bounded (flex-1 min-h-0) or the form grows to content height and
|
||||
spills out of the panel. -->
|
||||
<div class="py-2 px-4 flex-1 min-h-0">
|
||||
<EditableSchemaForm
|
||||
bind:this={chatEditableSchemaForm}
|
||||
bind:schema={flowStore.val.schema}
|
||||
hiddenArgs={['user_message']}
|
||||
lockedArgs={['user_message']}
|
||||
isFlowInput
|
||||
showSensitiveToggle
|
||||
workspace={opWs}
|
||||
@@ -713,44 +782,44 @@
|
||||
}}
|
||||
>
|
||||
{#snippet openEditTab()}
|
||||
<Button
|
||||
size="xs"
|
||||
variant={chatInputsEditTab ? 'contained' : 'border'}
|
||||
color={chatInputsEditTab ? 'blue' : 'light'}
|
||||
startIcon={{ icon: chatInputsEditTab ? ChevronRight : Pen }}
|
||||
title={chatInputsEditTab ? 'Close editor' : 'Edit inputs'}
|
||||
onClick={() => {
|
||||
chatInputsEditTab = !chatInputsEditTab
|
||||
}}
|
||||
/>
|
||||
{@render inputsEditButton(
|
||||
chatInputsEditTab,
|
||||
() => (chatInputsEditTab = !chatInputsEditTab)
|
||||
)}
|
||||
{/snippet}
|
||||
{#snippet addProperty()}
|
||||
<AddPropertyV2
|
||||
bind:this={chatInputsAddPropertyV2}
|
||||
bind:schema={flowStore.val.schema}
|
||||
onAddNew={() => {}}
|
||||
onAddNew={(argName) => {
|
||||
chatInputsEditTab = true
|
||||
chatEditableSchemaForm?.openField(argName)
|
||||
refreshStateStore(flowStore)
|
||||
}}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Button
|
||||
size="xs"
|
||||
color="light"
|
||||
startIcon={{ icon: Plus }}
|
||||
title="Add additional input"
|
||||
>
|
||||
Add input
|
||||
</Button>
|
||||
{@render inputsAddTrigger()}
|
||||
{/snippet}
|
||||
</AddPropertyV2>
|
||||
{/snippet}
|
||||
</EditableSchemaForm>
|
||||
</div>
|
||||
{/if}
|
||||
<FlowChat
|
||||
onRunFlow={runFlowWithMessage}
|
||||
path={$pathStore}
|
||||
hideSidebar={true}
|
||||
inputSchema={flowStore.val.schema}
|
||||
/>
|
||||
<!-- Hidden rather than unmounted: tearing the chat down ends every conversation's
|
||||
stream and poller, so a turn started here would finish server-side with
|
||||
nothing following it and the reader would come back to their own message and
|
||||
no answer. One display class at a time, so the two cannot race in the
|
||||
cascade. -->
|
||||
<div class={chatPanelTab === 'inputs' ? 'hidden' : 'flex flex-col flex-1 min-h-0'}>
|
||||
<FlowChat
|
||||
onRunFlow={runFlowWithMessage}
|
||||
conversationKind="test"
|
||||
path={$pathStore}
|
||||
useStreaming={shouldUseStreaming}
|
||||
inputSchema={flowStore.val.schema}
|
||||
flowModules={flowStore.val.value?.modules}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="py-2 px-4 flex-1 min-h-0">
|
||||
@@ -815,22 +884,9 @@
|
||||
<div class={twMerge('flex flex-row divide-x', ButtonType.ColorVariants.blue.divider)}>
|
||||
<SideBarTab {dropdownItems} fullMenu={!!$flowInputEditorState?.selectedTab}>
|
||||
{#snippet close_button()}
|
||||
<Button
|
||||
onClick={() => handleEditSchema()}
|
||||
{...!!$flowInputEditorState?.selectedTab
|
||||
? {
|
||||
title: 'Close input editor',
|
||||
startIcon: { icon: ChevronRight },
|
||||
btnClasses: 'rounded-none rounded-tl-md'
|
||||
}
|
||||
: {
|
||||
title: 'Open input editor',
|
||||
startIcon: { icon: Pen }
|
||||
}}
|
||||
variant="accent"
|
||||
iconOnly
|
||||
wrapperClasses="h-full"
|
||||
/>
|
||||
{@render inputsEditButton(!!$flowInputEditorState?.selectedTab, () =>
|
||||
handleEditSchema()
|
||||
)}
|
||||
{/snippet}
|
||||
</SideBarTab>
|
||||
</div>
|
||||
@@ -884,12 +940,7 @@
|
||||
}}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<div
|
||||
class="w-full py-2 flex justify-center items-center border border-dashed rounded-md hover:bg-surface-hover"
|
||||
id="add-flow-input-btn"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</div>
|
||||
{@render inputsAddTrigger()}
|
||||
{/snippet}
|
||||
</AddPropertyV2>
|
||||
{/if}
|
||||
|
||||
@@ -1,102 +1,133 @@
|
||||
<script lang="ts">
|
||||
import { enterpriseLicense, workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { createChat, type Chat, type ChatState } from 'windmill-chat'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { createFlowChatManager, type ConversationKind } from './FlowChatManager.svelte'
|
||||
import FlowConversationsSidebar from './FlowConversationsSidebar.svelte'
|
||||
import FlowChatInterface from './FlowChatInterface.svelte'
|
||||
import { getContext } from 'svelte'
|
||||
import { getContext, untrack } from 'svelte'
|
||||
import type { FlowEditorContext } from '../types'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
|
||||
type ChatFrame = 'boxed' | 'top' | 'none'
|
||||
|
||||
const FRAME_CLASS: Record<ChatFrame, string> = {
|
||||
boxed: 'border rounded-md',
|
||||
top: 'border-t',
|
||||
none: ''
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Runs the flow for one turn and returns the job id: the deployed flow on the
|
||||
* flow page, a preview run in the editor. The run must carry `memory_id` =
|
||||
* `conversationId`, which is what ties the job to the conversation.
|
||||
*/
|
||||
onRunFlow: (
|
||||
userMessage: string,
|
||||
conversationId: string,
|
||||
additionalInputs?: Record<string, any>
|
||||
) => Promise<string | undefined>
|
||||
useStreaming?: boolean
|
||||
deploymentInProgress?: boolean
|
||||
path: string
|
||||
hideSidebar?: boolean
|
||||
/** The flow's own description, shown where the chat has room for it: the empty
|
||||
* transcript, and the sidebar once a conversation has replaced it. */
|
||||
description?: string
|
||||
inputSchema?: Record<string, any>
|
||||
/** The flow's modules, used to find which inputs an AI agent step reads directly. */
|
||||
flowModules?: FlowModule[]
|
||||
/** Wider centered column, for the full-page chat. */
|
||||
wideLayout?: boolean
|
||||
/** 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. */
|
||||
frame?: ChatFrame
|
||||
/** Which chats the sidebar lists before the reader filters it themselves. */
|
||||
conversationKind?: ConversationKind
|
||||
/** Whether a turn may run while another chat's is still going. Off where one run
|
||||
* owns the surface — the editor's panel shows it on the graph. */
|
||||
parallelTurns?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
onRunFlow,
|
||||
deploymentInProgress = false,
|
||||
useStreaming = false,
|
||||
path,
|
||||
hideSidebar = false,
|
||||
inputSchema = undefined
|
||||
description = undefined,
|
||||
inputSchema = undefined,
|
||||
flowModules = undefined,
|
||||
wideLayout = false,
|
||||
frame = 'top',
|
||||
conversationKind = 'deployed',
|
||||
parallelTurns = false
|
||||
}: Props = $props()
|
||||
|
||||
const flowEditorContext = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
// The editor may act on a workspace other than the nav store's (AI-session live editor).
|
||||
const workspace = $derived(flowEditorContext?.opWorkspace?.() ?? $workspaceStore)
|
||||
|
||||
let chat = $state<Chat | undefined>(undefined)
|
||||
let chatState = $state<ChatState | undefined>(undefined)
|
||||
let sidebar = $state<FlowConversationsSidebar | undefined>(undefined)
|
||||
const manager = createFlowChatManager()
|
||||
manager.operatingWorkspace = () => flowEditorContext?.opWorkspace?.()
|
||||
manager.conversationKind = conversationKind
|
||||
manager.allowsParallelTurns = parallelTurns
|
||||
// The filter moves; what this surface runs does not.
|
||||
manager.surfaceKind = conversationKind === 'test' ? 'test' : 'deployed'
|
||||
// The editor is the only surface with both kinds in play, and it is the one that opens
|
||||
// on test chats. A deployed flow lists what its users started, with no way to ask for
|
||||
// anything else.
|
||||
manager.canFilterConversationKind = conversationKind !== 'deployed'
|
||||
|
||||
// Initialize manager when component mounts
|
||||
$effect(() => {
|
||||
const ws = workspace
|
||||
const flowPath = path
|
||||
if (!ws || !flowPath) return
|
||||
const created = createChat({
|
||||
flowPath,
|
||||
workspace: ws,
|
||||
baseUrl: window.location.origin,
|
||||
history: 'server',
|
||||
// Only an enterprise server honours it; elsewhere it would just log a warning per
|
||||
// poll. The license loads asynchronously, so a cold load may create the chat twice.
|
||||
pollDelayMs: $enterpriseLicense ? 50 : undefined,
|
||||
run: async ({ user_message, ...inputs }, { conversationId }) => {
|
||||
const jobId = await onRunFlow(String(user_message), conversationId, inputs)
|
||||
if (!jobId) throw new Error('the flow did not start')
|
||||
// The server creates the conversation with the run, so the sidebar can list
|
||||
// it now, whatever becomes of the turn.
|
||||
sidebar?.conversationStarted(conversationId)
|
||||
return jobId
|
||||
},
|
||||
onError: (error) => sendUserToast('Failed to run flow: ' + error.message, true)
|
||||
})
|
||||
const unsubscribe = created.subscribe((s) => (chatState = s))
|
||||
chat = created
|
||||
if ($workspaceStore) {
|
||||
manager.initialize(onRunFlow, path, useStreaming)
|
||||
// Reads the open conversation, and this effect tears down with `cleanup()`: tracked,
|
||||
// the first send of a fresh chat would select the conversation it just created and
|
||||
// so abort its own turn.
|
||||
untrack(() => manager.selectLatestConversation())
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
created.destroy()
|
||||
manager.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// Derive additional inputs schema (excluding user_message) for chat mode
|
||||
// Initialize InfiniteList when component mounts or flowPath changes
|
||||
$effect(() => {
|
||||
if ($workspaceStore && path && manager.conversationListComponent) {
|
||||
untrack(() => {
|
||||
manager.setupInfiniteList()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Everything the chat asks for beyond the message itself. `user_message` is the
|
||||
// composer: the server requires that exact argument on a chat-enabled flow and
|
||||
// stores it as the conversation's message (`handle_chat_conversation_messages`), so
|
||||
// the name is a contract rather than the author's choice.
|
||||
const additionalInputsSchema = $derived.by(() => {
|
||||
const props = inputSchema?.properties ?? {}
|
||||
const filtered = Object.fromEntries(Object.entries(props).filter(([k]) => k !== 'user_message'))
|
||||
const messageInput = 'user_message'
|
||||
const filtered = Object.fromEntries(Object.entries(props).filter(([k]) => k !== messageInput))
|
||||
if (Object.keys(filtered).length === 0) return undefined
|
||||
const required = inputSchema?.required
|
||||
const requiredArray: string[] = Array.isArray(required) ? required : []
|
||||
return {
|
||||
...inputSchema,
|
||||
properties: filtered,
|
||||
required: requiredArray.filter((k: string) => k !== 'user_message')
|
||||
required: requiredArray.filter((k: string) => k !== messageInput)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden flex-1">
|
||||
{#if chat && chatState}
|
||||
{#if !hideSidebar}
|
||||
<FlowConversationsSidebar bind:this={sidebar} {chat} {chatState} />
|
||||
{/if}
|
||||
<!-- The column's max width and side padding come from AIChatDisplay itself. -->
|
||||
<div class="flex overflow-hidden flex-1 {FRAME_CLASS[frame]}">
|
||||
<FlowConversationsSidebar {manager} {description} />
|
||||
<!-- 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">
|
||||
<FlowChatInterface
|
||||
{chat}
|
||||
{chatState}
|
||||
{manager}
|
||||
{deploymentInProgress}
|
||||
{additionalInputsSchema}
|
||||
{flowModules}
|
||||
{path}
|
||||
{workspace}
|
||||
{description}
|
||||
{wideLayout}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,79 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { Alert, Button } from '$lib/components/common'
|
||||
import { MessageCircle, Loader2, Settings2 } from 'lucide-svelte'
|
||||
import ChatMessage from '$lib/components/chat/ChatMessage.svelte'
|
||||
import ChatInput from '$lib/components/chat/ChatInput.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { Loader2, MessageSquare, SlidersHorizontal } from 'lucide-svelte'
|
||||
import { FlowChatManager } from './FlowChatManager.svelte'
|
||||
import { FlowChatViewHost } from './flowChatViewHost.svelte'
|
||||
import AIChatDisplay from '$lib/components/copilot/chat/AIChatDisplay.svelte'
|
||||
import { setChatViewHost } from '$lib/components/copilot/chat/chatViewHost'
|
||||
import Modal from '$lib/components/common/modal/Modal.svelte'
|
||||
import SchemaForm from '$lib/components/SchemaForm.svelte'
|
||||
import { type DynamicInput } from '$lib/utils'
|
||||
import { tick, untrack } from 'svelte'
|
||||
import type { Chat, ChatState } from 'windmill-chat'
|
||||
import { emptyString, type DynamicInput } from '$lib/utils'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
|
||||
import { type FlowModule } from '$lib/gen'
|
||||
import { useWorkspaceStorageConfigured } from '$lib/components/inputTransformEnv.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import FlowChatModelSettings from './FlowChatModelSettings.svelte'
|
||||
import {
|
||||
agentModelGap,
|
||||
agentModelWiringInputs,
|
||||
withoutRejectedEffort,
|
||||
composerOwnedInputs,
|
||||
attachmentsTargetFor,
|
||||
isEmptyAgentChatInputValue,
|
||||
PER_TURN_AGENT_CHAT_INPUT_KEY,
|
||||
resolveAgentChatInputs,
|
||||
resolveAgentModelWiring
|
||||
} from './agentChatInputs'
|
||||
|
||||
interface Props {
|
||||
chat: Chat
|
||||
chatState: ChatState
|
||||
manager: FlowChatManager
|
||||
deploymentInProgress?: boolean
|
||||
additionalInputsSchema?: Record<string, any>
|
||||
/** The flow's modules, used to find which inputs an AI agent step reads directly. */
|
||||
flowModules?: FlowModule[]
|
||||
path: string
|
||||
workspace?: string
|
||||
/** The flow's description, shown under the empty transcript's prompt. */
|
||||
description?: string
|
||||
wideLayout?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
chat,
|
||||
chatState,
|
||||
manager,
|
||||
deploymentInProgress = false,
|
||||
additionalInputsSchema,
|
||||
flowModules,
|
||||
path,
|
||||
workspace = undefined
|
||||
description = undefined,
|
||||
wideLayout = false
|
||||
}: Props = $props()
|
||||
|
||||
let inputMessage = $state('')
|
||||
let inputElement = $state<HTMLTextAreaElement | undefined>(undefined)
|
||||
let messagesContainer = $state<HTMLDivElement | undefined>(undefined)
|
||||
let loadingOlder = false
|
||||
|
||||
const busy = $derived(chatState.status === 'submitted' || chatState.status === 'streaming')
|
||||
// Deriveds notify only when their value changes; `chatState` itself is a new
|
||||
// object on every token, and following it would drag a reader who scrolled up
|
||||
// back to the end on each one.
|
||||
const messageCount = $derived(chatState.messages.length)
|
||||
const conversationId = $derived(chatState.conversationId)
|
||||
const loadingMessages = $derived(chatState.loadingMessages)
|
||||
|
||||
// Follow the conversation: new messages and a conversation switch scroll to the
|
||||
// end, older pages loaded at the top keep the viewport where it was.
|
||||
$effect(() => {
|
||||
messageCount
|
||||
conversationId
|
||||
loadingMessages
|
||||
untrack(() => {
|
||||
if (loadingOlder) return
|
||||
tick().then(() => {
|
||||
if (messagesContainer) messagesContainer.scrollTop = messagesContainer.scrollHeight
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
async function handleScroll() {
|
||||
if (
|
||||
!messagesContainer ||
|
||||
!chatState.hasMoreMessages ||
|
||||
chatState.loadingMessages ||
|
||||
loadingOlder
|
||||
)
|
||||
return
|
||||
if (messagesContainer.scrollTop > 10) return
|
||||
loadingOlder = true
|
||||
const previousHeight = messagesContainer.scrollHeight
|
||||
try {
|
||||
await chat.loadOlderMessages()
|
||||
await tick()
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight - previousHeight
|
||||
} finally {
|
||||
loadingOlder = false
|
||||
}
|
||||
}
|
||||
|
||||
// Derive helperScript for dynamic inputs from schema
|
||||
const dynamicInputHelperScript = $derived.by((): DynamicInput.HelperScript | undefined => {
|
||||
const dynCode = additionalInputsSchema?.['x-windmill-dyn-select-code']
|
||||
@@ -84,14 +58,60 @@
|
||||
return undefined
|
||||
})
|
||||
|
||||
// The flow inputs an AI agent step reads straight out of `flow_input`, which the
|
||||
// composer may then edit itself instead of asking for them in the modal.
|
||||
const agentChatInputs = $derived(resolveAgentChatInputs(flowModules, additionalInputsSchema))
|
||||
// The composer's attachments feed this input, and the paperclip is its whole editor.
|
||||
const attachmentsInput = $derived(
|
||||
agentChatInputs.find((input) => input.key === PER_TURN_AGENT_CHAT_INPUT_KEY)
|
||||
)
|
||||
const attachmentsTarget = $derived(attachmentsTargetFor(attachmentsInput))
|
||||
|
||||
const chatWorkspace = $derived(manager.operatingWorkspace?.() ?? $workspaceStore)
|
||||
|
||||
// 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(() => chatWorkspace)
|
||||
// The model gets its own button, shaped like the copilot's model settings, driven by
|
||||
// whichever provider fields the flow exposes. Attachments are the paperclip's. Nothing
|
||||
// else is promoted, so 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.
|
||||
const modelGap = $derived(agentModelGap(modelWiring))
|
||||
|
||||
// LocalStorage helpers
|
||||
const STORAGE_KEY_PREFIX = 'windmill_flow_chat_inputs_'
|
||||
|
||||
// State for additional inputs modal
|
||||
let showInputsModal = $state(false)
|
||||
let additionalInputsValues = $state<Record<string, any> | undefined>(
|
||||
loadInputsFromStorage() ?? undefined
|
||||
)
|
||||
// Conversation settings, persisted per flow. These can include a value for the
|
||||
// attachments input, saved while the modal was its editor; `sendRequest` drops that one
|
||||
// once the paperclip takes over, so a stored file never rides a later message.
|
||||
let inputValues = $state<Record<string, any>>(loadInputsFromStorage() ?? {})
|
||||
let modalDraft = $state<Record<string, any>>({})
|
||||
|
||||
/** What the flow's own form would open on. */
|
||||
function schemaDefaults(schema: Record<string, any> | undefined): Record<string, any> {
|
||||
const properties: Record<string, any> = schema?.properties ?? {}
|
||||
return Object.fromEntries(
|
||||
Object.entries(properties)
|
||||
.filter(([, property]) => property?.default !== undefined)
|
||||
.map(([name, property]) => [name, property.default])
|
||||
)
|
||||
}
|
||||
|
||||
// Derived rather than seeded into `inputValues`: the schema arrives with the flow, which
|
||||
// on the deployed page is after this mounts, and only what the reader actually chose
|
||||
// belongs in storage. A stored value wins over the default, including a deliberate empty.
|
||||
const effectiveInputs = $derived({
|
||||
...schemaDefaults(additionalInputsSchema),
|
||||
...inputValues
|
||||
})
|
||||
|
||||
// What the run actually gets. The composer's own controls keep themselves consistent as
|
||||
// they are used; this is where a pair that was never chosen through them — a stored
|
||||
// value, an author's default — is made safe before it reaches the provider.
|
||||
const runInputs = $derived(withoutRejectedEffort(modelWiring, effectiveInputs))
|
||||
|
||||
function getStorageKey(): string {
|
||||
return `${STORAGE_KEY_PREFIX}${path}`
|
||||
@@ -115,48 +135,79 @@
|
||||
}
|
||||
}
|
||||
|
||||
function setInputValue(name: string, value: any) {
|
||||
inputValues = { ...inputValues, [name]: value }
|
||||
saveInputsToStorage(inputValues)
|
||||
}
|
||||
|
||||
function handleModalConfirm() {
|
||||
saveInputsToStorage(additionalInputsValues ?? {})
|
||||
// The modal opens on `effectiveInputs`, so its draft carries a value for every
|
||||
// defaulted input whether or not the reader touched one. Storing those would pin
|
||||
// today's defaults for good — `effectiveInputs` gives a stored value precedence, so
|
||||
// a later change to the flow's schema would never reach this reader again.
|
||||
const defaults = schemaDefaults(additionalInputsSchema)
|
||||
const kept = Object.fromEntries(
|
||||
Object.entries({ ...inputValues, ...modalDraft }).filter(
|
||||
([name, value]) => !deepEqual(value, defaults[name])
|
||||
)
|
||||
)
|
||||
inputValues = kept
|
||||
saveInputsToStorage(inputValues)
|
||||
showInputsModal = false
|
||||
}
|
||||
|
||||
async function handleSendMessage() {
|
||||
const text = inputMessage.trim()
|
||||
if (!text || busy || deploymentInProgress) return
|
||||
const inputs = additionalInputsSchema
|
||||
? (loadInputsFromStorage() ?? additionalInputsValues)
|
||||
: undefined
|
||||
inputMessage = ''
|
||||
// A failure is reported through the chat's `onError` and as a failed message.
|
||||
await chat.sendMessage(text, { inputs }).catch(() => {})
|
||||
await tick()
|
||||
inputElement?.focus()
|
||||
}
|
||||
|
||||
function openInputsModal() {
|
||||
const stored = loadInputsFromStorage()
|
||||
if (stored) additionalInputsValues = stored
|
||||
modalDraft = { ...effectiveInputs, ...(loadInputsFromStorage() ?? inputValues) }
|
||||
showInputsModal = true
|
||||
}
|
||||
|
||||
const hasMissingRequired = $derived.by(() => {
|
||||
if (!additionalInputsSchema?.required?.length) return false
|
||||
const values = additionalInputsValues ?? {}
|
||||
return additionalInputsSchema.required.some(
|
||||
(field: string) =>
|
||||
values[field] === undefined || values[field] === '' || values[field] === null
|
||||
const chatHost = new FlowChatViewHost(manager, {
|
||||
additionalInputs: () => (additionalInputsSchema ? { ...runInputs } : undefined),
|
||||
attachmentsTarget: () => attachmentsTarget,
|
||||
workspace: () => chatWorkspace,
|
||||
attachmentsUnavailable: () =>
|
||||
workspaceStorage.current
|
||||
? undefined
|
||||
: 'This workspace has no object storage, so files cannot be attached.',
|
||||
inputsShownInComposer: () => agentModelWiringInputs(modelWiring),
|
||||
inputsSchema: () => additionalInputsSchema
|
||||
})
|
||||
setChatViewHost(chatHost)
|
||||
|
||||
// What the Configure-inputs modal asks for: every flow input the composer does not
|
||||
// edit itself. Below the host, because whether the paperclip is offered is its answer.
|
||||
const modalSchema = $derived.by(() => {
|
||||
if (!additionalInputsSchema) return undefined
|
||||
const promoted = new Set(composerOwnedInputs(modelWiring, attachmentsTarget))
|
||||
const properties = Object.fromEntries(
|
||||
Object.entries(additionalInputsSchema.properties ?? {}).filter(([key]) => !promoted.has(key))
|
||||
)
|
||||
if (Object.keys(properties).length === 0) return undefined
|
||||
const required: string[] = Array.isArray(additionalInputsSchema.required)
|
||||
? additionalInputsSchema.required
|
||||
: []
|
||||
return {
|
||||
...additionalInputsSchema,
|
||||
properties,
|
||||
required: required.filter((key) => !promoted.has(key))
|
||||
}
|
||||
})
|
||||
|
||||
const modalMissingRequired = $derived.by(() => {
|
||||
if (!modalSchema?.required?.length) return false
|
||||
return modalSchema.required.some((field: string) =>
|
||||
isEmptyAgentChatInputValue(effectiveInputs[field])
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- Additional Inputs Modal -->
|
||||
{#if additionalInputsSchema}
|
||||
{#if modalSchema}
|
||||
<Modal title="Configure inputs" bind:open={showInputsModal}>
|
||||
<SchemaForm
|
||||
schema={additionalInputsSchema}
|
||||
bind:args={additionalInputsValues}
|
||||
schema={modalSchema}
|
||||
bind:args={modalDraft}
|
||||
helperScript={dynamicInputHelperScript}
|
||||
{workspace}
|
||||
workspace={chatWorkspace}
|
||||
/>
|
||||
{#snippet actions()}
|
||||
<Button onClick={handleModalConfirm} variant="accent">Save</Button>
|
||||
@@ -164,82 +215,84 @@
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col h-full flex-1 min-w-0">
|
||||
<!-- Messages Container -->
|
||||
<div
|
||||
bind:this={messagesContainer}
|
||||
class="flex-1 min-h-0 overflow-y-auto p-4 bg-background"
|
||||
onscroll={handleScroll}
|
||||
>
|
||||
{#if deploymentInProgress}
|
||||
<Alert type="warning" title="Deployment in progress" size="xs" />
|
||||
{/if}
|
||||
{#if chatState.loadingMessages && chatState.messages.length === 0}
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<Loader2 size={32} class="animate-spin" />
|
||||
</div>
|
||||
{:else if chatState.messages.length === 0}
|
||||
<div class="text-center text-tertiary flex items-center justify-center flex-col h-full">
|
||||
<MessageCircle size={48} class="mx-auto mb-4 opacity-50" />
|
||||
<p class="text-lg font-medium">Start a conversation</p>
|
||||
<p class="text-sm">Send a message to run the flow and see the results</p>
|
||||
</div>
|
||||
{#snippet emptyHint()}
|
||||
<div class="flex-1 text-center text-tertiary flex items-center justify-center flex-col">
|
||||
{#if manager.isLoadingMessages}
|
||||
<Loader2 size={32} class="animate-spin" />
|
||||
{:else}
|
||||
<div class="w-full space-y-4 xl:max-w-7xl mx-auto">
|
||||
{#each chatState.messages as message (message.id)}
|
||||
<ChatMessage
|
||||
role={message.role}
|
||||
content={message.content}
|
||||
success={message.success}
|
||||
stepName={message.stepName}
|
||||
/>
|
||||
{/each}
|
||||
{#if busy}
|
||||
<div class="flex items-center gap-2 text-tertiary">
|
||||
<Loader2 size={16} class="animate-spin" />
|
||||
<span class="text-sm">Processing...</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Chat Input -->
|
||||
<div class="flex flex-col items-center p-2 xl:max-w-7xl mx-auto w-full gap-2">
|
||||
{#if additionalInputsSchema}
|
||||
<div class="flex items-center justify-end w-full">
|
||||
<div class="relative">
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="default"
|
||||
startIcon={{ icon: Settings2 }}
|
||||
title="Inputs"
|
||||
onClick={openInputsModal}
|
||||
>
|
||||
Inputs
|
||||
</Button>
|
||||
{#if hasMissingRequired}
|
||||
<span class="absolute -top-1 -right-1 w-2 h-2 bg-yellow-500 rounded-full"></span>
|
||||
{/if}
|
||||
<MessageSquare size={48} class="mx-auto mb-4 opacity-50" />
|
||||
<p class="text-lg font-medium">Start a conversation</p>
|
||||
<p class="text-sm">Send a message to run the flow and see the results</p>
|
||||
{#if !emptyString(description)}
|
||||
<!-- What this particular flow is for, in the author's own words. Narrower and
|
||||
dimmer than the prompt above it, and left-aligned because a description
|
||||
runs to several lines where the two lines above do not. -->
|
||||
<div class="mt-6 pt-4 border-t max-w-md text-left text-xs text-tertiary">
|
||||
<GfmMarkdown md={description ?? ''} noPadding prose="sm" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="w-full" class:opacity-50={deploymentInProgress}>
|
||||
<ChatInput
|
||||
bind:value={inputMessage}
|
||||
bind:bindTextarea={inputElement}
|
||||
disabled={busy || deploymentInProgress}
|
||||
onSend={handleSendMessage}
|
||||
onKeydown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
||||
e.preventDefault()
|
||||
handleSendMessage()
|
||||
}
|
||||
}}
|
||||
showCancelButton={busy}
|
||||
onCancel={() => chat.stop()}
|
||||
sendTitle={deploymentInProgress ? 'Deployment in progress' : 'Send message (Enter)'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet footerSettings()}
|
||||
{#if modalSchema}
|
||||
<div class="relative">
|
||||
<Button
|
||||
unifiedSize="2xs"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: SlidersHorizontal }}
|
||||
btnClasses="text-secondary font-normal"
|
||||
title="Configure the flow inputs sent with each message"
|
||||
onClick={openInputsModal}
|
||||
>
|
||||
Inputs
|
||||
</Button>
|
||||
{#if modalMissingRequired}
|
||||
<span class="absolute -top-0.5 -right-0.5 w-2 h-2 bg-yellow-500 rounded-full"></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if modelWiring}
|
||||
<FlowChatModelSettings
|
||||
wiring={modelWiring}
|
||||
values={effectiveInputs}
|
||||
setValue={setInputValue}
|
||||
workspace={chatWorkspace}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<!-- The transcript scroller fills its flex row, which needs a height to resolve
|
||||
against. Not every host gives one (the editor's Test-flow panel stacks the
|
||||
chat above the job result in an auto-height column), so claim one: enough to
|
||||
scroll in once there are messages, and before that enough for the empty-state
|
||||
prompt and the composer, which otherwise crowd the panel they collapse it to. -->
|
||||
<div
|
||||
class="flex flex-col h-full flex-1 min-w-0"
|
||||
class:min-h-96={chatHost.displayMessages.length > 0}
|
||||
class:min-h-64={chatHost.displayMessages.length === 0}
|
||||
>
|
||||
<AIChatDisplay
|
||||
messages={chatHost.displayMessages}
|
||||
bind:scrollElement={manager.messagesContainer}
|
||||
onTranscriptScroll={manager.handleScroll}
|
||||
pastChats={[]}
|
||||
diffMode={false}
|
||||
selectedContext={[]}
|
||||
availableContext={[]}
|
||||
hideHeader
|
||||
hideModeSelector
|
||||
{wideLayout}
|
||||
{emptyHint}
|
||||
footerSettings={modalSchema || modelWiring ? footerSettings : undefined}
|
||||
placeholder="Send a message to run the flow"
|
||||
disabled={deploymentInProgress || !!modelGap || !!manager.wrongKindReason}
|
||||
disabledMessage={deploymentInProgress
|
||||
? 'Deployment in progress'
|
||||
: (modelGap ?? manager.wrongKindReason ?? '')}
|
||||
loadPastChat={() => {}}
|
||||
deletePastChat={() => {}}
|
||||
saveAndClear={() => {}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,257 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { FlowConversationsService } from '$lib/gen'
|
||||
import { createFlowChatManager } from './FlowChatManager.svelte'
|
||||
|
||||
vi.mock('$lib/gen', () => ({
|
||||
FlowConversationsService: {
|
||||
listConversationMessages: vi.fn(),
|
||||
deleteFlowConversation: vi.fn()
|
||||
},
|
||||
JobService: {},
|
||||
FlowService: {}
|
||||
}))
|
||||
vi.mock('$lib/toast', () => ({ sendUserToast: vi.fn() }))
|
||||
vi.mock('$lib/stores', () => ({
|
||||
userStore: { subscribe: (run: (v: unknown) => void) => (run({ username: 'admin' }), () => {}) },
|
||||
workspaceStore: { subscribe: (run: (v: unknown) => void) => (run('ws'), () => {}) }
|
||||
}))
|
||||
|
||||
const rows = (conversationId: string, count: number) =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
id: `${conversationId}-${i}`,
|
||||
content: '',
|
||||
created_at: new Date().toISOString(),
|
||||
created_seq: i,
|
||||
conversation_id: conversationId,
|
||||
message_type: i % 2 === 0 ? 'user' : 'assistant'
|
||||
}))
|
||||
|
||||
function managerWithRows() {
|
||||
const manager = createFlowChatManager()
|
||||
manager.operatingWorkspace = () => 'ws'
|
||||
;(manager as any).initialize(vi.fn(), 'u/admin/flow', false)
|
||||
return manager
|
||||
}
|
||||
|
||||
/**
|
||||
* The unread badge is driven by a watermark the manager writes when the reader leaves a
|
||||
* chat. What it must never do is keep counting a chat the reader can no longer reach —
|
||||
* there would be no row left in the sidebar to clear it.
|
||||
*/
|
||||
describe('unread bookkeeping', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(FlowConversationsService.listConversationMessages).mockReset()
|
||||
vi.mocked(FlowConversationsService.deleteFlowConversation).mockReset()
|
||||
})
|
||||
|
||||
it('counts rows loaded for a chat the reader is not in', async () => {
|
||||
const manager = managerWithRows()
|
||||
manager.selectedConversationId = 'open'
|
||||
vi.mocked(FlowConversationsService.listConversationMessages).mockResolvedValue(
|
||||
rows('a', 2) as any
|
||||
)
|
||||
await manager.loadConversationMessages('a')
|
||||
|
||||
expect(manager.unreadCount('a')).toBe(2)
|
||||
expect(manager.totalUnread).toBe(2)
|
||||
})
|
||||
|
||||
it('stops counting a deleted chat', async () => {
|
||||
const manager = managerWithRows()
|
||||
manager.selectedConversationId = 'open'
|
||||
vi.mocked(FlowConversationsService.listConversationMessages).mockResolvedValue(
|
||||
rows('a', 2) as any
|
||||
)
|
||||
await manager.loadConversationMessages('a')
|
||||
expect(manager.totalUnread).toBe(2)
|
||||
|
||||
vi.mocked(FlowConversationsService.deleteFlowConversation).mockResolvedValue(undefined as any)
|
||||
await (manager as any).deleteConversation('a')
|
||||
|
||||
expect(manager.totalUnread).toBe(0)
|
||||
expect(manager.unreadCount('a')).toBe(0)
|
||||
})
|
||||
|
||||
it('treats the open chat as read', async () => {
|
||||
const manager = managerWithRows()
|
||||
vi.mocked(FlowConversationsService.listConversationMessages).mockResolvedValue(
|
||||
rows('a', 3) as any
|
||||
)
|
||||
await manager.selectConversation('a')
|
||||
|
||||
expect(manager.unreadCount('a')).toBe(0)
|
||||
expect(manager.totalUnread).toBe(0)
|
||||
})
|
||||
|
||||
// The message the reader just typed came back as unread behind them, because this path
|
||||
// moves the selection without going through `selectConversation`.
|
||||
it('marks the chat being left as read when a new one is started', async () => {
|
||||
const manager = managerWithRows()
|
||||
vi.mocked(FlowConversationsService.listConversationMessages).mockResolvedValue(
|
||||
rows('a', 2) as any
|
||||
)
|
||||
await manager.selectConversation('a')
|
||||
await manager.createConversation({ clearMessages: true })
|
||||
|
||||
expect(manager.unreadCount('a')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A queued message goes out when the turn ahead of it reaches a terminal state — not
|
||||
* merely when the chat stops looking busy. The stream dropping is the case that separates
|
||||
* the two: `onerror` ends the turn's client-side state, but the flow job it was following
|
||||
* keeps running on a worker, and starting the next turn there would interleave two runs
|
||||
* over one conversation's agent memory.
|
||||
*/
|
||||
describe('queued turns wait for a settled run', () => {
|
||||
it('does not flush when a turn ends without settling', () => {
|
||||
const manager = managerWithRows()
|
||||
const flushed: string[] = []
|
||||
manager.onTurnSettled = (id) => flushed.push(id)
|
||||
|
||||
manager.endTurn('a')
|
||||
|
||||
expect(flushed).toEqual([])
|
||||
})
|
||||
|
||||
it('flushes the chat whose run settled, naming it', () => {
|
||||
const manager = managerWithRows()
|
||||
const flushed: string[] = []
|
||||
manager.onTurnSettled = (id) => flushed.push(id)
|
||||
|
||||
manager.endTurn('a', { settled: true })
|
||||
|
||||
expect(flushed).toEqual(['a'])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The server ends every SSE stream on its own clock (`TIMEOUT_SSE_STREAM`, 60s by default)
|
||||
* and expects the client to re-attach. Re-entering the run-starting path instead spawned a
|
||||
* second flow job a minute, each one writing the same conversation — so this pins that a
|
||||
* timeout follows the job it already has, and resumes where the last one stopped.
|
||||
*/
|
||||
describe('an SSE timeout re-attaches instead of re-running', () => {
|
||||
class FakeEventSource {
|
||||
static opened: string[] = []
|
||||
static live: FakeEventSource[] = []
|
||||
onmessage: ((e: { data: string }) => void) | null = null
|
||||
onerror: ((e: unknown) => void) | null = null
|
||||
closed = false
|
||||
constructor(url: string) {
|
||||
FakeEventSource.opened.push(url)
|
||||
FakeEventSource.live.push(this)
|
||||
}
|
||||
close() {
|
||||
this.closed = true
|
||||
}
|
||||
emit(payload: unknown) {
|
||||
this.onmessage?.({ data: JSON.stringify(payload) })
|
||||
}
|
||||
}
|
||||
|
||||
let realEventSource: unknown
|
||||
let live: ReturnType<typeof managerWithRows> | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
FakeEventSource.opened = []
|
||||
FakeEventSource.live = []
|
||||
realEventSource = (globalThis as any).EventSource
|
||||
;(globalThis as any).EventSource = FakeEventSource
|
||||
// `test-setup.ts` makes `window` be `globalThis`, which has no `location` — so the
|
||||
// stream URL's base is what is missing, not the window itself.
|
||||
;(globalThis as any).location = { origin: 'http://localhost' }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// The turn is still running: its 500ms poll interval would otherwise keep calling
|
||||
// into the manager after the test. Here rather than in the body, which a failing
|
||||
// assertion would skip.
|
||||
live?.cleanup()
|
||||
live = undefined
|
||||
;(globalThis as any).EventSource = realEventSource
|
||||
delete (globalThis as any).location
|
||||
})
|
||||
|
||||
it('follows the same job and carries the offset forward', async () => {
|
||||
const manager = (live = managerWithRows())
|
||||
const onRunFlow = vi.fn(async () => 'job-1')
|
||||
;(manager as any).initialize(onRunFlow, 'u/admin/flow', true)
|
||||
manager.operatingWorkspace = () => 'ws'
|
||||
|
||||
manager.selectedConversationId = 'a'
|
||||
manager.inputMessage = 'hello'
|
||||
await manager.sendMessage(undefined, undefined, 'a')
|
||||
|
||||
expect(onRunFlow).toHaveBeenCalledTimes(1)
|
||||
// Stop has something to cancel before any token arrives: the flow job is named as
|
||||
// soon as it is enqueued, not when the streaming step starts.
|
||||
expect(manager.currentJobId).toBe('job-1')
|
||||
|
||||
FakeEventSource.live[0].emit({ type: 'update', stream_offset: 42 })
|
||||
FakeEventSource.live[0].emit({ type: 'timeout' })
|
||||
|
||||
// No second run, and the reconnect resumes rather than replaying the answer.
|
||||
expect(onRunFlow).toHaveBeenCalledTimes(1)
|
||||
expect(FakeEventSource.opened).toHaveLength(2)
|
||||
expect(FakeEventSource.opened[0]).toContain('/job-1')
|
||||
expect(FakeEventSource.opened[1]).toContain('/job-1')
|
||||
expect(FakeEventSource.opened[1]).toContain('stream_offset=42')
|
||||
})
|
||||
})
|
||||
|
||||
/** Why the row has to be stamped at all is on `#nameTurnJob`; this pins that it is, and
|
||||
* that it lands in the conversation the turn was sent to. */
|
||||
describe('a sent message names the run it started', () => {
|
||||
let realEventSource: unknown
|
||||
let live: ReturnType<typeof managerWithRows> | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(FlowConversationsService.listConversationMessages).mockResolvedValue([] as any)
|
||||
realEventSource = (globalThis as any).EventSource
|
||||
;(globalThis as any).EventSource = class {
|
||||
onmessage: unknown = null
|
||||
onerror: unknown = null
|
||||
close() {}
|
||||
}
|
||||
;(globalThis as any).location = { origin: 'http://localhost' }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
live?.cleanup()
|
||||
live = undefined
|
||||
;(globalThis as any).EventSource = realEventSource
|
||||
delete (globalThis as any).location
|
||||
})
|
||||
|
||||
it('stamps the row the turn began with, so the transcript can replay it', async () => {
|
||||
const manager = (live = managerWithRows())
|
||||
;(manager as any).initialize(vi.fn(async () => 'job-7'), 'u/admin/flow', true)
|
||||
manager.operatingWorkspace = () => 'ws'
|
||||
manager.selectedConversationId = 'a'
|
||||
manager.inputMessage = 'hello'
|
||||
|
||||
await manager.sendMessage(undefined, undefined, 'a')
|
||||
|
||||
const userRow = manager.messages.find((m) => m.message_type === 'user')
|
||||
expect(userRow?.job_id).toBe('job-7')
|
||||
})
|
||||
|
||||
// A queued message flushes into the chat it was typed in, which by then need not be the
|
||||
// one on screen — the row and its job must both land there, not in the open chat.
|
||||
it('stamps the row in the conversation the turn was sent to, not the open one', async () => {
|
||||
const manager = (live = managerWithRows())
|
||||
;(manager as any).initialize(vi.fn(async () => 'job-8'), 'u/admin/flow', true)
|
||||
manager.operatingWorkspace = () => 'ws'
|
||||
manager.selectedConversationId = 'open-chat'
|
||||
manager.inputMessage = 'sent to the background chat'
|
||||
|
||||
await manager.sendMessage(undefined, undefined, 'background-chat')
|
||||
|
||||
expect(manager.messages).toEqual([])
|
||||
await manager.selectConversation('background-chat')
|
||||
const userRow = manager.messages.find((m) => m.message_type === 'user')
|
||||
expect(userRow?.job_id).toBe('job-8')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,277 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The flow chat's model button: the same ChatModelSettings the session chat renders,
|
||||
* over whatever the flow exposes.
|
||||
*
|
||||
* The agent takes one `provider` object, but an author can expose it field by field —
|
||||
* fixing the resource in the step and letting the chat pick only the model, say. Each
|
||||
* control here appears exactly when the flow wired the field behind it, so a chat never
|
||||
* offers a knob whose value it could not write back.
|
||||
*/
|
||||
import ChatModelSettings from '$lib/components/copilot/ChatModelSettings.svelte'
|
||||
import {
|
||||
carriedReasoning,
|
||||
type ChatModelSettingsConfig
|
||||
} from '$lib/components/copilot/chatModelSettings'
|
||||
import AppConnect from '$lib/components/AppConnectDrawer.svelte'
|
||||
import { AI_PROVIDERS, fetchAvailableModels } from '$lib/components/copilot/lib'
|
||||
import {
|
||||
explicitOffToken,
|
||||
getReasoningCapability
|
||||
} from '$lib/components/copilot/reasoningRegistry'
|
||||
import { ResourceService, type AIProvider } from '$lib/gen'
|
||||
import type { Item } from '$lib/utils'
|
||||
import { Plug, Plus } from 'lucide-svelte'
|
||||
import { resource } from 'runed'
|
||||
import type { AgentModelWiring, ProviderField } from './agentChatInputs'
|
||||
|
||||
interface Props {
|
||||
wiring: AgentModelWiring
|
||||
/** Every flow input value the composer holds for this conversation. */
|
||||
values: Record<string, any>
|
||||
setValue: (name: string, value: any) => void
|
||||
workspace?: string
|
||||
}
|
||||
|
||||
let { wiring, values, setValue, workspace }: Props = $props()
|
||||
|
||||
function fieldValue(field: ProviderField): any {
|
||||
if (wiring.whole) return values[wiring.whole]?.[field]
|
||||
const name = wiring.fields[field]
|
||||
return name ? values[name] : wiring.fixed[field]
|
||||
}
|
||||
|
||||
function editable(field: ProviderField): boolean {
|
||||
return wiring.whole !== undefined || wiring.fields[field] !== undefined
|
||||
}
|
||||
|
||||
/** Written together, because choosing a resource also invalidates the model. */
|
||||
function setFields(patch: Partial<Record<ProviderField, any>>) {
|
||||
if (wiring.whole) {
|
||||
setValue(wiring.whole, { ...(values[wiring.whole] ?? {}), ...patch })
|
||||
return
|
||||
}
|
||||
for (const [field, value] of Object.entries(patch)) {
|
||||
const name = wiring.fields[field as ProviderField]
|
||||
if (name) setValue(name, value)
|
||||
}
|
||||
}
|
||||
|
||||
const resourceEditable = $derived(editable('resource'))
|
||||
const modelEditable = $derived(editable('model'))
|
||||
const effortEditable = $derived(editable('reasoning_effort'))
|
||||
// Nothing to write: the flow fixes the lot, so the button names it and opens nothing.
|
||||
const readOnly = $derived(!resourceEditable && !modelEditable && !effortEditable)
|
||||
|
||||
const AI_RESOURCE_TYPES = Object.keys(AI_PROVIDERS)
|
||||
|
||||
// `$res:` is the stored form; the picker works in bare paths.
|
||||
const resourcePath = $derived(
|
||||
typeof fieldValue('resource') === 'string'
|
||||
? fieldValue('resource').replace(/^\$res:/, '') || undefined
|
||||
: undefined
|
||||
)
|
||||
const model = $derived(fieldValue('model'))
|
||||
const effort = $derived(fieldValue('reasoning_effort'))
|
||||
|
||||
let appConnect: AppConnect | undefined = $state(undefined)
|
||||
// Bumped after the connect drawer creates one, to re-list.
|
||||
let resourcesVersion = $state(0)
|
||||
// Set when a resource is created here: it can only be selected once the re-listing
|
||||
// that follows tells us which provider it speaks.
|
||||
let pendingResourcePath = $state<string | undefined>(undefined)
|
||||
|
||||
// A flow that fixes `kind` but exposes `resource` accepts resources of that kind only:
|
||||
// `setFields` drops a `kind` it cannot write, so any other provider's resource would be
|
||||
// listed, selected, and then run against the kind the flow still fixes.
|
||||
const allowedResourceTypes = $derived.by(() => {
|
||||
const fixedKind = editable('kind') ? undefined : (fieldValue('kind') as string | undefined)
|
||||
return fixedKind && AI_RESOURCE_TYPES.includes(fixedKind) ? [fixedKind] : AI_RESOURCE_TYPES
|
||||
})
|
||||
|
||||
const resources = resource(
|
||||
() =>
|
||||
resourceEditable ? { workspace, version: resourcesVersion, allowedResourceTypes } : undefined,
|
||||
async (args) => {
|
||||
const ws = args?.workspace
|
||||
if (!ws) return []
|
||||
const rows = await ResourceService.listResource({
|
||||
workspace: ws,
|
||||
resourceType: (args?.allowedResourceTypes ?? AI_RESOURCE_TYPES).join(',')
|
||||
})
|
||||
return rows.map((r) => ({
|
||||
path: r.path,
|
||||
// The row's own type is the provider; an unrecognised one is a custom endpoint.
|
||||
provider: (AI_RESOURCE_TYPES.includes(r.resource_type ?? '')
|
||||
? r.resource_type
|
||||
: 'customai') as AIProvider
|
||||
}))
|
||||
}
|
||||
)
|
||||
|
||||
const provider = $derived(
|
||||
resources.current?.find((r) => r.path === resourcePath)?.provider ??
|
||||
(fieldValue('kind') as AIProvider | undefined)
|
||||
)
|
||||
|
||||
// Models the resource actually serves, asked of the provider. Its own catalogue is the
|
||||
// fallback, so a listing that fails or is unsupported still offers real ids rather than
|
||||
// an empty menu.
|
||||
const models = resource(
|
||||
() => ({ workspace, resourcePath, provider, modelEditable }),
|
||||
async ({ workspace, resourcePath, provider, modelEditable }, _prev, { onCleanup }) => {
|
||||
if (!modelEditable || !provider) return []
|
||||
const fallback = AI_PROVIDERS[provider]?.defaultModels ?? []
|
||||
if (!workspace || !resourcePath) return fallback
|
||||
const controller = new AbortController()
|
||||
onCleanup(() => controller.abort())
|
||||
try {
|
||||
const listed = await fetchAvailableModels(
|
||||
resourcePath,
|
||||
workspace,
|
||||
provider,
|
||||
controller.signal
|
||||
)
|
||||
return listed.length > 0 ? listed : fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
if (!pendingResourcePath) return
|
||||
const created = resources.current?.find((r) => r.path === pendingResourcePath)
|
||||
if (created) {
|
||||
pendingResourcePath = undefined
|
||||
selectResource(created.path, created.provider)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* The effort to write alongside a new model: `''` — the agent's "no effort" — wherever
|
||||
* that model has no such level, so the composer never leaves behind a level the provider
|
||||
* would reject. Writes nothing where the registry cannot speak for the model, since
|
||||
* clearing a value on a guess would destroy the author's own default.
|
||||
*/
|
||||
function effortPatch(nextModel: string | undefined): Partial<Record<ProviderField, any>> {
|
||||
if (!provider || !nextModel) return {}
|
||||
const capability = getReasoningCapability(provider, nextModel)
|
||||
if (!capability.known) return {}
|
||||
const carried = carriedReasoning(
|
||||
typeof effort === 'string' ? effort : undefined,
|
||||
explicitOffToken(provider, nextModel) ?? '',
|
||||
capability
|
||||
)
|
||||
return { reasoning_effort: carried ?? '' }
|
||||
}
|
||||
|
||||
function selectResource(path: string, picked: AIProvider) {
|
||||
setFields({
|
||||
kind: picked,
|
||||
resource: `$res:${path}`,
|
||||
// The models of one provider mean nothing to another, and the new list only
|
||||
// arrives async, so there is nothing to carry the current one against — nor the
|
||||
// effort, which only means something against a model. Cleared only where the
|
||||
// registry can speak for the new provider, for the same reason as `effortPatch`.
|
||||
model: undefined,
|
||||
...(getReasoningCapability(picked, '').known ? { reasoning_effort: '' } : {})
|
||||
})
|
||||
}
|
||||
|
||||
function providerItem(close: () => void): Item {
|
||||
const rows: Item[] = resources.loading
|
||||
? [{ displayName: 'Loading resources...', disabled: true }]
|
||||
: (resources.current ?? []).length === 0
|
||||
? [{ displayName: 'No AI resource in this workspace', disabled: true }]
|
||||
: (resources.current ?? []).map((r) => ({
|
||||
displayName: r.path,
|
||||
selected: r.path === resourcePath,
|
||||
action: () => selectResource(r.path, r.provider)
|
||||
}))
|
||||
return {
|
||||
displayName: 'Provider',
|
||||
icon: Plug,
|
||||
extra: providerSummary,
|
||||
submenuItems: [
|
||||
...rows,
|
||||
{
|
||||
// The same reach the form's ResourcePicker gives: create one without
|
||||
// leaving for workspace settings first.
|
||||
displayName: 'Add a resource',
|
||||
icon: Plus,
|
||||
separatorTop: true,
|
||||
action: () => {
|
||||
close()
|
||||
appConnect?.open()
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const config = $derived<ChatModelSettingsConfig>({
|
||||
label: typeof model === 'string' && model ? model : 'Select a model',
|
||||
title: 'Model & reasoning settings',
|
||||
readOnly,
|
||||
readOnlyReason: 'Set in the flow',
|
||||
topItems: resourceEditable ? (close) => [providerItem(close)] : undefined,
|
||||
sections: modelEditable
|
||||
? [
|
||||
{
|
||||
label: 'Model',
|
||||
options: (models.current ?? []).map((m) => ({
|
||||
key: m,
|
||||
label: m,
|
||||
selected: m === model,
|
||||
onSelect: () => setFields({ model: m, ...effortPatch(m) })
|
||||
})),
|
||||
loading: models.loading,
|
||||
emptyMessage: provider ? 'No model available' : 'Pick a provider first'
|
||||
}
|
||||
]
|
||||
: undefined,
|
||||
// Always present, whatever we can say about it: the run uses an effort either way, and
|
||||
// the control is the only place it can be read or set. What varies is the state it
|
||||
// renders — a ladder, a typed token, why there is none, or what the flow fixed.
|
||||
reasoning: {
|
||||
provider,
|
||||
model: typeof model === 'string' && model ? model : undefined,
|
||||
value: typeof effort === 'string' ? effort : undefined,
|
||||
// An agent writes the provider-native token straight into its step, so there is no
|
||||
// sentinel to translate later. Where a model disables by omission instead, the empty
|
||||
// string is that off: the run reads an empty `reasoning_effort` as absent
|
||||
// (types.rs `get_reasoning_effort`).
|
||||
offToken:
|
||||
provider && typeof model === 'string' && model
|
||||
? (explicitOffToken(provider, model) ?? '')
|
||||
: '',
|
||||
// An agent step omits `reasoning_effort` when it is unset, so the provider picks —
|
||||
// naming a level would claim something the run does not do.
|
||||
sendsDefaultWhenUnset: false,
|
||||
writable: effortEditable,
|
||||
onSelect: (token) => setFields({ reasoning_effort: token })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#snippet providerSummary()}
|
||||
{#if resourcePath}
|
||||
<span class="shrink-0 text-tertiary truncate max-w-[80px]">{resourcePath}</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if resourceEditable}
|
||||
<AppConnect
|
||||
bind:this={appConnect}
|
||||
{workspace}
|
||||
on:refresh={(e) => {
|
||||
resourcesVersion++
|
||||
if (e.detail) {
|
||||
pendingResourcePath = e.detail
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<ChatModelSettings {config} />
|
||||
@@ -1,204 +1,373 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import { MessageCircle, Plus, Trash2, PanelLeftClose, PanelLeftOpen } from 'lucide-svelte'
|
||||
import CountBadge from '$lib/components/common/badge/CountBadge.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { tick } from 'svelte'
|
||||
import {
|
||||
MessageSquare,
|
||||
Pen,
|
||||
Plus,
|
||||
Trash2,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Info
|
||||
} from 'lucide-svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import SessionStatusDot from '$lib/components/sessions/SessionStatusDot.svelte'
|
||||
import type { SessionChatStatus } from '$lib/components/sessions/sessionRuntime.svelte'
|
||||
import UnreadCountBadge from '$lib/components/common/badge/UnreadCountBadge.svelte'
|
||||
import { PencilLine } from 'lucide-svelte'
|
||||
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
|
||||
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
|
||||
import { emptyString, type Item } from '$lib/utils'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import { Filter } from 'lucide-svelte'
|
||||
import { type FlowConversation } from '$lib/gen'
|
||||
import InfiniteList from '$lib/components/InfiniteList.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import {
|
||||
FlowChatManager,
|
||||
type ConversationKind,
|
||||
type ConversationWithDraft
|
||||
} from './FlowChatManager.svelte'
|
||||
import { fade } from 'svelte/transition'
|
||||
import { untrack } from 'svelte'
|
||||
import type { Chat, ChatState, Conversation } from 'windmill-chat'
|
||||
|
||||
interface Props {
|
||||
chat: Chat
|
||||
chatState: ChatState
|
||||
manager: FlowChatManager
|
||||
/** The flow's description. The empty transcript shows it in full, but that is gone
|
||||
* once a chat is under way — this keeps it reachable for the rest of the session. */
|
||||
description?: string
|
||||
}
|
||||
|
||||
let { chat, chatState }: Props = $props()
|
||||
let { manager, description = undefined }: Props = $props()
|
||||
|
||||
let expanded = $state(false)
|
||||
let list = $state<InfiniteList | undefined>(undefined)
|
||||
let items = $state<Conversation[]>([])
|
||||
let deletingId = $state<string | undefined>(undefined)
|
||||
// A conversation exists on the server only once its first turn ran, so "New chat"
|
||||
// shows a draft row until then.
|
||||
let draft = $state(false)
|
||||
// The chat being renamed, and the text typed so far. One at a time: the input is the
|
||||
// row's own label, so a second one would have nowhere to go.
|
||||
let renamingId = $state<string | undefined>(undefined)
|
||||
let renameDraft = $state('')
|
||||
let renameInput = $state<TextInput | undefined>(undefined)
|
||||
|
||||
$effect(() => {
|
||||
const l = list
|
||||
const c = chat
|
||||
if (!l) return
|
||||
untrack(() => {
|
||||
l.setLoader((page, perPage) => c.loadConversations({ page, perPage }))
|
||||
l.setDeleteItemFn(async (id: string) => {
|
||||
deletingId = id
|
||||
try {
|
||||
await c.deleteConversation(id)
|
||||
sendUserToast('Conversation deleted successfully')
|
||||
} catch (error) {
|
||||
console.error('Failed to delete conversation:', error)
|
||||
sendUserToast('Failed to delete conversation', true)
|
||||
throw error
|
||||
} finally {
|
||||
deletingId = undefined
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/** The container reports a started turn: a conversation's first one creates its server entry. */
|
||||
export async function conversationStarted(conversationId: string) {
|
||||
if (items.some((c) => c.id === conversationId)) return
|
||||
draft = false
|
||||
await list?.loadData('forceRefresh')
|
||||
async function startRename(conversation: FlowConversation) {
|
||||
renamingId = conversation.id
|
||||
renameDraft = getConversationTitle(conversation)
|
||||
// The field replaces the row, so it exists only after this render.
|
||||
await tick()
|
||||
renameInput?.focus()
|
||||
renameInput?.select()
|
||||
}
|
||||
|
||||
const draftShown = $derived(draft && !items.some((c) => c.id === chatState.conversationId))
|
||||
|
||||
function newChat() {
|
||||
chat.newConversation()
|
||||
draft = true
|
||||
async function commitRename() {
|
||||
const id = renamingId
|
||||
renamingId = undefined
|
||||
if (id) await manager.renameConversation(id, renameDraft)
|
||||
}
|
||||
|
||||
function getConversationTitle(conversation: Conversation): string {
|
||||
return conversation.title || `Conversation ${conversation.createdAt.slice(0, 10)}`
|
||||
function deleteConversation(conversation: ConversationWithDraft) {
|
||||
if (conversation.isDraft) {
|
||||
// The draft is the first row and exists only here; there is nothing to delete.
|
||||
manager.conversations = [...manager.conversations.slice(1)]
|
||||
} else {
|
||||
manager.conversationListComponent?.deleteItem(conversation.id)
|
||||
}
|
||||
}
|
||||
|
||||
function rowActions(conversation: ConversationWithDraft): Item[] {
|
||||
return [
|
||||
{ displayName: 'Rename', icon: Pen, action: () => startRename(conversation) },
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash2,
|
||||
type: 'delete',
|
||||
disabled: manager.deletingConversationId === conversation.id,
|
||||
action: () => deleteConversation(conversation)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const KIND_LABELS: Record<ConversationKind, string> = {
|
||||
test: 'Test',
|
||||
deployed: 'Deployed',
|
||||
all: 'All'
|
||||
}
|
||||
|
||||
/**
|
||||
* The sessions sidebar's own vocabulary, so the two lists read alike: a running turn is
|
||||
* its streaming signal and a failed one its error signal. A queued message is not a dot
|
||||
* there either — it is the pencil beside the count.
|
||||
*/
|
||||
function dotStatus(conversationId: string): SessionChatStatus {
|
||||
const status = manager.conversationStatus(conversationId)
|
||||
return status === 'running' ? 'streaming' : status === 'error' ? 'error' : 'idle'
|
||||
}
|
||||
|
||||
/** Why this row cannot be opened, when something stops it. */
|
||||
function rowLocked(conversation: ConversationWithDraft): string | undefined {
|
||||
return manager.lockedReason(conversation.id)
|
||||
}
|
||||
|
||||
function getConversationTitle(conversation: FlowConversation): string {
|
||||
return conversation.title || `Conversation ${conversation.created_at.slice(0, 10)}`
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet statusDot(conversation: ConversationWithDraft)}
|
||||
<!-- The AI session sidebar's dot, with the resting mark this list needs: a session rests
|
||||
as a workspace or a fork, a conversation as a test run or one of the deployed flow's. -->
|
||||
<SessionStatusDot
|
||||
status={dotStatus(conversation.id)}
|
||||
isFork={false}
|
||||
restingTitle={conversation.is_test
|
||||
? 'Test chat, run from the flow editor'
|
||||
: 'Chat on the deployed flow'}
|
||||
>
|
||||
{#snippet resting()}
|
||||
<span
|
||||
class="w-[6px] h-[6px] rounded-full {conversation.is_test
|
||||
? 'border border-gray-400 dark:border-gray-500'
|
||||
: 'bg-gray-300 dark:bg-gray-600'}"
|
||||
></span>
|
||||
{/snippet}
|
||||
</SessionStatusDot>
|
||||
{/snippet}
|
||||
|
||||
<div
|
||||
class="flex flex-col h-full bg-surface border-r transition-all duration-300 {expanded
|
||||
class="flex flex-col h-full bg-surface border-r transition-all duration-300 {manager.isSidebarExpanded
|
||||
? 'w-60'
|
||||
: '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"
|
||||
variant="subtle"
|
||||
startIcon={{
|
||||
icon: expanded ? PanelLeftClose : PanelLeftOpen,
|
||||
classes: 'ml-[2px]'
|
||||
}}
|
||||
onClick={() => (expanded = !expanded)}
|
||||
iconOnly={!expanded}
|
||||
btnClasses={'justify-start transition-all duration-150'}
|
||||
title="Conversations"
|
||||
<!-- Same shape as the New chat row below: the wide button takes the width and the
|
||||
icon-only one sits at the end, stacking into the rail once collapsed. -->
|
||||
<div
|
||||
class={manager.isSidebarExpanded
|
||||
? 'flex flex-row gap-1 items-center'
|
||||
: 'flex flex-col gap-2'}
|
||||
>
|
||||
<div transition:fade={{ duration: 100 }}> Conversations </div>
|
||||
</Button>
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: Plus, classes: 'ml-[2px]' }}
|
||||
onClick={newChat}
|
||||
title="Start new conversation"
|
||||
iconOnly={!expanded}
|
||||
btnClasses={'justify-start transition-all duration-150 whitespace-nowrap'}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="subtle"
|
||||
startIcon={{
|
||||
icon: manager.isSidebarExpanded ? PanelLeftClose : PanelLeftOpen,
|
||||
classes: 'ml-[2px]'
|
||||
}}
|
||||
onClick={() => (manager.isSidebarExpanded = !manager.isSidebarExpanded)}
|
||||
iconOnly={!manager.isSidebarExpanded}
|
||||
wrapperClasses={manager.isSidebarExpanded ? 'grow min-w-0' : ''}
|
||||
btnClasses={'w-full justify-start transition-all duration-150'}
|
||||
title="Conversations"
|
||||
>
|
||||
<div transition:fade={{ duration: 100 }}> Conversations </div>
|
||||
</Button>
|
||||
{#if !emptyString(description)}
|
||||
<!-- The icon is passed as the trigger rather than left to Tooltip's own: without
|
||||
children it renders an empty trigger span beside the icon, which takes a
|
||||
button's worth of height in this column.
|
||||
Anchored to the icon's top: a long description centred on it would grow up
|
||||
over the header above the chat. -->
|
||||
<!-- Sized and inset like an icon-only Button's own icon (px-2 plus the ml-[2px]
|
||||
every icon in this column carries), so it lands on their line in the rail. -->
|
||||
<Tooltip
|
||||
placement="right-start"
|
||||
class="inline-flex items-center size-8 shrink-0 pl-[10px] text-secondary hover:text-primary"
|
||||
>
|
||||
<Info size={14} />
|
||||
{#snippet text()}
|
||||
<!-- A flow description is markdown, and is rendered as such everywhere else it
|
||||
is shown. TooltipInner brings the width cap and the scroll. -->
|
||||
<GfmMarkdown md={description ?? ''} noPadding prose="sm" />
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Side by side while there is width for both labels; stacked once collapsed,
|
||||
where the rail fits one icon across. -->
|
||||
<div
|
||||
class={manager.isSidebarExpanded
|
||||
? 'flex flex-row gap-1 items-center'
|
||||
: 'flex flex-col gap-2'}
|
||||
>
|
||||
<div transition:fade={{ duration: 100 }}> New chat </div>
|
||||
</Button>
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: Plus, classes: 'ml-[2px]' }}
|
||||
onClick={() => manager.createConversation({ clearMessages: true })}
|
||||
disabled={!!manager.newChatReason}
|
||||
title={manager.newChatReason ?? 'Start new conversation'}
|
||||
iconOnly={!manager.isSidebarExpanded}
|
||||
wrapperClasses={manager.isSidebarExpanded ? 'grow min-w-0' : ''}
|
||||
btnClasses={'w-full justify-start transition-all duration-150 whitespace-nowrap'}
|
||||
>
|
||||
<div transition:fade={{ duration: 100 }}> New chat </div>
|
||||
</Button>
|
||||
{#if manager.canFilterConversationKind}
|
||||
<Popover placement="bottom-start" closeButton={false}>
|
||||
{#snippet trigger()}
|
||||
<!-- Icon-only next to the wider New chat: which kind is listed is named in
|
||||
the title and by the group inside. -->
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
unifiedSize="md"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: Filter }}
|
||||
disabled={manager.isTurnInFlight}
|
||||
title={manager.isTurnInFlight
|
||||
? 'Wait for the current answer to change which chats are listed'
|
||||
: `Filter conversations · ${KIND_LABELS[manager.conversationKind]}`}
|
||||
iconOnly
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="p-3">
|
||||
<ToggleButtonGroup
|
||||
selected={manager.conversationKind}
|
||||
onSelected={(kind) => manager.setConversationKind(kind as ConversationKind)}
|
||||
noWFull
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton size="sm" value="test" label={KIND_LABELS.test} {item} />
|
||||
<ToggleButton size="sm" value="deployed" label={KIND_LABELS.deployed} {item} />
|
||||
<ToggleButton size="sm" value="all" label={KIND_LABELS.all} {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
<p class="text-2xs text-tertiary mt-1.5 max-w-[190px]">
|
||||
Test chats are the ones run from the flow editor's test panel, kept apart from the
|
||||
conversations the deployed flow's users started.
|
||||
</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conversations List -->
|
||||
{#if !expanded}
|
||||
{#if !manager.isSidebarExpanded}
|
||||
<!-- Collapsed state - show single chat icon with badge -->
|
||||
<div class="p-1">
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: MessageCircle }}
|
||||
onClick={() => (expanded = true)}
|
||||
title="{items.length} conversation{items.length !== 1 ? 's' : ''}"
|
||||
startIcon={{ icon: MessageSquare, classes: 'ml-[2px]' }}
|
||||
onClick={() => (manager.isSidebarExpanded = true)}
|
||||
title="{manager.conversations.length} conversation{manager.conversations.length !== 1
|
||||
? 's'
|
||||
: ''}{manager.totalUnread > 0 ? `, ${manager.totalUnread} unread` : ''}"
|
||||
variant="subtle"
|
||||
btnClasses="w-fit px-2 relative"
|
||||
>
|
||||
<CountBadge count={items.length} small alwaysVisible={true} class="right-[3px] top-[3px]" />
|
||||
<!-- The same badge the rows carry, over the one icon that stands for all of them:
|
||||
collapsed, what is worth a number is what arrived, not how many chats exist. -->
|
||||
<UnreadCountBadge
|
||||
count={manager.totalUnread}
|
||||
small
|
||||
class="absolute right-[3px] top-[3px] pointer-events-none"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Always mount InfiniteList, but hide it when collapsed -->
|
||||
<div class="flex-1 overflow-hidden transition-all duration-150 p-1" class:hidden={!expanded}>
|
||||
{#if draftShown && expanded}
|
||||
<div class="w-full pb-1" transition:fade={{ duration: 100, delay: 30 }}>
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="subtle"
|
||||
selected={true}
|
||||
btnClasses="transition-all duration-150 group"
|
||||
>
|
||||
<span class="flex-1 text-left truncate">New chat</span>
|
||||
<Button
|
||||
wrapperClasses="ml-2 transition-all duration-100 opacity-0 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e?.stopPropagation()
|
||||
draft = false
|
||||
chat.newConversation()
|
||||
}}
|
||||
title="Discard draft"
|
||||
destructive
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
iconOnly
|
||||
startIcon={{ icon: Trash2 }}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="flex-1 overflow-hidden transition-all duration-150 p-1"
|
||||
class:hidden={!manager.isSidebarExpanded}
|
||||
>
|
||||
<InfiniteList
|
||||
bind:this={list}
|
||||
bind:items
|
||||
selectedItemId={chatState.conversationId}
|
||||
bind:this={manager.conversationListComponent}
|
||||
bind:items={manager.conversations}
|
||||
selectedItemId={manager.selectedConversationId}
|
||||
noBorder={true}
|
||||
rounded={false}
|
||||
preventXOverflow={true}
|
||||
>
|
||||
{#snippet customRow({ item: conversation })}
|
||||
{#if expanded}
|
||||
{#snippet customRow({ item: conversation, hover })}
|
||||
{#if manager.isSidebarExpanded}
|
||||
<div class={twMerge('w-full pb-1')} transition:fade={{ duration: 100, delay: 30 }}>
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
draft = false
|
||||
chat.selectConversation(conversation.id)
|
||||
}}
|
||||
selected={chatState.conversationId === conversation.id}
|
||||
btnClasses="transition-all duration-150 group"
|
||||
>
|
||||
<span class="flex-1 text-left truncate">
|
||||
{getConversationTitle(conversation)}
|
||||
</span>
|
||||
{#if renamingId === conversation.id}
|
||||
<!-- While renaming, the field replaces the row rather than sitting inside its
|
||||
button: a text input nested in a button is a nested interactive control,
|
||||
and every keystroke would have to be kept from reaching the row. -->
|
||||
<div class="flex flex-row items-center gap-1 h-8 px-2 rounded-md bg-surface-selected">
|
||||
{@render statusDot(conversation)}
|
||||
<TextInput
|
||||
bind:this={renameInput}
|
||||
bind:value={renameDraft}
|
||||
class="min-w-0 flex-1"
|
||||
size="sm"
|
||||
inputProps={{
|
||||
'aria-label': 'Chat name',
|
||||
onblur: commitRename,
|
||||
onkeydown: (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
commitRename()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
renamingId = undefined
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<Button
|
||||
wrapperClasses={twMerge(
|
||||
'ml-2 transition-all duration-100 opacity-0 group-hover:opacity-100',
|
||||
deletingId === conversation.id ? 'opacity-100' : ' '
|
||||
)}
|
||||
disabled={deletingId === conversation.id}
|
||||
onClick={(e) => {
|
||||
e?.stopPropagation()
|
||||
list?.deleteItem(conversation.id)
|
||||
}}
|
||||
title="Delete conversation"
|
||||
destructive
|
||||
unifiedSize="xs"
|
||||
unifiedSize="md"
|
||||
variant="subtle"
|
||||
loading={deletingId === conversation.id}
|
||||
iconOnly
|
||||
startIcon={{ icon: Trash2 }}
|
||||
/>
|
||||
</Button>
|
||||
onClick={() => manager.selectConversation(conversation.id, conversation.isDraft)}
|
||||
selected={manager.selectedConversationId === conversation.id}
|
||||
disabled={!!rowLocked(conversation)}
|
||||
title={rowLocked(conversation)}
|
||||
btnClasses="transition-all duration-150 group gap-2"
|
||||
>
|
||||
<!-- In the slot New chat's icon occupies above, so the column lines up. Says
|
||||
what the chat is doing where there is something to say, and which kind of
|
||||
chat it is otherwise. -->
|
||||
{@render statusDot(conversation)}
|
||||
{@const unread = manager.unreadCount(conversation.id)}
|
||||
<span
|
||||
class={twMerge(
|
||||
'flex-1 text-left truncate',
|
||||
unread > 0 ? 'font-semibold text-primary' : ''
|
||||
)}
|
||||
>
|
||||
{getConversationTitle(conversation)}
|
||||
</span>
|
||||
{#if manager.conversationStatus(conversation.id) === 'queued' || unread > 0}
|
||||
<span class="shrink-0 inline-flex items-center gap-1">
|
||||
{#if manager.conversationStatus(conversation.id) === 'queued'}
|
||||
<PencilLine class="w-3 h-3 text-tertiary" aria-label="Message waiting to send" />
|
||||
{/if}
|
||||
<UnreadCountBadge count={unread} />
|
||||
</span>
|
||||
{/if}
|
||||
<!-- Hidden while the row is disabled: it sits inside the row's button, and a
|
||||
disabled button swallows every click in its subtree, so a visible menu
|
||||
here would be an affordance that does nothing. -->
|
||||
{#if !rowLocked(conversation)}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class={twMerge(
|
||||
'ml-2 transition-all duration-100 opacity-0 group-hover:opacity-100',
|
||||
manager.deletingConversationId === conversation.id ? 'opacity-100' : ''
|
||||
)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownV2 items={() => rowActions(conversation)} size="xs" />
|
||||
</div>
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet empty()}
|
||||
{#if !draftShown}
|
||||
<div class="p-4 text-center">
|
||||
<p class="text-sm text-secondary mb-2">No conversations yet</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="p-4 text-center">
|
||||
<p class="text-sm text-secondary mb-2">No conversations yet</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
</InfiniteList>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
agentModelGap,
|
||||
agentModelWiringInputs,
|
||||
composerOwnedInputs,
|
||||
attachmentsTargetFor,
|
||||
parseProviderTransform,
|
||||
resolveAgentChatInputs,
|
||||
resolveAgentModelWiring,
|
||||
withoutRejectedEffort
|
||||
} from './agentChatInputs'
|
||||
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>): FlowModule {
|
||||
return {
|
||||
id: 'a',
|
||||
value: { type: 'aiagent', tools: [], input_transforms }
|
||||
} as unknown as FlowModule
|
||||
}
|
||||
|
||||
function agent(expr: string): FlowModule {
|
||||
return {
|
||||
id: 'a',
|
||||
value: {
|
||||
type: 'aiagent',
|
||||
tools: [],
|
||||
input_transforms: { provider: { type: 'javascript', expr } }
|
||||
}
|
||||
} as unknown as FlowModule
|
||||
}
|
||||
|
||||
describe('parseProviderTransform', () => {
|
||||
it('reads a whole-object reference', () => {
|
||||
expect(parseProviderTransform({ type: 'javascript', expr: 'flow_input.model' })).toEqual({
|
||||
whole: 'model',
|
||||
fields: {},
|
||||
fixed: {}
|
||||
})
|
||||
})
|
||||
|
||||
it('splits a partial expression into wired and fixed fields', () => {
|
||||
const wiring = parseProviderTransform({
|
||||
type: 'javascript',
|
||||
expr: "{ kind: 'anthropic', resource: '$res:u/admin/claude', model: flow_input.model, reasoning_effort: flow_input.thinking }"
|
||||
})
|
||||
expect(wiring).toEqual({
|
||||
fields: { model: 'model', reasoning_effort: 'thinking' },
|
||||
fixed: { kind: 'anthropic', resource: '$res:u/admin/claude' }
|
||||
})
|
||||
})
|
||||
|
||||
// The regression this detector exists for: counting flow_input references would read
|
||||
// this as one input carrying the whole provider object, and the composer would write
|
||||
// {kind, resource, model} into an input the expression uses as the model name.
|
||||
it('does not mistake a single-reference partial expression for a whole-object one', () => {
|
||||
const wiring = parseProviderTransform({
|
||||
type: 'javascript',
|
||||
expr: "{ kind: 'anthropic', resource: '$res:u/admin/claude', model: flow_input.model }"
|
||||
})
|
||||
expect(wiring?.whole).toBeUndefined()
|
||||
expect(wiring?.fields).toEqual({ model: 'model' })
|
||||
})
|
||||
|
||||
// The flow editor's JS field commonly holds a parenthesised object, which is how an
|
||||
// author writes one without it reading as a block.
|
||||
it('accepts a parenthesised object expression', () => {
|
||||
const wiring = parseProviderTransform({
|
||||
type: 'javascript',
|
||||
expr: `({
|
||||
"kind": "anthropic",
|
||||
"resource": "$res:u/admin/anthropic_windmill_codegen",
|
||||
"model": "claude-sonnet-5",
|
||||
"reasoning_effort": flow_input.thinking
|
||||
})`
|
||||
})
|
||||
expect(wiring).toEqual({
|
||||
fields: { reasoning_effort: 'thinking' },
|
||||
fixed: {
|
||||
kind: 'anthropic',
|
||||
resource: '$res:u/admin/anthropic_windmill_codegen',
|
||||
model: 'claude-sonnet-5'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a static provider as entirely fixed', () => {
|
||||
expect(
|
||||
parseProviderTransform({
|
||||
type: 'static',
|
||||
value: { kind: 'openai', resource: '$res:u/admin/oai', model: 'gpt-5.6' }
|
||||
})
|
||||
).toEqual({
|
||||
fields: {},
|
||||
fixed: { kind: 'openai', resource: '$res:u/admin/oai', model: 'gpt-5.6' }
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['{ ...base, model: flow_input.model }', 'a spread could supply any field'],
|
||||
['{ model: pickModel(flow_input.x) }', 'a call is not classifiable'],
|
||||
['flow_input.model + 1', 'not a bare reference'],
|
||||
['{ model: ', 'unparseable']
|
||||
])('gives up on %s', (expr) => {
|
||||
expect(parseProviderTransform({ type: 'javascript', expr } as any)).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([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentModelWiringInputs', () => {
|
||||
// The button writes `kind` only alongside a resource, since a provider is picked as a
|
||||
// pair. Hiding a kind input it cannot write would leave the run without one.
|
||||
it('keeps a kind input the model button cannot write', () => {
|
||||
const wiring = resolveAgentModelWiring([
|
||||
agent(`({ kind: flow_input.k, "resource": "$res:u/admin/claude", model: flow_input.m })`)
|
||||
])
|
||||
expect(agentModelWiringInputs(wiring)).toEqual(['m'])
|
||||
})
|
||||
|
||||
it('hides a kind input it writes with the resource', () => {
|
||||
const wiring = resolveAgentModelWiring([
|
||||
agent(`({ kind: flow_input.k, resource: flow_input.r, model: flow_input.m })`)
|
||||
])
|
||||
expect(agentModelWiringInputs(wiring)?.sort()).toEqual(['k', 'm', 'r'])
|
||||
})
|
||||
|
||||
// The button always draws a thinking control — a ladder, a typed token, or why there is
|
||||
// neither — so a wired effort is the button's whatever the registry knows about the model.
|
||||
it('always claims a wired reasoning_effort, whatever the provider', () => {
|
||||
const custom = resolveAgentModelWiring([
|
||||
agent(
|
||||
`({ "kind": "customai", "resource": "$res:u/admin/custom", model: flow_input.m, reasoning_effort: flow_input.thinking })`
|
||||
)
|
||||
])
|
||||
expect(agentModelWiringInputs(custom)?.sort()).toEqual(['m', 'thinking'])
|
||||
|
||||
const known = resolveAgentModelWiring([
|
||||
agent(
|
||||
`({ "kind": "openai", "resource": "$res:u/admin/oai", "model": "gpt-4o", reasoning_effort: flow_input.thinking })`
|
||||
)
|
||||
])
|
||||
expect(agentModelWiringInputs(known)).toEqual(['thinking'])
|
||||
})
|
||||
})
|
||||
|
||||
// The modal is whatever this does not return, so the two can no longer disagree about an
|
||||
// input — and what a control can do *right now* is deliberately not part of the answer.
|
||||
describe('composerOwnedInputs', () => {
|
||||
const wiring = () =>
|
||||
resolveAgentModelWiring([
|
||||
agent(`({ "kind": "openai", "resource": "$res:u/admin/oai", model: flow_input.m })`)
|
||||
])
|
||||
|
||||
it('claims the model wiring and the attachments target together', () => {
|
||||
expect(composerOwnedInputs(wiring(), { name: 'files' })?.sort()).toEqual(['files', 'm'])
|
||||
})
|
||||
|
||||
it('claims the attachments input whatever the workspace can do with it', () => {
|
||||
// No object storage is a state the paperclip shows, not a handover: the modal could
|
||||
// not upload either, and the run would fail fetching a key typed there.
|
||||
expect(composerOwnedInputs(undefined, { name: 'files' })).toEqual(['files'])
|
||||
})
|
||||
|
||||
it('leaves an input no control fits to the modal', () => {
|
||||
// `attachmentsTargetFor` returns nothing for a shape the paperclip cannot write — a
|
||||
// plain string key, say — and then the input is genuinely the modal's.
|
||||
expect(composerOwnedInputs(wiring(), undefined)).toEqual(['m'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveAgentModelWiring', () => {
|
||||
const fixedResource = `"kind": "anthropic", "resource": "$res:u/admin/claude"`
|
||||
|
||||
it('drives a field every agent reads from the same input', () => {
|
||||
const wiring = resolveAgentModelWiring([
|
||||
agent(
|
||||
`({ ${fixedResource}, "model": "claude-sonnet-5", reasoning_effort: flow_input.thinking })`
|
||||
),
|
||||
agent(
|
||||
`({ ${fixedResource}, "model": "claude-opus-5", reasoning_effort: flow_input.thinking })`
|
||||
)
|
||||
])
|
||||
expect(wiring?.fields).toEqual({ reasoning_effort: 'thinking' })
|
||||
// The agents run different models, so there is no single one to name.
|
||||
expect(wiring?.fixed).toEqual({ kind: 'anthropic', resource: '$res:u/admin/claude' })
|
||||
})
|
||||
|
||||
it('drops a field the agents disagree about', () => {
|
||||
const wiring = resolveAgentModelWiring([
|
||||
agent(`({ ${fixedResource}, reasoning_effort: flow_input.thinking })`),
|
||||
agent(`({ ${fixedResource}, reasoning_effort: flow_input.other })`)
|
||||
])
|
||||
expect(wiring?.fields.reasoning_effort).toBeUndefined()
|
||||
})
|
||||
|
||||
// A nested agent is the parent agent's tool, not a step the reader is talking to: the
|
||||
// graph walks it as a child module, and counting it here would defeat the chat's own
|
||||
// model control (this is the shape of the all-tools example flow).
|
||||
it("ignores an agent carried as another agent's tool", () => {
|
||||
const parent = agent('flow_input.model')
|
||||
;(parent.value as any).tools = [
|
||||
{
|
||||
id: 'summarize',
|
||||
value: {
|
||||
tool_type: 'flowmodule',
|
||||
type: 'aiagent',
|
||||
tools: [],
|
||||
input_transforms: {
|
||||
provider: { type: 'static', value: { kind: 'anthropic', model: 'claude-sonnet-5' } }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
expect(resolveAgentModelWiring([parent])).toEqual({
|
||||
whole: 'model',
|
||||
fields: {},
|
||||
fixed: {},
|
||||
someAgentCannotRun: false
|
||||
})
|
||||
})
|
||||
|
||||
// The control writes one flow input; an agent that fixes the field instead never reads
|
||||
// it, so offering the control would move one agent and leave the other where it was.
|
||||
it('does not offer a field one agent wires and another fixes', () => {
|
||||
const wiring = resolveAgentModelWiring([
|
||||
agent(`({ ${fixedResource}, model: flow_input.model })`),
|
||||
agent(`({ ${fixedResource}, "model": "claude-opus-5" })`)
|
||||
])
|
||||
expect(wiring?.fields.model).toBeUndefined()
|
||||
expect(wiring?.fixed.model).toBeUndefined()
|
||||
})
|
||||
|
||||
// Disagreeing about the model is not the same as having no model: the flow runs, on a
|
||||
// different one per agent, and the composer has nothing to fix.
|
||||
it('says nothing about a model the agents merely disagree about', () => {
|
||||
const wiring = resolveAgentModelWiring([
|
||||
agent(`({ ${fixedResource}, "model": "claude-sonnet-5" })`),
|
||||
agent(`({ ${fixedResource}, "model": "claude-opus-5" })`)
|
||||
])
|
||||
expect(agentModelGap(wiring)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('still reports an agent with nothing to call', () => {
|
||||
expect(
|
||||
agentModelGap(resolveAgentModelWiring([agent(`({ "kind": "openai", "model": "" })`)]))
|
||||
).toBe('Pick a provider and model on the AI agent step to use this chat.')
|
||||
})
|
||||
|
||||
// An expression the parser cannot account for could supply anything, so the agents it
|
||||
// belongs to cannot be spoken for either.
|
||||
it("offers nothing when one agent's provider cannot be read", () => {
|
||||
expect(
|
||||
resolveAgentModelWiring([
|
||||
agent(`({ ${fixedResource}, model: flow_input.model })`),
|
||||
agent(`({ ...base, model: flow_input.model })`)
|
||||
])
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
// Disagreement is not the same as absence, but an agent with an empty model still
|
||||
// cannot run, however well its neighbour is configured.
|
||||
it('keeps warning when one agent has no model and another does', () => {
|
||||
const wiring = resolveAgentModelWiring([
|
||||
agent(`({ ${fixedResource}, "model": "claude-sonnet-5" })`),
|
||||
agent(`({ ${fixedResource}, "model": "" })`)
|
||||
])
|
||||
expect(agentModelGap(wiring)).toBe(
|
||||
'Pick a provider and model on the AI agent step to use this chat.'
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a flow mixing whole-object and field-by-field wiring', () => {
|
||||
expect(
|
||||
resolveAgentModelWiring([
|
||||
agent('flow_input.provider'),
|
||||
agent(`({ ${fixedResource}, model: flow_input.model })`)
|
||||
])
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
describe('withoutRejectedEffort', () => {
|
||||
const wiring = (fields: Record<string, string>, fixed: Record<string, any> = {}) =>
|
||||
({ fields, fixed }) as any
|
||||
|
||||
// The live 400 this guards: Anthropic turns any effort into adaptive thinking, which
|
||||
// Haiku rejects outright ("adaptive thinking is not supported on this model").
|
||||
it('drops an effort the chosen model rejects', () => {
|
||||
const values = { model: 'claude-haiku-4-5-20251001', reasoning_effort: 'high' }
|
||||
expect(
|
||||
withoutRejectedEffort(
|
||||
wiring({ model: 'model', reasoning_effort: 'reasoning_effort' }, { kind: 'anthropic' }),
|
||||
values
|
||||
)
|
||||
).toEqual({ model: 'claude-haiku-4-5-20251001', reasoning_effort: '' })
|
||||
})
|
||||
|
||||
it('keeps an effort the model takes', () => {
|
||||
const values = { model: 'claude-sonnet-5', reasoning_effort: 'high' }
|
||||
expect(
|
||||
withoutRejectedEffort(
|
||||
wiring({ model: 'model', reasoning_effort: 'reasoning_effort' }, { kind: 'anthropic' }),
|
||||
values
|
||||
)
|
||||
).toBe(values)
|
||||
})
|
||||
|
||||
// Clearing on a guess would override the author's own default.
|
||||
it('leaves the value alone for a family the registry cannot speak for', () => {
|
||||
const values = { model: 'some-model', reasoning_effort: 'high' }
|
||||
expect(
|
||||
withoutRejectedEffort(
|
||||
wiring({ model: 'model', reasoning_effort: 'reasoning_effort' }, { kind: 'customai' }),
|
||||
values
|
||||
)
|
||||
).toBe(values)
|
||||
})
|
||||
|
||||
// A flow that wires `provider` as one object keeps the effort inside it, so reading
|
||||
// `fields.reasoning_effort` finds nothing and the 400 would go out unchecked.
|
||||
it('clears the effort inside a whole-object provider input', () => {
|
||||
const wiring = resolveAgentModelWiring([agent('flow_input.provider')])
|
||||
const values = {
|
||||
provider: {
|
||||
kind: 'anthropic',
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
reasoning_effort: 'high'
|
||||
}
|
||||
}
|
||||
expect(withoutRejectedEffort(wiring, values)).toEqual({
|
||||
provider: {
|
||||
kind: 'anthropic',
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
reasoning_effort: ''
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves a whole-object provider alone when the model takes the effort', () => {
|
||||
const wiring = resolveAgentModelWiring([agent('flow_input.provider')])
|
||||
const values = {
|
||||
provider: { kind: 'anthropic', model: 'claude-sonnet-5', reasoning_effort: 'high' }
|
||||
}
|
||||
expect(withoutRejectedEffort(wiring, values)).toBe(values)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The shape agent chat is usually built in: one agent answers the reader, others do work of
|
||||
* their own in branches. A sub-agent never sees the message, so what it runs on is not a
|
||||
* setting this conversation has.
|
||||
*/
|
||||
describe('agents that do not read the message', () => {
|
||||
const answerer = (provider: string) =>
|
||||
({
|
||||
id: 'answerer',
|
||||
value: {
|
||||
type: 'aiagent',
|
||||
tools: [],
|
||||
input_transforms: {
|
||||
user_message: { type: 'javascript', expr: 'flow_input.user_message' },
|
||||
provider: { type: 'javascript', expr: provider }
|
||||
}
|
||||
}
|
||||
}) as unknown as FlowModule
|
||||
const subAgent = (provider: string, message = "'critique: ' + results.answerer") =>
|
||||
({
|
||||
id: 'critic',
|
||||
value: {
|
||||
type: 'aiagent',
|
||||
tools: [],
|
||||
input_transforms: {
|
||||
user_message: { type: 'javascript', expr: message },
|
||||
provider: { type: 'javascript', expr: provider }
|
||||
}
|
||||
}
|
||||
}) as unknown as FlowModule
|
||||
|
||||
const wired = `({ kind: 'anthropic', resource: '$res:u/admin/c', model: flow_input.model })`
|
||||
const fixed = `({ kind: 'anthropic', resource: '$res:u/admin/c', model: 'claude-sonnet-5' })`
|
||||
|
||||
it('keeps the model control when only a sub-agent fixes its own model', () => {
|
||||
const wiring = resolveAgentModelWiring([answerer(wired), 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', () => {
|
||||
const wiring = resolveAgentModelWiring([
|
||||
answerer(wired),
|
||||
subAgent(fixed, 'flow_input.user_message')
|
||||
])
|
||||
expect(wiring?.fields.model).toBeUndefined()
|
||||
})
|
||||
|
||||
// An author wraps the message in context freely; that is still the agent being talked to.
|
||||
it('counts an agent whose prompt embeds the message', () => {
|
||||
const wrapped = subAgent(fixed, "'Answer politely: ' + flow_input.user_message")
|
||||
const wiring = resolveAgentModelWiring([answerer(wired), wrapped])
|
||||
expect(wiring?.fields.model).toBeUndefined()
|
||||
})
|
||||
|
||||
// Nothing to scope to means the flow is shaped in some way this cannot read, so every
|
||||
// agent counts again rather than none.
|
||||
it('falls back to every agent when none reads the message', () => {
|
||||
const wiring = resolveAgentModelWiring([subAgent(wired), subAgent(fixed)])
|
||||
expect(wiring?.fields.model).toBeUndefined()
|
||||
})
|
||||
|
||||
// The editor writes the dot form, but an author may hand-edit either. A shape this does
|
||||
// not recognise drops that agent out of the unanimity check it should be part of.
|
||||
it.each([
|
||||
["flow_input['user_message']", 'bracket access'],
|
||||
['flow_input?.user_message', 'optional chaining'],
|
||||
["'Answer politely: ' + flow_input.user_message", 'embedded in a prompt'],
|
||||
['flow_input.user_message + flow_input.tone', 'read alongside another input']
|
||||
])('treats %s as reading the message', (message) => {
|
||||
const wiring = resolveAgentModelWiring([
|
||||
answerer(wired),
|
||||
subAgent(fixed, message as string)
|
||||
])
|
||||
expect(wiring?.fields.model).toBeUndefined()
|
||||
})
|
||||
|
||||
// A trailing line comment used to swallow the closing paren the parser adds, which made
|
||||
// the whole expression unreadable and dropped the agent out of the check.
|
||||
it.each([
|
||||
['flow_input.user_message // the message', 'a trailing comment'],
|
||||
['flow_input.user_message\n// why', 'a comment on its own last line']
|
||||
])('still reads the message with %s', (message) => {
|
||||
const wiring = resolveAgentModelWiring([
|
||||
answerer(wired),
|
||||
subAgent(fixed, message as string)
|
||||
])
|
||||
expect(wiring?.fields.model).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['// see flow_input.user_message', 'a mention in a comment'],
|
||||
["'flow_input.user_message'", 'a mention in a string'],
|
||||
['flow_input.user_message_extra', 'a different input with the same prefix']
|
||||
])('does not treat %s as reading the message', (message) => {
|
||||
const wiring = resolveAgentModelWiring([
|
||||
answerer(wired),
|
||||
subAgent(fixed, message as string)
|
||||
])
|
||||
expect(wiring?.fields.model).toBe('model')
|
||||
})
|
||||
})
|
||||
|
||||
// An author annotating their own provider must not lose the model control for it: a
|
||||
// comment beside the expression is not another expression.
|
||||
describe('parseProviderTransform with comments', () => {
|
||||
it.each([
|
||||
['flow_input.provider // the one to use', 'a trailing line comment'],
|
||||
['flow_input.provider /* the one to use */', 'a trailing block comment'],
|
||||
['/* pick one */ flow_input.provider', 'a leading comment']
|
||||
])('reads a whole-object reference despite %s', (expr) => {
|
||||
expect(parseProviderTransform({ type: 'javascript', expr })?.whole).toBe('provider')
|
||||
})
|
||||
|
||||
it('reads an object literal with a comment inside it', () => {
|
||||
const wiring = parseProviderTransform({
|
||||
type: 'javascript',
|
||||
expr: `({ kind: 'anthropic', /* fixed */ resource: '$res:u/admin/c', model: flow_input.model })`
|
||||
})
|
||||
expect(wiring?.fields.model).toBe('model')
|
||||
})
|
||||
|
||||
// The check exists to reject an expression with something else beside it; a second
|
||||
// expression is still something else.
|
||||
it('still refuses a second expression beside it', () => {
|
||||
expect(parseProviderTransform({ type: 'javascript', expr: 'flow_input.provider, 1' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,536 @@
|
||||
import type { AIProvider, FlowModule, InputTransform } from '$lib/gen'
|
||||
import { getReasoningCapability } from '$lib/components/copilot/reasoningRegistry'
|
||||
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
|
||||
}
|
||||
|
||||
/** Block and line comments removed, so what is left is only what affects the value. */
|
||||
function withoutComments(source: string): string {
|
||||
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* An expression wrapped so acorn will read it: parenthesised, because a leading `{` would
|
||||
* otherwise parse as a block, and on its own line, because a trailing `// comment` would
|
||||
* otherwise swallow the closing paren and make the whole thing unparseable.
|
||||
*/
|
||||
function parenthesised(expr: string): string {
|
||||
return `(\n${expr}\n)`
|
||||
}
|
||||
|
||||
/** The flow input the server requires on a chat-enabled flow, and stores as the message. */
|
||||
const MESSAGE_INPUT = 'user_message'
|
||||
|
||||
/**
|
||||
* The agents the reader is talking to: the ones the chat's message is fed to.
|
||||
*
|
||||
* A flow commonly runs one agent on the message and others on work of their own — a
|
||||
* critic reading `results.x`, a classifier in a branch. Those never see what was typed,
|
||||
* so what they run on is not a setting this conversation has: letting one of them differ
|
||||
* on the model would take the model control away from the agent that does answer.
|
||||
*
|
||||
* The message need not be the whole prompt — an author wraps it in context freely — so
|
||||
* this asks whether the expression reads it at all. A flow where no agent reads it is one
|
||||
* shaped in some way this cannot speak for, and every agent counts again rather than none.
|
||||
*/
|
||||
function chatFacingAgents(modules: FlowModule[] | undefined): FlowModule[] {
|
||||
const agents = agentSteps(modules)
|
||||
const facing = agents.filter((module) => {
|
||||
const transform = (module.value as any).input_transforms?.[MESSAGE_INPUT]
|
||||
return transform?.type === 'javascript' && readsFlowInput(transform.expr, MESSAGE_INPUT)
|
||||
})
|
||||
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, so this cannot use `flowInputRef`,
|
||||
* which answers a different question — which single input feeds a field.
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* AI agent inputs the chat composer can drive.
|
||||
*
|
||||
* The composer never edits the flow: an agent field is reachable only when the author
|
||||
* wired a flow input to it, so what the chip writes is a run input like any other. That
|
||||
* also keeps it working on the deployed chat, where the reader has no write access, and
|
||||
* on a step linked to an `ai_agent` resource, where every field but `user_message` /
|
||||
* `user_attachments` comes from the resource and is not overridable at all.
|
||||
*
|
||||
* Only what the person chatting legitimately owns turn to turn belongs here, which today
|
||||
* is the files they attach and nothing else. `system_prompt`, `temperature` and
|
||||
* `max_completion_tokens` shape how the agent behaves for everyone who runs the flow —
|
||||
* surfacing them per conversation invites tuning the flow from the chat instead of
|
||||
* fixing it in the editor. They stay flow settings, reachable through Configure inputs
|
||||
* when the author deliberately exposes them. `max_iterations` is absent for the same
|
||||
* reason, and because it caps the tool-use loop rather than a single generation. The
|
||||
* model has its own control, resolved separately through `resolveAgentModelWiring`.
|
||||
*/
|
||||
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, which the composer
|
||||
* clears on send. A later key of the other sort would not be this one. */
|
||||
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 — the chip renders it with the same editor the modal would. */
|
||||
property: Record<string, any>
|
||||
}
|
||||
|
||||
const FLOW_INPUT_REF = /flow_input\??\.([A-Za-z_$][\w$]*)/g
|
||||
|
||||
/**
|
||||
* The flow input a transform is fed by, when exactly one feeds it.
|
||||
*
|
||||
* The expression need not be a bare pass-through — a step commonly reshapes what it
|
||||
* reads, e.g. `(flow_input.files || []).map(f => ({ bucket: f.storage, key: f.s3 }))`.
|
||||
* Writing that input is still right, because the expression consumes it. Two or more
|
||||
* inputs are ambiguous: the composer would have no way to say which one it is editing.
|
||||
*/
|
||||
export function flowInputRef(transform: InputTransform | undefined): string | undefined {
|
||||
if (transform?.type !== 'javascript') return undefined
|
||||
const names = new Set([...transform.expr.matchAll(FLOW_INPUT_REF)].map((match) => match[1]))
|
||||
return names.size === 1 ? [...names][0] : undefined
|
||||
}
|
||||
|
||||
/** A provider value as the agent stores it. */
|
||||
export type AgentModel = { kind?: string; model?: string; reasoning_effort?: string }
|
||||
|
||||
/** The provider fields the composer can read or drive. */
|
||||
const PROVIDER_FIELDS = ['kind', 'resource', 'model', 'reasoning_effort'] as const
|
||||
export type ProviderField = (typeof PROVIDER_FIELDS)[number]
|
||||
|
||||
/**
|
||||
* How an AI agent step's `provider` is supplied, field by field.
|
||||
*
|
||||
* The agent takes one `provider` object, so an author who wants the chat to choose only
|
||||
* the model writes the rest as literals around it:
|
||||
*
|
||||
* { kind: 'anthropic', resource: '$res:u/admin/claude', model: flow_input.model }
|
||||
*
|
||||
* `fields` names the flow input behind each field the author exposed, `fixed` holds the
|
||||
* literals, and `whole` covers the plain `flow_input.x` case where one input carries the
|
||||
* entire object. Reading them apart is what lets the composer offer exactly the knobs the
|
||||
* flow exposed — and stops a partial expression from being mistaken for a whole-object
|
||||
* one, which would write a provider object into an input the flow reads as a model name.
|
||||
*/
|
||||
export type AgentModelWiring = {
|
||||
whole?: string
|
||||
fields: Partial<Record<ProviderField, string>>
|
||||
fixed: Partial<Record<ProviderField, any>>
|
||||
/**
|
||||
* One of the agents names no resource or no model of its own and no flow input feeds
|
||||
* it, so that agent's run fails whatever the others do. Held apart from the fields,
|
||||
* which describe what the composer may offer.
|
||||
*/
|
||||
someAgentCannotRun?: boolean
|
||||
}
|
||||
|
||||
/** The flow input behind `flow_input.x`, `flow_input?.x` or `flow_input['x']`. */
|
||||
function flowInputName(node: any): string | undefined {
|
||||
const member = node?.type === 'ChainExpression' ? node.expression : node
|
||||
if (member?.type !== 'MemberExpression') return undefined
|
||||
if (member.object?.type !== 'Identifier' || member.object.name !== 'flow_input') return undefined
|
||||
if (!member.computed && member.property?.type === 'Identifier') return member.property.name
|
||||
if (member.computed && member.property?.type === 'Literal') {
|
||||
return typeof member.property.value === 'string' ? member.property.value : undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** A property's name, for the plain `key:` and `'key':` forms only. */
|
||||
function propertyKey(property: any): string | undefined {
|
||||
if (property?.type !== 'Property' || property.computed) return undefined
|
||||
if (property.key?.type === 'Identifier') return property.key.name
|
||||
if (property.key?.type === 'Literal' && typeof property.key.value === 'string') {
|
||||
return property.key.value
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a `provider` input transform. Anything this cannot account for in full returns
|
||||
* undefined rather than a guess: the composer then leaves the field alone instead of
|
||||
* writing into an expression it does not understand.
|
||||
*/
|
||||
export function parseProviderTransform(
|
||||
transform: InputTransform | undefined
|
||||
): AgentModelWiring | undefined {
|
||||
if (transform?.type === 'static') {
|
||||
const value = transform.value
|
||||
if (!value || typeof value !== 'object') return undefined
|
||||
const fixed: AgentModelWiring['fixed'] = {}
|
||||
for (const field of PROVIDER_FIELDS) {
|
||||
if (value[field] !== undefined) fixed[field] = value[field]
|
||||
}
|
||||
return { fields: {}, fixed }
|
||||
}
|
||||
if (transform?.type !== 'javascript') return undefined
|
||||
|
||||
// The author's own text may already be wrapped, so any balanced surround is fine — what
|
||||
// the span check rejects is an expression with something else beside it.
|
||||
const source = parenthesised(transform.expr)
|
||||
let node: any
|
||||
try {
|
||||
node = parseExpressionAt(source, 0, { ecmaVersion: 'latest' })
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
// A comment beside the expression is not another expression: the author annotating their
|
||||
// own provider must not cost them the model control. Dropped before the check so what
|
||||
// remains is only what would change the value — and so a paren inside a comment is not
|
||||
// counted as one of the wrapping pair.
|
||||
const before = withoutComments(source.slice(0, node.start))
|
||||
const after = withoutComments(source.slice(node.end))
|
||||
if (!/^[\s(]*$/.test(before) || !/^[\s)]*$/.test(after)) return undefined
|
||||
if ((before.match(/\(/g)?.length ?? 0) !== (after.match(/\)/g)?.length ?? 0)) return undefined
|
||||
|
||||
const whole = flowInputName(node)
|
||||
if (whole) return { whole, fields: {}, fixed: {} }
|
||||
if (node.type !== 'ObjectExpression') return undefined
|
||||
|
||||
const fields: AgentModelWiring['fields'] = {}
|
||||
const fixed: AgentModelWiring['fixed'] = {}
|
||||
for (const property of node.properties) {
|
||||
const key = propertyKey(property)
|
||||
// A spread or a computed key could supply any field, so nothing here is knowable.
|
||||
if (!key) return undefined
|
||||
if (!(PROVIDER_FIELDS as readonly string[]).includes(key)) continue
|
||||
const name = flowInputName(property.value)
|
||||
if (name) {
|
||||
fields[key as ProviderField] = name
|
||||
} else if (property.value?.type === 'Literal') {
|
||||
fixed[key as ProviderField] = property.value.value
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
return { fields, fixed }
|
||||
}
|
||||
|
||||
/** How one agent supplies a provider field: from an input, as a literal, or not at all. */
|
||||
type FieldSupply =
|
||||
| { kind: 'wired'; name: string }
|
||||
| { kind: 'fixed'; value: any }
|
||||
| { kind: 'absent' }
|
||||
|
||||
/** Whether one agent supplies a field with nothing usable: no input, and no literal. */
|
||||
function agentFieldEmpty(wiring: AgentModelWiring, field: ProviderField): boolean {
|
||||
if (wiring.fields[field] !== undefined) return false
|
||||
const value = wiring.fixed[field]
|
||||
return value === undefined || value === ''
|
||||
}
|
||||
|
||||
function fieldSupply(wiring: AgentModelWiring, field: ProviderField): FieldSupply {
|
||||
const name = wiring.fields[field]
|
||||
if (name !== undefined) return { kind: 'wired', name }
|
||||
const value = wiring.fixed[field]
|
||||
if (value !== undefined) return { kind: 'fixed', value }
|
||||
return { kind: 'absent' }
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider wiring the chat can act on, across every AI agent in the flow.
|
||||
*
|
||||
* With several agents a field is drivable when they agree on it: one flow input feeding
|
||||
* it, or one literal fixing it. Where they disagree there is no single value to show or
|
||||
* write, so that field is dropped and the others still work. A flow mixing whole-object
|
||||
* and field-by-field wiring is ambiguous throughout and yields nothing.
|
||||
*/
|
||||
export function resolveAgentModelWiring(
|
||||
modules: FlowModule[] | undefined
|
||||
): AgentModelWiring | undefined {
|
||||
const agents = chatFacingAgents(modules)
|
||||
const parsed = agents.map((agent) =>
|
||||
parseProviderTransform((agent.value as any).input_transforms?.['provider'])
|
||||
)
|
||||
if (parsed.length === 0) return undefined
|
||||
// An agent whose provider cannot be read is an agent the composer cannot speak for:
|
||||
// dropping it would let the rest declare a control that governs only some of them.
|
||||
if (parsed.some((wiring) => wiring === undefined)) return undefined
|
||||
const wirings = parsed as AgentModelWiring[]
|
||||
// Whether any single agent has nothing to call, which stays true however the others
|
||||
// are wired — the gap message is about that agent, not about their agreement.
|
||||
const someAgentCannotRun = wirings.some(
|
||||
(wiring) =>
|
||||
!wiring.whole && (agentFieldEmpty(wiring, 'resource') || agentFieldEmpty(wiring, 'model'))
|
||||
)
|
||||
if (wirings.length === 1) return { ...wirings[0], someAgentCannotRun }
|
||||
|
||||
const wholes = new Set(wirings.map((w) => w.whole))
|
||||
if (wholes.size === 1 && !wholes.has(undefined)) {
|
||||
return { whole: [...wholes][0], fields: {}, fixed: {}, someAgentCannotRun }
|
||||
}
|
||||
if (wirings.some((w) => w.whole !== undefined)) return undefined
|
||||
|
||||
const fields: AgentModelWiring['fields'] = {}
|
||||
const fixed: AgentModelWiring['fixed'] = {}
|
||||
for (const field of PROVIDER_FIELDS) {
|
||||
// Every agent has to supply the field the same way for the composer to speak for
|
||||
// them all. One wired name among agents that otherwise fix it is not agreement:
|
||||
// the control would move that one agent and leave the others where they are.
|
||||
const supplies = new Set(wirings.map((w) => JSON.stringify(fieldSupply(w, field))))
|
||||
// Disagreement leaves the field neither editable nor known: a control offered here
|
||||
// would govern one agent while the rest ran on something else.
|
||||
if (supplies.size > 1) continue
|
||||
const supply: FieldSupply = JSON.parse([...supplies][0])
|
||||
if (supply.kind === 'wired') fields[field] = supply.name
|
||||
else if (supply.kind === 'fixed') fixed[field] = supply.value
|
||||
}
|
||||
return { fields, fixed, someAgentCannotRun }
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the chat cannot run, when the agent's own provider is incomplete.
|
||||
*
|
||||
* A freshly added agent carries `{ kind: 'openai', model: '', resource: '' }`, so it names
|
||||
* a provider kind while having nothing to call — the run fails and the chat can do nothing
|
||||
* about it, because no flow input feeds either field. Saying so beats a dead model button.
|
||||
* A field the flow exposes is never a gap: the reader picks it in the composer.
|
||||
*/
|
||||
export function agentModelGap(wiring: AgentModelWiring | undefined): string | undefined {
|
||||
// No agent, several of them, or an expression we cannot read: not ours to judge.
|
||||
if (!wiring || wiring.whole) return undefined
|
||||
// Asked of each agent rather than of what they agree on: agents that merely disagree
|
||||
// about the model all have one, and the message would be false — while an agent with
|
||||
// an empty model still cannot run, however well the others are configured.
|
||||
return wiring.someAgentCannotRun
|
||||
? 'Pick a provider and model on the AI agent step to use this chat.'
|
||||
: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The flow inputs the model button actually writes, so the modal does not ask for them a
|
||||
* second time — and, just as much, so it still asks for the ones the button cannot reach.
|
||||
*
|
||||
* `kind` is the one to watch: the button writes it only alongside a resource, since a
|
||||
* provider is picked as a pair. A flow that wires `kind` to an input while fixing the
|
||||
* resource leaves the button nothing to write it with, and hiding it would leave the run
|
||||
* without a provider kind and no way to supply one.
|
||||
*
|
||||
* `reasoning_effort` needs no such condition: the button always draws a thinking control,
|
||||
* whatever it can say about the model — a ladder, a typed token, or why there is neither — so
|
||||
* a wired effort is always the button's. Asking for it in the modal as well would be a second
|
||||
* editor for a field that already has one.
|
||||
*/
|
||||
export function agentModelWiringInputs(wiring: AgentModelWiring | undefined): string[] {
|
||||
if (!wiring) return []
|
||||
if (wiring.whole) return [wiring.whole]
|
||||
const driven: ProviderField[] = ['resource', 'model', 'reasoning_effort']
|
||||
if (wiring.fields.resource !== undefined) driven.push('kind')
|
||||
return driven.map((field) => wiring.fields[field]).filter((name): name is string => !!name)
|
||||
}
|
||||
|
||||
/**
|
||||
* The flow inputs the composer edits, and therefore the ones the Configure-inputs modal must
|
||||
* not ask for. The modal is whatever is left, so this is the single answer to "who edits
|
||||
* this" rather than a list kept in step with the controls that render.
|
||||
*
|
||||
* Only the shape of the flow decides it. Whether a control can act *right now* — no object
|
||||
* storage to upload to, no rules for a provider's thinking levels — is a state that control
|
||||
* shows, not a reason to hand the input to an editor that would be no more able.
|
||||
*/
|
||||
export function composerOwnedInputs(
|
||||
wiring: AgentModelWiring | undefined,
|
||||
attachmentsTarget: { name: string } | undefined
|
||||
): string[] {
|
||||
return [...agentModelWiringInputs(wiring), ...(attachmentsTarget ? [attachmentsTarget.name] : [])]
|
||||
}
|
||||
|
||||
/** 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'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the composer's attachments go, or nothing when there is nowhere they fit.
|
||||
*
|
||||
* The agent reads `user_attachments` through a transform that may reshape what it takes, so
|
||||
* the flow input feeding it is not necessarily an s3 field: an expression building the s3
|
||||
* object itself promotes a plain string. Writing `{ s3, filename }` into that input fails at
|
||||
* run time, so the paperclip appears only where the schema says the value belongs.
|
||||
*/
|
||||
export function attachmentsTargetFor(
|
||||
input: AgentChatInput | undefined
|
||||
): { name: string; multiple: boolean } | 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
|
||||
}
|
||||
|
||||
export function isEmptyAgentChatInputValue(value: any): boolean {
|
||||
if (value === undefined || value === null || value === '') return true
|
||||
return Array.isArray(value) && value.length === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The flow inputs that an AI agent step reads directly into one of its chat-relevant
|
||||
* fields. Several agents may resolve to the same flow input; it is one chip either way,
|
||||
* and one that stays unambiguous however many agents read it.
|
||||
*/
|
||||
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 chatFacingAgents(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.
|
||||
if (transform?.type !== 'javascript' || !transform.expr.includes('flow_input')) continue
|
||||
const name = flowInputRef(transform)
|
||||
// A name the schema doesn't declare has no field to promote, and one expression
|
||||
// reading two inputs names none: either way this agent reads something the
|
||||
// composer cannot drive, which is what disagreement means here.
|
||||
const usable = name && name in properties ? name : undefined
|
||||
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]
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* The run's inputs with a reasoning effort the chosen model cannot take removed.
|
||||
*
|
||||
* `effortPatch` reconciles the two when the reader switches model, which covers the only
|
||||
* way the composer can put them out of step. It is not the only way they get out of step:
|
||||
* a value stored from an earlier visit, a default the flow author wrote, or a model chosen
|
||||
* before the effort was, all arrive already mismatched — and the provider answers a
|
||||
* mismatch with a 400 that names neither input ("adaptive thinking is not supported on this
|
||||
* model"). Checked here, where the run's arguments are settled, so every route is covered.
|
||||
*
|
||||
* Only where the registry positively knows the model rejects it. An unknown family keeps
|
||||
* whatever the author wrote: dropping a value on a guess would override their own default.
|
||||
*/
|
||||
export function withoutRejectedEffort(
|
||||
wiring: AgentModelWiring | undefined,
|
||||
values: Record<string, any>
|
||||
): Record<string, any> {
|
||||
if (!wiring) return values
|
||||
|
||||
// One input carrying the whole provider object: the three fields are read from it and
|
||||
// the effort is cleared inside it, since that is where the agent will look for them.
|
||||
if (wiring.whole) {
|
||||
const provider = values[wiring.whole]
|
||||
if (!provider || typeof provider !== 'object') return values
|
||||
if (!rejectsEffort(provider.kind, provider.model, provider.reasoning_effort)) return values
|
||||
return { ...values, [wiring.whole]: { ...provider, reasoning_effort: '' } }
|
||||
}
|
||||
|
||||
const effortInput = wiring.fields.reasoning_effort
|
||||
if (!effortInput) return values
|
||||
const kindInput = wiring.fields.kind
|
||||
const modelInput = wiring.fields.model
|
||||
const rejected = rejectsEffort(
|
||||
kindInput ? values[kindInput] : wiring.fixed.kind,
|
||||
modelInput ? values[modelInput] : wiring.fixed.model,
|
||||
values[effortInput]
|
||||
)
|
||||
return rejected ? { ...values, [effortInput]: '' } : values
|
||||
}
|
||||
|
||||
/** Whether the registry positively says this model will not take this effort. */
|
||||
function rejectsEffort(provider: unknown, model: unknown, effort: unknown): boolean {
|
||||
if (typeof effort !== 'string' || effort === '') return false
|
||||
if (typeof provider !== 'string' || typeof model !== 'string' || !provider || !model) {
|
||||
return false
|
||||
}
|
||||
const capability = getReasoningCapability(provider as AIProvider, model)
|
||||
return capability.known && !capability.supported
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { storedAttachmentName } from './attachmentNames'
|
||||
|
||||
/**
|
||||
* The worker reads an attachment's media type from the object key and nothing else, so a
|
||||
* key whose extension disagrees with the bytes reaches the provider mislabelled.
|
||||
*/
|
||||
describe('storedAttachmentName', () => {
|
||||
// The composer re-encodes images, so the picked extension is the one that lies.
|
||||
it('renames a re-encoded image to the type it was encoded as', () => {
|
||||
expect(storedAttachmentName('photo.webp', 'image/png')).toBe('photo.png')
|
||||
expect(storedAttachmentName('holiday.png', 'image/jpeg')).toBe('holiday.jpg')
|
||||
})
|
||||
|
||||
it('gives an extension to a name that has none', () => {
|
||||
expect(storedAttachmentName('attachment-1', 'image/png')).toBe('attachment-1.png')
|
||||
expect(storedAttachmentName('contract', 'application/pdf')).toBe('contract.pdf')
|
||||
})
|
||||
|
||||
it('replaces only the last extension', () => {
|
||||
expect(storedAttachmentName('report.2026.final.webp', 'image/png')).toBe(
|
||||
'report.2026.final.png'
|
||||
)
|
||||
})
|
||||
|
||||
// Blobs upload byte for byte, so a type we do not re-encode keeps the name as picked.
|
||||
it('leaves a type it does not re-encode alone', () => {
|
||||
expect(storedAttachmentName('notes.csv', 'text/csv')).toBe('notes.csv')
|
||||
expect(storedAttachmentName('archive.zip', 'application/zip')).toBe('archive.zip')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* What a chat attachment is stored under in object storage.
|
||||
*
|
||||
* The worker reads an attachment's media type from the object key and nothing else —
|
||||
* `mime_guess::from_path` in `windmill-ai/src/image_handler.rs`, falling back to
|
||||
* `image/png` when it can read no extension — and never from the content type stored
|
||||
* beside it. So the key's extension is a claim about the bytes, and it has to be true.
|
||||
*/
|
||||
|
||||
/** The extension each type the composer can send must be stored under. */
|
||||
const EXTENSION_BY_MEDIA_TYPE: Record<string, string> = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
'application/pdf': 'pdf'
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer re-encodes every image to PNG or JPEG, so keeping the picked `photo.webp`
|
||||
* would hand the provider PNG bytes labelled webp, which Anthropic rejects outright; and a
|
||||
* PDF picked without an extension would be read back as the `image/png` fallback. A type
|
||||
* not listed is left as picked — blobs upload byte for byte, so their name is already true.
|
||||
*/
|
||||
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}`
|
||||
}
|
||||
@@ -0,0 +1,702 @@
|
||||
import type {
|
||||
ChatSendRequestOptions,
|
||||
ChatViewHost
|
||||
} from '$lib/components/copilot/chat/chatViewHost'
|
||||
import type { DisplayMessage } from '$lib/components/copilot/chat/shared'
|
||||
import type { ChatMessage, FlowChatManager } from './FlowChatManager.svelte'
|
||||
import { AIAutonomyMode } from '$lib/components/copilot/chat/AIChatManager.svelte'
|
||||
import { isPlanCardTool } from '$lib/components/copilot/chat/planMode'
|
||||
import { ToolCallStore, type ToolCallDetails } from './toolCallContext.svelte'
|
||||
import { AttachedFilesStore } from '$lib/components/copilot/chat/files/attachedFiles.svelte'
|
||||
import { SessionArtifactsStore } from '$lib/components/copilot/chat/artifacts/artifactsState.svelte'
|
||||
import { dataUrlToBlob, type AttachedBlob } from '$lib/components/copilot/chat/blobUtils'
|
||||
import { storedAttachmentName } from './attachmentNames'
|
||||
import type { AttachedImage } from '$lib/components/copilot/chat/imageUtils'
|
||||
import type { AttachedTextFile } from '$lib/components/copilot/chat/textFileUtils'
|
||||
import { HelpersService, JobService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { randomUUID } from '$lib/utils/uuid'
|
||||
import { turnFailed } from './turnTranscript'
|
||||
import {
|
||||
argsToMessageInputs,
|
||||
attachmentsToMessageInputs,
|
||||
MessageInputsStore,
|
||||
type MessageInputs
|
||||
} from './messageInputContext.svelte'
|
||||
|
||||
/** A row that needs no job behind it. */
|
||||
const EMPTY_TOOL_CALL: ToolCallDetails = {}
|
||||
|
||||
/** What an AI agent step reads out of `user_attachments`. */
|
||||
type S3Attachment = { s3: string; filename?: string }
|
||||
|
||||
/** The flow input the composer's attachments feed, and whether it holds a list. */
|
||||
export type AttachmentsTarget = { name: string; multiple: boolean }
|
||||
|
||||
export type FlowChatViewHostOptions = {
|
||||
additionalInputs?: () => Record<string, any> | undefined
|
||||
attachmentsTarget?: () => AttachmentsTarget | undefined
|
||||
workspace?: () => string | 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
|
||||
/** Flow inputs the composer renders a control of its own for, so a message does not
|
||||
* repeat them as context chips. */
|
||||
inputsShownInComposer?: () => string[]
|
||||
/** The flow's input schema, which says which of a run's arguments are secret. */
|
||||
inputsSchema?: () => { properties?: Record<string, any> } | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool's arguments and result reach us as strings: the provider's JSON for the call, and
|
||||
* whatever the tool returned, which is often but not always JSON. Parsed where it parses so
|
||||
* the card can fold it, kept verbatim where it does not.
|
||||
*/
|
||||
function parseToolPayload(raw: string | null | undefined): any {
|
||||
if (raw === undefined || raw === null || raw === '') return undefined
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
function toDisplayMessage(
|
||||
message: ChatMessage,
|
||||
userIndex: number,
|
||||
showStepNames: boolean,
|
||||
inputs: MessageInputsStore,
|
||||
toolCalls: ToolCallStore,
|
||||
failed: boolean,
|
||||
pendingInputs: MessageInputs | undefined
|
||||
): DisplayMessage {
|
||||
switch (message.message_type) {
|
||||
case 'user': {
|
||||
// What the turn ran with — the message row itself keeps only the text. Renders
|
||||
// through the same lanes the copilot uses.
|
||||
// What this tab sent wins wherever it has it, and the job answers for the rest:
|
||||
// a row read back from the server on a later visit, a conversation reopened.
|
||||
// The row is named with its job as soon as the run starts, so a send that kept
|
||||
// nothing would switch lanes mid-run and fetch arguments it had just handed over.
|
||||
const { images, contextElements } =
|
||||
pendingInputs ??
|
||||
(message.job_id ? inputs.get(message.job_id) : { images: [], contextElements: [] })
|
||||
return {
|
||||
role: 'user',
|
||||
index: userIndex,
|
||||
content: message.content,
|
||||
// Drives the shared Retry button: the turn this message started failed.
|
||||
error: failed || undefined,
|
||||
images: images.length > 0 ? images : undefined,
|
||||
contextElements: contextElements.length > 0 ? contextElements : undefined
|
||||
}
|
||||
}
|
||||
case 'tool': {
|
||||
const failed = message.success === false
|
||||
// The same three details reach a row from one of two places, never both: the row
|
||||
// itself for a tool with no job of its own, and for one still streaming; the
|
||||
// tool's own job otherwise.
|
||||
const fromRow: ToolCallDetails = {
|
||||
toolName: message.tool_name,
|
||||
parameters: parseToolPayload(message.tool_arguments),
|
||||
result: parseToolPayload(message.tool_result)
|
||||
}
|
||||
// A row that carries its own call must not ask a job for it: an MCP tool runs
|
||||
// inside the agent's job and names it, so the answer would be the agent's own
|
||||
// arguments and result rather than the tool's. (It also saves a fetch per row
|
||||
// when a conversation opens.)
|
||||
const carriesItsOwnCall = fromRow.parameters !== undefined || fromRow.result !== undefined
|
||||
const fromJob = carriesItsOwnCall ? EMPTY_TOOL_CALL : toolCalls.get(message.job_id)
|
||||
const toolName = fromRow.toolName ?? fromJob.toolName
|
||||
const parameters = fromRow.parameters ?? fromJob.parameters
|
||||
const result = failed ? undefined : (fromRow.result ?? fromJob.result)
|
||||
return {
|
||||
role: 'tool',
|
||||
tool_call_id: message.id,
|
||||
content: message.content,
|
||||
// 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(toolName) ? undefined : toolName,
|
||||
parameters,
|
||||
result,
|
||||
// The card's fold is opt-in (ToolExecutionDisplay reads showDetails), so it is
|
||||
// offered only when there is a call or a result behind it to reveal.
|
||||
showDetails: parameters !== undefined || result !== undefined,
|
||||
error: failed ? message.content : undefined,
|
||||
isLoading: message.loading
|
||||
}
|
||||
}
|
||||
default:
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: message.content,
|
||||
streaming: message.streaming,
|
||||
reasoning: message.reasoning ?? undefined,
|
||||
stepName: showStepNames ? (message.step_name ?? undefined) : undefined,
|
||||
// The run behind the answer, so a reader can open what produced it. Absent on
|
||||
// the temp message a stream builds, which has no job id until it settles.
|
||||
jobId: message.job_id || undefined,
|
||||
createdAt: message.created_at
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a flow run's conversation through the AI session chat components. The turn is a
|
||||
* flow job rather than an LLM call this host makes, so what it can offer is whatever it can
|
||||
* write back into the run's arguments — each field below says for itself (see ChatViewHost).
|
||||
*/
|
||||
export class FlowChatViewHost implements ChatViewHost {
|
||||
#manager: FlowChatManager
|
||||
#options: FlowChatViewHostOptions
|
||||
|
||||
constructor(manager: FlowChatManager, options: FlowChatViewHostOptions = {}) {
|
||||
this.#manager = manager
|
||||
this.#options = options
|
||||
// The queue belongs to the chat it was typed into, and that chat's run can finish
|
||||
// while the reader is in another one — so the manager says which turn settled rather
|
||||
// than the composer watching the open chat.
|
||||
manager.onTurnSettled = (conversationId) => this.flushQueuedMessage(conversationId)
|
||||
// The sidebar marks a chat with something waiting to go out; the queue lives here.
|
||||
manager.hasQueuedMessage = (conversationId) => !!this.#queues[conversationId]?.text.trim()
|
||||
}
|
||||
|
||||
// The step name says which AI agent step wrote a message, so it only tells the
|
||||
// reader anything once a conversation holds more than one. Counted over the
|
||||
// transcript rather than over the flow's current steps: a conversation outlives
|
||||
// edits to the flow, so it can carry labels from a shape the flow no longer has.
|
||||
#showStepNames = $derived.by(
|
||||
() => new Set(this.#manager.messages.map((m) => m.step_name).filter(Boolean)).size > 1
|
||||
)
|
||||
|
||||
// The inputs a turn was sent with, by the id of the row the composer added for it.
|
||||
// The row is named with its job once the run starts, so the job could answer this too —
|
||||
// but that is a fetch for arguments this tab just sent, and one that fails would blank
|
||||
// the row for the rest of the session. Every send records, not only one carrying files:
|
||||
// a row with no entry switches to the job lane the moment it is named, which renders it
|
||||
// empty for the length of a round trip.
|
||||
#sentInputs = $state<Record<string, MessageInputs>>({})
|
||||
|
||||
#messageInputs = new MessageInputsStore(
|
||||
() => this.#options.workspace?.(),
|
||||
() => this.#options.inputsSchema?.(),
|
||||
() => new Set(this.#options.inputsShownInComposer?.() ?? [])
|
||||
)
|
||||
#toolCalls = new ToolCallStore(() => this.#options.workspace?.())
|
||||
|
||||
displayMessages = $derived.by(() => {
|
||||
let userIndex = 0
|
||||
const showStepNames = this.#showStepNames
|
||||
const messages = this.#manager.messages
|
||||
return messages.map((message, i) =>
|
||||
toDisplayMessage(
|
||||
message,
|
||||
message.message_type === 'user' ? userIndex++ : -1,
|
||||
showStepNames,
|
||||
this.#messageInputs,
|
||||
this.#toolCalls,
|
||||
message.message_type === 'user' && turnFailed(messages, i),
|
||||
this.#sentInputs[message.id]
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Run the turn at this transcript position again, as it ran the first time: its own
|
||||
* arguments, read back from its job, rather than whatever the composer holds now. The
|
||||
* row shows the attachments and inputs it ran with, so a retry that quietly used today's
|
||||
* settings would run something other than what the reader is looking at.
|
||||
*
|
||||
* The composer's own controls are the exception. What they edit — the provider, the
|
||||
* model, the thinking level — is on screen beside the transcript rather than on the row,
|
||||
* and a model that cannot answer is one of the likelier reasons a turn failed. Changing
|
||||
* it and pressing Retry has to run the new one, or the retry fails the same way with no
|
||||
* sign of why.
|
||||
*
|
||||
* A job that has been purged can no longer say what it ran with; the composer is then
|
||||
* the only account left, and the row shows nothing either, so the two still agree.
|
||||
*/
|
||||
retryRequest = async (messageIndex: number) => {
|
||||
const message = this.#manager.messages[messageIndex]
|
||||
if (!message || message.message_type !== 'user' || this.loading || this.#readingReplayArgs)
|
||||
return
|
||||
// The turn belongs to the chat it was clicked in. Read before the fetch below, since
|
||||
// the reader can select another conversation while it runs — the same reason the
|
||||
// upload path pins it.
|
||||
const conversationId = this.#manager.selectedConversationId
|
||||
const workspace = this.#options.workspace?.()
|
||||
let replayArgs: Record<string, any> | undefined
|
||||
if (message.job_id && workspace) {
|
||||
this.#readingReplayArgs = true
|
||||
try {
|
||||
const original = (await JobService.getJobArgs({
|
||||
workspace,
|
||||
id: message.job_id
|
||||
})) as Record<string, any>
|
||||
// `user_message` is the message itself, passed as the instructions below.
|
||||
const { user_message: _sent, ...rest } = original ?? {}
|
||||
const composerOwned = this.#options.inputsShownInComposer?.() ?? []
|
||||
const current = this.#options.additionalInputs?.() ?? {}
|
||||
for (const name of composerOwned) {
|
||||
if (name in current) rest[name] = current[name]
|
||||
else delete rest[name]
|
||||
}
|
||||
replayArgs = rest
|
||||
} catch (error) {
|
||||
// Only a job that is gone justifies running something else. Anything else —
|
||||
// a network blip, a 500 — would substitute a different turn silently, which
|
||||
// is the whole thing this guards against.
|
||||
if ((error as { status?: number })?.status !== 404) {
|
||||
sendUserToast('Could not read what that turn ran with. Try again.', true)
|
||||
return
|
||||
}
|
||||
} finally {
|
||||
this.#readingReplayArgs = false
|
||||
}
|
||||
}
|
||||
// A send typed while the arguments were being read has already started a turn here,
|
||||
// and `sendRequest` would refuse this one by handing its text to the composer as if
|
||||
// the reader had typed it.
|
||||
if (conversationId && this.#manager.isConversationBusy(conversationId)) {
|
||||
sendUserToast('That chat started another turn. Retry once it finishes.', true)
|
||||
return
|
||||
}
|
||||
void this.sendRequest({ instructions: message.content, conversationId }, replayArgs)
|
||||
}
|
||||
messages: readonly unknown[] = []
|
||||
contextTokens = 0
|
||||
operatingWorkspace = $derived.by(() => this.#options.workspace?.())
|
||||
/** A retry reading back the turn it is about to replay. Read only by `retryRequest`, so a
|
||||
* plain field: nothing renders from it. Deliberately not part of `loading`, which renders
|
||||
* Stop — there is no run yet to stop, and Stop would cancel the failed turn's own job and
|
||||
* take the chat's queue back. A normal send in this window still gets ahead of the retry;
|
||||
* the toast below is what says so. */
|
||||
#readingReplayArgs = false
|
||||
loading = $derived.by(
|
||||
() =>
|
||||
this.#manager.isLoading ||
|
||||
this.#manager.isWaitingForResponse ||
|
||||
this.#manager.isDispatchingTurn
|
||||
)
|
||||
// A flow run is followed from its job, so another tab holds nothing this one can't read.
|
||||
runHeldElsewhere = false
|
||||
loadingLabel = undefined
|
||||
compacting = false
|
||||
currentReply = ''
|
||||
// The turn's thinking while it streams; it moves onto the answer once that starts.
|
||||
currentReasoning = $derived.by(() => this.#manager.currentReasoning)
|
||||
currentReasoningActive = $derived.by(() => this.#manager.isReasoningActive)
|
||||
reasoningHiddenIndicatorLabel = undefined
|
||||
|
||||
#automaticScroll = $state(true)
|
||||
get automaticScroll() {
|
||||
return this.#automaticScroll
|
||||
}
|
||||
enableAutomaticScroll = () => {
|
||||
this.#automaticScroll = true
|
||||
}
|
||||
disableAutomaticScroll = () => {
|
||||
this.#automaticScroll = false
|
||||
}
|
||||
|
||||
instructions = ''
|
||||
// The flow run is the send: it is in flight for as long as the job is.
|
||||
get sendInFlight() {
|
||||
return this.#manager.isLoading
|
||||
}
|
||||
/**
|
||||
* Chats whose in-flight send the reader stopped before it had a job to cancel. Read only
|
||||
* by the send that set it aside, so a plain Set: nothing renders from this.
|
||||
*/
|
||||
#abortedSends = new Set<string>()
|
||||
|
||||
/**
|
||||
* Give a spent draft back after a send that did not run. The composer took it before
|
||||
* calling, so something has to.
|
||||
*
|
||||
* The composer belongs to whichever chat is on screen, so a turn that was not the open
|
||||
* one's goes back to its own queue instead: a flush fires when *its* chat's run settles,
|
||||
* which with turns running side by side can be while the reader is somewhere else, and
|
||||
* dropping the text into the composer there would put it in the wrong conversation.
|
||||
*/
|
||||
#restoreToComposer(options: ChatSendRequestOptions) {
|
||||
const conversationId = options.conversationId ?? this.#manager.selectedConversationId
|
||||
// Back to the queue whenever that chat has one, open or not. The composer is the
|
||||
// right home for a spent draft only while nothing is waiting behind it: anything
|
||||
// queued was typed later, and that chat's next settled run sends its queue whole —
|
||||
// so putting the older draft in the composer would run the two out of order.
|
||||
const waiting = conversationId ? this.#queueOf(conversationId) : undefined
|
||||
const queueHasMore =
|
||||
!!waiting && (!!waiting.text.trim() || waiting.images.length > 0 || waiting.blobs.length > 0)
|
||||
if (
|
||||
conversationId &&
|
||||
(queueHasMore || conversationId !== this.#manager.selectedConversationId)
|
||||
) {
|
||||
this.#enqueue(
|
||||
conversationId,
|
||||
options.instructions ?? '',
|
||||
options.images ?? [],
|
||||
options.blobs ?? [],
|
||||
'front'
|
||||
)
|
||||
return
|
||||
}
|
||||
this.#aiChatInput?.prependText(
|
||||
options.instructions ?? '',
|
||||
options.images ?? [],
|
||||
[],
|
||||
options.blobs ?? []
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `replayArgs` are a failed turn's own run arguments, read back from its job. They stand
|
||||
* in for the composer's current inputs — attachments included — so a retry runs the turn
|
||||
* that failed rather than a new one wearing its text.
|
||||
*/
|
||||
sendRequest = async (options: ChatSendRequestOptions = {}, replayArgs?: Record<string, any>) => {
|
||||
const text = options.instructions?.trim() ?? ''
|
||||
const args = { ...(replayArgs ?? this.#options.additionalInputs?.() ?? {}) }
|
||||
const target = this.#options.attachmentsTarget?.()
|
||||
// Where the paperclip is the input's editor, the stored settings have no say over it:
|
||||
// a value saved while the modal owned it — before this workspace had object storage —
|
||||
// would otherwise ride along on every later message. Attachments are set below or not
|
||||
// at all. Where the modal still owns it, what the reader typed there stands. A replay
|
||||
// is the exception: its attachments are the ones the failed turn ran with.
|
||||
if (target && this.supportsMessageAttachments && !replayArgs) {
|
||||
delete args[target.name]
|
||||
}
|
||||
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
|
||||
// And nothing runs into a conversation belonging to the other surface: the composer
|
||||
// is shut for it, but a queued turn could have been written before it was opened.
|
||||
// Reads the open conversation, which is the turn's own except for a flush into a
|
||||
// background chat — reachable only if a surface ever both runs turns in parallel and
|
||||
// lists both kinds of chat. No surface does today; one that did would need this to
|
||||
// take `conversationId`.
|
||||
const wrongKind = this.#manager.wrongKindReason
|
||||
if (wrongKind) {
|
||||
sendUserToast(wrongKind, true)
|
||||
this.#restoreToComposer(options)
|
||||
return false
|
||||
}
|
||||
// The per-turn cap again, at the place the truncation would happen: the composer
|
||||
// enforces it as files are attached, but a queue built over several turns arrives
|
||||
// here as one send, and a scalar input keeps `uploaded[0]` — uploading the rest
|
||||
// would strand them in storage while the transcript claimed they went.
|
||||
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 = [...images, ...blobs]
|
||||
// Settled before the upload below, and created here when there is none yet rather than
|
||||
// left to `sendMessage` afterwards: everything that has to name this turn while it
|
||||
// uploads — its busy state, a Stop, a message typed behind it — needs an id to name it
|
||||
// by, and a send with none had no way to be marked, stopped or queued against.
|
||||
// Read before the upload for the same reason: the reader can pick another chat while
|
||||
// it runs, and the turn belongs to the one they sent it from.
|
||||
const conversationId =
|
||||
options.conversationId ??
|
||||
this.#manager.selectedConversationId ??
|
||||
(await this.#manager.createConversation({ clearMessages: false }))
|
||||
// Whether the composer's own files are what this turn's attachment input holds. A
|
||||
// replay's do not: its attachments are the ones its job already has.
|
||||
let attachedLocally = false
|
||||
if (target && attachments.length > 0) {
|
||||
// Named, not "the open chat": the reader can switch while the upload runs, and
|
||||
// where turns run in parallel the other chat has its own status to keep.
|
||||
this.#manager.setDispatching(conversationId, true)
|
||||
this.#abortedSends.delete(conversationId)
|
||||
try {
|
||||
const uploaded = await this.#uploadAttachments(attachments)
|
||||
args[target.name] = target.multiple ? uploaded : uploaded[0]
|
||||
} catch (e) {
|
||||
sendUserToast(
|
||||
`Could not upload the attachments: ${e instanceof Error ? e.message : String(e)}`,
|
||||
true
|
||||
)
|
||||
// The composer already took the draft; without this the turn is simply lost. The
|
||||
// id is the one captured before the upload, not whatever is open now: the reader
|
||||
// can switch while it runs, and the draft belongs to the chat they sent it from.
|
||||
this.#restoreToComposer({ ...options, conversationId })
|
||||
return false
|
||||
} finally {
|
||||
this.#manager.setDispatching(conversationId, false)
|
||||
}
|
||||
// Stop pressed while the upload ran has no job to cancel yet, so it is honoured
|
||||
// here — the run has not started, and starting it now would execute a message the
|
||||
// reader already took back.
|
||||
if (this.#abortedSends.delete(conversationId)) {
|
||||
this.#restoreToComposer({ ...options, conversationId })
|
||||
return false
|
||||
}
|
||||
attachedLocally = true
|
||||
}
|
||||
const sentInputs = this.#describeSentInputs(args, images, blobs, attachedLocally)
|
||||
|
||||
this.#manager.inputMessage = text
|
||||
const started = await this.#manager.sendMessage(
|
||||
Object.keys(args).length > 0 || replayArgs || this.#options.additionalInputs?.()
|
||||
? args
|
||||
: undefined,
|
||||
(rowId) => {
|
||||
if (!sentInputs) return
|
||||
// Keep entries for rows some conversation still holds, not just the open one:
|
||||
// a queued message flushes into the chat it was typed in, which by then need
|
||||
// not be on screen, and pruning against the open chat would drop its chips.
|
||||
const live = this.#manager.liveRowIds
|
||||
const kept = Object.fromEntries(
|
||||
Object.entries(this.#sentInputs).filter(([id]) => live.has(id))
|
||||
)
|
||||
this.#sentInputs = { ...kept, [rowId]: sentInputs }
|
||||
},
|
||||
conversationId
|
||||
)
|
||||
if (!started) {
|
||||
// The upload succeeded and the run did not, so the composer's draft was spent on
|
||||
// nothing. The uploaded objects stay where they are — a resend uploads its own,
|
||||
// under its own prefix — but what the reader wrote comes back, to the chat it was
|
||||
// written in rather than the one open by now.
|
||||
this.#restoreToComposer({ ...options, conversationId })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The chips a row shows for the turn just sent, built from the arguments going out
|
||||
* through the same split `argsToMessageInputs` makes of a job's arguments.
|
||||
*
|
||||
* Files the composer attached are the exception: the data URLs are still in hand and
|
||||
* render with no network, where the args hold S3 references `argsToMessageInputs` would
|
||||
* turn into `download_s3_file` links for bytes this tab already has. So that one input
|
||||
* is described from what was attached and every other from the args. A replay attached
|
||||
* nothing locally and is described entirely from the args, which are its own job's.
|
||||
*/
|
||||
#describeSentInputs(
|
||||
args: Record<string, any>,
|
||||
images: AttachedImage[],
|
||||
blobs: AttachedBlob[],
|
||||
attachedLocally: boolean
|
||||
): MessageInputs | undefined {
|
||||
const workspace = this.#options.workspace?.()
|
||||
// Without one there is no way to build a file's link, so the job lane answers instead.
|
||||
if (!workspace) return undefined
|
||||
const target = this.#options.attachmentsTarget?.()
|
||||
const shownElsewhere = new Set(this.#options.inputsShownInComposer?.() ?? [])
|
||||
if (attachedLocally && target) shownElsewhere.add(target.name)
|
||||
const fromArgs = argsToMessageInputs(
|
||||
workspace,
|
||||
args,
|
||||
this.#options.inputsSchema?.(),
|
||||
shownElsewhere
|
||||
)
|
||||
const attached = attachedLocally
|
||||
? attachmentsToMessageInputs(images, blobs)
|
||||
: { images: [], contextElements: [] }
|
||||
return {
|
||||
images: [...attached.images, ...fromArgs.images],
|
||||
contextElements: [...attached.contextElements, ...fromArgs.contextElements]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Put each attachment in the workspace's object storage and hand back what the
|
||||
* agent reads. The flow runs on a worker, so the bytes have to exist somewhere
|
||||
* the worker can fetch — unlike the copilot, which sends them from the browser.
|
||||
*/
|
||||
async #uploadAttachments(
|
||||
attachments: { name?: string; dataUrl: string; mediaType?: string }[]
|
||||
): Promise<S3Attachment[]> {
|
||||
const workspace = this.#options.workspace?.()
|
||||
if (!workspace) throw new Error('no workspace')
|
||||
// One prefix per turn keeps a re-attached filename from overwriting the copy an
|
||||
// earlier message still refers to.
|
||||
const prefix = `windmill_chat_uploads/${randomUUID()}`
|
||||
return Promise.all(
|
||||
attachments.map(async (attachment, index) => {
|
||||
const blob = dataUrlToBlob(attachment.dataUrl, attachment.mediaType)
|
||||
const filename = storedAttachmentName(
|
||||
attachment.name ?? `attachment-${index + 1}`,
|
||||
blob.type
|
||||
)
|
||||
const { file_key } = await HelpersService.fileUpload({
|
||||
workspace,
|
||||
fileKey: `${prefix}/${filename}`,
|
||||
contentType: blob.type,
|
||||
requestBody: blob
|
||||
})
|
||||
return { s3: file_key, filename }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
cancel = () => {
|
||||
// Stop means stop: what was typed during the run goes back to the composer rather
|
||||
// than waiting there to go out after some later turn settles.
|
||||
this.dequeueMessage()
|
||||
// A send still uploading has no job to cancel; it reads this once the upload lands.
|
||||
const open = this.#manager.selectedConversationId
|
||||
if (open) this.#abortedSends.add(open)
|
||||
void this.#manager.cancelCurrentJob()
|
||||
}
|
||||
// Typed off the interface: a Svelte component's own type resolves differently
|
||||
// across import specifiers, and the two would then not be assignable.
|
||||
#aiChatInput: Parameters<ChatViewHost['setAiChatInput']>[0] = null
|
||||
setAiChatInput: ChatViewHost['setAiChatInput'] = (aiChatInput) => {
|
||||
this.#aiChatInput = aiChatInput
|
||||
}
|
||||
|
||||
// A message typed while a chat is running waits here with its attachments and goes out
|
||||
// whole when that chat's run finishes (see flushQueuedMessage). Held per conversation:
|
||||
// turns run side by side, so a message typed into one must not ride out of another.
|
||||
#queues = $state<
|
||||
Record<string, { text: string; images: AttachedImage[]; blobs: AttachedBlob[] }>
|
||||
>({})
|
||||
queuedContext = undefined
|
||||
queuedFiles: AttachedTextFile[] = []
|
||||
|
||||
#queueOf(conversationId: string | undefined) {
|
||||
return (
|
||||
(conversationId ? this.#queues[conversationId] : undefined) ?? {
|
||||
text: '',
|
||||
images: [] as AttachedImage[],
|
||||
blobs: [] as AttachedBlob[]
|
||||
}
|
||||
)
|
||||
}
|
||||
get queuedMessage(): string {
|
||||
return this.#queueOf(this.#manager.selectedConversationId).text
|
||||
}
|
||||
get queuedImages(): AttachedImage[] {
|
||||
return this.#queueOf(this.#manager.selectedConversationId).images
|
||||
}
|
||||
get queuedBlobs(): AttachedBlob[] {
|
||||
return this.#queueOf(this.#manager.selectedConversationId).blobs
|
||||
}
|
||||
|
||||
queueMessage = (
|
||||
text: string,
|
||||
images: AttachedImage[] = [],
|
||||
_context?: unknown,
|
||||
_files?: unknown,
|
||||
blobs: AttachedBlob[] = []
|
||||
) => {
|
||||
this.#enqueue(this.#manager.selectedConversationId, text, images, blobs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add to what a chat already has waiting, rather than replacing it: the reader can type
|
||||
* again while a flush of the previous queue is still uploading, and that second message
|
||||
* is in the queue by the time a failed flush hands the first one back.
|
||||
*/
|
||||
#enqueue(
|
||||
conversationId: string | undefined,
|
||||
text: string,
|
||||
images: AttachedImage[],
|
||||
blobs: AttachedBlob[],
|
||||
/** Where this belongs in what is already waiting. A draft handed back by a send that
|
||||
* did not run was typed before anything queued behind it, and goes back in front. */
|
||||
at: 'end' | 'front' = 'end'
|
||||
) {
|
||||
if (!conversationId) return
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed && images.length === 0 && blobs.length === 0) return
|
||||
const queue = this.#queueOf(conversationId)
|
||||
const joined = !trimmed
|
||||
? queue.text
|
||||
: !queue.text
|
||||
? trimmed
|
||||
: at === 'front'
|
||||
? `${trimmed}\n${queue.text}`
|
||||
: `${queue.text}\n${trimmed}`
|
||||
this.#queues[conversationId] = {
|
||||
text: joined,
|
||||
images: at === 'front' ? [...images, ...queue.images] : [...queue.images, ...images],
|
||||
blobs: at === 'front' ? [...blobs, ...queue.blobs] : [...queue.blobs, ...blobs]
|
||||
}
|
||||
}
|
||||
/** Put the queued draft back in the composer, attachments included. */
|
||||
dequeueMessage = () => {
|
||||
const { text, images, blobs } = this.#takeQueue(this.#manager.selectedConversationId)
|
||||
if (!text && images.length === 0 && blobs.length === 0) return
|
||||
this.#aiChatInput?.prependText(text, images, [], blobs)
|
||||
}
|
||||
#takeQueue(conversationId: string | undefined) {
|
||||
const taken = this.#queueOf(conversationId)
|
||||
if (conversationId) delete this.#queues[conversationId]
|
||||
return taken
|
||||
}
|
||||
/** Send whatever was typed during the run. Called once that chat's run settles. */
|
||||
flushQueuedMessage = (conversationId = this.#manager.selectedConversationId) => {
|
||||
// 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.#queueOf(conversationId).text.trim()) return
|
||||
const { text, images, blobs } = this.#takeQueue(conversationId)
|
||||
void this.sendRequest({ instructions: text, images, blobs, conversationId })
|
||||
}
|
||||
setComposerStaged = () => {}
|
||||
clearComposerStaged = () => {}
|
||||
attachmentBytesExcluding = () => 0
|
||||
|
||||
storedImages = () => undefined
|
||||
restartGeneration = () => {}
|
||||
handleUserQuestionAnswer = () => false
|
||||
handleToolConfirmation = () => {}
|
||||
// A flow's tools take their arguments from the model, never from a form the reader fills.
|
||||
hasPendingRunForm = false
|
||||
isRunFormPending = () => false
|
||||
|
||||
mode = undefined
|
||||
isSessionChat = false
|
||||
supportsModelSettings = false
|
||||
supportsMessageEditing = false
|
||||
// The turn is a flow run, and an AI agent step refuses one with neither a
|
||||
// `user_message` nor manual memory (ai_executor.rs) — so files alone cannot be sent.
|
||||
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.
|
||||
// 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() {
|
||||
return !!this.#options.attachmentsTarget?.()
|
||||
}
|
||||
get attachmentsUnavailableReason() {
|
||||
return this.#options.attachmentsUnavailable?.()
|
||||
}
|
||||
supportsLinkedFolders = false
|
||||
attachmentsAsBlobs = true
|
||||
// A scalar flow input holds one file; sending more would upload every one and run with
|
||||
// the first, leaving the rest orphaned in storage and the transcript claiming otherwise.
|
||||
get maxMessageAttachments() {
|
||||
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 = []
|
||||
autonomyMode = AIAutonomyMode.DEFAULT
|
||||
setAutonomyMode = () => {}
|
||||
autoAcceptEditsActive = false
|
||||
autoAcceptEditsAvailable = false
|
||||
autoAcceptToolConfirmationsAvailable = false
|
||||
planModeAvailable = false
|
||||
attachedFiles = new AttachedFilesStore()
|
||||
artifacts = new SessionArtifactsStore()
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { HelpersService, JobService } from '$lib/gen'
|
||||
import { FlowChatViewHost } from './flowChatViewHost.svelte'
|
||||
|
||||
vi.mock('$lib/gen', () => ({
|
||||
HelpersService: { fileUpload: vi.fn() },
|
||||
FlowConversationsService: { listConversationMessages: vi.fn(), deleteFlowConversation: vi.fn() },
|
||||
JobService: { getJobArgs: vi.fn() },
|
||||
FlowService: {}
|
||||
}))
|
||||
vi.mock('$lib/toast', () => ({ sendUserToast: vi.fn() }))
|
||||
// The host imports it only for the AIAutonomyMode enum, and the real module pulls the
|
||||
// editor in behind it.
|
||||
vi.mock('$lib/components/copilot/chat/AIChatManager.svelte', () => ({
|
||||
AIAutonomyMode: { DEFAULT: 'default' },
|
||||
AIMode: { GLOBAL: 'global' }
|
||||
}))
|
||||
|
||||
/** Just enough manager for the send protocol: the host only touches these. */
|
||||
function stubManager(selectedConversationId: string | undefined) {
|
||||
return {
|
||||
selectedConversationId,
|
||||
inputMessage: '',
|
||||
messages: [] as unknown[],
|
||||
isLoading: false,
|
||||
isWaitingForResponse: false,
|
||||
isDispatchingTurn: false,
|
||||
currentReasoning: '',
|
||||
isReasoningActive: false,
|
||||
wrongKindReason: undefined,
|
||||
liveRowIds: new Set<string>(),
|
||||
dispatching: [] as { id: string; on: boolean }[],
|
||||
setDispatching(id: string, on: boolean) {
|
||||
this.dispatching.push({ id, on })
|
||||
},
|
||||
createConversation: vi.fn(async function (this: any) {
|
||||
this.selectedConversationId = 'made-for-the-send'
|
||||
return 'made-for-the-send'
|
||||
}),
|
||||
sendMessage: vi.fn(async () => true),
|
||||
cancelCurrentJob: vi.fn(async () => {}),
|
||||
conversationStatus: () => 'idle',
|
||||
unreadCount: () => 0,
|
||||
busy: false,
|
||||
isConversationBusy(this: any) {
|
||||
return this.busy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const host = (manager: ReturnType<typeof stubManager>) =>
|
||||
new FlowChatViewHost(manager as any, {
|
||||
workspace: () => 'ws',
|
||||
attachmentsTarget: () => ({ name: 'files', multiple: true }),
|
||||
additionalInputs: () => ({})
|
||||
})
|
||||
|
||||
const anAttachment = {
|
||||
name: 'shot.png',
|
||||
dataUrl: 'data:image/png;base64,iVBORw0KGgo=',
|
||||
mediaType: 'image/png'
|
||||
}
|
||||
|
||||
/**
|
||||
* The window between the composer taking a draft and the flow job existing. Everything that
|
||||
* has to name the turn during it — its busy state, a Stop, a message typed behind it — needs
|
||||
* a conversation to name it by, and a first message has none until one is made.
|
||||
*/
|
||||
describe('a send whose attachments are still uploading', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(HelpersService.fileUpload).mockReset()
|
||||
})
|
||||
|
||||
it('marks the conversation it was sent from, not whichever is open when the upload lands', async () => {
|
||||
const manager = stubManager('a')
|
||||
vi.mocked(HelpersService.fileUpload).mockImplementation(async () => {
|
||||
manager.selectedConversationId = 'b'
|
||||
return { file_key: 'k' } as any
|
||||
})
|
||||
await host(manager).sendRequest({ instructions: 'hi', blobs: [anAttachment] as any })
|
||||
|
||||
expect(manager.dispatching).toEqual([
|
||||
{ id: 'a', on: true },
|
||||
{ id: 'a', on: false }
|
||||
])
|
||||
expect(manager.sendMessage.mock.calls[0]?.[2]).toBe('a')
|
||||
})
|
||||
|
||||
// Stop has no job to cancel yet, so it has to be honoured when the upload lands —
|
||||
// otherwise the run starts and the reader watches a message they took back execute.
|
||||
it('does not run after a Stop pressed while it uploaded', async () => {
|
||||
const manager = stubManager('a')
|
||||
const chatHost = host(manager)
|
||||
vi.mocked(HelpersService.fileUpload).mockImplementation(async () => {
|
||||
chatHost.cancel()
|
||||
return { file_key: 'k' } as any
|
||||
})
|
||||
|
||||
const started = await chatHost.sendRequest({
|
||||
instructions: 'stop me',
|
||||
blobs: [anAttachment] as any
|
||||
})
|
||||
|
||||
expect(started).toBe(false)
|
||||
expect(manager.sendMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The first message of a chat has no conversation until one is made. Left to
|
||||
// `sendMessage` afterwards, nothing in this window had an id to work with.
|
||||
it('creates the conversation before uploading, so a first message can be stopped too', async () => {
|
||||
const manager = stubManager(undefined)
|
||||
const chatHost = host(manager)
|
||||
vi.mocked(HelpersService.fileUpload).mockImplementation(async () => {
|
||||
chatHost.cancel()
|
||||
return { file_key: 'k' } as any
|
||||
})
|
||||
|
||||
const started = await chatHost.sendRequest({
|
||||
instructions: 'first message',
|
||||
blobs: [anAttachment] as any
|
||||
})
|
||||
|
||||
expect(manager.createConversation).toHaveBeenCalled()
|
||||
expect(manager.dispatching[0]?.id).toBe('made-for-the-send')
|
||||
expect(started).toBe(false)
|
||||
expect(manager.sendMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Where a refused draft lands. The composer holds whichever chat is on screen, so it is the
|
||||
* right home only when the turn was that chat's and nothing is queued behind it.
|
||||
*/
|
||||
describe('a send that did not run', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(HelpersService.fileUpload).mockReset()
|
||||
vi.mocked(HelpersService.fileUpload).mockResolvedValue({ file_key: 'k' } as any)
|
||||
})
|
||||
|
||||
it('hands the draft back to the composer when it was the open chat with nothing waiting', async () => {
|
||||
const manager = stubManager('a')
|
||||
manager.sendMessage = vi.fn(async () => false)
|
||||
const chatHost = host(manager)
|
||||
const prepended: string[] = []
|
||||
chatHost.setAiChatInput({ prependText: (text: string) => prepended.push(text) } as any)
|
||||
|
||||
await chatHost.sendRequest({ instructions: 'came back', blobs: [anAttachment] as any })
|
||||
|
||||
expect(prepended).toEqual(['came back'])
|
||||
})
|
||||
|
||||
// The composer on screen belongs to another conversation by now, and anything queued was
|
||||
// typed later — so the draft joins its own chat's queue, in front of what followed it.
|
||||
it('queues it in front of its own chat when that chat is not the open one', async () => {
|
||||
const manager = stubManager('a')
|
||||
manager.sendMessage = vi.fn(async () => false)
|
||||
const chatHost = host(manager)
|
||||
const prepended: string[] = []
|
||||
chatHost.setAiChatInput({ prependText: (text: string) => prepended.push(text) } as any)
|
||||
chatHost.queueMessage('typed after')
|
||||
manager.selectedConversationId = 'b'
|
||||
|
||||
await chatHost.sendRequest({
|
||||
instructions: 'sent first',
|
||||
blobs: [anAttachment] as any,
|
||||
conversationId: 'a'
|
||||
})
|
||||
|
||||
expect(prepended).toEqual([])
|
||||
manager.selectedConversationId = 'a'
|
||||
expect(chatHost.queuedMessage).toBe('sent first\ntyped after')
|
||||
})
|
||||
|
||||
// The open chat with something already queued — reachable in the editor, where a turn
|
||||
// running in another chat refuses this one. The composer would put the older draft
|
||||
// beside the newer text, and the next settled run would send them inverted.
|
||||
it('queues it in front even when it is the open chat, if something is waiting', async () => {
|
||||
const manager = stubManager('a')
|
||||
manager.sendMessage = vi.fn(async () => false)
|
||||
const chatHost = host(manager)
|
||||
const prepended: string[] = []
|
||||
chatHost.setAiChatInput({ prependText: (text: string) => prepended.push(text) } as any)
|
||||
chatHost.queueMessage('typed after')
|
||||
|
||||
await chatHost.sendRequest({ instructions: 'sent first', blobs: [anAttachment] as any })
|
||||
|
||||
expect(prepended).toEqual([])
|
||||
expect(chatHost.queuedMessage).toBe('sent first\ntyped after')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Retry runs the turn that failed, not a new one wearing its text. The row shows the
|
||||
* attachments and inputs it ran with — read back from its job — so a retry that quietly
|
||||
* used the composer's current settings would run something other than what is on screen.
|
||||
*/
|
||||
describe('retrying a failed turn', () => {
|
||||
const failedRow = {
|
||||
id: 'row-1',
|
||||
message_type: 'user',
|
||||
content: 'summarise this',
|
||||
job_id: 'job-that-failed'
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(JobService.getJobArgs).mockReset()
|
||||
})
|
||||
|
||||
it('runs with the arguments that turn ran with, not the ones on screen now', async () => {
|
||||
const manager = stubManager('a')
|
||||
manager.messages = [failedRow] as any
|
||||
const chatHost = new FlowChatViewHost(manager as any, {
|
||||
workspace: () => 'ws',
|
||||
attachmentsTarget: () => ({ name: 'files', multiple: true }),
|
||||
// What the composer holds now, which must not be what the retry runs with.
|
||||
additionalInputs: () => ({ tone: 'breezy', files: [] })
|
||||
})
|
||||
vi.mocked(JobService.getJobArgs).mockResolvedValue({
|
||||
user_message: 'summarise this',
|
||||
tone: 'formal',
|
||||
files: [{ s3: 'windmill_chat_uploads/abc/report.pdf' }]
|
||||
} as any)
|
||||
|
||||
await chatHost.retryRequest(0)
|
||||
|
||||
expect(JobService.getJobArgs).toHaveBeenCalledWith({ workspace: 'ws', id: 'job-that-failed' })
|
||||
// The original attachment and the original input; `user_message` goes as the text.
|
||||
expect(manager.sendMessage.mock.calls[0]?.[0]).toEqual({
|
||||
tone: 'formal',
|
||||
files: [{ s3: 'windmill_chat_uploads/abc/report.pdf' }]
|
||||
})
|
||||
})
|
||||
|
||||
// A purged job can no longer say what it ran with, and refusing to retry would strand
|
||||
// the reader on a failed turn. The composer is the only account left, and the row shows
|
||||
// nothing either, so the two still agree.
|
||||
it('falls back to the composer when the original run is gone', async () => {
|
||||
const manager = stubManager('a')
|
||||
manager.messages = [failedRow] as any
|
||||
const chatHost = new FlowChatViewHost(manager as any, {
|
||||
workspace: () => 'ws',
|
||||
additionalInputs: () => ({ tone: 'breezy' })
|
||||
})
|
||||
vi.mocked(JobService.getJobArgs).mockRejectedValue({ status: 404 })
|
||||
|
||||
await chatHost.retryRequest(0)
|
||||
|
||||
expect(manager.sendMessage.mock.calls[0]?.[0]).toEqual({ tone: 'breezy' })
|
||||
})
|
||||
|
||||
// Anything other than a purged job leaves the turn unknowable rather than gone, and
|
||||
// running the text with today's settings would be the silent substitution this guards.
|
||||
it('refuses rather than running something else when the read fails', async () => {
|
||||
const manager = stubManager('a')
|
||||
manager.messages = [failedRow] as any
|
||||
const chatHost = new FlowChatViewHost(manager as any, {
|
||||
workspace: () => 'ws',
|
||||
additionalInputs: () => ({ tone: 'breezy' })
|
||||
})
|
||||
vi.mocked(JobService.getJobArgs).mockRejectedValue({ status: 500 })
|
||||
|
||||
await chatHost.retryRequest(0)
|
||||
|
||||
expect(manager.sendMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// A turn started in that chat while the arguments were being read: the retry must say so
|
||||
// rather than let `sendRequest` refuse it by dropping its text into the composer.
|
||||
it('says so when a send got ahead of it', async () => {
|
||||
const manager = stubManager('a')
|
||||
manager.messages = [failedRow] as any
|
||||
const chatHost = new FlowChatViewHost(manager as any, {
|
||||
workspace: () => 'ws',
|
||||
additionalInputs: () => ({})
|
||||
})
|
||||
vi.mocked(JobService.getJobArgs).mockImplementation(async () => {
|
||||
manager.busy = true
|
||||
return { user_message: 'summarise this' } as any
|
||||
})
|
||||
|
||||
await chatHost.retryRequest(0)
|
||||
|
||||
expect(manager.sendMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The flag that refuses a second Retry is deliberately not part of `loading`, so nothing
|
||||
// disables the button — this is what stops two fetches racing into two runs.
|
||||
it('refuses a second Retry while the first is still reading', async () => {
|
||||
const manager = stubManager('a')
|
||||
manager.messages = [failedRow] as any
|
||||
const chatHost = new FlowChatViewHost(manager as any, {
|
||||
workspace: () => 'ws',
|
||||
additionalInputs: () => ({})
|
||||
})
|
||||
let release: (v: any) => void = () => {}
|
||||
vi.mocked(JobService.getJobArgs).mockReturnValue(
|
||||
new Promise((resolve) => (release = resolve)) as any
|
||||
)
|
||||
|
||||
const first = chatHost.retryRequest(0)
|
||||
await chatHost.retryRequest(0)
|
||||
release({ user_message: 'summarise this' })
|
||||
await first
|
||||
|
||||
expect(JobService.getJobArgs).toHaveBeenCalledTimes(1)
|
||||
expect(manager.sendMessage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// The reader can pick another chat while the arguments are being read back, and the
|
||||
// replay belongs to the one they clicked Retry in — the same window the upload path pins.
|
||||
it('runs in the chat it was clicked in, not the one open when the read lands', async () => {
|
||||
const manager = stubManager('a')
|
||||
manager.messages = [failedRow] as any
|
||||
const chatHost = new FlowChatViewHost(manager as any, {
|
||||
workspace: () => 'ws',
|
||||
additionalInputs: () => ({})
|
||||
})
|
||||
vi.mocked(JobService.getJobArgs).mockImplementation(async () => {
|
||||
manager.selectedConversationId = 'b'
|
||||
return { user_message: 'summarise this', tone: 'formal' } as any
|
||||
})
|
||||
|
||||
await chatHost.retryRequest(0)
|
||||
|
||||
expect(manager.sendMessage.mock.calls[0]?.[2]).toBe('a')
|
||||
})
|
||||
|
||||
// The provider and model are edited beside the transcript, not on the row — and a model
|
||||
// that cannot answer is a likely reason the turn failed, so Retry must run the new one.
|
||||
it('replays the turn but with the model the composer now holds', async () => {
|
||||
const manager = stubManager('a')
|
||||
manager.messages = [failedRow] as any
|
||||
const chatHost = new FlowChatViewHost(manager as any, {
|
||||
workspace: () => 'ws',
|
||||
inputsShownInComposer: () => ['model'],
|
||||
additionalInputs: () => ({ model: 'claude-sonnet-5', tone: 'breezy' })
|
||||
})
|
||||
vi.mocked(JobService.getJobArgs).mockResolvedValue({
|
||||
user_message: 'summarise this',
|
||||
model: 'a-model-that-failed',
|
||||
tone: 'formal'
|
||||
} as any)
|
||||
|
||||
await chatHost.retryRequest(0)
|
||||
|
||||
expect(manager.sendMessage.mock.calls[0]?.[0]).toEqual({
|
||||
model: 'claude-sonnet-5',
|
||||
tone: 'formal'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// The row is named with its job as soon as the run starts, so a send that kept nothing
|
||||
// would swap to the job lane mid-run and render empty for the length of a round trip.
|
||||
describe('the inputs a row shows for the turn just sent', () => {
|
||||
it('come from what was sent, with no fetch, once the row has its job', async () => {
|
||||
vi.mocked(JobService.getJobArgs).mockReset()
|
||||
const manager = stubManager('a')
|
||||
const row = { id: 'temp-1', message_type: 'user', content: 'bonjour', job_id: undefined }
|
||||
manager.messages = [row]
|
||||
manager.liveRowIds = new Set(['temp-1'])
|
||||
manager.sendMessage = vi.fn(async (_args: any, nameRow: any) => {
|
||||
nameRow?.('temp-1')
|
||||
return true
|
||||
}) as any
|
||||
const chatHost = new FlowChatViewHost(manager as any, {
|
||||
workspace: () => 'ws',
|
||||
additionalInputs: () => ({ tone: 'formal' })
|
||||
})
|
||||
|
||||
await chatHost.sendRequest({ instructions: 'bonjour' })
|
||||
// What `#nameTurnJob` does once the flow job exists.
|
||||
row.job_id = 'job-1' as any
|
||||
|
||||
expect(chatHost.displayMessages[0]?.contextElements).toEqual([
|
||||
{ type: 'attached_file', title: 'tone', content: 'formal' }
|
||||
])
|
||||
expect(JobService.getJobArgs).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* What a row can only learn from the job behind it, fetched once and kept while mounted.
|
||||
*
|
||||
* A conversation row stores the little it must; the rest — a tool call's arguments and
|
||||
* result, the attachments a message ran with — already exists on that turn's job. Reading
|
||||
* it back keeps one copy of the data instead of two, at the cost of a fetch per row, and
|
||||
* of the same three rules wherever it is done: ask once, answer empty until it lands, and
|
||||
* cache the empty answer when the job is gone so a purged run is not re-fetched forever.
|
||||
*
|
||||
* The rules live here; what to fetch and how to read it is the caller's.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetches allowed out at once. A page of conversation rows asks for all of its jobs in the
|
||||
* same render, and the answers only fill chips in below text that is already on screen.
|
||||
*/
|
||||
const MAX_CONCURRENT = 6
|
||||
|
||||
export class JobBackedStore<T> {
|
||||
#workspace: () => string | undefined
|
||||
#load: (workspace: string, jobId: string) => Promise<T>
|
||||
#empty: T
|
||||
#byJob = $state<Record<string, T>>({})
|
||||
#inFlight = new Set<string>()
|
||||
#waiting: string[] = []
|
||||
#running = 0
|
||||
|
||||
constructor(
|
||||
workspace: () => string | undefined,
|
||||
empty: T,
|
||||
load: (workspace: string, jobId: string) => Promise<T>
|
||||
) {
|
||||
this.#workspace = workspace
|
||||
this.#empty = empty
|
||||
this.#load = load
|
||||
}
|
||||
|
||||
/** What the job holds, fetching on first ask. Empty until it lands. */
|
||||
get(jobId: string | null | undefined): T {
|
||||
if (!jobId) return this.#empty
|
||||
const cached = this.#byJob[jobId]
|
||||
if (cached) return cached
|
||||
this.#enqueue(jobId)
|
||||
return this.#empty
|
||||
}
|
||||
|
||||
#enqueue(jobId: string) {
|
||||
if (this.#inFlight.has(jobId)) return
|
||||
this.#inFlight.add(jobId)
|
||||
this.#waiting.push(jobId)
|
||||
this.#pump()
|
||||
}
|
||||
|
||||
#pump() {
|
||||
while (this.#running < MAX_CONCURRENT && this.#waiting.length > 0) {
|
||||
const jobId = this.#waiting.shift()!
|
||||
this.#running++
|
||||
void this.#fetch(jobId).finally(() => {
|
||||
this.#running--
|
||||
this.#pump()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async #fetch(jobId: string) {
|
||||
const workspace = this.#workspace()
|
||||
if (!workspace) {
|
||||
// Neither cached nor in flight, so the row asks again once a workspace is known.
|
||||
this.#inFlight.delete(jobId)
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.#byJob = { ...this.#byJob, [jobId]: await this.#load(workspace, jobId) }
|
||||
} catch {
|
||||
// A purged job, or one this user cannot read: the row keeps what it stored.
|
||||
this.#byJob = { ...this.#byJob, [jobId]: this.#empty }
|
||||
} finally {
|
||||
this.#inFlight.delete(jobId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* The inputs a chat message ran with, recovered from its job.
|
||||
*
|
||||
* A conversation message stores only its text, so what the user attached and which
|
||||
* settings the turn used exist nowhere but the run's arguments. The user row carries
|
||||
* the flow job id, whose args are the raw run arguments — `user_message` plus every
|
||||
* other flow input.
|
||||
*
|
||||
* Fetched lazily and kept in memory only: a purged job leaves a dangling id and the
|
||||
* turn simply shows no inputs, which is honest — the arguments are gone.
|
||||
*/
|
||||
import { JobService } from '$lib/gen'
|
||||
import { JobBackedStore } from './jobBackedStore.svelte'
|
||||
import { base } from '$lib/base'
|
||||
import { redactFileArgs, redactSecretArgs } from '$lib/components/job_args'
|
||||
import {
|
||||
createAttachedFileContextElement,
|
||||
type ContextElement
|
||||
} from '$lib/components/copilot/chat/context'
|
||||
import type { AttachedImage } from '$lib/components/copilot/chat/imageUtils'
|
||||
|
||||
type S3Ref = { s3: string; filename?: string; storage?: string }
|
||||
|
||||
const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.avif']
|
||||
|
||||
function isS3Ref(value: any): value is S3Ref {
|
||||
return !!value && typeof value === 'object' && typeof value.s3 === 'string' && value.s3 !== ''
|
||||
}
|
||||
|
||||
function s3Refs(value: any): S3Ref[] {
|
||||
if (isS3Ref(value)) return [value]
|
||||
if (Array.isArray(value)) return value.filter(isS3Ref)
|
||||
return []
|
||||
}
|
||||
|
||||
function displayName(ref: S3Ref): string {
|
||||
return ref.filename ?? ref.s3.split('/').pop() ?? ref.s3
|
||||
}
|
||||
|
||||
function looksLikeImage(ref: S3Ref): boolean {
|
||||
const name = displayName(ref).toLowerCase()
|
||||
return IMAGE_EXTENSIONS.some((ext) => name.endsWith(ext))
|
||||
}
|
||||
|
||||
/** Same-origin, cookie-authed GET — usable directly as an <img src>, no blob fetch. */
|
||||
function downloadUrl(workspace: string, ref: S3Ref): string {
|
||||
const params = new URLSearchParams({ file_key: ref.s3 })
|
||||
if (ref.storage) params.set('storage', ref.storage)
|
||||
return `${base}/api/w/${workspace}/job_helpers/download_s3_file?${params.toString()}`
|
||||
}
|
||||
|
||||
/** A scalar input, summarised for a chip. Objects are left to the file/JSON branches. */
|
||||
function scalarSummary(value: any): string | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
||||
return undefined
|
||||
}
|
||||
|
||||
export type MessageInputs = { images: AttachedImage[]; contextElements: ContextElement[] }
|
||||
|
||||
const EMPTY: MessageInputs = { images: [], contextElements: [] }
|
||||
|
||||
/**
|
||||
* Split a turn's run arguments into the lanes a user message renders: image
|
||||
* thumbnails, and a chip per remaining input. `user_message` is the bubble itself.
|
||||
*/
|
||||
export function argsToMessageInputs(
|
||||
workspace: string,
|
||||
args: Record<string, any> | undefined,
|
||||
schema: { properties?: Record<string, any> } | undefined,
|
||||
shownElsewhere: ReadonlySet<string> = new Set()
|
||||
): MessageInputs {
|
||||
if (!args) return EMPTY
|
||||
const images: AttachedImage[] = []
|
||||
const contextElements: ContextElement[] = []
|
||||
// A chip is text on screen, so it goes through the same redaction the run page and the
|
||||
// copilot apply to a job's arguments: a password input must not be readable here, and a
|
||||
// base64 file is unreadable anyway.
|
||||
const shown = redactFileArgs(redactSecretArgs(args, schema), schema)
|
||||
for (const [name, value] of Object.entries(shown)) {
|
||||
if (name === 'user_message') continue
|
||||
// An input the composer has its own control for — the model button's provider fields —
|
||||
// is already on screen, and repeating it under every message is noise.
|
||||
if (shownElsewhere.has(name)) continue
|
||||
const files = s3Refs(value)
|
||||
if (files.length > 0) {
|
||||
for (const ref of files) {
|
||||
if (looksLikeImage(ref)) {
|
||||
images.push({
|
||||
dataUrl: downloadUrl(workspace, ref),
|
||||
mediaType: 'image/png',
|
||||
name: displayName(ref)
|
||||
})
|
||||
} else {
|
||||
contextElements.push(
|
||||
createAttachedFileContextElement(displayName(ref), `Attached file · ${ref.s3}`)
|
||||
)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
const summary = scalarSummary(value)
|
||||
if (summary !== undefined) {
|
||||
contextElements.push(createAttachedFileContextElement(name, summary))
|
||||
}
|
||||
}
|
||||
return images.length > 0 || contextElements.length > 0 ? { images, contextElements } : EMPTY
|
||||
}
|
||||
|
||||
/**
|
||||
* The same lanes, built from what the composer just sent. The turn in flight has no
|
||||
* job yet, so its row cannot read its inputs back from one; the data URLs are still
|
||||
* in hand, so the thumbnails need no fetch.
|
||||
*/
|
||||
export function attachmentsToMessageInputs(
|
||||
images: AttachedImage[],
|
||||
blobs: { name: string }[]
|
||||
): MessageInputs {
|
||||
const contextElements = blobs.map((blob) =>
|
||||
createAttachedFileContextElement(blob.name, `Attached file · ${blob.name}`)
|
||||
)
|
||||
return images.length > 0 || contextElements.length > 0 ? { images, contextElements } : EMPTY
|
||||
}
|
||||
|
||||
/** The run arguments behind the transcript's user rows. One fetch per turn while mounted. */
|
||||
export class MessageInputsStore extends JobBackedStore<MessageInputs> {
|
||||
constructor(
|
||||
workspace: () => string | undefined,
|
||||
schema: () => { properties?: Record<string, any> } | undefined,
|
||||
shownElsewhere: () => ReadonlySet<string>
|
||||
) {
|
||||
super(workspace, EMPTY, async (ws, jobId) =>
|
||||
argsToMessageInputs(
|
||||
ws,
|
||||
(await JobService.getJobArgs({ workspace: ws, id: jobId })) as any,
|
||||
schema(),
|
||||
shownElsewhere()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { argsToMessageInputs } from './messageInputContext.svelte'
|
||||
|
||||
const SCHEMA = {
|
||||
properties: {
|
||||
token: { type: 'string', password: true },
|
||||
city: { type: 'string' },
|
||||
report: { type: 'object', format: 'resource-s3_object' }
|
||||
}
|
||||
}
|
||||
|
||||
function summaries(elements: { title?: string; content?: string }[]) {
|
||||
return elements.map((e) => e.content)
|
||||
}
|
||||
|
||||
describe('argsToMessageInputs', () => {
|
||||
it('never puts a secret input on screen', () => {
|
||||
const { contextElements } = argsToMessageInputs(
|
||||
'ws',
|
||||
{ token: 'hunter2', city: 'Paris' },
|
||||
SCHEMA
|
||||
)
|
||||
expect(summaries(contextElements as any)).toEqual(['<hidden>', 'Paris'])
|
||||
})
|
||||
|
||||
// Only images get a thumbnail lane; every other s3 file is a chip naming its key.
|
||||
it('splits attachments into thumbnails and file chips', () => {
|
||||
const { images, contextElements } = argsToMessageInputs(
|
||||
'ws',
|
||||
{ report: [{ s3: 'a/shot.png' }, { s3: 'a/notes.pdf' }] },
|
||||
SCHEMA
|
||||
)
|
||||
expect(images.map((i) => i.name)).toEqual(['shot.png'])
|
||||
expect(images[0].dataUrl).toContain('file_key=a%2Fshot.png')
|
||||
expect(contextElements).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('leaves out the message and anything the composer already shows', () => {
|
||||
const { contextElements } = argsToMessageInputs(
|
||||
'ws',
|
||||
{ user_message: 'hi', city: 'Paris' },
|
||||
SCHEMA,
|
||||
new Set(['city'])
|
||||
)
|
||||
expect(contextElements).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* What a tool call ran with and returned, recovered from its job.
|
||||
*
|
||||
* A conversation row stores only a summary sentence ("Used X tool"), but a Windmill tool
|
||||
* runs as its own job, and that job already holds everything the card needs: `args` are
|
||||
* the arguments the model supplied, `result` is what came back, and `script_path` names
|
||||
* the tool. Reading them there keeps one copy of the data instead of two.
|
||||
*
|
||||
* Two kinds of tool are out of reach and keep the sentence: an MCP tool, and a
|
||||
* provider-native one (web search). Neither gets a job of its own — both run inside the
|
||||
* agent's — so both rows name the agent's job, which retention needs to collect them. Its
|
||||
* args are the agent's configuration and its result the agent's answer, so reading them
|
||||
* would show confidently wrong details: an `aiagent` job is ignored here, and an MCP row
|
||||
* stores its own call on the row instead (`tool_arguments` / `tool_result`).
|
||||
*/
|
||||
import { JobService } from '$lib/gen'
|
||||
import { JobBackedStore } from './jobBackedStore.svelte'
|
||||
|
||||
export type ToolCallDetails = {
|
||||
toolName?: string
|
||||
parameters?: any
|
||||
result?: any
|
||||
}
|
||||
|
||||
const EMPTY: ToolCallDetails = {}
|
||||
|
||||
/** The tool's own name, which is the last segment of the job's path. */
|
||||
function toolNameFromPath(path: string | undefined): string | undefined {
|
||||
const name = path?.split('/').filter(Boolean).pop()
|
||||
return name && name !== '' ? name : undefined
|
||||
}
|
||||
|
||||
export function jobToToolCallDetails(job: any): ToolCallDetails {
|
||||
// The agent's own job means this row is a tool that ran inside it — MCP or provider-native
|
||||
// — and names that job so retention can collect it. Its args describe the agent and its
|
||||
// result is the agent's answer, so reading either would show confidently wrong details.
|
||||
if (!job || job.job_kind === 'aiagent') return EMPTY
|
||||
const parameters =
|
||||
job.args && typeof job.args === 'object' && Object.keys(job.args).length > 0
|
||||
? job.args
|
||||
: undefined
|
||||
return {
|
||||
toolName: toolNameFromPath(job.script_path),
|
||||
parameters,
|
||||
result: job.result
|
||||
}
|
||||
}
|
||||
|
||||
/** The tool jobs behind the transcript's tool rows. One fetch per row while mounted. */
|
||||
export class ToolCallStore extends JobBackedStore<ToolCallDetails> {
|
||||
constructor(workspace: () => string | undefined) {
|
||||
super(workspace, EMPTY, async (ws, jobId) =>
|
||||
jobToToolCallDetails(await JobService.getJob({ workspace: ws, id: jobId, noLogs: true }))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { jobToToolCallDetails } from './toolCallContext.svelte'
|
||||
|
||||
/**
|
||||
* Every conversation row names a job so retention can collect it, which means a tool that
|
||||
* ran inside the agent — MCP, or provider-native web search — points at the agent's own
|
||||
* job. Reading that job for the call would show the agent's configuration as the tool's
|
||||
* arguments and the agent's answer as its result.
|
||||
*/
|
||||
describe('jobToToolCallDetails', () => {
|
||||
it('reads nothing from the agent job a jobless tool points at', () => {
|
||||
expect(
|
||||
jobToToolCallDetails({
|
||||
job_kind: 'aiagent',
|
||||
script_path: 'f/chat/agent',
|
||||
args: { user_message: 'hi', provider: { model: 'claude-sonnet-5' } },
|
||||
result: 'the agent answer'
|
||||
})
|
||||
).toEqual({})
|
||||
})
|
||||
|
||||
it('reads the call from a tool that has a job of its own', () => {
|
||||
expect(
|
||||
jobToToolCallDetails({
|
||||
job_kind: 'script',
|
||||
script_path: 'f/tools/search_docs',
|
||||
args: { query: 'retention' },
|
||||
result: ['a', 'b']
|
||||
})
|
||||
).toEqual({ toolName: 'search_docs', parameters: { query: 'retention' }, result: ['a', 'b'] })
|
||||
})
|
||||
|
||||
it('treats an argument-less call as having none rather than an empty object', () => {
|
||||
expect(
|
||||
jobToToolCallDetails({ job_kind: 'script', script_path: 'f/t/now', args: {} }).parameters
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendRevealed,
|
||||
applyStreamEvent,
|
||||
emptyTurnState,
|
||||
turnFailed,
|
||||
type TurnStep
|
||||
} from './turnTranscript'
|
||||
import type { ChatMessage } from './FlowChatManager.svelte'
|
||||
import type { StreamEvent } from '$lib/components/chat/utils'
|
||||
|
||||
function start(): TurnStep {
|
||||
return { rows: [], state: emptyTurnState('conv') }
|
||||
}
|
||||
|
||||
let n = 0
|
||||
const nextId = () => `row-${++n}`
|
||||
|
||||
function apply(step: TurnStep, events: StreamEvent[]): TurnStep {
|
||||
return events.reduce((acc, event) => applyStreamEvent(acc, event, nextId), step)
|
||||
}
|
||||
|
||||
describe('turn transcript', () => {
|
||||
it('keeps thinking that arrives in the same chunk as the tool call it led to', () => {
|
||||
// The regression: reasoning revealed and a tool call applied back to back, which is
|
||||
// how one SSE chunk delivers "thought about it, then called the tool".
|
||||
let step = appendRevealed(start(), 'reasoning', 'Checking the issue first.', nextId)
|
||||
step = apply(step, [{ kind: 'tool_call', callId: 'c1', name: 'mcp_linear_get_issue' }])
|
||||
|
||||
expect(step.rows.map((r) => r.message_type)).toEqual(['assistant', 'tool'])
|
||||
expect(step.rows[0].reasoning).toBe('Checking the issue first.')
|
||||
expect(step.rows[0].streaming).toBe(false)
|
||||
})
|
||||
|
||||
it('starts a new answer row after a tool call instead of extending the one before it', () => {
|
||||
let step = appendRevealed(start(), 'answer', 'Let me look. ', nextId)
|
||||
step = apply(step, [
|
||||
{ kind: 'tool_call', callId: 'c1', name: 'get_time' },
|
||||
{ kind: 'tool_result', callId: 'c1', name: 'get_time', result: '{}', success: true }
|
||||
])
|
||||
step = appendRevealed(step, 'answer', 'It is noon.', nextId)
|
||||
|
||||
expect(step.rows.map((r) => r.content)).toEqual([
|
||||
'Let me look. ',
|
||||
'Used get_time tool',
|
||||
'It is noon.'
|
||||
])
|
||||
})
|
||||
|
||||
it('lands a tool call and its result on one row', () => {
|
||||
const step = apply(start(), [
|
||||
{ kind: 'tool_call', callId: 'c1', name: 'get_time' },
|
||||
{ kind: 'tool_arguments', callId: 'c1', name: 'get_time', arguments: '{"tz":"UTC"}' },
|
||||
{ kind: 'tool_execution', callId: 'c1', name: 'get_time' },
|
||||
{ kind: 'tool_result', callId: 'c1', name: 'get_time', result: '{"now":1}', success: true }
|
||||
])
|
||||
|
||||
const tools = step.rows.filter((r) => r.message_type === 'tool')
|
||||
expect(tools).toHaveLength(1)
|
||||
expect(tools[0].tool_arguments).toBe('{"tz":"UTC"}')
|
||||
expect(tools[0].tool_result).toBe('{"now":1}')
|
||||
expect(tools[0].loading).toBe(false)
|
||||
})
|
||||
|
||||
it('marks a failed tool call on its row', () => {
|
||||
const step = apply(start(), [
|
||||
{ kind: 'tool_call', callId: 'c1', name: 'get_time' },
|
||||
{ kind: 'tool_result', callId: 'c1', name: 'get_time', result: 'boom', success: false }
|
||||
])
|
||||
|
||||
const tool = step.rows.find((r) => r.message_type === 'tool')
|
||||
expect(tool?.success).toBe(false)
|
||||
expect(tool?.content).toBe('Failed to use get_time tool')
|
||||
})
|
||||
|
||||
it('grows one answer row as text is revealed', () => {
|
||||
let step = appendRevealed(start(), 'answer', 'Hel', nextId)
|
||||
step = appendRevealed(step, 'answer', 'lo', nextId)
|
||||
|
||||
expect(step.rows).toHaveLength(1)
|
||||
expect(step.rows[0].content).toBe('Hello')
|
||||
expect(step.rows[0].streaming).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Gates the Retry button, which on a flow re-runs the whole thing — side effects included —
|
||||
* so it has to mean "this turn produced no answer", not "something inside it went wrong".
|
||||
*/
|
||||
describe('turnFailed', () => {
|
||||
const row = (over: Partial<ChatMessage>): ChatMessage =>
|
||||
({ id: 'x', message_type: 'assistant', content: '', ...over }) as ChatMessage
|
||||
|
||||
it('is false when a tool failed but the agent went on to answer', () => {
|
||||
const messages = [
|
||||
row({ message_type: 'user' }),
|
||||
row({ message_type: 'tool', success: false }),
|
||||
row({ message_type: 'assistant', success: true })
|
||||
]
|
||||
expect(turnFailed(messages, 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('is true when the turn ends on a failure', () => {
|
||||
const messages = [
|
||||
row({ message_type: 'user' }),
|
||||
row({ message_type: 'tool', success: true }),
|
||||
row({ message_type: 'assistant', success: false })
|
||||
]
|
||||
expect(turnFailed(messages, 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('reports nothing while the turn is still running', () => {
|
||||
const messages = [
|
||||
row({ message_type: 'user' }),
|
||||
row({ message_type: 'tool', success: false }),
|
||||
row({ message_type: 'assistant', streaming: true })
|
||||
]
|
||||
expect(turnFailed(messages, 0)).toBe(false)
|
||||
})
|
||||
|
||||
// The window stops at the next user message, so a later turn's failure is not this one's.
|
||||
it('does not read past the next user message', () => {
|
||||
const messages = [
|
||||
row({ message_type: 'user' }),
|
||||
row({ message_type: 'assistant', success: true }),
|
||||
row({ message_type: 'user' }),
|
||||
row({ message_type: 'assistant', success: false })
|
||||
]
|
||||
expect(turnFailed(messages, 0)).toBe(false)
|
||||
expect(turnFailed(messages, 2)).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for a turn that has produced nothing yet', () => {
|
||||
expect(turnFailed([row({ message_type: 'user' })], 0)).toBe(false)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user