mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-03 00:02:08 +00:00
feat: run turns in several flow chat conversations at once (#11202)
* feat: run turns in several flow chat conversations at once Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep finished turns finished and cached chats current in the flow chat pool Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: attribute a turn's rows by job id as well as sequence Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: count only real stream updates and retry the job-id read Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep a chat that holds an unsent draft, and take one back when its first turn is withdrawn Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: ignore a stale running-turn snapshot, keep a withdrawn chat's draft, poll after clean stream ends Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: follow the turn running now when the listing named one already over Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep replacement turns and SSE fallback moving * fix: keep replacement turn handoffs active * fix: preserve unread badge line height * fix: settle local fallback handoffs * fix: settle refused turn handoffs * fix: scope turn handoffs to conversation * fix: drop stale turn handoffs * refactor: move the queued message and 409 handling into per-conversation turns Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: address cubic's review of the parallel flow chat turns Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: clear a stale failure on refresh, and tighten the docs and test waits Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: recover running rows past the first page, and drop the failure a re-read disproves Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: check the running-turn query at compile time, and narrow what a refresh clears Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: settle a failed turn only from an answer that turn wrote Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: settle a failed turn from its own answer, and only while it is still the failure shown Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: drop a failure whose answer arrived even when a newer turn owns the error Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: free an answered failure whatever the turn that started meanwhile is doing Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: drop a rows read that a turn outran, rather than merging it under newer messages Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: drop a rows read whose conversation was left and opened again Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: hand over a file still being read when its composer goes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: count a drop's routing as work in flight, so its file is handed over too Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: hold the send until every file a conversation is owed has landed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: keep a panel mounted per conversation instead of handing its draft over Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the withdrawn chat whose composer was written in, not the empty one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the chat in front of the reader when both withdrawn composers were written in Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep a retry's own run arguments when a turn elsewhere refuses it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: name panels apart across pools, and read a flow's inputs when its chat is built Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+18
@@ -48,8 +48,26 @@ _Avoid_: thread, session (that names an AI session, a different thing), chat (th
|
||||
One question and the answer to it: the run the question started, the handle that stops it,
|
||||
and the rows it is writing. At most one per conversation, and the chat is held for its whole
|
||||
length — from the moment the question takes the chat, before it has a job, until it is ended.
|
||||
The server holds the same rule: a question sent to a conversation whose turn is still running
|
||||
is refused, whoever sends it. Several conversations of one flow can each have a turn running.
|
||||
_Avoid_: request, exchange, message round
|
||||
|
||||
**Running turn**:
|
||||
A turn whose run has not finished. Which conversations have one is the server's to say, so a
|
||||
chat opened after the turn started — a reload, another tab — still sees it and follows it.
|
||||
_Avoid_: busy, active, in flight
|
||||
|
||||
**Queued message**:
|
||||
A question typed into a conversation while its turn runs, sent when that turn ends answered.
|
||||
At most one per conversation; typing another adds to it. A turn that fails, or is stopped,
|
||||
hands it back to the composer instead of sending it.
|
||||
_Avoid_: pending message (a pending message is one already sent and not yet confirmed)
|
||||
|
||||
**Unread**:
|
||||
The answers that arrived in a conversation while it was not the one shown. Counted per open
|
||||
chat and forgotten on reload.
|
||||
_Avoid_: new messages, notifications
|
||||
|
||||
**Transcript**:
|
||||
The rows a conversation's chat holds. Not the conversation: it is the newest page plus
|
||||
whatever older pages the reader has scrolled back through, so a question it cannot answer
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT c.id AS \"id!\", u.job_id AS \"job_id!\", u.created_seq AS \"user_seq!\"\n FROM unnest($1::uuid[]) AS c(id)\n CROSS JOIN LATERAL (\n SELECT job_id, created_seq\n FROM flow_conversation_message\n WHERE conversation_id = c.id AND message_type = 'user'\n ORDER BY created_seq DESC\n LIMIT 1\n ) u\n WHERE u.job_id IS NOT NULL\n AND EXISTS (SELECT 1 FROM v2_job_queue q WHERE q.id = u.job_id)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id!",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "job_id!",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "user_seq!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"UuidArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "2bf039a2880555c5e6eec9a29c4a380aa832d72d2aaf5b109ee9554f1e9da6a4"
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! A conversation answers one message at a time: while the run its newest user message started
|
||||
//! is queued or running, the list reports that turn and a run into the conversation is refused.
|
||||
//!
|
||||
//! Uses runtime `sqlx::query` (not the compile-time macros) so no offline query cache is
|
||||
//! needed, matching v2_job_delete_orphans.rs.
|
||||
|
||||
use sqlx::{Pool, Postgres};
|
||||
use uuid::Uuid;
|
||||
use windmill_common::error::Error;
|
||||
use windmill_common::flow_conversations::{get_or_create_conversation_with_id, running_turns};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
const WS: &str = "test-workspace";
|
||||
const CONV: Uuid = Uuid::from_u128(0x5eed);
|
||||
|
||||
/// Takes the conversation for a turn, as a run does, without keeping what it writes.
|
||||
async fn take_conversation(db: &Pool<Postgres>) -> windmill_common::error::Result<Uuid> {
|
||||
let mut tx = db.begin().await?;
|
||||
let result =
|
||||
get_or_create_conversation_with_id(&mut tx, WS, "f/flow", "test-user", "t", CONV, false)
|
||||
.await;
|
||||
tx.rollback().await?;
|
||||
result.map(|c| c.id)
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_a_conversation_refuses_a_turn_while_one_runs(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let job_id = Uuid::new_v4();
|
||||
sqlx::query("INSERT INTO v2_job (id, workspace_id, kind) VALUES ($1, $2, 'flow')")
|
||||
.bind(job_id)
|
||||
.bind(WS)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO v2_job_queue (id, workspace_id, scheduled_for) VALUES ($1, $2, now())",
|
||||
)
|
||||
.bind(job_id)
|
||||
.bind(WS)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by)
|
||||
VALUES ($1, $2, 'f/flow', 'test-user')",
|
||||
)
|
||||
.bind(CONV)
|
||||
.bind(WS)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let user_seq: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id)
|
||||
VALUES ($1, 'user', 'hi', $2) RETURNING created_seq",
|
||||
)
|
||||
.bind(CONV)
|
||||
.bind(job_id)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
|
||||
let running = running_turns(&db, &[CONV]).await?;
|
||||
let turn = running
|
||||
.get(&CONV)
|
||||
.expect("the queued run is the conversation's running turn");
|
||||
assert_eq!((turn.job_id, turn.user_seq), (job_id, user_seq));
|
||||
|
||||
match take_conversation(&db).await {
|
||||
Err(Error::Generic(status, body)) => {
|
||||
assert_eq!(status.as_u16(), 409);
|
||||
assert!(
|
||||
body.contains(&job_id.to_string()),
|
||||
"the refusal names the running job: {body}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected a 409 while the first turn runs, got {other:?}"),
|
||||
}
|
||||
|
||||
// The run ends: it leaves the queue, and the conversation takes the next message.
|
||||
sqlx::query("DELETE FROM v2_job_queue WHERE id = $1")
|
||||
.bind(job_id)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
assert!(running_turns(&db, &[CONV]).await?.is_empty());
|
||||
assert_eq!(take_conversation(&db).await?, CONV);
|
||||
Ok(())
|
||||
}
|
||||
@@ -14,7 +14,7 @@ pub use windmill_common::flow_conversations::FlowConversation;
|
||||
use windmill_common::{
|
||||
db::{UserDB, DB},
|
||||
error::{JsonResult, Result},
|
||||
flow_conversations::MessageType,
|
||||
flow_conversations::{running_turns, MessageType, RunningTurn},
|
||||
utils::{not_found_if_none, paginate, truncate_with_ellipsis, Pagination},
|
||||
};
|
||||
|
||||
@@ -81,7 +81,7 @@ async fn list_conversations(
|
||||
Path(w_id): Path<String>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
Query(query): Query<ListConversationsQuery>,
|
||||
) -> JsonResult<Vec<FlowConversation>> {
|
||||
) -> JsonResult<Vec<ListedConversation>> {
|
||||
let (per_page, offset) = paginate(pagination);
|
||||
let mut tx = user_db.clone().begin(&authed).await?;
|
||||
|
||||
@@ -123,9 +123,27 @@ async fn list_conversations(
|
||||
let conversations = sqlx::query_as::<Postgres, FlowConversation>(&sql)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
let ids: Vec<Uuid> = conversations.iter().map(|c| c.id).collect();
|
||||
let mut running = running_turns(&mut *tx, &ids).await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(Json(conversations))
|
||||
Ok(Json(
|
||||
conversations
|
||||
.into_iter()
|
||||
.map(|conversation| ListedConversation {
|
||||
running_turn: running.remove(&conversation.id),
|
||||
conversation,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ListedConversation {
|
||||
#[serde(flatten)]
|
||||
pub conversation: FlowConversation,
|
||||
/// Lets a chat that opens after a turn started follow it, and tell which chats are busy.
|
||||
pub running_turn: Option<RunningTurn>,
|
||||
}
|
||||
|
||||
async fn delete_conversation(
|
||||
|
||||
@@ -11791,6 +11791,11 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
"409":
|
||||
description: >-
|
||||
Chat-enabled flow only: the conversation named by `memory_id` is still answering
|
||||
a message. The body is JSON: `{ "error": string, "running_turn": { "job_id",
|
||||
"user_seq" } }`.
|
||||
|
||||
/w/{workspace}/jobs/run_wait_result/fv/{version}:
|
||||
post:
|
||||
@@ -11831,6 +11836,11 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
"409":
|
||||
description: >-
|
||||
Chat-enabled flow only: the conversation named by `memory_id` is still answering
|
||||
a message. The body is JSON: `{ "error": string, "running_turn": { "job_id",
|
||||
"user_seq" } }`.
|
||||
|
||||
get:
|
||||
summary: run flow by version with GET and wait until completion
|
||||
@@ -11863,6 +11873,11 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
"409":
|
||||
description: >-
|
||||
Chat-enabled flow only: the conversation named by `memory_id` is still answering
|
||||
a message. The body is JSON: `{ "error": string, "running_turn": { "job_id",
|
||||
"user_seq" } }`.
|
||||
|
||||
/w/{workspace}/jobs/run_and_stream/f/{path}:
|
||||
post:
|
||||
@@ -11904,6 +11919,11 @@ paths:
|
||||
text/event-stream:
|
||||
schema:
|
||||
type: string
|
||||
"409":
|
||||
description: >-
|
||||
Chat-enabled flow only: the conversation named by `memory_id` is still answering
|
||||
a message. The body is JSON: `{ "error": string, "running_turn": { "job_id",
|
||||
"user_seq" } }`.
|
||||
|
||||
get:
|
||||
summary: run flow by path with GET and stream updates via SSE
|
||||
@@ -11937,6 +11957,11 @@ paths:
|
||||
text/event-stream:
|
||||
schema:
|
||||
type: string
|
||||
"409":
|
||||
description: >-
|
||||
Chat-enabled flow only: the conversation named by `memory_id` is still answering
|
||||
a message. The body is JSON: `{ "error": string, "running_turn": { "job_id",
|
||||
"user_seq" } }`.
|
||||
|
||||
/w/{workspace}/jobs/run_and_stream/fv/{version}:
|
||||
post:
|
||||
@@ -11984,6 +12009,11 @@ paths:
|
||||
text/event-stream:
|
||||
schema:
|
||||
type: string
|
||||
"409":
|
||||
description: >-
|
||||
Chat-enabled flow only: the conversation named by `memory_id` is still answering
|
||||
a message. The body is JSON: `{ "error": string, "running_turn": { "job_id",
|
||||
"user_seq" } }`.
|
||||
|
||||
get:
|
||||
summary: run flow by version with GET and stream updates via SSE
|
||||
@@ -12023,6 +12053,11 @@ paths:
|
||||
text/event-stream:
|
||||
schema:
|
||||
type: string
|
||||
"409":
|
||||
description: >-
|
||||
Chat-enabled flow only: the conversation named by `memory_id` is still answering
|
||||
a message. The body is JSON: `{ "error": string, "running_turn": { "job_id",
|
||||
"user_seq" } }`.
|
||||
|
||||
/w/{workspace}/jobs/run_and_stream/p/{path}:
|
||||
post:
|
||||
@@ -15432,6 +15467,11 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
"409":
|
||||
description: >-
|
||||
Chat-enabled flow only: the conversation named by `memory_id` is still answering
|
||||
a message. The body is JSON: `{ "error": string, "running_turn": { "job_id",
|
||||
"user_seq" } }`.
|
||||
|
||||
/w/{workspace}/jobs/run/fv/{version}:
|
||||
post:
|
||||
@@ -15489,6 +15529,11 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
"409":
|
||||
description: >-
|
||||
Chat-enabled flow only: the conversation named by `memory_id` is still answering
|
||||
a message. The body is JSON: `{ "error": string, "running_turn": { "job_id",
|
||||
"user_seq" } }`.
|
||||
|
||||
/w/{workspace}/jobs/run/batch_rerun_jobs:
|
||||
post:
|
||||
@@ -15982,6 +16027,11 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
"409":
|
||||
description: >-
|
||||
Chat-enabled flow only: the conversation named by `memory_id` is still answering
|
||||
a message. The body is JSON: `{ "error": string, "running_turn": { "job_id",
|
||||
"user_seq" } }`.
|
||||
|
||||
/w/{workspace}/jobs/run_wait_result/preview_flow:
|
||||
post:
|
||||
@@ -16011,6 +16061,11 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
"409":
|
||||
description: >-
|
||||
Chat-enabled flow only: the conversation named by `memory_id` is still answering
|
||||
a message. The body is JSON: `{ "error": string, "running_turn": { "job_id",
|
||||
"user_seq" } }`.
|
||||
|
||||
/w/{workspace}/jobs/run/dynamic_select:
|
||||
post:
|
||||
@@ -28902,6 +28957,23 @@ components:
|
||||
is_test:
|
||||
type: boolean
|
||||
description: Started from the flow editor's test panel rather than a deployed run
|
||||
running_turn:
|
||||
type: object
|
||||
nullable: true
|
||||
description: >-
|
||||
The turn the conversation is still answering, set by the list endpoint: its
|
||||
newest user message, while the flow run it started is queued or running. A
|
||||
run into this conversation is refused with 409 until the turn ends.
|
||||
required: [job_id, user_seq]
|
||||
properties:
|
||||
job_id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: The flow run of the turn
|
||||
user_seq:
|
||||
type: integer
|
||||
format: int64
|
||||
description: created_seq of the user message that started the turn
|
||||
|
||||
FlowConversationMessage:
|
||||
type: object
|
||||
|
||||
@@ -48,7 +48,9 @@ impl<B> OnResponse<B> for MyOnResponse {
|
||||
let status = response.status().as_u16();
|
||||
if response.status().is_success() || response.status().is_redirection() {
|
||||
tracing::info!(latency = latency, status = status, "response")
|
||||
} else if response.status().as_u16() == 404 {
|
||||
} else if status == 404 || status == 409 {
|
||||
// A refused turn is as expected as a miss: the flow chat takes the turn that
|
||||
// refused it and sends its message after it.
|
||||
tracing::warn!(latency = latency, status = status, "response")
|
||||
} else {
|
||||
tracing::error!(latency = latency, status = status, "response")
|
||||
|
||||
@@ -303,7 +303,11 @@ impl IntoResponse for Error {
|
||||
|
||||
let e = &self;
|
||||
|
||||
if matches!(status, axum::http::StatusCode::NOT_FOUND) {
|
||||
// A refused turn is as expected as a miss: the chat follows the turn that refused it.
|
||||
if matches!(
|
||||
status,
|
||||
axum::http::StatusCode::NOT_FOUND | axum::http::StatusCode::CONFLICT
|
||||
) {
|
||||
tracing::warn!(message = e.to_string());
|
||||
} else {
|
||||
tracing::error!(message = e.to_string(), error = ?e);
|
||||
|
||||
@@ -69,7 +69,9 @@ pub async fn get_or_create_conversation_with_id(
|
||||
is_test: bool,
|
||||
) -> Result<FlowConversation> {
|
||||
if let Some(existing) = lock_conversation(tx, w_id, conversation_id).await? {
|
||||
return same_kind(existing, is_test);
|
||||
let existing = same_kind(existing, is_test)?;
|
||||
refuse_running_turn(tx, conversation_id).await?;
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
// Truncate title to 25 characters max
|
||||
@@ -104,7 +106,74 @@ pub async fn get_or_create_conversation_with_id(
|
||||
"conversation {conversation_id} belongs to another workspace"
|
||||
))
|
||||
})?;
|
||||
same_kind(existing, is_test)
|
||||
let existing = same_kind(existing, is_test)?;
|
||||
refuse_running_turn(tx, conversation_id).await?;
|
||||
Ok(existing)
|
||||
}
|
||||
|
||||
/// The turn a conversation is still answering: its newest user message, while the flow run
|
||||
/// that message started is still queued or running.
|
||||
#[derive(Serialize, Debug, Clone, Copy)]
|
||||
pub struct RunningTurn {
|
||||
pub job_id: Uuid,
|
||||
/// `created_seq` of the user message that started the turn.
|
||||
pub user_seq: i64,
|
||||
}
|
||||
|
||||
/// One running turn per conversation holds its agent memory; a second run would write the
|
||||
/// same memory concurrently. Checked under the conversation's row lock, so two runs sent at
|
||||
/// once cannot both pass: the second waits, then sees the first's message and queued job.
|
||||
async fn refuse_running_turn(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
conversation_id: Uuid,
|
||||
) -> Result<()> {
|
||||
let Some(turn) = running_turns(&mut **tx, &[conversation_id])
|
||||
.await?
|
||||
.remove(&conversation_id)
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
// A JSON body, so a chat client can follow the running turn instead of failing.
|
||||
Err(crate::error::Error::Generic(
|
||||
axum::http::StatusCode::CONFLICT,
|
||||
serde_json::json!({
|
||||
"error": "this conversation is still answering a message; wait for it to finish or stop it before sending another",
|
||||
"running_turn": turn,
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// The running turn of each of `conversation_ids` that has one.
|
||||
///
|
||||
/// It answers for whatever ids it is given and checks no permission of its own, so the
|
||||
/// executor must be one the caller is entitled to read those conversations through: a
|
||||
/// `user_db` transaction under RLS, or a transaction holding ids the caller has already
|
||||
/// authorized. Handed a raw pool and ids from a request, it would report other users' jobs.
|
||||
pub async fn running_turns<'e, E: sqlx::PgExecutor<'e>>(
|
||||
executor: E,
|
||||
conversation_ids: &[Uuid],
|
||||
) -> Result<std::collections::HashMap<Uuid, RunningTurn>> {
|
||||
let rows = sqlx::query!(
|
||||
r#"SELECT c.id AS "id!", u.job_id AS "job_id!", u.created_seq AS "user_seq!"
|
||||
FROM unnest($1::uuid[]) AS c(id)
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT job_id, created_seq
|
||||
FROM flow_conversation_message
|
||||
WHERE conversation_id = c.id AND message_type = 'user'
|
||||
ORDER BY created_seq DESC
|
||||
LIMIT 1
|
||||
) u
|
||||
WHERE u.job_id IS NOT NULL
|
||||
AND EXISTS (SELECT 1 FROM v2_job_queue q WHERE q.id = u.job_id)"#,
|
||||
conversation_ids
|
||||
)
|
||||
.fetch_all(executor)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| (r.id, RunningTurn { job_id: r.job_id, user_seq: r.user_seq }))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// `memory_id` is the caller's to choose, so a preview run could name a deployed
|
||||
|
||||
+23
-1
@@ -229,7 +229,8 @@ set) means the turn could not run or be followed at all, such as a refused reque
|
||||
Methods: `sendMessage(text, { inputs?, attachments?, attachmentsInput? })`, `stop()`,
|
||||
`newConversation()`, `selectConversation(id)`, `loadConversations({ page?, perPage?, kind? })`,
|
||||
`deleteConversation(id)`, `renameConversation(id, title)`, `loadOlderMessages()`,
|
||||
`destroy()`. `kind` lists the flow editor's test chats (`'test'`), the deployed flow's
|
||||
`resumeTurn(turn)`, `refreshMessages()` (reads what another tab added to the open
|
||||
conversation), `destroy()`. `kind` lists the flow editor's test chats (`'test'`), the deployed flow's
|
||||
own (`'deployed'`, the server's default) or both (`'all'`); each `Conversation` carries
|
||||
`isTest`. A rename keeps the conversation's place in the list. Switching conversations
|
||||
stops following the current answer; the flow keeps running and, with server history,
|
||||
@@ -261,6 +262,27 @@ user needs read and write on `windmill_uploads/*`, which the default rules grant
|
||||
`job_helpers`, so a restricted token needs `job_helpers:write`; a sandboxed raw app cannot
|
||||
request that scope today, so attachments are not available there yet.
|
||||
|
||||
## One turn at a time
|
||||
|
||||
A conversation answers one message at a time, and Windmill enforces it: a message sent
|
||||
while its previous turn still runs (from another tab, or before a reload) is refused.
|
||||
`sendMessage` then rejects with a `TurnRunningError` and shows nothing of the message.
|
||||
With server history, a listed `Conversation` also carries `runningTurn` while it is
|
||||
answering. Either way, `resumeTurn(turn)` follows that turn in the selected
|
||||
conversation: its answer streams in from the start and the turn finishes as if it had
|
||||
been sent here, after which the message can be sent again.
|
||||
|
||||
```ts
|
||||
// `runningTurn` comes from the listing, so read it before selecting.
|
||||
await chat.loadConversations()
|
||||
const running = chat.getState().conversations.find((c) => c.id === id)?.runningTurn
|
||||
await chat.selectConversation(id)
|
||||
if (running) await chat.resumeTurn(running)
|
||||
```
|
||||
|
||||
A dropped connection to the answer is retried; when it keeps failing, the chat stops
|
||||
streaming and waits for the flow's result instead, so the turn still ends.
|
||||
|
||||
## History
|
||||
|
||||
Windmill stores every conversation of a chat-mode flow, and each Windmill user sees
|
||||
|
||||
+16
-1
@@ -169,9 +169,19 @@ function chunkStream(
|
||||
for (const e of event.events) parts.apply(e)
|
||||
continue
|
||||
}
|
||||
// A result polled after the stream failed: the round that was streaming stopped
|
||||
// wherever the connection did. Sent chunks cannot be taken back, so the answer's
|
||||
// rest follows them, or the whole answer when it does not continue them.
|
||||
const cut = parts.openText ?? ''
|
||||
parts.closeOpen()
|
||||
failure = await failureText(api, entry.jobId, event.result, signal)
|
||||
if (failure === undefined && !parts.streamedText) {
|
||||
if (failure === undefined && event.streamLost) {
|
||||
const answer = extractChatAnswer(event.result)
|
||||
if (answer !== undefined && answer !== cut) {
|
||||
parts.text(answer.startsWith(cut) ? answer.slice(cut.length) : answer)
|
||||
parts.closeOpen()
|
||||
}
|
||||
} else if (failure === undefined && !parts.streamedText) {
|
||||
// No agent streamed: the flow's result is the answer.
|
||||
const answer = extractChatAnswer(event.result)
|
||||
if (answer !== undefined) {
|
||||
@@ -216,6 +226,8 @@ async function failureText(
|
||||
*/
|
||||
class PartWriter {
|
||||
streamedText = false
|
||||
/** The text of the round still streaming, until a tool call or the end closes it. */
|
||||
openText: string | undefined
|
||||
#textId: string | undefined
|
||||
#reasoningId: string | undefined
|
||||
#started = new Set<string>()
|
||||
@@ -227,8 +239,10 @@ class PartWriter {
|
||||
this.streamedText = true
|
||||
if (!this.#textId) {
|
||||
this.#textId = randomId()
|
||||
this.openText = ''
|
||||
this.emit({ type: 'text-start', id: this.#textId })
|
||||
}
|
||||
this.openText += delta
|
||||
this.emit({ type: 'text-delta', id: this.#textId, delta })
|
||||
}
|
||||
|
||||
@@ -248,6 +262,7 @@ class PartWriter {
|
||||
if (this.#textId) {
|
||||
this.emit({ type: 'text-end', id: this.#textId })
|
||||
this.#textId = undefined
|
||||
this.openText = undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+64
-10
@@ -13,13 +13,53 @@ export interface WindmillChatApiOptions {
|
||||
export class WindmillApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number
|
||||
readonly status: number,
|
||||
/** The response body, as the server sent it. */
|
||||
readonly body?: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'WindmillApiError'
|
||||
}
|
||||
}
|
||||
|
||||
/** The turn a conversation is still answering, as the server reports it. */
|
||||
export interface RunningTurn {
|
||||
jobId: string
|
||||
/** `created_seq` of the user message that started the turn. */
|
||||
userSeq: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A message was sent to a conversation whose turn is still running. The server refuses
|
||||
* it (409) so two runs never write one agent memory; `turn` is the run to follow instead.
|
||||
*/
|
||||
export class TurnRunningError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly turn: RunningTurn
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'TurnRunningError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The running turn named by a run's 409 body, or undefined for any other body. Exported
|
||||
* for a custom `run` that calls Windmill through its own client: it rethrows the refusal
|
||||
* as a `TurnRunningError` so the chat can follow the running turn.
|
||||
*/
|
||||
export function turnRunningError(body: string): TurnRunningError | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(body) as { error?: unknown; running_turn?: FlowConversation['running_turn'] }
|
||||
const turn = parsed?.running_turn
|
||||
if (!turn || typeof turn.job_id !== 'string' || typeof turn.user_seq !== 'number') return undefined
|
||||
const message = typeof parsed.error === 'string' ? parsed.error : 'this conversation is still answering a message'
|
||||
return new TurnRunningError(message, { jobId: turn.job_id, userSeq: turn.user_seq })
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export interface FlowConversation {
|
||||
id: string
|
||||
workspace_id: string
|
||||
@@ -30,6 +70,8 @@ export interface FlowConversation {
|
||||
created_by: string
|
||||
/** Started from the flow editor's test panel rather than a deployed run. */
|
||||
is_test: boolean
|
||||
/** Set by the list: the turn this conversation is still answering. */
|
||||
running_turn?: { job_id: string; user_seq: number } | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,18 +153,27 @@ export class WindmillChatApi {
|
||||
this.#pollDelayMs = options.pollDelayMs
|
||||
}
|
||||
|
||||
/** Starts a turn: runs the flow with `memory_id` set to the conversation id. Returns the job id. */
|
||||
/**
|
||||
* Starts a turn: runs the flow with `memory_id` set to the conversation id. Returns the
|
||||
* job id. Throws `TurnRunningError` when the conversation is still answering.
|
||||
*/
|
||||
async runFlow(
|
||||
flowPath: string,
|
||||
args: Record<string, unknown>,
|
||||
options: { memoryId: string; signal?: AbortSignal }
|
||||
): Promise<string> {
|
||||
const res = await this.#request(`jobs/run/f/${encodePath(flowPath)}`, {
|
||||
method: 'POST',
|
||||
query: { memory_id: options.memoryId, skip_preprocessor: 'true' },
|
||||
body: args,
|
||||
signal: options.signal
|
||||
})
|
||||
let res: Response
|
||||
try {
|
||||
res = await this.#request(`jobs/run/f/${encodePath(flowPath)}`, {
|
||||
method: 'POST',
|
||||
query: { memory_id: options.memoryId, skip_preprocessor: 'true' },
|
||||
body: args,
|
||||
signal: options.signal
|
||||
})
|
||||
} catch (e) {
|
||||
if (e instanceof WindmillApiError && e.status === 409) throw turnRunningError(e.body ?? '') ?? e
|
||||
throw e
|
||||
}
|
||||
return (await res.text()).trim()
|
||||
}
|
||||
|
||||
@@ -146,7 +197,9 @@ export class WindmillChatApi {
|
||||
signal: options.signal
|
||||
})
|
||||
if (!res.body) {
|
||||
throw new WindmillApiError('The job update stream has no body', res.status)
|
||||
// Status 0, whatever the status line said: a response with no stream in it comes from
|
||||
// something in front of Windmill, so it is followed by a reconnect like any other.
|
||||
throw new WindmillApiError('The job update stream has no body', 0)
|
||||
}
|
||||
for await (const data of readServerSentEvents(res.body)) {
|
||||
try {
|
||||
@@ -294,7 +347,8 @@ export class WindmillChatApi {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new WindmillApiError(
|
||||
`${init.method ?? 'GET'} ${path} failed (${res.status})${text ? `: ${text}` : ''}`,
|
||||
res.status
|
||||
res.status,
|
||||
text
|
||||
)
|
||||
}
|
||||
return res
|
||||
|
||||
+297
-49
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
TurnRunningError,
|
||||
WindmillApiError,
|
||||
WindmillChatApi,
|
||||
type ConversationKind,
|
||||
type FlowConversation,
|
||||
type FlowConversationMessage
|
||||
type FlowConversationMessage,
|
||||
type RunningTurn
|
||||
} from './api'
|
||||
import { resolveConfig, type ResolvedConfig } from './config'
|
||||
import { followJob } from './follow'
|
||||
@@ -38,6 +40,8 @@ const PERSIST_DEBOUNCE_MS = 250
|
||||
/** Messages persist from spawned tasks that can land just after the flow completes. */
|
||||
const RECONCILE_ATTEMPTS = 3
|
||||
const RECONCILE_DELAY_MS = 400
|
||||
/** Rows per read of what a turn wrote; a fuller page is read on from its last row. */
|
||||
const ROWS_PAGE = 100
|
||||
|
||||
interface Turn {
|
||||
controller: AbortController
|
||||
@@ -73,6 +77,11 @@ class ChatImpl implements Chat {
|
||||
/** The kind the caller last listed, so the refresh after a new turn lists the same rows. */
|
||||
#conversationKind: ConversationKind | undefined
|
||||
#persistTimer: ReturnType<typeof setTimeout> | undefined
|
||||
/** Settles once the selected conversation's first page has been read. */
|
||||
#selecting: Promise<void> = Promise.resolve()
|
||||
/** Bumped whenever a turn is taken or the conversation is left: a read that started before
|
||||
* it describes a conversation this chat has moved past. */
|
||||
#epoch = 0
|
||||
|
||||
constructor(options: ChatOptions) {
|
||||
this.#config = resolveConfig(options)
|
||||
@@ -141,6 +150,7 @@ class ChatImpl implements Chat {
|
||||
streamedText: false
|
||||
}
|
||||
this.#turn = turn
|
||||
this.#epoch++
|
||||
|
||||
const timestamp = now()
|
||||
const conversation: Conversation = this.#state.conversations.find(
|
||||
@@ -186,6 +196,109 @@ class ChatImpl implements Chat {
|
||||
turn.jobId = this.#config.run
|
||||
? await this.#config.run(args, context)
|
||||
: await this.#api.runFlow(this.#config.flowPath, args, context)
|
||||
} catch (e) {
|
||||
try {
|
||||
// Nothing of this message reached the server, whether the conversation was already
|
||||
// answering one sent elsewhere or an attachment never uploaded: the message is taken
|
||||
// back rather than shown as a failed turn, and the caller gets the reason.
|
||||
if (e instanceof TurnRunningError || !turn.started) {
|
||||
this.#withdrawTurn(turn)
|
||||
throw e
|
||||
}
|
||||
// stop() and a conversation switch abort the turn and settle the state themselves.
|
||||
if (turn.controller.signal.aborted || isAbortError(e)) return
|
||||
this.#failTurn(turn, e)
|
||||
return
|
||||
} finally {
|
||||
if (this.#turn === turn) this.#turn = undefined
|
||||
}
|
||||
}
|
||||
await this.#followTurn(turn, isNew)
|
||||
}
|
||||
|
||||
resumeTurn = async ({ jobId, userSeq }: RunningTurn): Promise<void> => {
|
||||
const conversationId = this.#state.conversationId
|
||||
if (!conversationId || this.#turn) return
|
||||
if (this.#state.history !== 'server') {
|
||||
if (this.#state.status === 'submitted') this.#set({ status: 'idle' })
|
||||
return
|
||||
}
|
||||
const turn: Turn = {
|
||||
controller: new AbortController(),
|
||||
conversationId,
|
||||
userMessageId: '',
|
||||
jobId,
|
||||
// Its run was asked for elsewhere and is already going: there is nothing to withdraw,
|
||||
// and the conversation it belongs to is listed.
|
||||
isNew: false,
|
||||
started: true,
|
||||
withdrawn: false,
|
||||
streamedText: false
|
||||
}
|
||||
this.#turn = turn
|
||||
this.#epoch++
|
||||
this.#set({ status: 'submitted', error: undefined })
|
||||
try {
|
||||
// The conversation's first page may still be on its way; it would land over the turn.
|
||||
await this.#selecting
|
||||
if (!this.#turnActive(turn)) return
|
||||
// A listing is a snapshot: the turn it named can have ended and another one started
|
||||
// since. The newest user message this chat holds names the turn to follow instead —
|
||||
// following the one the listing named would drop the newer turn's rows and leave the
|
||||
// chat idle while it runs.
|
||||
const newest = this.#latestUserAfter(userSeq)
|
||||
if (newest) {
|
||||
if (!newest.jobId) {
|
||||
// Its row is written and its run is not named yet: there is nothing to follow, so
|
||||
// it is left to the next listing to name the turn that runs now.
|
||||
if (this.#turn === turn) this.#turn = undefined
|
||||
this.#set({ status: 'idle' })
|
||||
return
|
||||
}
|
||||
turn.jobId = newest.jobId
|
||||
userSeq = newest.seq!
|
||||
}
|
||||
// The stream replays the turn from its start, so what this chat shows of it goes and
|
||||
// comes back as it replays: its rows, and what an earlier follow of it streamed or
|
||||
// failed with, its own unread question included. The message that started it stays:
|
||||
// it is the turn's anchor, and a long turn can have pushed it off the page this chat
|
||||
// opened on. An earlier turn's answer that only its flow result gave has no row and so
|
||||
// no seq; it names that turn's job, and it stays wherever the fallback put it.
|
||||
const held = this.#state.messages
|
||||
const at = held.findIndex((m) => m.seq === userSeq)
|
||||
let user = at === -1 ? undefined : held[at]
|
||||
let messages = held.filter((m, i) => {
|
||||
if (m.seq !== undefined) return m.seq < userSeq
|
||||
if (m.pending || m.jobId === turn.jobId) return false
|
||||
return m.jobId !== undefined || (at !== -1 && i < at)
|
||||
})
|
||||
if (user) messages = [...messages, user]
|
||||
if (!user) {
|
||||
const [row] = await this.#api.listMessages(conversationId, {
|
||||
afterSeq: userSeq - 1,
|
||||
perPage: 1,
|
||||
signal: turn.controller.signal
|
||||
})
|
||||
if (!this.#turnActive(turn)) return
|
||||
if (row?.created_seq !== userSeq || row.message_type !== 'user') {
|
||||
throw new Error('windmill-chat: the message that started the running turn is gone')
|
||||
}
|
||||
user = fromRow(row)
|
||||
messages = [...messages, user]
|
||||
}
|
||||
turn.userMessageId = user.id
|
||||
this.#set({ messages })
|
||||
} catch (e) {
|
||||
if (!(turn.controller.signal.aborted || isAbortError(e))) this.#failTurn(turn, e)
|
||||
if (this.#turn === turn) this.#turn = undefined
|
||||
return
|
||||
}
|
||||
await this.#followTurn(turn, false)
|
||||
}
|
||||
|
||||
async #followTurn(turn: Turn, isNew: boolean): Promise<void> {
|
||||
let nextTurn: RunningTurn | undefined
|
||||
try {
|
||||
const stopPolling = this.#state.history === 'server' ? this.#startPolling(turn) : () => {}
|
||||
let result: unknown
|
||||
try {
|
||||
@@ -193,7 +306,7 @@ class ChatImpl implements Chat {
|
||||
} finally {
|
||||
stopPolling()
|
||||
}
|
||||
await this.#finishTurn(turn, result, isNew)
|
||||
nextTurn = await this.#finishTurn(turn, result, isNew)
|
||||
} catch (e) {
|
||||
if (!turn.started) {
|
||||
// Nothing ran: the message is withdrawn rather than shown as a failed turn, and the
|
||||
@@ -208,6 +321,7 @@ class ChatImpl implements Chat {
|
||||
} finally {
|
||||
if (this.#turn === turn) this.#turn = undefined
|
||||
}
|
||||
if (nextTurn) await this.resumeTurn(nextTurn)
|
||||
}
|
||||
|
||||
stop = async (): Promise<void> => {
|
||||
@@ -249,8 +363,14 @@ class ChatImpl implements Chat {
|
||||
})
|
||||
}
|
||||
|
||||
selectConversation = async (conversationId: string): Promise<void> => {
|
||||
if (conversationId === this.#state.conversationId) return
|
||||
selectConversation = (conversationId: string): Promise<void> => {
|
||||
if (conversationId === this.#state.conversationId) return this.#selecting
|
||||
const selecting = this.#select(conversationId)
|
||||
this.#selecting = selecting.catch(() => {})
|
||||
return selecting
|
||||
}
|
||||
|
||||
async #select(conversationId: string): Promise<void> {
|
||||
this.#leaveConversation()
|
||||
this.#page = 1
|
||||
this.#set({
|
||||
@@ -379,9 +499,19 @@ class ChatImpl implements Chat {
|
||||
})
|
||||
if (this.#state.conversationId !== conversationId) return
|
||||
const known = new Set(this.#state.messages.map((m) => m.serverId ?? m.id))
|
||||
// Pages count back from the newest row, so rows written since the first page shift
|
||||
// newer rows into this one; and a resumed turn drops the rows it replays. Only what
|
||||
// is older than everything held belongs above it.
|
||||
const oldest = this.#state.messages.reduce<number | undefined>(
|
||||
(min, m) => (m.seq !== undefined && (min === undefined || m.seq < min) ? m.seq : min),
|
||||
undefined
|
||||
)
|
||||
const older = rows
|
||||
.map(fromRow)
|
||||
.filter((m) => !known.has(m.id) && (oldest === undefined || m.seq! < oldest))
|
||||
this.#page = page
|
||||
this.#set({
|
||||
messages: [...rows.map(fromRow).filter((m) => !known.has(m.id)), ...this.#state.messages],
|
||||
messages: [...older, ...this.#state.messages],
|
||||
hasMoreMessages: rows.length === this.#config.pageSize
|
||||
})
|
||||
} finally {
|
||||
@@ -389,6 +519,56 @@ class ChatImpl implements Chat {
|
||||
}
|
||||
}
|
||||
|
||||
refreshMessages = async (): Promise<void> => {
|
||||
const conversationId = this.#state.conversationId
|
||||
if (
|
||||
!conversationId ||
|
||||
this.#turn ||
|
||||
this.#state.history !== 'server' ||
|
||||
this.#state.loadingMessages
|
||||
) {
|
||||
return
|
||||
}
|
||||
const newestBefore = latestSeq(this.#state.messages)
|
||||
const failure = lastFailureShown(this.#state.messages)
|
||||
// A turn that starts and ends while these rows are read, or the conversation being left
|
||||
// and opened again, leaves the chat holding messages this read knows nothing about, and
|
||||
// the rows would land under them rather than in their own place. They are left to the
|
||||
// next read, which sees the conversation as it is now.
|
||||
const epoch = this.#epoch
|
||||
await this.#syncFromServer(conversationId, () => this.#epoch === epoch)
|
||||
if (this.#epoch !== epoch) return
|
||||
if (!failure || this.#state.conversationId !== conversationId) return
|
||||
if (!this.#state.messages.some((m) => m.id === failure.id)) return
|
||||
// The turn this chat lost may have been carried to its end elsewhere, which only a row
|
||||
// this read brought can say, and only one of that turn's own: an agent writes its answer
|
||||
// from a task the run does not wait for, so an earlier turn's answer can commit after
|
||||
// this turn's question and would otherwise settle it with someone else's answer. The
|
||||
// failed turn's job is on the message it left; unknown jobs accept the row, as everywhere
|
||||
// else the turn's jobs are read.
|
||||
const jobs = failure.jobId ? await this.#turnJobIds(failure.jobId) : undefined
|
||||
if (this.#state.conversationId !== conversationId) return
|
||||
const held = this.#state.messages
|
||||
if (!held.some((m) => m.id === failure.id)) return
|
||||
const answered = held.some(
|
||||
(m) =>
|
||||
m.seq !== undefined &&
|
||||
m.seq > newestBefore &&
|
||||
m.role === 'assistant' &&
|
||||
m.success &&
|
||||
(jobs === undefined || m.jobId === undefined || jobs.has(m.jobId))
|
||||
)
|
||||
if (!answered) return
|
||||
// A turn that ran, or failed, while those jobs were read owns the state now: this one's
|
||||
// failure still goes, since its answer is here, but that turn's outcome stands.
|
||||
const settles =
|
||||
this.#state.status === 'error' && lastFailureShown(held)?.id === failure.id
|
||||
this.#set({
|
||||
messages: held.filter((m) => m.id !== failure.id),
|
||||
...(settles ? { status: 'idle' as const, error: undefined } : {})
|
||||
})
|
||||
}
|
||||
|
||||
destroy = (): void => {
|
||||
this.#leaveConversation()
|
||||
}
|
||||
@@ -398,7 +578,10 @@ class ChatImpl implements Chat {
|
||||
async #follow(turn: Turn, onStreamStart: () => void): Promise<unknown> {
|
||||
let started = false
|
||||
for await (const event of followJob(this.#api, turn.jobId!, { signal: turn.controller.signal })) {
|
||||
if (event.type === 'completed') return event.result
|
||||
if (event.type === 'completed') {
|
||||
if (event.streamLost) this.#dropCutRound(turn)
|
||||
return event.result
|
||||
}
|
||||
if (!started) {
|
||||
started = true
|
||||
// Persisted rows for the streaming step would duplicate what is streaming.
|
||||
@@ -409,6 +592,19 @@ class ChatImpl implements Chat {
|
||||
throw new Error('windmill-chat: the job stream ended before the flow completed')
|
||||
}
|
||||
|
||||
/**
|
||||
* The stream failed while a round's text was arriving, so that text stops wherever the
|
||||
* connection did. It goes: the persisted rows or, without them, the flow result give
|
||||
* the whole answer instead. Rounds a tool call closed were complete and stay.
|
||||
*/
|
||||
#dropCutRound(turn: Turn): void {
|
||||
if (!this.#turnActive(turn)) return
|
||||
const cut = turn.assistantId
|
||||
turn.assistantId = undefined
|
||||
turn.streamedText = false
|
||||
if (cut) this.#set({ messages: this.#state.messages.filter((m) => m.id !== cut) })
|
||||
}
|
||||
|
||||
#applyEvents(turn: Turn, events: AgentStreamEvent[]): void {
|
||||
if (events.length === 0 || !this.#turnActive(turn)) return
|
||||
let messages = [...this.#state.messages]
|
||||
@@ -506,18 +702,20 @@ class ChatImpl implements Chat {
|
||||
this.#set({ messages, status: 'streaming' })
|
||||
}
|
||||
|
||||
async #finishTurn(turn: Turn, result: unknown, isNew: boolean): Promise<void> {
|
||||
async #finishTurn(turn: Turn, result: unknown, isNew: boolean): Promise<RunningTurn | undefined> {
|
||||
if (!this.#turnActive(turn)) return
|
||||
if (this.#state.history === 'server') {
|
||||
turn.jobIds = await this.#turnJobIds(turn)
|
||||
turn.jobIds = await this.#turnJobIds(turn.jobId!, turn.controller.signal)
|
||||
if (!this.#turnActive(turn)) return
|
||||
const reconciled = await this.#reconcileTurn(turn)
|
||||
if (!this.#turnActive(turn)) return
|
||||
if (reconciled) {
|
||||
this.#set({ status: 'idle' })
|
||||
const nextTurn = this.#nextTurnAfter(turn)
|
||||
this.#set({ status: nextTurn ? 'submitted' : 'idle' })
|
||||
this.#config.onFinish?.({ conversationId: turn.conversationId, jobId: turn.jobId, messages: this.#state.messages })
|
||||
if (isNew) await this.loadConversations().catch(() => {})
|
||||
return
|
||||
if (!this.#turnActive(turn)) return
|
||||
return nextTurn
|
||||
}
|
||||
// Server history just proved unreadable: the turn completes as local history.
|
||||
}
|
||||
@@ -540,9 +738,13 @@ class ChatImpl implements Chat {
|
||||
messages = [...messages, assistantMessage(answer, true, turn.jobId)]
|
||||
}
|
||||
}
|
||||
this.#set({ messages: finalized(messages), status: 'idle' })
|
||||
const nextTurn = this.#state.history === 'server' ? this.#nextTurnAfter(turn) : undefined
|
||||
this.#set({ messages: finalized(messages), status: nextTurn ? 'submitted' : 'idle' })
|
||||
this.#persistLocal()
|
||||
this.#config.onFinish?.({ conversationId: turn.conversationId, jobId: turn.jobId, messages: this.#state.messages })
|
||||
// `onFinish` can switch conversation, and the next turn belongs to this one.
|
||||
if (!this.#turnActive(turn)) return
|
||||
return nextTurn
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -561,11 +763,7 @@ class ChatImpl implements Chat {
|
||||
for (let attempt = 1; attempt <= RECONCILE_ATTEMPTS; attempt++) {
|
||||
let rows: FlowConversationMessage[]
|
||||
try {
|
||||
rows = await this.#api.listMessages(turn.conversationId, {
|
||||
afterSeq: this.#lastSeq(),
|
||||
perPage: 100,
|
||||
signal: turn.controller.signal
|
||||
})
|
||||
rows = await this.#rowsAfterLastSeq(turn.conversationId, turn.controller.signal)
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) throw e
|
||||
if (this.#fallBackToLocal(e)) return false
|
||||
@@ -592,22 +790,26 @@ class ChatImpl implements Chat {
|
||||
* a badly delayed one can invert that order at the cost of the reconcile
|
||||
* retries). The content is not compared with the flow result: an image answer, a
|
||||
* structured one and a forwarded agent result are all persisted in a shape the
|
||||
* result does not reproduce. Rows carrying a job id belong to the turn when the
|
||||
* job is one of the turn's, which leaves out an earlier turn whose job outlived
|
||||
* `stop()` (a token without `jobs:write` cannot cancel it); a tool row without one
|
||||
* (an MCP call runs inside the agent step) belongs to whatever turn is under way.
|
||||
* result does not reproduce. A row belongs to the turn when it was created after
|
||||
* the user message and, when it carries a job id, the job is one of the turn's. The
|
||||
* server refuses a turn while the previous run is queued, but an agent writes its
|
||||
* answer row from a task the run does not wait for, so that row can still land after
|
||||
* the next user message; its job says whose it is. A tool row without a job (an MCP
|
||||
* call runs inside the agent step) belongs to the turn under way. Until the user
|
||||
* message's own row has been read, its position in the list stands in for its seq.
|
||||
*/
|
||||
#answered(turn: Turn): boolean {
|
||||
const messages = this.#state.messages
|
||||
const from = messages.findIndex((m) => m.id === turn.userMessageId)
|
||||
const userSeq = messages[from]?.seq
|
||||
const ownJob = (m: ChatMessage) =>
|
||||
turn.jobIds === undefined || (m.jobId === undefined ? m.role === 'tool' : turn.jobIds.has(m.jobId))
|
||||
let latest: ChatMessage | undefined
|
||||
for (let i = from + 1; i < messages.length; i++) {
|
||||
const m = messages[i]
|
||||
if (m.seq === undefined || m.role === 'user' || !ownJob(m)) continue
|
||||
messages.forEach((m, i) => {
|
||||
if (m.seq === undefined || m.role === 'user' || !ownJob(m)) return
|
||||
if (userSeq !== undefined ? m.seq <= userSeq : i <= from) return
|
||||
if (latest === undefined || m.seq > latest.seq!) latest = m
|
||||
}
|
||||
})
|
||||
return latest?.role === 'assistant'
|
||||
}
|
||||
|
||||
@@ -615,22 +817,29 @@ class ChatImpl implements Chat {
|
||||
* The flow job plus every step job it ran, the failure and preprocessor steps
|
||||
* included (a failure handler's answer is persisted under its own job), and the
|
||||
* jobs an agent step's tool calls ran as (a tool row is persisted under its own
|
||||
* job too). Unknown when the read fails.
|
||||
* job too). A failed read is retried like the rows are; unknown when it keeps
|
||||
* failing or the credential may not read jobs. Unknown accepts every row after the
|
||||
* question: refusing them would leave a token without job access with no turn ever
|
||||
* answered, each one finished a second time from its result.
|
||||
*/
|
||||
async #turnJobIds(turn: Turn): Promise<Set<string> | undefined> {
|
||||
try {
|
||||
const job = await this.#api.getFlowJob(turn.jobId!, turn.controller.signal)
|
||||
const ids = new Set([turn.jobId!])
|
||||
const status = job.flow_status
|
||||
for (const m of [...(status?.modules ?? []), status?.failure_module, status?.preprocessor_module]) {
|
||||
if (m?.job) ids.add(m.job)
|
||||
for (const j of m?.flow_jobs ?? []) ids.add(j)
|
||||
for (const a of m?.agent_actions ?? []) if (a.job_id) ids.add(a.job_id)
|
||||
async #turnJobIds(jobId: string, signal?: AbortSignal): Promise<Set<string> | undefined> {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
const job = await this.#api.getFlowJob(jobId, signal)
|
||||
const ids = new Set([jobId])
|
||||
const status = job.flow_status
|
||||
for (const m of [...(status?.modules ?? []), status?.failure_module, status?.preprocessor_module]) {
|
||||
if (m?.job) ids.add(m.job)
|
||||
for (const j of m?.flow_jobs ?? []) ids.add(j)
|
||||
for (const a of m?.agent_actions ?? []) if (a.job_id) ids.add(a.job_id)
|
||||
}
|
||||
return ids
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) throw e
|
||||
const refused = e instanceof WindmillApiError && e.status >= 400 && e.status < 500
|
||||
if (refused || attempt === RECONCILE_ATTEMPTS) return undefined
|
||||
await sleep(RECONCILE_DELAY_MS, signal)
|
||||
}
|
||||
return ids
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) throw e
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -689,11 +898,7 @@ class ChatImpl implements Chat {
|
||||
}
|
||||
if (stopped) return
|
||||
try {
|
||||
const rows = await this.#api.listMessages(turn.conversationId, {
|
||||
afterSeq: this.#lastSeq(),
|
||||
perPage: 100,
|
||||
signal
|
||||
})
|
||||
const rows = await this.#rowsAfterLastSeq(turn.conversationId, signal)
|
||||
if (!stopped && this.#turnActive(turn)) this.#mergeRows(rows)
|
||||
} catch {
|
||||
// transient; the completion reconciliation catches up
|
||||
@@ -706,12 +911,22 @@ class ChatImpl implements Chat {
|
||||
}
|
||||
}
|
||||
|
||||
async #syncFromServer(conversationId: string): Promise<void> {
|
||||
const rows = await this.#api.listMessages(conversationId, {
|
||||
afterSeq: this.#lastSeq(),
|
||||
perPage: 100
|
||||
})
|
||||
/** Every row created after the newest one held, however many pages that takes. */
|
||||
async #rowsAfterLastSeq(conversationId: string, signal?: AbortSignal): Promise<FlowConversationMessage[]> {
|
||||
const rows: FlowConversationMessage[] = []
|
||||
let afterSeq = this.#lastSeq()
|
||||
while (true) {
|
||||
const page = await this.#api.listMessages(conversationId, { afterSeq, perPage: ROWS_PAGE, signal })
|
||||
rows.push(...page)
|
||||
if (page.length < ROWS_PAGE) return rows
|
||||
afterSeq = page[page.length - 1].created_seq
|
||||
}
|
||||
}
|
||||
|
||||
async #syncFromServer(conversationId: string, stillCurrent?: () => boolean): Promise<void> {
|
||||
const rows = await this.#rowsAfterLastSeq(conversationId)
|
||||
if (this.#turn || this.#state.conversationId !== conversationId) return
|
||||
if (stillCurrent && !stillCurrent()) return
|
||||
this.#mergeRows(rows)
|
||||
this.#set({ messages: finalized(this.#state.messages) })
|
||||
}
|
||||
@@ -828,6 +1043,21 @@ class ChatImpl implements Chat {
|
||||
return this.#turn === turn && this.#state.conversationId === turn.conversationId
|
||||
}
|
||||
|
||||
#latestUserAfter(userSeq: number): ChatMessage | undefined {
|
||||
return this.#state.messages.reduce<ChatMessage | undefined>(
|
||||
(found, message) =>
|
||||
message.role === 'user' && message.seq !== undefined && message.seq > (found?.seq ?? userSeq) ? message : found,
|
||||
undefined
|
||||
)
|
||||
}
|
||||
|
||||
#nextTurnAfter(turn: Turn): RunningTurn | undefined {
|
||||
const userSeq = this.#state.messages.find((message) => message.id === turn.userMessageId)?.seq
|
||||
if (userSeq === undefined) return undefined
|
||||
const next = this.#latestUserAfter(userSeq)
|
||||
return next?.jobId && next.seq !== undefined ? { jobId: next.jobId, userSeq: next.seq } : undefined
|
||||
}
|
||||
|
||||
/** Stops following the current answer; the flow itself keeps running. */
|
||||
#detachTurn(): void {
|
||||
const turn = this.#turn
|
||||
@@ -842,6 +1072,7 @@ class ChatImpl implements Chat {
|
||||
* written out now rather than on the debounce that may never fire.
|
||||
*/
|
||||
#leaveConversation(): void {
|
||||
this.#epoch++
|
||||
if (this.#turn) {
|
||||
this.#detachTurn()
|
||||
this.#set({ messages: finalized(this.#state.messages), status: 'idle' })
|
||||
@@ -859,6 +1090,20 @@ class ChatImpl implements Chat {
|
||||
}
|
||||
}
|
||||
|
||||
/** The failure this chat is showing: the message `#failTurn` left, which is never a row. */
|
||||
function lastFailureShown(messages: readonly ChatMessage[]): ChatMessage | undefined {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]
|
||||
if (m.seq === undefined && m.success === false) return m
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** The newest row seq a list holds; 0 when it holds none. */
|
||||
function latestSeq(messages: readonly ChatMessage[]): number {
|
||||
return messages.reduce((newest, m) => (m.seq !== undefined && m.seq > newest ? m.seq : newest), 0)
|
||||
}
|
||||
|
||||
function fromRow(row: FlowConversationMessage): ChatMessage {
|
||||
const toolName =
|
||||
row.message_type === 'tool'
|
||||
@@ -898,7 +1143,10 @@ function fromConversation(row: FlowConversation): Conversation {
|
||||
title: row.title ?? undefined,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
isTest: row.is_test
|
||||
isTest: row.is_test,
|
||||
runningTurn: row.running_turn
|
||||
? { jobId: row.running_turn.job_id, userSeq: row.running_turn.user_seq }
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+86
-36
@@ -1,13 +1,18 @@
|
||||
import type { WindmillChatApi } from './api'
|
||||
import { WindmillApiError, type WindmillChatApi } from './api'
|
||||
import { createStreamEventParser, type AgentStreamEvent } from './stream'
|
||||
import { abortError, sleep } from './utils'
|
||||
import { abortError, isAbortError, sleep } from './utils'
|
||||
|
||||
const RECONNECT_DELAY_MS = 300
|
||||
const MAX_RECONNECT_DELAY_MS = 5000
|
||||
/** Consecutive failed connections before the job is polled instead. */
|
||||
const MAX_CONNECTION_FAILURES = 3
|
||||
const RESULT_POLL_MS = 2000
|
||||
|
||||
export type FollowEvent =
|
||||
/** Agent events decoded from the job's result stream; empty when a chunk ended mid-line. */
|
||||
| { type: 'stream'; events: AgentStreamEvent[] }
|
||||
| { type: 'completed'; result: unknown }
|
||||
/** `streamLost`: the result was polled after the stream failed, so what streamed may stop short. */
|
||||
| { type: 'completed'; result: unknown; streamLost?: boolean }
|
||||
|
||||
/**
|
||||
* Follows a job to completion across the server's stream timeouts: every
|
||||
@@ -18,6 +23,10 @@ export type FollowEvent =
|
||||
* The offset indexes the stream of one sub-job (`flow_stream_job_id`, the flow's
|
||||
* streaming step). A retried step gets a new one, so when the id changes the
|
||||
* offset is dropped and the connection reopened from that sub-job's start.
|
||||
*
|
||||
* A connection that fails (a proxy restarting, the network dropping) is retried with
|
||||
* backoff. The run is still going, so after a few failures in a row the job's result is
|
||||
* polled instead: the rest of the answer is not streamed, but the turn still ends.
|
||||
*/
|
||||
export async function* followJob(
|
||||
api: WindmillChatApi,
|
||||
@@ -27,45 +36,86 @@ export async function* followJob(
|
||||
let parser = createStreamEventParser()
|
||||
let offset = options.streamOffset
|
||||
let streamJobId: string | undefined
|
||||
while (true) {
|
||||
let failures = 0
|
||||
while (failures < MAX_CONNECTION_FAILURES) {
|
||||
let reopen = false
|
||||
for await (const update of api.streamJob(jobId, { streamOffset: offset, signal: options.signal })) {
|
||||
if (update.type === 'ping') continue
|
||||
if (update.type === 'timeout') {
|
||||
reopen = true
|
||||
break
|
||||
}
|
||||
if (update.type === 'error') throw new Error(update.error)
|
||||
if (update.type === 'notfound') throw new Error(`Job ${jobId} not found`)
|
||||
if (update.flow_stream_job_id && update.flow_stream_job_id !== streamJobId) {
|
||||
const switched = streamJobId !== undefined && offset !== undefined
|
||||
streamJobId = update.flow_stream_job_id
|
||||
if (switched) {
|
||||
// This connection skipped the new sub-job's first chunks: start it over.
|
||||
offset = undefined
|
||||
options.onOffset?.(undefined)
|
||||
parser = createStreamEventParser()
|
||||
try {
|
||||
for await (const update of api.streamJob(jobId, { streamOffset: offset, signal: options.signal })) {
|
||||
// Opening a connection does not prove it is carrying the job. Only stream progress
|
||||
// clears the count, or status snapshots followed by EOF would never reach polling.
|
||||
if (update.type === 'ping') continue
|
||||
if (update.type === 'timeout') {
|
||||
reopen = true
|
||||
break
|
||||
}
|
||||
if (update.type === 'error') throw new Error(update.error)
|
||||
if (update.type === 'notfound') throw new Error(`Job ${jobId} not found`)
|
||||
if (update.flow_stream_job_id && update.flow_stream_job_id !== streamJobId) {
|
||||
const switched = streamJobId !== undefined && offset !== undefined
|
||||
streamJobId = update.flow_stream_job_id
|
||||
if (switched) {
|
||||
// This connection skipped the new sub-job's first chunks: start it over.
|
||||
failures = 0
|
||||
offset = undefined
|
||||
options.onOffset?.(undefined)
|
||||
parser = createStreamEventParser()
|
||||
reopen = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (update.stream_offset !== undefined) {
|
||||
if (update.stream_offset !== offset) failures = 0
|
||||
offset = update.stream_offset
|
||||
options.onOffset?.(offset)
|
||||
}
|
||||
if (update.new_result_stream) {
|
||||
failures = 0
|
||||
yield { type: 'stream', events: parser.push(update.new_result_stream) }
|
||||
}
|
||||
if (update.completed) {
|
||||
const rest = parser.flush()
|
||||
if (rest.length > 0) yield { type: 'stream', events: rest }
|
||||
yield { type: 'completed', result: update.only_result }
|
||||
return
|
||||
}
|
||||
}
|
||||
if (update.stream_offset !== undefined) {
|
||||
offset = update.stream_offset
|
||||
options.onOffset?.(offset)
|
||||
}
|
||||
if (update.new_result_stream) {
|
||||
yield { type: 'stream', events: parser.push(update.new_result_stream) }
|
||||
}
|
||||
if (update.completed) {
|
||||
const rest = parser.flush()
|
||||
if (rest.length > 0) yield { type: 'stream', events: rest }
|
||||
yield { type: 'completed', result: update.only_result }
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
if (options.signal?.aborted || isAbortError(e) || !isConnectionFailure(e)) throw e
|
||||
failures++
|
||||
if (failures >= MAX_CONNECTION_FAILURES) break
|
||||
await sleep(Math.min(RECONNECT_DELAY_MS * 2 ** failures, MAX_RECONNECT_DELAY_MS), options.signal)
|
||||
continue
|
||||
}
|
||||
if (options.signal?.aborted) throw abortError()
|
||||
// The server closes the connection after its timeout; a dropped connection looks
|
||||
// the same minus the event. Either way the offset lets the next one resume.
|
||||
if (!reopen) await sleep(RECONNECT_DELAY_MS, options.signal)
|
||||
// The server closes the connection after its timeout; a connection that ends without
|
||||
// that event, and without the job completing, carried nothing to its end. The offset
|
||||
// lets the next one resume, and it counts like a failed one so a gateway closing every
|
||||
// stream this way still reaches the polling below rather than reconnecting for ever.
|
||||
if (!reopen) {
|
||||
failures++
|
||||
if (failures >= MAX_CONNECTION_FAILURES) break
|
||||
await sleep(RECONNECT_DELAY_MS, options.signal)
|
||||
}
|
||||
}
|
||||
while (true) {
|
||||
await sleep(RESULT_POLL_MS, options.signal)
|
||||
try {
|
||||
const { completed, result } = await api.getCompletedResult(jobId, options.signal)
|
||||
if (completed) {
|
||||
yield { type: 'completed', result, streamLost: true }
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
if (options.signal?.aborted || isAbortError(e) || !isConnectionFailure(e)) throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A failure that says nothing about the job: the request never reached Windmill, or a
|
||||
* gateway in front of it answered. A 4xx from Windmill itself (not found, refused) does.
|
||||
*/
|
||||
function isConnectionFailure(e: unknown): boolean {
|
||||
if (e instanceof WindmillApiError) return e.status >= 500 || e.status === 0
|
||||
return e instanceof TypeError
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ export { detectRawApp, type RawAppContext } from './config'
|
||||
export {
|
||||
WindmillChatApi,
|
||||
WindmillApiError,
|
||||
TurnRunningError,
|
||||
turnRunningError,
|
||||
readServerSentEvents,
|
||||
type RunningTurn,
|
||||
type WindmillChatApiOptions,
|
||||
type ConversationKind,
|
||||
type FlowConversation,
|
||||
|
||||
@@ -6,6 +6,7 @@ export type UseWindmillChat = ChatState &
|
||||
Pick<
|
||||
Chat,
|
||||
| 'sendMessage'
|
||||
| 'resumeTurn'
|
||||
| 'stop'
|
||||
| 'newConversation'
|
||||
| 'selectConversation'
|
||||
@@ -13,6 +14,7 @@ export type UseWindmillChat = ChatState &
|
||||
| 'deleteConversation'
|
||||
| 'renameConversation'
|
||||
| 'loadOlderMessages'
|
||||
| 'refreshMessages'
|
||||
> & { chat: Chat }
|
||||
|
||||
/**
|
||||
@@ -61,13 +63,15 @@ export function useWindmillChat(options: ChatOptions): UseWindmillChat {
|
||||
chat,
|
||||
sendMessage: (text, options) =>
|
||||
chat.sendMessage(text, { ...options, inputs: { ...latest.current.inputs, ...options?.inputs } }),
|
||||
resumeTurn: chat.resumeTurn,
|
||||
stop: chat.stop,
|
||||
newConversation: chat.newConversation,
|
||||
selectConversation: chat.selectConversation,
|
||||
loadConversations: chat.loadConversations,
|
||||
deleteConversation: chat.deleteConversation,
|
||||
renameConversation: chat.renameConversation,
|
||||
loadOlderMessages: chat.loadOlderMessages
|
||||
loadOlderMessages: chat.loadOlderMessages,
|
||||
refreshMessages: chat.refreshMessages
|
||||
}),
|
||||
[state, chat]
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { RunningTurn } from './api'
|
||||
|
||||
export type ChatRole = 'user' | 'assistant' | 'tool' | 'system'
|
||||
|
||||
/**
|
||||
@@ -61,6 +63,11 @@ export interface Conversation {
|
||||
* server has listed the conversation; unset for one only this client has seen.
|
||||
*/
|
||||
isTest?: boolean
|
||||
/**
|
||||
* The turn the conversation was still answering when the server listed it: started in
|
||||
* another tab, or before this chat was created. Pass it to `resumeTurn` to follow it.
|
||||
*/
|
||||
runningTurn?: RunningTurn
|
||||
}
|
||||
|
||||
export interface ChatState {
|
||||
@@ -164,8 +171,17 @@ export interface Chat {
|
||||
* Sends a message in the current conversation, starting one when there is none. Resolves
|
||||
* when the answer is complete. Rejects when the message could not be sent at all — a turn
|
||||
* already running, an attachment that failed to upload — without touching the transcript.
|
||||
* A conversation still answering a message sent elsewhere rejects with `TurnRunningError`:
|
||||
* follow that turn with `resumeTurn`, then send again.
|
||||
*/
|
||||
sendMessage(text: string, options?: SendMessageOptions): Promise<void>
|
||||
/**
|
||||
* Follows a turn of the current conversation that this chat did not start, as named by
|
||||
* `Conversation.runningTurn` or a `TurnRunningError`: its answer streams into `messages`
|
||||
* from the start and the turn finishes like one sent here. Server history only; resolves
|
||||
* when the answer is complete, at once when a turn is already being followed.
|
||||
*/
|
||||
resumeTurn(turn: RunningTurn): Promise<void>
|
||||
/** Stops following the answer and asks Windmill to cancel the run. */
|
||||
stop(): Promise<void>
|
||||
newConversation(): void
|
||||
@@ -183,6 +199,11 @@ export interface Chat {
|
||||
/** Sets a conversation's title. The list keeps its order: only a turn moves a conversation. */
|
||||
renameConversation(conversationId: string, title: string): Promise<void>
|
||||
loadOlderMessages(): Promise<void>
|
||||
/**
|
||||
* Reads what the current conversation gained since its newest message held here, such
|
||||
* as a turn another tab ran. Server history only; does nothing while a turn is followed.
|
||||
*/
|
||||
refreshMessages(): Promise<void>
|
||||
/** Stops background work (stream, polling) and writes local history out. The chat stays usable. */
|
||||
destroy(): void
|
||||
}
|
||||
|
||||
@@ -59,4 +59,26 @@ describe('AI SDK Chat over the Windmill transport', () => {
|
||||
expect(chat.error?.message).toBe('boom')
|
||||
expect(memoryIds.length === 1 || calls.filter((c) => c.method === 'POST')[1].url.searchParams.get('memory_id') === memoryIds[0]).toBe(true)
|
||||
})
|
||||
|
||||
test('a text part cut by a lost stream is completed from the polled result', async () => {
|
||||
let streams = 0
|
||||
const { fetch } = fetchMock(
|
||||
(c) => (c.method === 'POST' && c.url.pathname === `/api/w/ws/jobs/run/f/${FLOW}` ? text('job-1') : undefined),
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/getupdate_sse/job-1')
|
||||
? ++streams === 1
|
||||
? sse([{ type: 'update', new_result_stream: ndjson({ type: 'token_delta', content: 'The ans' }), stream_offset: 1 }])
|
||||
: text('bad gateway', 502)
|
||||
: undefined,
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/get_result_maybe/job-1')
|
||||
? json({ completed: true, success: true, result: { output: 'The answer is 42', messages: [] } })
|
||||
: undefined
|
||||
)
|
||||
const transport = createWindmillChatTransport({ baseUrl: 'http://wm.test', workspace: 'ws', flowPath: FLOW, token: 'tok', fetch })
|
||||
const chat = new Chat({ id: 'cut-chat', transport })
|
||||
await chat.sendMessage({ text: 'what is it?' })
|
||||
const texts = chat.messages[1].parts.filter((p) => p.type === 'text').map((p) => (p as { text: string }).text)
|
||||
expect(texts.join('')).toBe('The answer is 42')
|
||||
}, 15000)
|
||||
})
|
||||
|
||||
+801
-22
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { TurnRunningError } from '../src/api'
|
||||
import { createChat } from '../src/chat'
|
||||
import type { ChatOptions } from '../src/types'
|
||||
import { fetchMock, json, memoryStorage, messageRow, ndjson, sse, sseTimed, text, type Route } from './support'
|
||||
@@ -11,6 +12,15 @@ const run: Route = (c) =>
|
||||
|
||||
const streamPath = '/api/w/ws/jobs_u/getupdate_sse/job-1'
|
||||
|
||||
/** Waits for a condition the code under test must reach, and fails saying which one. */
|
||||
async function until(done: () => boolean, what: string, timeoutMs = 2000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (!done()) {
|
||||
if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
}
|
||||
|
||||
function options(extra: Partial<ChatOptions>, fetch: ChatOptions['fetch']): ChatOptions {
|
||||
return { flowPath: FLOW, baseUrl: BASE, workspace: 'ws', fetch, storage: memoryStorage(), ...extra }
|
||||
}
|
||||
@@ -945,41 +955,752 @@ describe('createChat with server history', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('a late answer from a stopped job is not taken as the next turn answer', async () => {
|
||||
let jobs = 0
|
||||
let reads = 0
|
||||
test('a message refused because a turn is running leaves nothing behind and names that turn', async () => {
|
||||
const { fetch } = fetchMock(
|
||||
(c) => (c.method === 'POST' && c.url.pathname.includes('/jobs/run/f/') ? text(`job-${++jobs}`) : undefined),
|
||||
// job-1 never completes: the connection just ends, so the turn keeps waiting.
|
||||
(c) => (c.url.pathname.endsWith('/getupdate_sse/job-1') ? sse([{ type: 'update' }]) : undefined),
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/getupdate_sse/job-2')
|
||||
? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'second answer' } }])
|
||||
c.method === 'POST' && c.url.pathname === `/api/w/ws/jobs/run/f/${FLOW}`
|
||||
? text(JSON.stringify({ error: 'still answering', running_turn: { job_id: 'job-9', user_seq: 41 } }), 409)
|
||||
: undefined,
|
||||
(c) => (c.url.pathname.endsWith('/messages') ? json([messageRow(40, 'user', 'earlier')]) : undefined)
|
||||
)
|
||||
const chat = createChat(options({}, fetch))
|
||||
await chat.selectConversation('conv')
|
||||
const refused = await chat.sendMessage('again').catch((e) => e)
|
||||
expect(refused).toBeInstanceOf(TurnRunningError)
|
||||
expect(refused.turn).toEqual({ jobId: 'job-9', userSeq: 41 })
|
||||
const state = chat.getState()
|
||||
expect(state.messages.map((m) => m.content)).toEqual(['earlier'])
|
||||
expect(state.status).toBe('idle')
|
||||
// The conversation is a real one, answering elsewhere: it stays listed, its message gone.
|
||||
expect(state.conversations.map((c) => c.id)).toEqual(['conv'])
|
||||
})
|
||||
|
||||
test('a listing that names a turn already over follows the one running now', async () => {
|
||||
const { fetch, calls } = fetchMock(
|
||||
(c) =>
|
||||
c.url.pathname === '/api/w/ws/jobs_u/getupdate_sse/job-2'
|
||||
? sse([
|
||||
{
|
||||
type: 'update',
|
||||
new_result_stream: ndjson({ type: 'token_delta', content: 'second answer' }),
|
||||
stream_offset: 1,
|
||||
completed: true,
|
||||
only_result: { windmill_chat_answer: 'second answer' }
|
||||
}
|
||||
])
|
||||
: undefined,
|
||||
// The run-only token cannot cancel: job-1 keeps running after stop().
|
||||
(c) => (c.url.pathname.includes('/queue/cancel/') ? text('forbidden', 400) : undefined),
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/jobs_u/get/job-2')
|
||||
? json({ flow_status: { modules: [{ job: 'step-2' }] } })
|
||||
: undefined,
|
||||
// Read 1 is stop()'s sync; the stopped job's answer lands after the second user row.
|
||||
(c) => {
|
||||
if (!c.url.pathname.endsWith('/messages')) return undefined
|
||||
const after = c.url.searchParams.get('after_seq')
|
||||
if (after === '52') return json([messageRow(53, 'assistant', 'second answer', { job_id: 'step-2' })])
|
||||
return json([
|
||||
messageRow(50, 'user', 'first'),
|
||||
messageRow(51, 'assistant', 'first answer'),
|
||||
messageRow(52, 'user', 'second', { job_id: 'job-2' })
|
||||
])
|
||||
}
|
||||
)
|
||||
const chat = createChat(options({}, fetch))
|
||||
await chat.selectConversation('conv')
|
||||
// The listing named the first turn; it ended and the second one started before this select.
|
||||
await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 })
|
||||
expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-1'))).toBe(false)
|
||||
expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-2'))).toBe(true)
|
||||
expect(chat.getState().messages.map((m) => [m.serverId, m.content])).toEqual([
|
||||
['row-50', 'first'],
|
||||
['row-51', 'first answer'],
|
||||
['row-52', 'second'],
|
||||
['row-53', 'second answer']
|
||||
])
|
||||
expect(chat.getState().status).toBe('idle')
|
||||
expect(chat.getState().error).toBeUndefined()
|
||||
})
|
||||
|
||||
test('a newer message whose run is not named yet leaves the chat free', async () => {
|
||||
const { fetch, calls } = fetchMock(
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/messages')
|
||||
? json(
|
||||
++reads === 1
|
||||
? [messageRow(71, 'user', 'first')]
|
||||
: reads === 2
|
||||
? [messageRow(72, 'user', 'second'), messageRow(73, 'assistant', 'first answer, late', { job_id: 'step-1' })]
|
||||
: [messageRow(74, 'assistant', 'second answer', { job_id: 'step-2' })]
|
||||
)
|
||||
? json([messageRow(50, 'user', 'first'), messageRow(52, 'user', 'second')])
|
||||
: undefined
|
||||
)
|
||||
const chat = createChat(options({}, fetch))
|
||||
await chat.selectConversation('conv')
|
||||
await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 })
|
||||
expect(chat.getState().status).toBe('idle')
|
||||
expect(calls.some((c) => c.url.pathname.includes('getupdate_sse'))).toBe(false)
|
||||
// A message sent now starts its own turn rather than being refused.
|
||||
expect(chat.getState().error).toBeUndefined()
|
||||
})
|
||||
|
||||
test('a turn started while a resumed turn reconciles is followed next', async () => {
|
||||
const { fetch, calls } = fetchMock(
|
||||
(c) =>
|
||||
c.url.pathname === streamPath
|
||||
? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'first answer' } }])
|
||||
: undefined,
|
||||
(c) =>
|
||||
c.url.pathname === '/api/w/ws/jobs_u/getupdate_sse/job-2'
|
||||
? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'second answer' } }])
|
||||
: undefined,
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/jobs_u/get/job-1')
|
||||
? json({ flow_status: { modules: [{ job: 'step-1' }] } })
|
||||
: undefined,
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/jobs_u/get/job-2')
|
||||
? json({ flow_status: { modules: [{ job: 'step-2' }] } })
|
||||
: undefined,
|
||||
(c) => {
|
||||
if (!c.url.pathname.endsWith('/messages')) return undefined
|
||||
const after = c.url.searchParams.get('after_seq')
|
||||
if (after === null) return json([messageRow(50, 'user', 'first', { job_id: 'job-1' })])
|
||||
if (after === '50') {
|
||||
return json([
|
||||
messageRow(51, 'assistant', 'first answer', { job_id: 'step-1' }),
|
||||
messageRow(52, 'user', 'second', { job_id: 'job-2' })
|
||||
])
|
||||
}
|
||||
if (after === '52') return json([messageRow(53, 'assistant', 'second answer', { job_id: 'step-2' })])
|
||||
return json([])
|
||||
}
|
||||
)
|
||||
const chat = createChat(options({}, fetch))
|
||||
const statuses: string[] = []
|
||||
chat.subscribe((state) => statuses.push(state.status))
|
||||
await chat.selectConversation('conv')
|
||||
await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 })
|
||||
expect(calls.some((c) => c.url.pathname === streamPath)).toBe(true)
|
||||
expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-2'))).toBe(true)
|
||||
expect(chat.getState().messages.map((m) => [m.serverId, m.content])).toEqual([
|
||||
['row-50', 'first'],
|
||||
['row-51', 'first answer'],
|
||||
['row-52', 'second'],
|
||||
['row-53', 'second answer']
|
||||
])
|
||||
expect(chat.getState().status).toBe('idle')
|
||||
// Vacuous without this: with no 'submitted' at all, both lookups are -1 and the slice empty.
|
||||
expect(statuses).toContain('submitted')
|
||||
expect(statuses.slice(statuses.indexOf('submitted'), statuses.lastIndexOf('submitted') + 1)).not.toContain('idle')
|
||||
})
|
||||
|
||||
test('a fallback to local history during handoff does not leave the chat busy', async () => {
|
||||
let messageFetches = 0
|
||||
const { fetch, calls } = fetchMock(
|
||||
(c) =>
|
||||
c.url.pathname === streamPath
|
||||
? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'first answer' } }])
|
||||
: undefined,
|
||||
(c) => {
|
||||
if (!c.url.pathname.endsWith('/messages')) return undefined
|
||||
messageFetches++
|
||||
if (messageFetches === 1) return json([messageRow(50, 'user', 'first', { job_id: 'job-1' })])
|
||||
if (messageFetches === 2) return json([messageRow(52, 'user', 'second', { job_id: 'job-2' })])
|
||||
return text('forbidden', 403)
|
||||
}
|
||||
)
|
||||
const chat = createChat(options({}, fetch))
|
||||
await chat.selectConversation('conv')
|
||||
await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 })
|
||||
expect(chat.getState().history).toBe('local')
|
||||
expect(chat.getState().status).toBe('idle')
|
||||
expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-2'))).toBe(false)
|
||||
})
|
||||
|
||||
test('a conversation list fallback during handoff does not leave the chat busy', async () => {
|
||||
const { fetch, calls } = fetchMock(
|
||||
run,
|
||||
(c) =>
|
||||
c.url.pathname === streamPath
|
||||
? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'first answer' } }])
|
||||
: undefined,
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/jobs_u/get/job-1')
|
||||
? json({ flow_status: { modules: [{ job: 'job-1' }] } })
|
||||
: undefined,
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/messages')
|
||||
? json([
|
||||
messageRow(50, 'user', 'first', { job_id: 'job-1' }),
|
||||
messageRow(51, 'assistant', 'first answer', { job_id: 'job-1' }),
|
||||
messageRow(52, 'user', 'second', { job_id: 'job-2' })
|
||||
])
|
||||
: undefined,
|
||||
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? text('forbidden', 403) : undefined)
|
||||
)
|
||||
const chat = createChat(options({}, fetch))
|
||||
await chat.sendMessage('first')
|
||||
expect(chat.getState().history).toBe('local')
|
||||
expect(chat.getState().status).toBe('idle')
|
||||
expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-2'))).toBe(false)
|
||||
})
|
||||
|
||||
test('a conversation switch during handoff does not resume the next turn elsewhere', async () => {
|
||||
let releaseList!: (response: Response) => void
|
||||
const listGate = new Promise<Response>((resolve) => {
|
||||
releaseList = resolve
|
||||
})
|
||||
const { fetch, calls } = fetchMock(
|
||||
run,
|
||||
(c) =>
|
||||
c.url.pathname === streamPath
|
||||
? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'first answer' } }])
|
||||
: undefined,
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/jobs_u/get/job-1')
|
||||
? json({ flow_status: { modules: [{ job: 'job-1' }] } })
|
||||
: undefined,
|
||||
(c) => {
|
||||
if (!c.url.pathname.endsWith('/messages')) return undefined
|
||||
if (c.url.pathname.includes('/flow_conversations/other/messages')) return json([])
|
||||
return json([
|
||||
messageRow(50, 'user', 'first', { job_id: 'job-1' }),
|
||||
messageRow(51, 'assistant', 'first answer', { job_id: 'job-1' }),
|
||||
messageRow(52, 'user', 'second', { job_id: 'job-2' })
|
||||
])
|
||||
},
|
||||
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? listGate : undefined)
|
||||
)
|
||||
const chat = createChat(options({}, fetch))
|
||||
const sent = chat.sendMessage('first')
|
||||
await until(() => calls.some((c) => c.url.pathname === '/api/w/ws/flow_conversations/list'), 'the list to be asked for')
|
||||
await chat.selectConversation('other')
|
||||
releaseList(json([]))
|
||||
await sent
|
||||
expect(chat.getState().conversationId).toBe('other')
|
||||
expect(chat.getState().status).toBe('idle')
|
||||
expect(chat.getState().messages).toEqual([])
|
||||
expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-2'))).toBe(false)
|
||||
})
|
||||
|
||||
test('resuming a later turn keeps an earlier answer that only the flow result gave', async () => {
|
||||
const { fetch } = fetchMock(
|
||||
run,
|
||||
(c) =>
|
||||
c.url.pathname === streamPath
|
||||
? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'first answer' } }])
|
||||
: undefined,
|
||||
(c) =>
|
||||
c.url.pathname === '/api/w/ws/jobs_u/getupdate_sse/job-2'
|
||||
? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'second answer' } }])
|
||||
: undefined,
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/jobs_u/get/job-1')
|
||||
? json({ flow_status: { modules: [{ job: 'step-1' }] } })
|
||||
: undefined,
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/jobs_u/get/job-2')
|
||||
? json({ flow_status: { modules: [{ job: 'step-2' }] } })
|
||||
: undefined,
|
||||
(c) => {
|
||||
if (!c.url.pathname.endsWith('/messages')) return undefined
|
||||
const after = c.url.searchParams.get('after_seq')
|
||||
if (after === '51') return json([messageRow(52, 'user', 'second', { job_id: 'job-2' })])
|
||||
if (after === '52') return json([messageRow(53, 'assistant', 'second answer', { job_id: 'step-2' })])
|
||||
// The first answer never got a row: the turn finishes from the flow result.
|
||||
return json([messageRow(50, 'user', 'first', { job_id: 'job-1' })])
|
||||
},
|
||||
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined)
|
||||
)
|
||||
const chat = createChat(options({}, fetch))
|
||||
await chat.sendMessage('first')
|
||||
await chat.resumeTurn({ jobId: 'job-2', userSeq: 52 })
|
||||
expect(chat.getState().messages.map((m) => m.content)).toEqual([
|
||||
'first',
|
||||
'first answer',
|
||||
'second',
|
||||
'second answer'
|
||||
])
|
||||
})
|
||||
|
||||
test('resuming a turn this chat lost drops what it had shown of that turn', async () => {
|
||||
let streams = 0
|
||||
const { fetch } = fetchMock(
|
||||
(c) => {
|
||||
if (c.url.pathname !== streamPath) return undefined
|
||||
return ++streams === 1
|
||||
? sse([
|
||||
{ type: 'update', new_result_stream: ndjson({ type: 'token_delta', content: 'partial' }), stream_offset: 1 },
|
||||
{ type: 'error', error: 'stream broke' }
|
||||
])
|
||||
: sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'the answer' } }])
|
||||
},
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/jobs_u/get/job-1')
|
||||
? json({ flow_status: { modules: [{ job: 'step-1' }] } })
|
||||
: undefined,
|
||||
(c) => {
|
||||
if (!c.url.pathname.endsWith('/messages')) return undefined
|
||||
if (c.url.searchParams.get('after_seq') === '50') {
|
||||
return json([messageRow(51, 'assistant', 'the answer', { job_id: 'step-1' })])
|
||||
}
|
||||
return json([messageRow(50, 'user', 'question', { job_id: 'job-1' })])
|
||||
}
|
||||
)
|
||||
const chat = createChat(options({}, fetch))
|
||||
await chat.selectConversation('conv')
|
||||
await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 })
|
||||
expect(chat.getState().status).toBe('error')
|
||||
// The run goes on; following it again replays the whole answer under its question.
|
||||
await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 })
|
||||
expect(chat.getState().messages.map((m) => m.content)).toEqual(['question', 'the answer'])
|
||||
})
|
||||
|
||||
test('resuming a turn this chat sent and lost shows its question once', async () => {
|
||||
let streams = 0
|
||||
const { fetch } = fetchMock(
|
||||
run,
|
||||
(c) => {
|
||||
if (c.url.pathname !== streamPath) return undefined
|
||||
return ++streams === 1
|
||||
? sse([
|
||||
{ type: 'update', new_result_stream: ndjson({ type: 'token_delta', content: 'partial' }), stream_offset: 1 },
|
||||
{ type: 'error', error: 'stream broke' }
|
||||
])
|
||||
: sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'the answer' } }])
|
||||
},
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/jobs_u/get/job-1')
|
||||
? json({ flow_status: { modules: [{ job: 'step-1' }] } })
|
||||
: undefined,
|
||||
(c) => {
|
||||
if (!c.url.pathname.endsWith('/messages')) return undefined
|
||||
const after = c.url.searchParams.get('after_seq')
|
||||
if (after === '49') return json([messageRow(50, 'user', 'question', { job_id: 'job-1' })])
|
||||
if (after === '50') return json([messageRow(51, 'assistant', 'the answer', { job_id: 'step-1' })])
|
||||
// The stream fails before the question's row is read.
|
||||
return json([])
|
||||
},
|
||||
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined)
|
||||
)
|
||||
const chat = createChat(options({}, fetch))
|
||||
await chat.sendMessage('question')
|
||||
expect(chat.getState().status).toBe('error')
|
||||
await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 })
|
||||
expect(chat.getState().messages.map((m) => m.content)).toEqual(['question', 'the answer'])
|
||||
})
|
||||
|
||||
test('a re-read that finds the lost turn answered clears the failure it was left with', async () => {
|
||||
let answered = false
|
||||
const { fetch } = fetchMock(
|
||||
(c) =>
|
||||
c.url.pathname === streamPath
|
||||
? sse([{ type: 'error', error: 'stream broke' }])
|
||||
: undefined,
|
||||
(c) => {
|
||||
if (!c.url.pathname.endsWith('/messages')) return undefined
|
||||
const rows = [messageRow(50, 'user', 'question', { job_id: 'job-1' })]
|
||||
if (answered) rows.push(messageRow(51, 'assistant', 'the answer', { job_id: 'job-1' }))
|
||||
return json(rows)
|
||||
}
|
||||
)
|
||||
const chat = createChat(options({}, fetch))
|
||||
await chat.selectConversation('conv')
|
||||
await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 })
|
||||
expect(chat.getState().status).toBe('error')
|
||||
// Another tab carried the turn to its end and its answer is in the rows now.
|
||||
answered = true
|
||||
await chat.refreshMessages()
|
||||
// The answer stands alone: the failure this chat showed was never a row.
|
||||
expect(chat.getState().messages.map((m) => m.content)).toEqual(['question', 'the answer'])
|
||||
expect(chat.getState().status).toBe('idle')
|
||||
expect(chat.getState().error).toBeUndefined()
|
||||
})
|
||||
|
||||
test("a re-read does not settle a failed turn with the previous turn's late answer", async () => {
|
||||
const { fetch } = fetchMock(
|
||||
(c) =>
|
||||
c.url.pathname === '/api/w/ws/jobs_u/getupdate_sse/job-b'
|
||||
? sse([{ type: 'error', error: 'stream broke' }])
|
||||
: undefined,
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/jobs_u/get/job-b')
|
||||
? json({ flow_status: { modules: [{ job: 'step-b' }] } })
|
||||
: undefined,
|
||||
(c) => {
|
||||
if (!c.url.pathname.endsWith('/messages')) return undefined
|
||||
const rows = [
|
||||
messageRow(50, 'user', 'qA', { job_id: 'job-a' }),
|
||||
messageRow(51, 'user', 'qB', { job_id: 'job-b' })
|
||||
]
|
||||
// Turn A's answer commits from its detached task, after B's question.
|
||||
if (c.url.searchParams.get('after_seq') === '51') {
|
||||
return json([messageRow(52, 'assistant', "A's answer", { job_id: 'step-a' })])
|
||||
}
|
||||
return json(rows)
|
||||
}
|
||||
)
|
||||
const chat = createChat(options({}, fetch))
|
||||
await chat.selectConversation('conv')
|
||||
await chat.resumeTurn({ jobId: 'job-b', userSeq: 51 })
|
||||
expect(chat.getState().status).toBe('error')
|
||||
await chat.refreshMessages()
|
||||
// A's answer is not B's: B's failure stands, and it is still shown.
|
||||
expect(chat.getState().status).toBe('error')
|
||||
expect(chat.getState().messages.some((m) => m.success === false && m.seq === undefined)).toBe(true)
|
||||
})
|
||||
|
||||
test("a re-read settles the failed turn from its own answer, not only from the newest row", async () => {
|
||||
const { fetch } = fetchMock(
|
||||
(c) =>
|
||||
c.url.pathname === streamPath ? sse([{ type: 'error', error: 'stream broke' }]) : undefined,
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/jobs_u/get/job-1')
|
||||
? json({ flow_status: { modules: [{ job: 'step-1' }] } })
|
||||
: undefined,
|
||||
(c) => {
|
||||
if (!c.url.pathname.endsWith('/messages')) return undefined
|
||||
if (c.url.searchParams.get('after_seq') === '50') {
|
||||
// The turn's answer, and behind it the question of a turn started elsewhere.
|
||||
return json([
|
||||
messageRow(51, 'assistant', 'the answer', { job_id: 'step-1' }),
|
||||
messageRow(52, 'user', 'next question', { job_id: 'job-2' })
|
||||
])
|
||||
}
|
||||
return json([messageRow(50, 'user', 'question', { job_id: 'job-1' })])
|
||||
}
|
||||
)
|
||||
const chat = createChat(options({}, fetch))
|
||||
await chat.selectConversation('conv')
|
||||
await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 })
|
||||
expect(chat.getState().status).toBe('error')
|
||||
await chat.refreshMessages()
|
||||
expect(chat.getState().status).toBe('idle')
|
||||
expect(chat.getState().messages.map((m) => m.content)).toEqual([
|
||||
'question',
|
||||
'the answer',
|
||||
'next question'
|
||||
])
|
||||
})
|
||||
|
||||
test('a conversation left and opened again while its rows are read drops that read', async () => {
|
||||
let releaseRows!: (r: Response) => void
|
||||
const rowsGate = new Promise<Response>((resolve) => (releaseRows = resolve))
|
||||
let reads = 0
|
||||
const { fetch } = fetchMock((c) => {
|
||||
if (!c.url.pathname.endsWith('/messages')) return undefined
|
||||
if (c.url.pathname.includes('/flow_conversations/other/')) return json([])
|
||||
reads++
|
||||
// The read the refresh made, answered only after the reader has come back.
|
||||
if (reads === 2) return rowsGate
|
||||
return json([messageRow(150, 'user', 'newest page', { job_id: 'job-x' })])
|
||||
})
|
||||
const chat = createChat(options({}, fetch))
|
||||
await chat.selectConversation('conv')
|
||||
const refreshed = chat.refreshMessages()
|
||||
await chat.selectConversation('other')
|
||||
await chat.selectConversation('conv')
|
||||
// Rows from before the reader left: older than the page the chat holds now.
|
||||
releaseRows(json([messageRow(51, 'user', 'older page', { job_id: 'job-y' })]))
|
||||
await refreshed
|
||||
expect(chat.getState().messages.map((m) => m.content)).toEqual(['newest page'])
|
||||
})
|
||||
|
||||
test('a turn that runs while the rows are read leaves them to the next read', async () => {
|
||||
let releaseRows!: (r: Response) => void
|
||||
const rowsGate = new Promise<Response>((resolve) => (releaseRows = resolve))
|
||||
let reads = 0
|
||||
const { fetch } = fetchMock(
|
||||
run,
|
||||
(c) =>
|
||||
c.url.pathname.includes('/jobs_u/getupdate_sse/')
|
||||
? sse([{ type: 'error', error: 'stream broke' }])
|
||||
: undefined,
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/jobs_u/get/job-1')
|
||||
? json({ flow_status: { modules: [{ job: 'job-1' }] } })
|
||||
: undefined,
|
||||
(c) => {
|
||||
if (!c.url.pathname.endsWith('/messages')) return undefined
|
||||
if (++reads === 1) return json([messageRow(50, 'user', 'qA', { job_id: 'job-a' })])
|
||||
// The read the refresh made, answered only after another turn has come and gone.
|
||||
if (reads === 2) return rowsGate
|
||||
return json([])
|
||||
},
|
||||
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined)
|
||||
)
|
||||
const chat = createChat(options({}, fetch))
|
||||
await chat.selectConversation('conv')
|
||||
const refreshed = chat.refreshMessages()
|
||||
// A turn starts and fails entirely while that read is in flight.
|
||||
await chat.sendMessage('qB')
|
||||
expect(chat.getState().status).toBe('error')
|
||||
releaseRows(json([messageRow(51, 'assistant', "A's answer", { job_id: 'job-a' })]))
|
||||
await refreshed
|
||||
// A's answer is not appended under B: it waits for a read that sees the conversation as
|
||||
// it is now.
|
||||
expect(chat.getState().messages.map((m) => m.content)).toEqual([
|
||||
'qA',
|
||||
'qB',
|
||||
'stream broke'
|
||||
])
|
||||
})
|
||||
|
||||
test('a turn that starts while the jobs are read still frees the answered failure', async () => {
|
||||
let releaseJobs!: (r: Response) => void
|
||||
const jobsGate = new Promise<Response>((resolve) => (releaseJobs = resolve))
|
||||
let releaseRun!: (r: Response) => void
|
||||
const runGate = new Promise<Response>((resolve) => (releaseRun = resolve))
|
||||
const { fetch, calls } = fetchMock(
|
||||
(c) => (c.method === 'POST' && c.url.pathname === `/api/w/ws/jobs/run/f/${FLOW}` ? runGate : undefined),
|
||||
(c) =>
|
||||
c.url.pathname.includes('/jobs_u/getupdate_sse/')
|
||||
? sse([{ type: 'error', error: 'stream broke' }])
|
||||
: undefined,
|
||||
(c) => (c.url.pathname.endsWith('/jobs_u/get/job-a') ? jobsGate : undefined),
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/jobs_u/get/job-b')
|
||||
? json({ flow_status: { modules: [{ job: 'job-b' }] } })
|
||||
: undefined,
|
||||
(c) => {
|
||||
if (!c.url.pathname.endsWith('/messages')) return undefined
|
||||
if (c.url.searchParams.get('after_seq') === '50') {
|
||||
return json([messageRow(51, 'assistant', "A's answer", { job_id: 'job-a' })])
|
||||
}
|
||||
return json([messageRow(50, 'user', 'qA', { job_id: 'job-a' })])
|
||||
},
|
||||
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined)
|
||||
)
|
||||
const chat = createChat(options({}, fetch))
|
||||
await chat.selectConversation('conv')
|
||||
await chat.resumeTurn({ jobId: 'job-a', userSeq: 50 })
|
||||
expect(chat.getState().status).toBe('error')
|
||||
const refreshed = chat.refreshMessages()
|
||||
await until(
|
||||
() => calls.some((c) => c.url.pathname.endsWith('/jobs_u/get/job-a')),
|
||||
'the job read to start'
|
||||
)
|
||||
// A turn starts while that read is in flight, and is still running when it answers.
|
||||
const sent = chat.sendMessage('qB')
|
||||
await until(() => chat.getState().status === 'submitted', 'the new turn to take the chat')
|
||||
releaseJobs(json({ flow_status: { modules: [{ job: 'job-a' }] } }))
|
||||
await refreshed
|
||||
// A's failure goes with its answer; the running turn keeps the chat busy.
|
||||
expect(chat.getState().messages.some((m) => m.success === false)).toBe(false)
|
||||
expect(chat.getState().status).toBe('submitted')
|
||||
releaseRun(text('job-b'))
|
||||
await sent
|
||||
})
|
||||
|
||||
test('a turn that fails while the jobs are read keeps its own failure', async () => {
|
||||
let releaseJobs!: (r: Response) => void
|
||||
const jobsGate = new Promise<Response>((resolve) => (releaseJobs = resolve))
|
||||
const { fetch, calls } = fetchMock(
|
||||
run,
|
||||
(c) =>
|
||||
c.url.pathname === streamPath || c.url.pathname === '/api/w/ws/jobs_u/getupdate_sse/job-1'
|
||||
? sse([{ type: 'error', error: 'stream broke' }])
|
||||
: undefined,
|
||||
(c) => (c.url.pathname.endsWith('/jobs_u/get/job-a') ? jobsGate : undefined),
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/jobs_u/get/job-1')
|
||||
? json({ flow_status: { modules: [{ job: 'job-1' }] } })
|
||||
: undefined,
|
||||
(c) => {
|
||||
if (!c.url.pathname.endsWith('/messages')) return undefined
|
||||
if (c.url.searchParams.get('after_seq') === '50') {
|
||||
return json([messageRow(51, 'assistant', "A's answer", { job_id: 'job-a' })])
|
||||
}
|
||||
return json([messageRow(50, 'user', 'qA', { job_id: 'job-a' })])
|
||||
},
|
||||
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined)
|
||||
)
|
||||
const chat = createChat(options({}, fetch))
|
||||
await chat.selectConversation('conv')
|
||||
await chat.resumeTurn({ jobId: 'job-a', userSeq: 50 })
|
||||
expect(chat.getState().status).toBe('error')
|
||||
// The refresh stops on the job read; a second turn is sent and fails while it waits.
|
||||
const refreshed = chat.refreshMessages()
|
||||
await until(
|
||||
() => calls.some((c) => c.url.pathname.endsWith('/jobs_u/get/job-a')),
|
||||
'the job read to start'
|
||||
)
|
||||
await chat.sendMessage('qB')
|
||||
expect(chat.getState().status).toBe('error')
|
||||
releaseJobs(json({ flow_status: { modules: [{ job: 'job-a' }] } }))
|
||||
await refreshed
|
||||
// A's failure goes, since its answer is here; the error the chat shows is B's.
|
||||
expect(chat.getState().status).toBe('error')
|
||||
const failures = chat.getState().messages.filter((m) => m.success === false)
|
||||
expect(failures).toHaveLength(1)
|
||||
expect(chat.getState().messages.map((m) => m.content)).toContain("A's answer")
|
||||
})
|
||||
|
||||
test('a re-read that finds no answer leaves the failure standing', async () => {
|
||||
const { fetch } = fetchMock(
|
||||
(c) =>
|
||||
c.url.pathname === streamPath ? sse([{ type: 'error', error: 'stream broke' }]) : undefined,
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/messages')
|
||||
? json([messageRow(50, 'user', 'question', { job_id: 'job-1' })])
|
||||
: undefined
|
||||
)
|
||||
const chat = createChat(options({}, fetch))
|
||||
await chat.selectConversation('conv')
|
||||
await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 })
|
||||
expect(chat.getState().status).toBe('error')
|
||||
await chat.refreshMessages()
|
||||
expect(chat.getState().status).toBe('error')
|
||||
expect(chat.getState().messages.map((m) => m.content)).toEqual(['question', 'stream broke'])
|
||||
})
|
||||
|
||||
test('a conversation switch from onFinish does not resume the next turn elsewhere', async () => {
|
||||
const { fetch, calls } = fetchMock(
|
||||
run,
|
||||
(c) =>
|
||||
c.url.pathname === streamPath
|
||||
? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'first answer' } }])
|
||||
: undefined,
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/jobs_u/get/job-1')
|
||||
? json({ flow_status: { modules: [{ job: 'job-1' }] } })
|
||||
: undefined,
|
||||
(c) => {
|
||||
if (!c.url.pathname.endsWith('/messages')) return undefined
|
||||
if (c.url.pathname.includes('/flow_conversations/other/messages')) return json([])
|
||||
// No answer row lands, so the turn finishes from the flow result.
|
||||
return json([
|
||||
messageRow(50, 'user', 'first', { job_id: 'job-1' }),
|
||||
messageRow(52, 'user', 'second', { job_id: 'job-2' })
|
||||
])
|
||||
},
|
||||
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined)
|
||||
)
|
||||
const chat = createChat(options({ onFinish: () => void chat.selectConversation('other') }, fetch))
|
||||
await chat.sendMessage('first')
|
||||
expect(chat.getState().conversationId).toBe('other')
|
||||
expect(chat.getState().messages).toEqual([])
|
||||
expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-2'))).toBe(false)
|
||||
})
|
||||
|
||||
test('stopping during handoff does not resume the next turn', async () => {
|
||||
let releaseList!: (response: Response) => void
|
||||
const listGate = new Promise<Response>((resolve) => {
|
||||
releaseList = resolve
|
||||
})
|
||||
const { fetch, calls } = fetchMock(
|
||||
run,
|
||||
(c) =>
|
||||
c.url.pathname === streamPath
|
||||
? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'first answer' } }])
|
||||
: undefined,
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/jobs_u/get/job-1')
|
||||
? json({ flow_status: { modules: [{ job: 'job-1' }] } })
|
||||
: undefined,
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/messages')
|
||||
? json([
|
||||
messageRow(50, 'user', 'first', { job_id: 'job-1' }),
|
||||
messageRow(51, 'assistant', 'first answer', { job_id: 'job-1' }),
|
||||
messageRow(52, 'user', 'second', { job_id: 'job-2' })
|
||||
])
|
||||
: undefined,
|
||||
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? listGate : undefined)
|
||||
)
|
||||
const chat = createChat(options({}, fetch))
|
||||
const sent = chat.sendMessage('first')
|
||||
await until(() => calls.some((c) => c.url.pathname === '/api/w/ws/flow_conversations/list'), 'the list to be asked for')
|
||||
const stopped = chat.stop()
|
||||
releaseList(json([]))
|
||||
await Promise.all([sent, stopped])
|
||||
expect(chat.getState().status).toBe('idle')
|
||||
expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-2'))).toBe(false)
|
||||
})
|
||||
|
||||
test('a stream that keeps ending before the job completes hands the turn to polling', async () => {
|
||||
let streams = 0
|
||||
const { fetch } = fetchMock(
|
||||
run,
|
||||
// Every real connection starts with a status snapshot. It is not stream progress.
|
||||
(c) => (c.url.pathname === streamPath ? (streams++, sse([{ type: 'update', running: true }])) : undefined),
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/get_result_maybe/job-1')
|
||||
? json({ completed: true, success: true, result: { windmill_chat_answer: 'polled' } })
|
||||
: undefined,
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/messages')
|
||||
? json([messageRow(81, 'user', 'hi'), messageRow(82, 'assistant', 'polled')])
|
||||
: undefined,
|
||||
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined)
|
||||
)
|
||||
const chat = createChat(options({}, fetch))
|
||||
const first = chat.sendMessage('first')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
await chat.stop()
|
||||
await first
|
||||
await chat.sendMessage('hi')
|
||||
expect(streams).toBe(3)
|
||||
expect(chat.getState().status).toBe('idle')
|
||||
expect(chat.getState().messages.map((m) => m.content)).toEqual(['hi', 'polled'])
|
||||
}, 15000)
|
||||
|
||||
test('resuming a turn whose message is off the first page replays it without duplicating rows', async () => {
|
||||
const { fetch } = fetchMock(
|
||||
(c) =>
|
||||
c.url.pathname === streamPath && !c.url.searchParams.has('stream_offset')
|
||||
? sse([
|
||||
{
|
||||
type: 'update',
|
||||
new_result_stream: ndjson(
|
||||
{ type: 'tool_call', call_id: 'c1', function_name: 'lookup' },
|
||||
{ type: 'tool_result', call_id: 'c1', function_name: 'lookup', result: '1', success: true },
|
||||
{ type: 'token_delta', content: 'Done' }
|
||||
),
|
||||
stream_offset: 3,
|
||||
completed: true,
|
||||
only_result: { output: 'Done', messages: [] }
|
||||
}
|
||||
])
|
||||
: undefined,
|
||||
(c) => {
|
||||
if (!c.url.pathname.endsWith('/messages')) return undefined
|
||||
const after = c.url.searchParams.get('after_seq')
|
||||
// The first page holds only what the running turn wrote so far.
|
||||
if (after === null) return json([messageRow(51, 'tool', 'Used lookup tool')])
|
||||
if (after === '49') return json([messageRow(50, 'user', 'hi')])
|
||||
return json([messageRow(51, 'tool', 'Used lookup tool'), messageRow(52, 'assistant', 'Done')])
|
||||
}
|
||||
)
|
||||
const chat = createChat(options({}, fetch))
|
||||
void chat.selectConversation('conv')
|
||||
await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 })
|
||||
expect(chat.getState().messages.map((m) => [m.serverId, m.role, m.content, m.pending])).toEqual([
|
||||
['row-50', 'user', 'hi', false],
|
||||
['row-51', 'tool', 'Used lookup tool', false],
|
||||
['row-52', 'assistant', 'Done', false]
|
||||
])
|
||||
expect(chat.getState().status).toBe('idle')
|
||||
})
|
||||
|
||||
test("the previous run's answer landing after the next message is not that turn's answer", async () => {
|
||||
let reads = 0
|
||||
let jobReads = 0
|
||||
const { fetch } = fetchMock(
|
||||
run,
|
||||
(c) =>
|
||||
c.url.pathname === streamPath
|
||||
? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'second answer' } }])
|
||||
: undefined,
|
||||
// A transient failure of the job read is retried, not taken as "any row counts".
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/jobs_u/get/job-1')
|
||||
? ++jobReads === 1
|
||||
? text('bad gateway', 502)
|
||||
: json({ flow_status: { modules: [{ job: 'step-2' }] } })
|
||||
: undefined,
|
||||
(c) => {
|
||||
if (!c.url.pathname.endsWith('/messages')) return undefined
|
||||
if (!c.url.searchParams.has('after_seq')) return json([messageRow(71, 'user', 'first')])
|
||||
// The earlier agent wrote its answer from a task its run did not wait for.
|
||||
return json(
|
||||
++reads === 1
|
||||
? [messageRow(72, 'user', 'second'), messageRow(73, 'assistant', 'first answer, late', { job_id: 'step-1' })]
|
||||
: [messageRow(74, 'assistant', 'second answer', { job_id: 'step-2' })]
|
||||
)
|
||||
}
|
||||
)
|
||||
const chat = createChat(options({}, fetch))
|
||||
await chat.selectConversation('conv')
|
||||
await chat.sendMessage('second')
|
||||
expect(chat.getState().messages.map((m) => [m.role, m.content, m.serverId])).toEqual([
|
||||
['user', 'first', 'row-71'],
|
||||
@@ -989,6 +1710,64 @@ describe('createChat with server history', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('an answer cut by a lost stream gives way to the polled result', async () => {
|
||||
let streams = 0
|
||||
const { fetch } = fetchMock(
|
||||
run,
|
||||
(c) =>
|
||||
c.url.pathname === streamPath
|
||||
? ++streams === 1
|
||||
? sse([{ type: 'update', new_result_stream: ndjson({ type: 'token_delta', content: 'Hel' }), stream_offset: 1 }])
|
||||
: text('bad gateway', 502)
|
||||
: undefined,
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/get_result_maybe/job-1')
|
||||
? json({ completed: true, success: true, result: { windmill_chat_answer: 'Hello, full answer' } })
|
||||
: undefined
|
||||
)
|
||||
const chat = createChat(options({ history: 'none' }, fetch))
|
||||
await chat.sendMessage('hi')
|
||||
expect(chat.getState().messages.map((m) => [m.role, m.content, m.pending])).toEqual([
|
||||
['user', 'hi', false],
|
||||
['assistant', 'Hello, full answer', false]
|
||||
])
|
||||
}, 15000)
|
||||
|
||||
test('a stream that keeps failing hands the turn to polling the job', async () => {
|
||||
// Each connection opens, sends the server's ping, then drops.
|
||||
const pingThenDrop = () =>
|
||||
new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({ type: 'ping' })}\n\n`))
|
||||
setTimeout(() => controller.error(new TypeError('network connection was lost')), 20)
|
||||
}
|
||||
}),
|
||||
{ status: 200, headers: { 'content-type': 'text/event-stream' } }
|
||||
)
|
||||
const { fetch } = fetchMock(
|
||||
run,
|
||||
(c) => (c.url.pathname === streamPath ? pingThenDrop() : undefined),
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/get_result_maybe/job-1')
|
||||
? json({ completed: true, success: true, result: { windmill_chat_answer: 'polled' } })
|
||||
: undefined,
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/messages')
|
||||
? json([messageRow(61, 'user', 'hi'), messageRow(62, 'assistant', 'polled')])
|
||||
: undefined,
|
||||
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined)
|
||||
)
|
||||
const chat = createChat(options({}, fetch))
|
||||
await chat.sendMessage('hi')
|
||||
const state = chat.getState()
|
||||
expect(state.status).toBe('idle')
|
||||
expect(state.messages.map((m) => [m.role, m.content, m.success])).toEqual([
|
||||
['user', 'hi', true],
|
||||
['assistant', 'polled', true]
|
||||
])
|
||||
}, 15000)
|
||||
|
||||
test('a failure handler answer is attributed to the turn', async () => {
|
||||
let reads = 0
|
||||
const { fetch } = fetchMock(
|
||||
|
||||
@@ -23,3 +23,20 @@ test('a retried streaming step reports its offset as lost before the new sub-job
|
||||
}
|
||||
expect(offsets).toEqual([3, undefined, 1])
|
||||
})
|
||||
|
||||
test('a 200 carrying no stream is reconnected like any other failed connection', async () => {
|
||||
let streams = 0
|
||||
const { fetch } = fetchMock((c) => {
|
||||
if (!c.url.pathname.endsWith('/jobs_u/getupdate_sse/job-1')) return undefined
|
||||
// Something in front of Windmill answers 200 with nothing in it.
|
||||
if (++streams === 1) return new Response(null, { status: 200 })
|
||||
return sse([{ type: 'update', completed: true, only_result: 'ok' }])
|
||||
})
|
||||
const api = new WindmillChatApi({ baseUrl: 'http://wm.test', workspace: 'ws', token: 'tok', fetch })
|
||||
let result: unknown
|
||||
for await (const event of followJob(api, 'job-1')) {
|
||||
if (event.type === 'completed') result = event.result
|
||||
}
|
||||
expect(streams).toBe(2)
|
||||
expect(result).toBe('ok')
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ApiError,
|
||||
type Job,
|
||||
JobService,
|
||||
type RestartedFrom,
|
||||
@@ -216,9 +217,12 @@
|
||||
}
|
||||
onRunPreview?.(newJobId)
|
||||
} catch (e) {
|
||||
sendUserToast('Could not run preview', true, undefined, e.toString())
|
||||
isRunning = false
|
||||
jobId = undefined
|
||||
// The chat follows the turn its conversation is still answering rather than
|
||||
// reporting a failed run, so the refusal goes back to it.
|
||||
if (conversationId && e instanceof ApiError && e.status === 409) throw e
|
||||
sendUserToast('Could not run preview', true, undefined, e.toString())
|
||||
}
|
||||
schemaFormWithArgPicker?.refreshHistory()
|
||||
return newJobId
|
||||
@@ -465,12 +469,14 @@
|
||||
<div class="flex flex-row justify-center w-full mb-6">
|
||||
<FlowChat
|
||||
onRunFlow={async (userMessage, conversationId, additionalInputs) => {
|
||||
await runPreview(
|
||||
// Its own run's id, not `jobId`: several test chats can start a run at
|
||||
// once, and `jobId` is whichever started last.
|
||||
const started = await runPreview(
|
||||
{ user_message: userMessage, ...(additionalInputs ?? {}) },
|
||||
undefined,
|
||||
conversationId
|
||||
)
|
||||
return jobId ?? ''
|
||||
return started ?? ''
|
||||
}}
|
||||
conversationKind="test"
|
||||
frame="boxed"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<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]',
|
||||
className,
|
||||
// Keep this last: tailwind-merge counts a text size as resetting line-height,
|
||||
// including a size supplied by a caller.
|
||||
'leading-none'
|
||||
)}
|
||||
role="img"
|
||||
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>
|
||||
@@ -247,8 +247,16 @@
|
||||
function onWindowKeydownCapture(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape' || !chatHost.loading) return
|
||||
const active = document.activeElement
|
||||
// Focus parked on the body answers for the panel on screen only: the flow chat keeps
|
||||
// a panel per conversation mounted, several of which can be loading, and a hidden
|
||||
// one's listener would otherwise swallow the press (immediate form, below) and stop
|
||||
// its own turn instead. `inert` marks the panels behind, and is matched as an
|
||||
// attribute so the browsers without `checkVisibility` read it too.
|
||||
const hidden =
|
||||
panelEl?.closest('[inert]') != null ||
|
||||
panelEl?.checkVisibility?.({ visibilityProperty: true }) === false
|
||||
const focusOnChat =
|
||||
!active || active === document.body || (panelEl?.contains(active) ?? false)
|
||||
!hidden && (!active || active === document.body || panelEl?.contains(active) === true)
|
||||
// An Escape while a run form is open must not discard what the user typed, so the action
|
||||
// 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
|
||||
@@ -487,22 +495,30 @@
|
||||
const flatFiles = Array.from(dt.files ?? [])
|
||||
const topLevelImages = flatFiles.filter(isImageFile)
|
||||
const imageWork: Promise<unknown>[] = []
|
||||
// The composer this drop landed on, held for the whole routing: a panel destroyed
|
||||
// while a file is being read clears the binding, and the file would then reach no
|
||||
// composer at all.
|
||||
const dropped = aiChatInput
|
||||
if (topLevelImages.length > 0) {
|
||||
imageWork.push(aiChatInput?.addImages(topLevelImages) ?? Promise.resolve())
|
||||
imageWork.push(dropped?.addImages(topLevelImages) ?? Promise.resolve())
|
||||
}
|
||||
// Text-file routing must await handle/entry resolution before it can call
|
||||
// addTextFiles — hold sending across that window (taken BEFORE the first
|
||||
// await) or a send mid-resolution would land the drop on the next message.
|
||||
const releaseSendHold = aiChatInput?.holdSendForIngestion()
|
||||
const releaseSendHold = dropped?.holdSendForIngestion()
|
||||
try {
|
||||
await routeDroppedTextAndFolders(dt, flatFiles)
|
||||
await routeDroppedTextAndFolders(dt, flatFiles, dropped)
|
||||
} finally {
|
||||
releaseSendHold?.()
|
||||
}
|
||||
await Promise.all(imageWork)
|
||||
}
|
||||
|
||||
async function routeDroppedTextAndFolders(dt: DataTransfer, flatFiles: File[]) {
|
||||
async function routeDroppedTextAndFolders(
|
||||
dt: DataTransfer,
|
||||
flatFiles: File[],
|
||||
input: typeof aiChatInput
|
||||
) {
|
||||
if (canUseFsAccess) {
|
||||
// getAsFileSystemHandle calls are kicked off synchronously inside this call.
|
||||
const handles = await handlesFromDataTransfer(dt)
|
||||
@@ -514,7 +530,10 @@
|
||||
? flatFiles
|
||||
: await Promise.all(handles.filter(isFileHandle).map((h) => h.getFile()))
|
||||
// Loose files attach to the message, like images.
|
||||
await attachNonImageFiles(looseFiles.filter((f) => !isImageFile(f)))
|
||||
await attachNonImageFiles(
|
||||
looseFiles.filter((f) => !isImageFile(f)),
|
||||
input
|
||||
)
|
||||
// Folders link as a live handle.
|
||||
const dirs = handles.filter(isDirectoryHandle)
|
||||
if (dirs.length > 0 && !canLinkFolders) {
|
||||
@@ -551,7 +570,7 @@
|
||||
if (canLinkFolders) await handleAddFiles(folderEntries)
|
||||
else sendUserToast('Folders cannot be attached in this chat — drop individual files.', true)
|
||||
}
|
||||
await attachNonImageFiles(topLevelText)
|
||||
await attachNonImageFiles(topLevelText, input)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,8 +582,10 @@
|
||||
input.value = '' // allow re-selecting the same file
|
||||
}
|
||||
|
||||
async function attachNonImageFiles(files: File[]) {
|
||||
await aiChatInput?.addNonImageFiles(files)
|
||||
/** `input` is the composer the files are for, captured before any await: read off the
|
||||
* binding afterwards it would be null once the panel has been destroyed. */
|
||||
async function attachNonImageFiles(files: File[], input: typeof aiChatInput) {
|
||||
await input?.addNonImageFiles(files)
|
||||
}
|
||||
|
||||
async function attachPickedFiles(picked: File[]) {
|
||||
@@ -572,7 +593,7 @@
|
||||
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 attachNonImageFiles(others, aiChatInput)
|
||||
await imageWork
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import FlowModuleSchemaMap from '$lib/components/flows/map/FlowModuleSchemaMap.svelte'
|
||||
import { getContext, tick, untrack } from 'svelte'
|
||||
import type { ExtendedOpenFlow, FlowEditorContext } from '$lib/components/flows/types'
|
||||
import type { InputTransform } from '$lib/gen'
|
||||
import { ApiError, type InputTransform } from '$lib/gen'
|
||||
import type { FlowAIChatHelpers } from './core'
|
||||
import { chatMemoryId } from '../global/core'
|
||||
import { createInlineScriptSession } from './inlineScriptsUtils'
|
||||
@@ -174,7 +174,19 @@
|
||||
previewArgs.val = args
|
||||
}
|
||||
// Call the UI test function which opens preview panel
|
||||
return await onTestFlow?.(conversationId ?? chatMemoryId(flowStore.val.value))
|
||||
try {
|
||||
return await onTestFlow?.(conversationId ?? chatMemoryId(flowStore.val.value))
|
||||
} catch (e) {
|
||||
// The flow chat is still answering in that conversation, and the server takes one
|
||||
// turn at a time. Said plainly here: the raw refusal names a job the model has
|
||||
// no use for.
|
||||
if (e instanceof ApiError && e.status === 409) {
|
||||
throw new Error(
|
||||
'That chat is still answering an earlier message; wait for it to finish before testing again.'
|
||||
)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
},
|
||||
|
||||
getLintErrors: async (moduleId: string): Promise<ScriptLintResult> => {
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { createChat, type Chat, type ChatState } from 'windmill-chat'
|
||||
import {
|
||||
createChat,
|
||||
turnRunningError,
|
||||
WindmillChatApi,
|
||||
type Chat,
|
||||
type ChatOptions,
|
||||
type ChatState
|
||||
} from 'windmill-chat'
|
||||
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'
|
||||
import { FRAME_CLASS, type ChatFrame } from './flowChatProps'
|
||||
import { ApiError, type FlowModule } from '$lib/gen'
|
||||
import { FlowChatPool, type FlowChatPoolState } from './flowChatPool'
|
||||
import { FlowChatViewHost, type ComposerAttachment } from './flowChatViewHost.svelte'
|
||||
import { FRAME_CLASS, loadFlowChatInputs, type ChatFrame } from './flowChatProps'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
@@ -73,24 +82,51 @@
|
||||
// The editor may act on a workspace other than the nav store's (AI-session live editor).
|
||||
const workspace = $derived(flowEditorContext?.opWorkspace?.() ?? $operatingWorkspace)
|
||||
|
||||
let chat = $state<Chat | undefined>(undefined)
|
||||
let chatState = $state<ChatState | undefined>(undefined)
|
||||
// The sidebar lists, renames and deletes through `listChat`; each conversation runs its
|
||||
// turns on its own chat in the pool, so several can answer at once.
|
||||
let listChat = $state<Chat | undefined>(undefined)
|
||||
let listState = $state<ChatState | undefined>(undefined)
|
||||
let pool = $state<FlowChatPool<FlowChatViewHost, ComposerAttachment> | undefined>(undefined)
|
||||
let poolState = $state<FlowChatPoolState | undefined>(undefined)
|
||||
let sidebar = $state<FlowConversationsSidebar | undefined>(undefined)
|
||||
/** Each conversation's kind as a listing gave it, kept past a filter that stops listing it. */
|
||||
const conversationKinds = new Map<string, boolean>()
|
||||
|
||||
// What the reader chose for this flow — the model among them — held here rather than in
|
||||
// each panel, so every conversation's composer sends and shows the same values. Read again
|
||||
// wherever the chat is rebuilt below, since this component is reused from one flow to the
|
||||
// next and would otherwise send one flow's settings with another's messages.
|
||||
let inputValues = $state<Record<string, any>>({})
|
||||
|
||||
$effect(() => {
|
||||
const ws = workspace
|
||||
const flowPath = path
|
||||
if (!ws || !flowPath) return
|
||||
const created = createChat({
|
||||
// Untracked: the identity only names what the path already changed, and reading it here
|
||||
// would rebuild every chat of a flow whose identity merely arrived late.
|
||||
inputValues = loadFlowChatInputs({ path: flowPath, identity: untrack(() => identity) })
|
||||
conversationKinds.clear()
|
||||
const baseUrl = window.location.origin
|
||||
const options: ChatOptions = {
|
||||
flowPath,
|
||||
workspace: ws,
|
||||
baseUrl: window.location.origin,
|
||||
baseUrl,
|
||||
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)
|
||||
let jobId: string | undefined
|
||||
try {
|
||||
jobId = await onRunFlow(String(user_message), conversationId, inputs)
|
||||
} catch (e) {
|
||||
// The conversation is still answering a message sent elsewhere; the chat
|
||||
// follows that turn instead of failing this one.
|
||||
if (e instanceof ApiError && e.status === 409) {
|
||||
throw turnRunningError(String(e.body)) ?? e
|
||||
}
|
||||
throw e
|
||||
}
|
||||
if (!jobId) throw new Error(`the ${subject} did not start`)
|
||||
// The server creates the conversation with the run, so the sidebar can list
|
||||
// it now, whatever becomes of the turn.
|
||||
@@ -98,15 +134,46 @@
|
||||
return jobId
|
||||
},
|
||||
onError: (error) => sendUserToast(`Failed to run ${subject}: ${error.message}`, true)
|
||||
}
|
||||
const api = new WindmillChatApi({ baseUrl, workspace: ws })
|
||||
const createdList = createChat(options)
|
||||
const createdPool = new FlowChatPool<FlowChatViewHost, ComposerAttachment>({
|
||||
createChat: () => createChat(options),
|
||||
createHost: (turns) => new FlowChatViewHost(turns),
|
||||
disposeHost: (host) => host.dispose(),
|
||||
holdsDraft: (host) => host.holdsDraft(),
|
||||
// Every kind: a conversation the reader follows keeps running whatever the filter lists.
|
||||
listRecent: async (page) =>
|
||||
(await api.listConversations(flowPath, { page, perPage: 50, kind: 'all' })).map((row) => ({
|
||||
id: row.id,
|
||||
runningTurn: row.running_turn
|
||||
? { jobId: row.running_turn.job_id, userSeq: row.running_turn.user_seq }
|
||||
: undefined
|
||||
}))
|
||||
})
|
||||
const unsubscribe = created.subscribe((s) => (chatState = s))
|
||||
chat = created
|
||||
const unsubscribeList = createdList.subscribe((s) => {
|
||||
listState = s
|
||||
// The kind filter narrows what the listing holds, while a conversation of the other
|
||||
// kind keeps its panel mounted behind it. Remembered as each row is seen, so that
|
||||
// panel's composer keeps saying why it cannot be written to.
|
||||
for (const row of s.conversations) {
|
||||
if (row.isTest !== undefined) conversationKinds.set(row.id, row.isTest)
|
||||
}
|
||||
})
|
||||
const unsubscribePool = createdPool.subscribe((s) => (poolState = s))
|
||||
listChat = createdList
|
||||
pool = createdPool
|
||||
return () => {
|
||||
unsubscribe()
|
||||
created.destroy()
|
||||
unsubscribeList()
|
||||
unsubscribePool()
|
||||
createdPool.destroy()
|
||||
createdList.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
// The panel on screen. Every other panel the pool holds stays mounted behind it.
|
||||
const shownKey = $derived(poolState?.shownKey)
|
||||
|
||||
// Derive additional inputs schema (excluding user_message) for chat mode
|
||||
const additionalInputsSchema = $derived.by(() => {
|
||||
const props = inputSchema?.properties ?? {}
|
||||
@@ -123,12 +190,13 @@
|
||||
</script>
|
||||
|
||||
<div class="flex overflow-hidden flex-1 {FRAME_CLASS[frame]}">
|
||||
{#if chat && chatState}
|
||||
{#if listChat && listState && pool && poolState}
|
||||
{#if !hideSidebar}
|
||||
<FlowConversationsSidebar
|
||||
bind:this={sidebar}
|
||||
{chat}
|
||||
{chatState}
|
||||
{listChat}
|
||||
{pool}
|
||||
{poolState}
|
||||
defaultKind={conversationKind}
|
||||
canFilterKind={conversationKind !== 'deployed' && subject === 'flow'}
|
||||
/>
|
||||
@@ -136,24 +204,45 @@
|
||||
<!-- 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">
|
||||
<!-- The interface's host subscribes to the chat it was given, so a replaced chat
|
||||
(another flow or workspace) mounts a fresh interface rather than a stale host. -->
|
||||
{#key chat}
|
||||
<FlowChatInterface
|
||||
{chat}
|
||||
{deploymentInProgress}
|
||||
{additionalInputsSchema}
|
||||
{flowModules}
|
||||
{path}
|
||||
{identity}
|
||||
{workspace}
|
||||
{description}
|
||||
{wideLayout}
|
||||
{conversationKind}
|
||||
{subject}
|
||||
/>
|
||||
{/key}
|
||||
<div class="relative flex flex-1 min-w-0 min-h-0">
|
||||
<!-- One panel per chat the pool holds, all mounted: the composer keeps what the
|
||||
reader typed, and what is still being read finishes into it, so leaving a
|
||||
conversation and coming back finds it as it was. The shown panel is in flow, so
|
||||
the chat keeps a height of its own where the host gives none (the editor's
|
||||
Test-flow panel); the others lie over it, invisible but laid out, which keeps
|
||||
their transcript scrolled where it was. -->
|
||||
{#each poolState.mounted as key (key)}
|
||||
{@const panel = pool.get(key)}
|
||||
{#if panel}
|
||||
{@const shown = key === shownKey}
|
||||
<div
|
||||
class="flex min-w-0 min-h-0 pb-3 {shown ? 'relative flex-1' : 'absolute inset-0'}"
|
||||
class:invisible={!shown}
|
||||
class:pointer-events-none={!shown}
|
||||
aria-hidden={!shown}
|
||||
inert={!shown}
|
||||
>
|
||||
<FlowChatInterface
|
||||
chat={panel.chat}
|
||||
chatHost={panel.host}
|
||||
bind:inputValues
|
||||
isTestOf={(id) =>
|
||||
listState?.conversations.find((c) => c.id === id)?.isTest ??
|
||||
conversationKinds.get(id)}
|
||||
{deploymentInProgress}
|
||||
{additionalInputsSchema}
|
||||
{flowModules}
|
||||
{path}
|
||||
{identity}
|
||||
{workspace}
|
||||
{description}
|
||||
{wideLayout}
|
||||
{conversationKind}
|
||||
{subject}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
import { Loader2, MessageCircle, Settings2 } from 'lucide-svelte'
|
||||
import AIChatDisplay from '$lib/components/copilot/chat/AIChatDisplay.svelte'
|
||||
import { setChatViewHost } from '$lib/components/copilot/chat/chatViewHost'
|
||||
import { FlowChatViewHost } from './flowChatViewHost.svelte'
|
||||
import type { FlowChatViewHost } from './flowChatViewHost.svelte'
|
||||
import Modal from '$lib/components/common/modal/Modal.svelte'
|
||||
import SchemaForm from '$lib/components/SchemaForm.svelte'
|
||||
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
|
||||
import { emptyString, type DynamicInput } from '$lib/utils'
|
||||
import { onDestroy, tick, untrack } from 'svelte'
|
||||
import { tick, untrack } from 'svelte'
|
||||
import type { Chat } from 'windmill-chat'
|
||||
import { chatFlowKey } from './flowChatProps'
|
||||
import { saveFlowChatInputs } from './flowChatProps'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { useWorkspaceStorageConfigured } from '$lib/components/inputTransformEnv.svelte'
|
||||
import {
|
||||
@@ -30,6 +30,14 @@
|
||||
|
||||
interface Props {
|
||||
chat: Chat
|
||||
/** The conversation's host, which outlives this panel. One panel follows one chat for
|
||||
* its whole life, so a later value of either prop never reaches it. */
|
||||
chatHost: FlowChatViewHost
|
||||
/** The flow inputs the reader chose, shared by every conversation in the flow: FlowChat
|
||||
* holds them, since each conversation has a panel of its own mounted. */
|
||||
inputValues: Record<string, any>
|
||||
/** Whether a conversation is a test chat, once the list has said. */
|
||||
isTestOf?: (conversationId: string) => boolean | undefined
|
||||
deploymentInProgress?: boolean
|
||||
additionalInputsSchema?: Record<string, any>
|
||||
/** The flow's modules, read for the AI agent inputs the composer drives: the provider wiring
|
||||
@@ -50,6 +58,9 @@
|
||||
|
||||
let {
|
||||
chat,
|
||||
chatHost: chatHostProp,
|
||||
inputValues = $bindable(),
|
||||
isTestOf = undefined,
|
||||
deploymentInProgress = false,
|
||||
additionalInputsSchema,
|
||||
flowModules,
|
||||
@@ -97,12 +108,7 @@
|
||||
const modelGap = $derived(agentModelGap(modelWiring, subject))
|
||||
const showModelButton = $derived(showsModelButton(modelWiring))
|
||||
|
||||
// LocalStorage helpers
|
||||
const STORAGE_KEY_PREFIX = 'windmill_flow_chat_inputs_'
|
||||
|
||||
let showInputsModal = $state(false)
|
||||
// Conversation settings, persisted per flow: what the reader chose, and nothing else.
|
||||
let inputValues = $state<Record<string, any>>(loadInputsFromStorage() ?? {})
|
||||
let modalDraft = $state<Record<string, any>>({})
|
||||
|
||||
/** What the flow's own form would open on. */
|
||||
@@ -128,31 +134,9 @@
|
||||
// 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}${chatFlowKey({ path, identity })}`
|
||||
}
|
||||
|
||||
function loadInputsFromStorage(): Record<string, any> | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(getStorageKey())
|
||||
return stored ? JSON.parse(stored) : null
|
||||
} catch (e) {
|
||||
console.error('Failed to load inputs from localStorage:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function saveInputsToStorage(values: Record<string, any>) {
|
||||
try {
|
||||
localStorage.setItem(getStorageKey(), JSON.stringify(values))
|
||||
} catch (e) {
|
||||
console.error('Failed to save inputs to localStorage:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function setInputValue(name: string, value: any) {
|
||||
inputValues = { ...inputValues, [name]: value }
|
||||
saveInputsToStorage(inputValues)
|
||||
saveFlowChatInputs({ path, identity }, inputValues)
|
||||
}
|
||||
|
||||
function handleModalConfirm() {
|
||||
@@ -167,47 +151,48 @@
|
||||
)
|
||||
)
|
||||
inputValues = kept
|
||||
saveInputsToStorage(inputValues)
|
||||
saveFlowChatInputs({ path, identity }, inputValues)
|
||||
showInputsModal = false
|
||||
}
|
||||
|
||||
function openInputsModal() {
|
||||
modalDraft = { ...effectiveInputs, ...(loadInputsFromStorage() ?? inputValues) }
|
||||
modalDraft = { ...effectiveInputs, ...inputValues }
|
||||
showInputsModal = true
|
||||
}
|
||||
|
||||
// The host follows the chat it was built on for the life of this component: FlowChat
|
||||
// remounts the interface under `{#key chat}`, so a later value of the prop never reaches it.
|
||||
const chatHost = new FlowChatViewHost(
|
||||
untrack(() => chat),
|
||||
{
|
||||
additionalInputs: () => (additionalInputsSchema ? { ...runInputs } : undefined),
|
||||
attachmentsTarget: () => attachmentsTarget,
|
||||
attachmentsUnavailable: () =>
|
||||
workspaceStorage.current
|
||||
? undefined
|
||||
: 'This workspace has no object storage, so files cannot be attached.',
|
||||
workspace: () => workspace,
|
||||
sendDisabled: () => deploymentInProgress || !!modelGap || !!wrongKindReason,
|
||||
// The model controls only: a retry changes model when the reader did, but replays
|
||||
// the run's own attachments rather than whatever the composer holds now.
|
||||
inputsShownInComposer: () => composerOwnedInputs(modelWiring, undefined)
|
||||
}
|
||||
)
|
||||
// The host belongs to the conversation, not to this panel: the pool keeps it alive so a
|
||||
// message queued here still goes out once the reader has moved on. What it reads is this
|
||||
// panel's, set on mount; one panel shows one conversation for its whole life.
|
||||
const chatHost = untrack(() => chatHostProp)
|
||||
chatHost.setOptions({
|
||||
additionalInputs: () => (additionalInputsSchema ? { ...runInputs } : undefined),
|
||||
attachmentsTarget: () => attachmentsTarget,
|
||||
attachmentsUnavailable: () =>
|
||||
workspaceStorage.current
|
||||
? undefined
|
||||
: 'This workspace has no object storage, so files cannot be attached.',
|
||||
workspace: () => workspace,
|
||||
sendDisabled: () => deploymentInProgress || !!modelGap || !!wrongKindReason,
|
||||
// The model controls only: a retry changes model when the reader did, but replays
|
||||
// the run's own attachments rather than whatever the composer holds now.
|
||||
inputsShownInComposer: () => composerOwnedInputs(modelWiring, undefined)
|
||||
})
|
||||
setChatViewHost(chatHost)
|
||||
|
||||
// Read off the host's own conversation: a message queued here goes out after the reader
|
||||
// has moved on, when the shown conversation may be of the other kind.
|
||||
const isTest = $derived.by(() => {
|
||||
const id = chatHost.state.conversationId
|
||||
return id === undefined ? undefined : isTestOf?.(id)
|
||||
})
|
||||
// A chat of the other kind can be read from here but not added to: the server refuses a
|
||||
// preview run into a deployed conversation and the reverse, so the composer says why first.
|
||||
const wrongKindReason = $derived.by(() => {
|
||||
const { conversationId, conversations } = chatHost.state
|
||||
const open = conversations.find((c) => c.id === conversationId)
|
||||
if (open?.isTest === undefined || open.isTest === (conversationKind === 'test'))
|
||||
return undefined
|
||||
return open.isTest
|
||||
if (isTest === undefined || isTest === (conversationKind === 'test')) return undefined
|
||||
return isTest
|
||||
? 'This chat was run from the flow editor. Start a new chat to continue here.'
|
||||
: 'This chat belongs to the deployed flow. Start a new chat to test.'
|
||||
})
|
||||
onDestroy(() => chatHost.dispose())
|
||||
|
||||
// What the Configure-inputs modal asks for: every flow input the composer does not
|
||||
// edit itself.
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
Plus,
|
||||
Trash2,
|
||||
Pen,
|
||||
PencilLine,
|
||||
Filter,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen
|
||||
} from 'lucide-svelte'
|
||||
import CountBadge from '$lib/components/common/badge/CountBadge.svelte'
|
||||
import UnreadCountBadge from '$lib/components/common/badge/UnreadCountBadge.svelte'
|
||||
import SessionStatusDot from '$lib/components/sessions/SessionStatusDot.svelte'
|
||||
import InfiniteList from '$lib/components/InfiniteList.svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
@@ -21,11 +23,16 @@
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { fade } from 'svelte/transition'
|
||||
import { tick, untrack } from 'svelte'
|
||||
import type { Chat, ChatState, Conversation, ConversationKind } from 'windmill-chat'
|
||||
import type { Chat, Conversation, ConversationKind } from 'windmill-chat'
|
||||
import type { FlowChatPool, FlowChatPoolState } from './flowChatPool'
|
||||
import type { ComposerAttachment, FlowChatViewHost } from './flowChatViewHost.svelte'
|
||||
|
||||
interface Props {
|
||||
chat: Chat
|
||||
chatState: ChatState
|
||||
/** Lists, renames and deletes the flow's conversations; runs no turn itself. */
|
||||
listChat: Chat
|
||||
/** The conversations' own chats: which one is shown, and what each is doing. */
|
||||
pool: FlowChatPool<FlowChatViewHost, ComposerAttachment>
|
||||
poolState: FlowChatPoolState
|
||||
/**
|
||||
* Which conversations the list holds at first. The editor shows its own test chats,
|
||||
* since testing is what happens there; a deployed flow shows the chats its users
|
||||
@@ -40,7 +47,13 @@
|
||||
canFilterKind?: boolean
|
||||
}
|
||||
|
||||
let { chat, chatState, defaultKind = 'deployed', canFilterKind = false }: Props = $props()
|
||||
let {
|
||||
listChat,
|
||||
pool,
|
||||
poolState,
|
||||
defaultKind = 'deployed',
|
||||
canFilterKind = false
|
||||
}: Props = $props()
|
||||
|
||||
let expanded = $state(false)
|
||||
let list = $state<InfiniteList | undefined>(undefined)
|
||||
@@ -58,13 +71,13 @@
|
||||
let renameDraft = $state('')
|
||||
let renameInput = $state<TextInput | undefined>(undefined)
|
||||
|
||||
const turnInFlight = $derived(
|
||||
chatState.status === 'submitted' || chatState.status === 'streaming'
|
||||
const totalUnread = $derived(
|
||||
Object.values(poolState.unread).reduce((total, count) => total + count, 0)
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
const l = list
|
||||
const c = chat
|
||||
const c = listChat
|
||||
if (!l) return
|
||||
untrack(() => {
|
||||
// Every load goes through here, the first one and infinite scroll included. A
|
||||
@@ -72,13 +85,18 @@
|
||||
// selected kind brings its own, whichever of the two lands last.
|
||||
l.setLoader(async (page, perPage) => {
|
||||
const requested = kind
|
||||
// Only a listing read now says which turns run: the rows the chat holds keep
|
||||
// what the last one said, and a rename or delete publishes those again.
|
||||
const since = pool.listingStarted()
|
||||
const rows = await c.loadConversations({ page, perPage, kind: requested })
|
||||
pool.setListed(rows, since)
|
||||
return requested === kind ? rows : items
|
||||
})
|
||||
l.setDeleteItemFn(async (id: string) => {
|
||||
deletingId = id
|
||||
try {
|
||||
await c.deleteConversation(id)
|
||||
pool.forget(id)
|
||||
sendUserToast('Conversation deleted successfully')
|
||||
} catch (error) {
|
||||
console.error('Failed to delete conversation:', error)
|
||||
@@ -103,10 +121,10 @@
|
||||
await list?.loadData('forceRefresh')
|
||||
}
|
||||
|
||||
const draftShown = $derived(draft && !items.some((c) => c.id === chatState.conversationId))
|
||||
const draftShown = $derived(draft && poolState.selectedId === undefined)
|
||||
|
||||
function newChat() {
|
||||
chat.newConversation()
|
||||
pool.newChat()
|
||||
draft = true
|
||||
}
|
||||
|
||||
@@ -118,17 +136,16 @@
|
||||
|
||||
/**
|
||||
* Narrow the list to one kind of chat and reload it. The open conversation goes with it
|
||||
* when it is not of the new kind: the composer sends into whatever is selected, and a
|
||||
* when it is not of the new kind: the composer sends into whatever is shown, and a
|
||||
* conversation keeps the kind it was created with, so a turn sent into one the list no
|
||||
* longer shows would be stored where nothing here lists it.
|
||||
* longer shows would be stored where nothing here lists it. A turn running in it goes on.
|
||||
*/
|
||||
async function setKind(next: ConversationKind) {
|
||||
// A turn writes into the open conversation, which a kind that excludes it would close.
|
||||
if (next === kind || turnInFlight) return
|
||||
if (next === kind) return
|
||||
kind = next
|
||||
const open = items.find((c) => c.id === chatState.conversationId)
|
||||
const open = items.find((c) => c.id === poolState.selectedId)
|
||||
const stillListed = open === undefined || next === 'all' || (next === 'test') === open.isTest
|
||||
if (!stillListed) chat.newConversation()
|
||||
if (!stillListed) pool.newChat()
|
||||
await list?.loadData('forceRefresh')
|
||||
}
|
||||
|
||||
@@ -149,11 +166,11 @@
|
||||
const current = items.find((c) => c.id === id)
|
||||
if (!current || title === '' || title === current.title) return
|
||||
try {
|
||||
await chat.renameConversation(id, title)
|
||||
await listChat.renameConversation(id, title)
|
||||
// The list holds its own rows, loaded through the loader: patched rather than
|
||||
// reloaded, so the row keeps its place without a round trip. The title is read
|
||||
// back from the chat, which holds it as the server stored it (a long one is cut).
|
||||
const stored = chat.getState().conversations.find((c) => c.id === id)?.title ?? title
|
||||
const stored = listChat.getState().conversations.find((c) => c.id === id)?.title ?? title
|
||||
items = items.map((c) => (c.id === id ? { ...c, title: stored } : c))
|
||||
} catch (error) {
|
||||
console.error('Failed to rename conversation:', error)
|
||||
@@ -177,8 +194,34 @@
|
||||
function getConversationTitle(conversation: Conversation): string {
|
||||
return conversation.title || `Conversation ${conversation.createdAt.slice(0, 10)}`
|
||||
}
|
||||
|
||||
/** The session sidebar's dot vocabulary, which knows no `queued`: that is its own mark. */
|
||||
function dotStatus(conversationId: string): 'streaming' | 'error' | 'idle' {
|
||||
const activity = poolState.activity[conversationId]
|
||||
return activity === 'running' ? 'streaming' : activity === 'error' ? 'error' : 'idle'
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet statusDot(conversation: Conversation)}
|
||||
<!-- 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.isTest
|
||||
? 'Test chat, run from the flow editor'
|
||||
: 'Chat on the deployed flow'}
|
||||
>
|
||||
{#snippet resting()}
|
||||
<span
|
||||
class="w-[6px] h-[6px] rounded-full {conversation.isTest
|
||||
? '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
|
||||
? 'w-60'
|
||||
@@ -219,12 +262,7 @@
|
||||
{#if canFilterKind}
|
||||
<!-- No focus trap: opening a row's menu does not close this popover, and a
|
||||
trapped popover pulls focus back from the rename field that menu opens. -->
|
||||
<Popover
|
||||
placement="bottom-start"
|
||||
closeButton={false}
|
||||
disableFocusTrap
|
||||
disabled={turnInFlight}
|
||||
>
|
||||
<Popover placement="bottom-start" closeButton={false} disableFocusTrap>
|
||||
{#snippet trigger()}
|
||||
<!-- Icon-only next to the wider New chat: which kind is listed is named in
|
||||
the title and by the group inside. -->
|
||||
@@ -233,10 +271,7 @@
|
||||
unifiedSize="md"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: Filter }}
|
||||
disabled={turnInFlight}
|
||||
title={turnInFlight
|
||||
? 'Wait for the current answer to change which chats are listed'
|
||||
: `Filter conversations · ${KIND_LABELS[kind]}`}
|
||||
title={`Filter conversations · ${KIND_LABELS[kind]}`}
|
||||
iconOnly
|
||||
/>
|
||||
{/snippet}
|
||||
@@ -245,7 +280,6 @@
|
||||
<ToggleButtonGroup
|
||||
selected={kind}
|
||||
onSelected={(next) => setKind(next as ConversationKind)}
|
||||
disabled={turnInFlight}
|
||||
noWFull
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
@@ -274,11 +308,19 @@
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: MessageCircle }}
|
||||
onClick={() => (expanded = true)}
|
||||
title="{items.length} conversation{items.length !== 1 ? 's' : ''}"
|
||||
title="{items.length} conversation{items.length !== 1 ? 's' : ''}{totalUnread > 0
|
||||
? `, ${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={totalUnread}
|
||||
small
|
||||
class="absolute right-[3px] top-[3px] pointer-events-none"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -299,7 +341,6 @@
|
||||
onClick={(e) => {
|
||||
e?.stopPropagation()
|
||||
draft = false
|
||||
chat.newConversation()
|
||||
}}
|
||||
title="Discard draft"
|
||||
destructive
|
||||
@@ -314,7 +355,7 @@
|
||||
<InfiniteList
|
||||
bind:this={list}
|
||||
bind:items
|
||||
selectedItemId={chatState.conversationId}
|
||||
selectedItemId={poolState.selectedId}
|
||||
noBorder={true}
|
||||
rounded={false}
|
||||
preventXOverflow={true}
|
||||
@@ -348,19 +389,41 @@
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
{@const unread = poolState.unread[conversation.id] ?? 0}
|
||||
{@const queued = !!poolState.queued[conversation.id]}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
draft = false
|
||||
chat.selectConversation(conversation.id)
|
||||
pool.select(conversation.id)
|
||||
}}
|
||||
selected={chatState.conversationId === conversation.id}
|
||||
btnClasses="transition-all duration-150 group"
|
||||
selected={poolState.selectedId === conversation.id}
|
||||
btnClasses="transition-all duration-150 group gap-2"
|
||||
>
|
||||
<span class="flex-1 text-left truncate">
|
||||
<!-- 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)}
|
||||
<span
|
||||
class={twMerge(
|
||||
'flex-1 text-left truncate',
|
||||
unread > 0 ? 'font-semibold text-primary' : ''
|
||||
)}
|
||||
>
|
||||
{getConversationTitle(conversation)}
|
||||
</span>
|
||||
{#if queued || unread > 0}
|
||||
<span class="shrink-0 inline-flex items-center gap-1">
|
||||
{#if queued}
|
||||
<PencilLine
|
||||
class="w-3 h-3 text-tertiary"
|
||||
aria-label="Message waiting to send"
|
||||
/>
|
||||
{/if}
|
||||
<UnreadCountBadge count={unread} />
|
||||
</span>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import {
|
||||
TurnRunningError,
|
||||
type Chat,
|
||||
type ChatMessage,
|
||||
type ChatState,
|
||||
type RunningTurn,
|
||||
type SendMessageOptions
|
||||
} from 'windmill-chat'
|
||||
|
||||
/**
|
||||
* Whether the turn the user message at `index` started failed: its last row before the
|
||||
* next user message reports `success: false`. The last row, not any row: a tool call can
|
||||
* fail and the agent still answer, and that turn completed.
|
||||
*/
|
||||
export function turnFailed(messages: readonly ChatMessage[], index: number): boolean {
|
||||
let last: ChatMessage | undefined
|
||||
for (let i = index + 1; i < messages.length; i++) {
|
||||
const message = messages[i]
|
||||
if (message.role === 'user') break
|
||||
last = message
|
||||
}
|
||||
return last?.success === false
|
||||
}
|
||||
|
||||
/** Whether the latest turn failed, per `turnFailed`. False before any turn. */
|
||||
export function lastTurnFailed(messages: readonly ChatMessage[]): boolean {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === 'user') return turnFailed(messages, i)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function isBusy(status: ChatState['status']): boolean {
|
||||
return status === 'submitted' || status === 'streaming'
|
||||
}
|
||||
|
||||
/** A message written and not yet sent. What it carries besides its text is opaque here. */
|
||||
export interface Draft<A> {
|
||||
text: string
|
||||
attachments: readonly A[]
|
||||
}
|
||||
|
||||
export function emptyDraft<A>(): Draft<A> {
|
||||
return { text: '', attachments: [] }
|
||||
}
|
||||
|
||||
export function isEmptyDraft<A>(draft: Draft<A>): boolean {
|
||||
return draft.text === '' && draft.attachments.length === 0
|
||||
}
|
||||
|
||||
/** `later` after `earlier`, one line each: typing again adds to what waits. */
|
||||
function mergeDrafts<A>(earlier: Draft<A>, later: Draft<A>): Draft<A> {
|
||||
return {
|
||||
text: [earlier.text, later.text].filter(Boolean).join('\n'),
|
||||
attachments: [...earlier.attachments, ...later.attachments]
|
||||
}
|
||||
}
|
||||
|
||||
/** What turns a draft into a send, and hears of a send the chat refused. */
|
||||
export interface DraftSender<A> {
|
||||
/**
|
||||
* The chat's send for `draft`, read as it goes out, so a queued message takes the inputs
|
||||
* of that moment. `replayInputs` are a failed turn's own run arguments, for a retry.
|
||||
* Undefined refuses it, and the draft is handed back.
|
||||
*/
|
||||
prepareSend(
|
||||
draft: Draft<A>,
|
||||
replayInputs?: Record<string, unknown>
|
||||
): SendMessageOptions | undefined
|
||||
/** The chat refused a send outright (an upload that failed, Stop during it); the draft
|
||||
* is handed back after this. */
|
||||
sendFailed(error: unknown, draft: Draft<A>): void
|
||||
}
|
||||
|
||||
/**
|
||||
* The turns of one conversation's chat, and what the reader wrote around them: the queued
|
||||
* message waiting for the running turn, and text a refused send handed back for a composer
|
||||
* to take. Both exist nowhere else, so a chat holding either is never released.
|
||||
*/
|
||||
export class ConversationTurns<A> {
|
||||
readonly chat: Chat
|
||||
readonly #sender: () => DraftSender<A>
|
||||
#queued: Draft<A> = emptyDraft()
|
||||
/** A retry's own run arguments, kept only while that retry is all there is to send: merged
|
||||
* with anything else it is no longer that turn's replay, and goes out on current inputs. */
|
||||
#queuedReplay: Record<string, unknown> | undefined
|
||||
#returned: Draft<A> = emptyDraft()
|
||||
/** Settles when the chat has released the last turn sent or followed here. */
|
||||
#turnDone: Promise<unknown> = Promise.resolve()
|
||||
/** Sends whose outcome has not reached this yet: a refusal hands their draft back. */
|
||||
#sending = 0
|
||||
/** Stops so far, so a send can tell it was stopped before the chat refused it. */
|
||||
#stops = 0
|
||||
#status: ChatState['status']
|
||||
readonly #listeners = new Set<() => void>()
|
||||
readonly #unsubscribe: () => void
|
||||
#disposed = false
|
||||
|
||||
constructor(chat: Chat, sender: () => DraftSender<A>) {
|
||||
this.chat = chat
|
||||
this.#sender = sender
|
||||
this.#status = chat.getState().status
|
||||
this.#unsubscribe = chat.subscribe((state) => this.#onState(state))
|
||||
}
|
||||
|
||||
/** The queued message; empty when nothing waits. */
|
||||
get queued(): Draft<A> {
|
||||
return this.#queued
|
||||
}
|
||||
|
||||
/** Text written here and not sent: queued, or handed back and not yet taken. */
|
||||
get holdsText(): boolean {
|
||||
return !isEmptyDraft(this.#queued) || !isEmptyDraft(this.#returned)
|
||||
}
|
||||
|
||||
/** Nothing here exists anywhere else: no text held, and no send that could still hand
|
||||
* its draft back. The chat publishes `idle` before it rejects a send it withdrew. */
|
||||
get releasable(): boolean {
|
||||
return !this.holdsText && this.#sending === 0
|
||||
}
|
||||
|
||||
/** Calls `listener` whenever the queued or handed-back text changes. */
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.#listeners.add(listener)
|
||||
return () => {
|
||||
this.#listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends `draft`, or queues it while a turn runs. False when nothing was sent: no text,
|
||||
* or the sender refused it and it was handed back. A conversation still answering a
|
||||
* message sent elsewhere has that turn followed here, with this one queued behind it.
|
||||
*/
|
||||
send = async (draft: Draft<A>, replayInputs?: Record<string, unknown>): Promise<boolean> => {
|
||||
const text = draft.text.trim()
|
||||
// An AI agent step refuses a run with no `user_message`.
|
||||
if (!text) return false
|
||||
const message = { text, attachments: draft.attachments }
|
||||
if (isBusy(this.chat.getState().status)) {
|
||||
this.queue(message, replayInputs)
|
||||
return true
|
||||
}
|
||||
const options = this.#sender().prepareSend(message, replayInputs)
|
||||
if (!options) {
|
||||
this.#handBack(message)
|
||||
return false
|
||||
}
|
||||
const stops = this.#stops
|
||||
this.#sending++
|
||||
// A run that fails is reported through the chat's `onError` and as a failed message;
|
||||
// the promise only rejects when the chat refuses the turn outright.
|
||||
const turn = this.chat
|
||||
.sendMessage(text, options)
|
||||
.catch((e) => {
|
||||
if (this.#disposed) return
|
||||
// Stop pressed while the run was asked for: the message goes back, whatever the
|
||||
// refusal says, rather than out after the turn it names.
|
||||
const stopped = this.#stops !== stops
|
||||
// A chat that fell back to local history cannot follow the running turn, and a
|
||||
// queue that did not wait for it would be refused again as soon as it went out.
|
||||
const followable = this.chat.getState().history === 'server'
|
||||
if (e instanceof TurnRunningError && !stopped && followable) {
|
||||
// Ahead of what was typed while it waited for the refusal: it was written first.
|
||||
const alone = isEmptyDraft(this.#queued)
|
||||
this.#queued = mergeDrafts(message, this.#queued)
|
||||
this.#queuedReplay = alone ? replayInputs : undefined
|
||||
this.#notify()
|
||||
this.resume(e.turn)
|
||||
return
|
||||
}
|
||||
if (!stopped) this.#sender().sendFailed(e, message)
|
||||
// What was queued behind it comes back first: the chat publishes `idle` when it
|
||||
// withdraws the turn, and a queue left in place would go out as if the turn had run.
|
||||
this.dequeue()
|
||||
this.#handBack(message)
|
||||
})
|
||||
.finally(() => {
|
||||
this.#sending--
|
||||
})
|
||||
this.#turnDone = turn
|
||||
await turn
|
||||
return true
|
||||
}
|
||||
|
||||
/** Follows a turn this chat did not start, so what is queued waits for it. */
|
||||
resume(turn: RunningTurn): void {
|
||||
this.#turnDone = this.chat.resumeTurn(turn)
|
||||
}
|
||||
|
||||
queue(draft: Draft<A>, replayInputs?: Record<string, unknown>): void {
|
||||
const text = draft.text.trim()
|
||||
if (!text && draft.attachments.length === 0) return
|
||||
const alone = isEmptyDraft(this.#queued)
|
||||
this.#queued = mergeDrafts(this.#queued, { text, attachments: draft.attachments })
|
||||
this.#queuedReplay = alone ? replayInputs : undefined
|
||||
this.#notify()
|
||||
}
|
||||
|
||||
/** Hands the queued message back for the composer. */
|
||||
dequeue(): void {
|
||||
const queued = this.#takeQueued()
|
||||
if (!isEmptyDraft(queued)) this.#handBack(queued)
|
||||
}
|
||||
|
||||
/** Stop means stop: what was queued goes back rather than out after some later turn. */
|
||||
stop(): void {
|
||||
this.#stops++
|
||||
this.dequeue()
|
||||
void this.chat.stop()
|
||||
}
|
||||
|
||||
/** The handed-back text, for the composer taking it. */
|
||||
takeReturned(): Draft<A> {
|
||||
const returned = this.#returned
|
||||
if (isEmptyDraft(returned)) return returned
|
||||
this.#returned = emptyDraft()
|
||||
this.#notify()
|
||||
return returned
|
||||
}
|
||||
|
||||
/** Everything written here and not sent, for the chat taking this one's place. */
|
||||
takeHeld(): Draft<A> {
|
||||
const held = mergeDrafts(this.#returned, this.#queued)
|
||||
this.#returned = emptyDraft()
|
||||
this.#queued = emptyDraft()
|
||||
this.#queuedReplay = undefined
|
||||
this.#notify()
|
||||
return held
|
||||
}
|
||||
|
||||
adopt(draft: Draft<A>): void {
|
||||
if (!isEmptyDraft(draft)) this.#handBack(draft)
|
||||
}
|
||||
|
||||
/** Drops what is queued: a flush still waiting on the turn would otherwise start a run
|
||||
* for a chat that is gone. */
|
||||
dispose(): void {
|
||||
this.#disposed = true
|
||||
this.#queued = emptyDraft()
|
||||
this.#queuedReplay = undefined
|
||||
this.#unsubscribe()
|
||||
this.#listeners.clear()
|
||||
}
|
||||
|
||||
#onState(state: ChatState): void {
|
||||
const was = this.#status
|
||||
this.#status = state.status
|
||||
if (!isBusy(was) || isBusy(state.status)) return
|
||||
// The turn settled. The queued message goes out once the turn is released, not now:
|
||||
// the chat publishes `idle` from inside its own `sendMessage`, which still counts the
|
||||
// turn as open until it returns, and a send made before that would be refused as a
|
||||
// second turn. After a failure it goes back instead, where the reader would rather look
|
||||
// at the error than pile on. A failed flow settles as `idle` too, with its error as the
|
||||
// answer, so the messages decide.
|
||||
if (state.status === 'idle' && !lastTurnFailed(state.messages)) {
|
||||
void this.#turnDone.then(() => this.#flush())
|
||||
} else {
|
||||
this.dequeue()
|
||||
}
|
||||
}
|
||||
|
||||
#flush(): void {
|
||||
// Same rule as `send`, 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.#queued.text || this.#disposed) return
|
||||
const replayInputs = this.#queuedReplay
|
||||
void this.send(this.#takeQueued(), replayInputs)
|
||||
}
|
||||
|
||||
#takeQueued(): Draft<A> {
|
||||
const queued = this.#queued
|
||||
if (isEmptyDraft(queued)) return queued
|
||||
this.#queued = emptyDraft()
|
||||
this.#queuedReplay = undefined
|
||||
this.#notify()
|
||||
return queued
|
||||
}
|
||||
|
||||
#handBack(draft: Draft<A>): void {
|
||||
// In front, as a composer puts it back: a refused message is handed back after what was
|
||||
// queued behind it, and it was written first.
|
||||
this.#returned = mergeDrafts(draft, this.#returned)
|
||||
this.#notify()
|
||||
}
|
||||
|
||||
#notify(): void {
|
||||
for (const listener of this.#listeners) listener()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Chat, ChatMessage, ChatState, Conversation } from 'windmill-chat'
|
||||
import { FlowChatPool, type ListedConversation } from './flowChatPool'
|
||||
import type { ConversationTurns, DraftSender } from './conversationTurns'
|
||||
|
||||
/** A chat whose state the test drives; `selectConversation` only sets the id. */
|
||||
function fakeChat() {
|
||||
let state: ChatState = {
|
||||
conversationId: undefined,
|
||||
messages: [],
|
||||
status: 'idle',
|
||||
error: undefined,
|
||||
conversations: [],
|
||||
history: 'server',
|
||||
loadingMessages: false,
|
||||
hasMoreMessages: false
|
||||
}
|
||||
const listeners = new Set<(s: ChatState) => void>()
|
||||
const set = (patch: Partial<ChatState>) => {
|
||||
state = { ...state, ...patch }
|
||||
for (const listener of listeners) listener(state)
|
||||
}
|
||||
const chat = {
|
||||
getState: () => state,
|
||||
subscribe: (listener: (s: ChatState) => void) => {
|
||||
listeners.add(listener)
|
||||
listener(state)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
sendMessage: vi.fn(async () => {}),
|
||||
resumeTurn: vi.fn(async () => {}),
|
||||
stop: vi.fn(async () => {}),
|
||||
newConversation: vi.fn(),
|
||||
// Like the SDK: the id and the loading flag are set before the first page is read.
|
||||
selectConversation: vi.fn(async (id: string) =>
|
||||
set({ conversationId: id, loadingMessages: true })
|
||||
),
|
||||
loadConversations: vi.fn(async () => []),
|
||||
deleteConversation: vi.fn(async () => {}),
|
||||
renameConversation: vi.fn(async () => {}),
|
||||
loadOlderMessages: vi.fn(async () => {}),
|
||||
refreshMessages: vi.fn(async () => {}),
|
||||
destroy: vi.fn()
|
||||
} satisfies Chat
|
||||
return { chat, set }
|
||||
}
|
||||
|
||||
function answer(id: string, pending = false): ChatMessage {
|
||||
return { id, role: 'assistant', content: 'done', success: true, createdAt: '', pending }
|
||||
}
|
||||
|
||||
function conversation(id: string, extra: Partial<Conversation> = {}): Conversation {
|
||||
return { id, title: id, createdAt: '', updatedAt: '', ...extra }
|
||||
}
|
||||
|
||||
type TestHost = DraftSender<never> & { chat: Chat; turns: ConversationTurns<never> }
|
||||
|
||||
function pool(
|
||||
options: {
|
||||
keepSettled?: number
|
||||
listRecent?: (page: number) => Promise<readonly ListedConversation[]>
|
||||
holdsDraft?: (host: TestHost) => boolean
|
||||
} = {}
|
||||
) {
|
||||
const chats: ReturnType<typeof fakeChat>[] = []
|
||||
const hosts: TestHost[] = []
|
||||
const dispose = vi.fn()
|
||||
const created = new FlowChatPool<TestHost, never>({
|
||||
createChat: () => {
|
||||
const fake = fakeChat()
|
||||
chats.push(fake)
|
||||
return fake.chat
|
||||
},
|
||||
createHost: (turns) => {
|
||||
const host: TestHost = {
|
||||
chat: turns.chat,
|
||||
turns,
|
||||
prepareSend: () => ({}),
|
||||
sendFailed: () => {}
|
||||
}
|
||||
hosts.push(host)
|
||||
return host
|
||||
},
|
||||
disposeHost: dispose,
|
||||
holdsDraft: options.holdsDraft ?? (() => false),
|
||||
listRecent: options.listRecent ?? (async () => []),
|
||||
keepSettled: options.keepSettled,
|
||||
pollMs: 10
|
||||
})
|
||||
const chatOf = (id: string) => chats.find((c) => c.chat.getState().conversationId === id)!
|
||||
const fakeOf = (chat: Chat) => chats.find((c) => c.chat === chat)!
|
||||
const turnsOf = (id: string) =>
|
||||
hosts.find((host) => host.chat.getState().conversationId === id)!.turns
|
||||
/** The panel on screen, as the state names it. */
|
||||
const shown = () => created.get(created.getState().shownKey)!
|
||||
/** The panel showing a conversation, while the pool still holds one for it. */
|
||||
const held = (id: string) =>
|
||||
created
|
||||
.getState()
|
||||
.mounted.map((key) => created.get(key)!)
|
||||
.find((panel) => panel.chat.getState().conversationId === id)
|
||||
return { pool: created, chatOf, fakeOf, turnsOf, held, shown, dispose }
|
||||
}
|
||||
|
||||
const typed = (text: string) => ({ text, attachments: [] })
|
||||
|
||||
describe('FlowChatPool', () => {
|
||||
it('runs a turn in each of two conversations and counts an answer that lands out of view', () => {
|
||||
const { pool: p, chatOf } = pool()
|
||||
p.select('a')
|
||||
chatOf('a').set({ loadingMessages: false, status: 'streaming' })
|
||||
p.select('b')
|
||||
chatOf('b').set({ status: 'submitted' })
|
||||
expect(p.getState().activity).toEqual({ a: 'running', b: 'running' })
|
||||
|
||||
chatOf('a').set({ status: 'streaming', messages: [answer('m1', true)] })
|
||||
chatOf('a').set({ status: 'idle', messages: [answer('m1')] })
|
||||
expect(p.getState().activity).toEqual({ b: 'running' })
|
||||
expect(p.getState().unread).toEqual({ a: 1 })
|
||||
|
||||
p.select('a')
|
||||
expect(p.getState().unread).toEqual({})
|
||||
expect(chatOf('a').chat.destroy).not.toHaveBeenCalled()
|
||||
p.destroy()
|
||||
})
|
||||
|
||||
it('watches a turn another page started through the listing, and follows it when selected', async () => {
|
||||
let listed: ListedConversation[] = []
|
||||
let reads = 0
|
||||
const { pool: p, chatOf } = pool({
|
||||
listRecent: async () => {
|
||||
reads++
|
||||
return listed
|
||||
}
|
||||
})
|
||||
const turn = { jobId: 'job-1', userSeq: 7 }
|
||||
p.setListed(
|
||||
[conversation('a', { runningTurn: turn }), conversation('b', { runningTurn: turn })],
|
||||
p.listingStarted()
|
||||
)
|
||||
expect(p.getState().activity).toEqual({ a: 'running', b: 'running' })
|
||||
|
||||
p.select('a')
|
||||
expect(chatOf('a').chat.resumeTurn).toHaveBeenCalledWith(turn)
|
||||
|
||||
// Turn 1 ended and another one started in b: the row keeps running on the newer turn.
|
||||
listed = [conversation('b', { runningTurn: { jobId: 'job-2', userSeq: 9 } })]
|
||||
await vi.waitFor(() => expect(reads).toBeGreaterThan(0))
|
||||
expect(p.getState().activity).toEqual({ b: 'running' })
|
||||
|
||||
listed = [conversation('b')]
|
||||
await vi.waitFor(() => expect(p.getState().activity).toEqual({}))
|
||||
p.destroy()
|
||||
})
|
||||
|
||||
it('reads past the first page for a running row that has written nothing for a while', async () => {
|
||||
let running = true
|
||||
const quiet = () =>
|
||||
conversation('z', running ? { runningTurn: { jobId: 'job-z', userSeq: 3 } } : {})
|
||||
const pagesRead: number[] = []
|
||||
const { pool: p } = pool({
|
||||
// Others were active since: the running row is on page 2.
|
||||
listRecent: async (page) => {
|
||||
pagesRead.push(page)
|
||||
return page === 1 ? [conversation('x'), conversation('y')] : page === 2 ? [quiet()] : []
|
||||
}
|
||||
})
|
||||
p.setListed([quiet()], p.listingStarted())
|
||||
// The poll must have read page 2 to still call it running: page 1 does not hold it.
|
||||
await vi.waitFor(() => expect(pagesRead).toContain(2))
|
||||
expect(p.getState().activity).toEqual({ z: 'running' })
|
||||
running = false
|
||||
await vi.waitFor(() => expect(p.getState().activity).toEqual({}))
|
||||
p.destroy()
|
||||
})
|
||||
|
||||
it('brings running rows back once the listing answers again, wherever they sit', async () => {
|
||||
let answers = false
|
||||
const turn = { jobId: 'job-1', userSeq: 7 }
|
||||
const { pool: p } = pool({
|
||||
listRecent: async (page) => {
|
||||
if (!answers) throw new Error('offline')
|
||||
// Watching nothing, the recovery read must go past the first page to find it.
|
||||
return page === 1
|
||||
? [conversation('x')]
|
||||
: page === 2
|
||||
? [conversation('a', { runningTurn: turn })]
|
||||
: []
|
||||
}
|
||||
})
|
||||
p.setListed([conversation('a', { runningTurn: turn })], p.listingStarted())
|
||||
expect(p.getState().activity).toEqual({ a: 'running' })
|
||||
// Three failed listings in a row: the row goes quiet rather than staying stuck.
|
||||
await vi.waitFor(() => expect(p.getState().activity).toEqual({}))
|
||||
answers = true
|
||||
await vi.waitFor(() => expect(p.getState().activity).toEqual({ a: 'running' }), {
|
||||
timeout: 3000
|
||||
})
|
||||
p.destroy()
|
||||
})
|
||||
|
||||
it('keeps what a later listing said over an earlier one that lands after it', () => {
|
||||
const { pool: p } = pool()
|
||||
const earlier = p.listingStarted()
|
||||
const later = p.listingStarted()
|
||||
p.setListed([conversation('a', { runningTurn: { jobId: 'job-2', userSeq: 9 } })], later)
|
||||
p.setListed([conversation('a')], earlier)
|
||||
expect(p.getState().activity).toEqual({ a: 'running' })
|
||||
p.destroy()
|
||||
})
|
||||
|
||||
it('does not take a turn back from a listing requested before it ended', () => {
|
||||
const { pool: p, chatOf } = pool()
|
||||
p.select('a')
|
||||
chatOf('a').set({ status: 'streaming' })
|
||||
const since = p.listingStarted()
|
||||
chatOf('a').set({ status: 'idle', messages: [answer('m1')] })
|
||||
p.setListed([conversation('a', { runningTurn: { jobId: 'job-1', userSeq: 3 } })], since)
|
||||
expect(p.getState().activity).toEqual({})
|
||||
expect(chatOf('a').chat.resumeTurn).not.toHaveBeenCalled()
|
||||
p.destroy()
|
||||
})
|
||||
|
||||
it('counts no answer of a first page that lands after the reader moved on, and rereads a held chat on return', () => {
|
||||
const { pool: p, chatOf } = pool()
|
||||
p.select('a')
|
||||
p.select('b')
|
||||
chatOf('a').set({ loadingMessages: false, messages: [answer('old-1'), answer('old-2')] })
|
||||
expect(p.getState().unread).toEqual({})
|
||||
|
||||
p.select('a')
|
||||
expect(chatOf('a').chat.refreshMessages).toHaveBeenCalledTimes(1)
|
||||
p.destroy()
|
||||
})
|
||||
|
||||
it('takes a chat back as the new one when its first message never ran', () => {
|
||||
const { pool: p, fakeOf, held, shown } = pool()
|
||||
const draft = shown()
|
||||
const { set } = fakeOf(draft.chat)
|
||||
// A new chat's first message names its conversation, then is withdrawn: the upload
|
||||
// failed, or Stop was pressed while it ran, so that conversation was never created.
|
||||
set({ conversationId: 'new-1' })
|
||||
expect(p.getState().selectedId).toBe('new-1')
|
||||
set({ conversationId: undefined })
|
||||
expect(p.getState().selectedId).toBeUndefined()
|
||||
expect(held('new-1')).toBeUndefined()
|
||||
expect(p.getState().activity).toEqual({})
|
||||
// The next message mints its own id on that same chat, and the pool follows it there.
|
||||
expect(shown()).toBe(draft)
|
||||
set({ conversationId: 'new-2' })
|
||||
expect(p.getState().selectedId).toBe('new-2')
|
||||
expect(held('new-2')).toBe(draft)
|
||||
p.destroy()
|
||||
})
|
||||
|
||||
it('hands what a withdrawn chat held to the new chat that took its place', async () => {
|
||||
const { pool: p, fakeOf, shown } = pool()
|
||||
const withdrawn = shown()
|
||||
const { set } = fakeOf(withdrawn.chat)
|
||||
set({ conversationId: 'new-1' })
|
||||
;(withdrawn.host as TestHost).turns.adopt(typed('typed while uploading'))
|
||||
// The reader opens another new chat while the first message is still uploading.
|
||||
const kept = p.newChat()
|
||||
expect(kept).not.toBe(withdrawn)
|
||||
set({ conversationId: undefined })
|
||||
// Not released yet: the send reports what it could not do after this.
|
||||
expect(withdrawn.chat.destroy).not.toHaveBeenCalled()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(withdrawn.chat.destroy).toHaveBeenCalled()
|
||||
expect((kept.host as TestHost).turns.takeReturned().text).toBe('typed while uploading')
|
||||
expect(shown()).toBe(kept)
|
||||
p.destroy()
|
||||
})
|
||||
|
||||
it('keeps the chat the reader is on when two first turns withdraw with both composers written in', async () => {
|
||||
const written = new Set<unknown>()
|
||||
const { pool: p, fakeOf, shown } = pool({ holdsDraft: (host) => written.has(host) })
|
||||
const first = shown()
|
||||
const firstChat = fakeOf(first.chat)
|
||||
firstChat.set({ conversationId: 'new-1' })
|
||||
written.add(first.host)
|
||||
// A second first message goes out from a new chat, and is the one on screen.
|
||||
const second = p.newChat()
|
||||
const secondChat = fakeOf(second.chat)
|
||||
secondChat.set({ conversationId: 'new-2' })
|
||||
written.add(second.host)
|
||||
expect(shown()).toBe(second)
|
||||
|
||||
// Both uploads fail, the hidden one first: the reader must keep the panel in front of
|
||||
// them, since its composer is the only place what they wrote in it exists.
|
||||
firstChat.set({ conversationId: undefined })
|
||||
expect(shown()).toBe(second)
|
||||
secondChat.set({ conversationId: undefined })
|
||||
expect(shown()).toBe(second)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(second.chat.destroy).not.toHaveBeenCalled()
|
||||
p.destroy()
|
||||
})
|
||||
|
||||
it('keeps a withdrawn chat whose composer holds a draft, over the empty one that replaced it', async () => {
|
||||
let written: unknown
|
||||
const { pool: p, fakeOf, shown } = pool({ holdsDraft: (host) => host === written })
|
||||
const withdrawn = shown()
|
||||
const { set } = fakeOf(withdrawn.chat)
|
||||
set({ conversationId: 'new-1' })
|
||||
// The reader writes a follow-up in its composer, then opens another new chat.
|
||||
written = withdrawn.host
|
||||
const opened = p.newChat()
|
||||
set({ conversationId: undefined })
|
||||
// Nothing but that composer holds what was written, so its panel is the one that stays.
|
||||
expect(shown()).toBe(withdrawn)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(withdrawn.chat.destroy).not.toHaveBeenCalled()
|
||||
expect(opened.chat.destroy).toHaveBeenCalled()
|
||||
p.destroy()
|
||||
})
|
||||
|
||||
it('keeps a settled chat that still holds a queued message, and marks its row', () => {
|
||||
const { pool: p, chatOf, turnsOf, held } = pool({ keepSettled: 1 })
|
||||
p.select('typed')
|
||||
turnsOf('typed').queue(typed('later'))
|
||||
for (const id of ['b', 'c', 'd']) p.select(id)
|
||||
expect(held('typed')).toBeDefined()
|
||||
expect(p.getState().queued).toEqual({ typed: true })
|
||||
expect(chatOf('typed').chat.destroy).not.toHaveBeenCalled()
|
||||
// Once it is taken back, the chat is releasable like any other.
|
||||
turnsOf('typed').takeHeld()
|
||||
p.select('e')
|
||||
expect(held('typed')).toBeUndefined()
|
||||
p.destroy()
|
||||
})
|
||||
|
||||
it('keeps a chat whose send has not settled, so a refusal can hand its draft back', async () => {
|
||||
const { pool: p, chatOf, turnsOf, held } = pool({ keepSettled: 1 })
|
||||
p.select('a')
|
||||
let refuse = (_e: Error) => {}
|
||||
chatOf('a').chat.sendMessage.mockImplementationOnce(
|
||||
() => new Promise<void>((_, reject) => (refuse = reject))
|
||||
)
|
||||
const sent = turnsOf('a').send(typed('with a file'))
|
||||
for (const id of ['b', 'c', 'd']) p.select(id)
|
||||
expect(held('a')).toBeDefined()
|
||||
refuse(new Error('upload failed (500)'))
|
||||
await sent
|
||||
expect(turnsOf('a').takeReturned().text).toBe('with a file')
|
||||
p.destroy()
|
||||
})
|
||||
|
||||
it('keeps a chat whose composer holds a draft, whatever its turns hold', () => {
|
||||
let typedIn = ''
|
||||
const {
|
||||
pool: p,
|
||||
chatOf,
|
||||
held
|
||||
} = pool({
|
||||
keepSettled: 1,
|
||||
holdsDraft: (host) => host.chat.getState().conversationId === typedIn
|
||||
})
|
||||
p.select('typed')
|
||||
typedIn = 'typed'
|
||||
for (const id of ['b', 'c', 'd']) p.select(id)
|
||||
expect(held('typed')).toBeDefined()
|
||||
expect(chatOf('typed').chat.destroy).not.toHaveBeenCalled()
|
||||
// Sent or taken back, the chat is releasable like any other.
|
||||
typedIn = ''
|
||||
p.select('e')
|
||||
expect(held('typed')).toBeUndefined()
|
||||
p.destroy()
|
||||
})
|
||||
|
||||
it('names its panels apart from another pool\'s, so a rebuilt chat cannot reuse one', () => {
|
||||
// The reader opens another flow: that pool is destroyed and a new one takes its place,
|
||||
// in the same `{#each}`. A key reused there would keep the old flow's panel alive —
|
||||
// composer, host and all — for a chat that no longer exists.
|
||||
const first = pool()
|
||||
const second = pool()
|
||||
const keys = [...first.pool.getState().mounted, ...second.pool.getState().mounted]
|
||||
expect(new Set(keys).size).toBe(keys.length)
|
||||
first.pool.destroy()
|
||||
second.pool.destroy()
|
||||
})
|
||||
|
||||
it('keeps one panel key per chat, across the first turn naming its conversation', () => {
|
||||
const { pool: p, fakeOf, held, shown } = pool()
|
||||
const startedOn = p.getState().shownKey
|
||||
expect(p.getState().mounted).toEqual([startedOn])
|
||||
const started = shown()
|
||||
|
||||
// The panel is where the reader is typing: its key must not change under it when the
|
||||
// send names the conversation, or Svelte tears the composer down mid-turn.
|
||||
fakeOf(started.chat).set({ conversationId: 'a', status: 'submitted' })
|
||||
expect(p.getState()).toMatchObject({ mounted: [startedOn], shownKey: startedOn })
|
||||
expect(p.getState().selectedId).toBe('a')
|
||||
expect(held('a')).toBe(started)
|
||||
|
||||
// Nor when the send is refused and the chat goes back to being the new chat.
|
||||
fakeOf(started.chat).set({ conversationId: undefined, status: 'idle' })
|
||||
expect(p.getState()).toMatchObject({ mounted: [startedOn], shownKey: startedOn })
|
||||
expect(p.getState().selectedId).toBeUndefined()
|
||||
|
||||
// A conversation opened beside it gets a key of its own, and both panels stay.
|
||||
p.select('b')
|
||||
const opened = p.getState().shownKey
|
||||
expect(opened).not.toBe(startedOn)
|
||||
expect(p.getState().mounted).toEqual([startedOn, opened])
|
||||
expect(p.get(opened)).toBe(shown())
|
||||
|
||||
// Moving between them must not reorder the keys: a key that moves takes its panel out
|
||||
// of the DOM and back in, which loses the transcript's scroll.
|
||||
p.newChat()
|
||||
p.select('b')
|
||||
expect(p.getState().mounted).toEqual([startedOn, opened])
|
||||
p.destroy()
|
||||
})
|
||||
|
||||
it('releases settled chats past the budget, never one still running', () => {
|
||||
const { pool: p, chatOf, held, dispose } = pool({ keepSettled: 1 })
|
||||
p.select('busy')
|
||||
chatOf('busy').set({ status: 'streaming' })
|
||||
p.select('old')
|
||||
p.select('recent')
|
||||
p.select('shown')
|
||||
expect(chatOf('old').chat.destroy).toHaveBeenCalled()
|
||||
expect(held('old')).toBeUndefined()
|
||||
expect(held('recent')).toBeDefined()
|
||||
expect(held('busy')).toBeDefined()
|
||||
expect(dispose).toHaveBeenCalledTimes(1)
|
||||
p.destroy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,468 @@
|
||||
import type { Chat, ChatState, Conversation } from 'windmill-chat'
|
||||
import { ConversationTurns, isBusy, lastTurnFailed, type DraftSender } from './conversationTurns'
|
||||
|
||||
/** Listing reads in a row that may fail before the rows stop saying their turns run. */
|
||||
const POLL_GIVE_UP = 3
|
||||
/** Pages of the listing one poll reads at most, looking for the rows it watches. */
|
||||
const POLL_PAGES = 5
|
||||
/** How much slower the listing is read once it has stopped answering. */
|
||||
const RECOVERY_SLOWDOWN = 10
|
||||
|
||||
/**
|
||||
* Names panels across every pool on the page, not just within one: a pool is rebuilt when the
|
||||
* reader opens another flow, and a key reused there would have Svelte keep the panel of the
|
||||
* flow just left — composer, host and all — for a chat that is gone.
|
||||
*/
|
||||
let panelKeys = 0
|
||||
|
||||
/** What a conversation's row says about it. */
|
||||
export type ConversationActivity = 'running' | 'error' | 'idle'
|
||||
|
||||
/** The part of a listed conversation that says whether a turn runs in it. */
|
||||
export type ListedConversation = Pick<Conversation, 'id' | 'runningTurn'>
|
||||
|
||||
export interface FlowChatPoolState {
|
||||
/** The conversation shown. Unset for a new chat that has not run its first turn. */
|
||||
selectedId: string | undefined
|
||||
/** Only conversations with something to say; absent means idle. */
|
||||
activity: Record<string, ConversationActivity>
|
||||
/** Answers that arrived in a conversation while another one was shown. */
|
||||
unread: Record<string, number>
|
||||
/** Conversations holding a queued message. */
|
||||
queued: Record<string, true>
|
||||
/**
|
||||
* A key per chat with a panel — the conversations followed, and the chat a new one starts
|
||||
* on — oldest chat first, an order that never changes while a chat is held. Each keeps its
|
||||
* panel mounted: a composer holds what the reader typed, and unmounting it would take that
|
||||
* with it. The key belongs to the chat, not to its conversation, so a new chat's panel
|
||||
* survives its first turn naming the conversation.
|
||||
*/
|
||||
mounted: string[]
|
||||
/** The key in `mounted` whose panel is on screen. */
|
||||
shownKey: string
|
||||
}
|
||||
|
||||
export interface PooledChat<H> {
|
||||
chat: Chat
|
||||
host: H
|
||||
}
|
||||
|
||||
export interface FlowChatPoolOptions<H extends DraftSender<A>, A> {
|
||||
/** A chat on the flow with no conversation selected. */
|
||||
createChat(): Chat
|
||||
/** The view of one conversation; its sends go through `turns`. */
|
||||
createHost(turns: ConversationTurns<A>): H
|
||||
disposeHost(host: H): void
|
||||
/** Whether the composer showing this conversation holds text or files not sent yet. Such
|
||||
* a chat is never released: its panel is the only place that draft exists. */
|
||||
holdsDraft(host: H): boolean
|
||||
/**
|
||||
* A page of the flow's conversations of every kind, most recently active first, as the
|
||||
* server lists them now; empty past the last one. Read while a conversation this pool
|
||||
* follows no chat for is running, so its row stops saying so once its turn ends.
|
||||
*/
|
||||
listRecent(page: number): Promise<readonly ListedConversation[]>
|
||||
/** Settled conversations kept in memory beside the shown and the busy ones. */
|
||||
keepSettled?: number
|
||||
pollMs?: number
|
||||
}
|
||||
|
||||
interface Entry<H, A> extends PooledChat<H> {
|
||||
/** Names this chat's panel for its whole life, whatever conversation the chat ends up on. */
|
||||
key: string
|
||||
turns: ConversationTurns<A>
|
||||
unsubscribe: () => void
|
||||
lastShownAt: number
|
||||
/** Assistant messages already counted, so a message counts once as it settles. */
|
||||
counted: Set<string>
|
||||
/** Its first page has been read: answers already there were never unread. */
|
||||
loaded: boolean
|
||||
busy: boolean
|
||||
/** The pool's clock when its last turn ended. */
|
||||
settledAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The conversations of one flow chat, each on its own `Chat` so that each can run a turn
|
||||
* while another is shown. A `Chat` follows one conversation for its whole life and is
|
||||
* never switched, which is what lets its turn keep running in the background.
|
||||
*
|
||||
* Plain TypeScript on the `windmill-chat` API, with the view host left generic: nothing
|
||||
* here is specific to Svelte or to the app, so it can move into the SDK as is.
|
||||
*/
|
||||
export class FlowChatPool<H extends DraftSender<A>, A> {
|
||||
readonly #options: FlowChatPoolOptions<H, A>
|
||||
readonly #entries = new Map<string, Entry<H, A>>()
|
||||
/** Every chat held, by panel key, so a panel can be found without knowing its conversation. */
|
||||
readonly #byKey = new Map<string, Entry<H, A>>()
|
||||
/** The chat a new conversation starts on; it joins `#entries` once its first turn names it. */
|
||||
#draft: Entry<H, A> | undefined
|
||||
/** Turns running in conversations this pool is not following, as a listing reported them. */
|
||||
readonly #running = new Map<string, NonNullable<Conversation['runningTurn']>>()
|
||||
/** The clock of the listing each conversation's row was last taken from. */
|
||||
readonly #listedAt = new Map<string, number>()
|
||||
/** Chats on their way out, kept until the send that withdrew them has settled. */
|
||||
readonly #retiring = new Set<Entry<H, A>>()
|
||||
readonly #unread = new Map<string, number>()
|
||||
readonly #listeners = new Set<(state: FlowChatPoolState) => void>()
|
||||
#selectedId: string | undefined
|
||||
#state: FlowChatPoolState = {
|
||||
selectedId: undefined,
|
||||
activity: {},
|
||||
unread: {},
|
||||
queued: {},
|
||||
mounted: [],
|
||||
shownKey: ''
|
||||
}
|
||||
#poll: ReturnType<typeof setTimeout> | undefined
|
||||
#polling = false
|
||||
#pollFailures = 0
|
||||
/** The listing stopped answering and its rows went quiet: it is read on, slowly, so they
|
||||
* come back when it answers again. */
|
||||
#recovering = false
|
||||
#clock = 0
|
||||
#destroyed = false
|
||||
|
||||
constructor(options: FlowChatPoolOptions<H, A>) {
|
||||
this.#options = options
|
||||
this.newChat()
|
||||
}
|
||||
|
||||
getState = (): FlowChatPoolState => this.#state
|
||||
|
||||
subscribe = (listener: (state: FlowChatPoolState) => void): (() => void) => {
|
||||
this.#listeners.add(listener)
|
||||
listener(this.#state)
|
||||
return () => {
|
||||
this.#listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
get #selectedEntry(): Entry<H, A> {
|
||||
// The shown conversation is never evicted, and forgetting it shows a new chat.
|
||||
return (this.#selectedId === undefined ? this.#draft : this.#entries.get(this.#selectedId))!
|
||||
}
|
||||
|
||||
/** The chat a key in `mounted` names, while this pool still holds it. */
|
||||
get(key: string): PooledChat<H> | undefined {
|
||||
return this.#byKey.get(key)
|
||||
}
|
||||
|
||||
/** Shows a new chat, reusing the one already waiting for its first message. */
|
||||
newChat = (): PooledChat<H> => {
|
||||
if (!this.#draft) this.#draft = this.#track(undefined)
|
||||
this.#draft.lastShownAt = ++this.#clock
|
||||
this.#selectedId = undefined
|
||||
this.#publish()
|
||||
return this.#draft
|
||||
}
|
||||
|
||||
/** Shows a conversation, following its running turn when another page started it. */
|
||||
select = (conversationId: string): void => {
|
||||
let entry = this.#entries.get(conversationId)
|
||||
if (!entry) {
|
||||
entry = this.#track(conversationId)
|
||||
this.#entries.set(conversationId, entry)
|
||||
}
|
||||
this.#selectedId = conversationId
|
||||
entry.lastShownAt = ++this.#clock
|
||||
this.#unread.delete(conversationId)
|
||||
const turn = this.#running.get(conversationId)
|
||||
if (turn && !entry.busy) {
|
||||
this.#running.delete(conversationId)
|
||||
entry.turns.resume(turn)
|
||||
} else if (entry.loaded && !entry.busy) {
|
||||
// Held while another conversation was shown: another tab may have written since.
|
||||
// A failed read leaves the rows it holds; the next return reads again.
|
||||
void entry.chat.refreshMessages().catch(() => {})
|
||||
}
|
||||
this.#evict()
|
||||
this.#publish()
|
||||
}
|
||||
|
||||
/** Marks a listing request; pass the mark to `setListed` with what it returns. */
|
||||
listingStarted = (): number => ++this.#clock
|
||||
|
||||
/**
|
||||
* What a listing requested at `since` said. A conversation it reports running that no
|
||||
* chat here is following is watched through `listRecent` until its row stops saying so.
|
||||
* Neither a turn that ended here after the request nor a row a later listing already
|
||||
* reported is taken from it, whichever of the two responses lands last.
|
||||
*/
|
||||
setListed = (conversations: readonly ListedConversation[], since: number): void => {
|
||||
for (const conversation of conversations) {
|
||||
const id = conversation.id
|
||||
if ((this.#listedAt.get(id) ?? 0) > since) continue
|
||||
this.#listedAt.set(id, since)
|
||||
const followed = this.#entries.get(id)
|
||||
const stale = followed && (followed.busy || followed.settledAt > since)
|
||||
if (conversation.runningTurn && !stale) this.#running.set(id, conversation.runningTurn)
|
||||
else this.#running.delete(id)
|
||||
}
|
||||
// The shown conversation follows its turn now rather than waiting for a poll to end it.
|
||||
if (this.#selectedId !== undefined && this.#running.has(this.#selectedId)) {
|
||||
this.select(this.#selectedId)
|
||||
}
|
||||
this.#schedulePoll()
|
||||
this.#publish()
|
||||
}
|
||||
|
||||
/** Drops a conversation that no longer exists; a new chat is shown in its place. */
|
||||
forget = (conversationId: string): void => {
|
||||
const entry = this.#entries.get(conversationId)
|
||||
if (entry) this.#release(entry)
|
||||
this.#entries.delete(conversationId)
|
||||
this.#running.delete(conversationId)
|
||||
this.#unread.delete(conversationId)
|
||||
if (this.#selectedId === conversationId) this.newChat()
|
||||
else this.#publish()
|
||||
}
|
||||
|
||||
destroy = (): void => {
|
||||
this.#destroyed = true
|
||||
clearTimeout(this.#poll)
|
||||
for (const entry of this.#entries.values()) this.#release(entry)
|
||||
for (const entry of this.#retiring) this.#release(entry)
|
||||
if (this.#draft) this.#release(this.#draft)
|
||||
this.#retiring.clear()
|
||||
this.#entries.clear()
|
||||
this.#draft = undefined
|
||||
this.#listeners.clear()
|
||||
}
|
||||
|
||||
#track(conversationId: string | undefined): Entry<H, A> {
|
||||
const chat = this.#options.createChat()
|
||||
// Selected before the host exists: a host treats a change of conversation as the
|
||||
// reader leaving one, and this chat never leaves its conversation.
|
||||
if (conversationId !== undefined) void chat.selectConversation(conversationId)
|
||||
const turns = new ConversationTurns<A>(chat, () => entry.host)
|
||||
const entry: Entry<H, A> = {
|
||||
key: `chat-${++panelKeys}`,
|
||||
chat,
|
||||
turns,
|
||||
host: this.#options.createHost(turns),
|
||||
unsubscribe: () => {},
|
||||
lastShownAt: ++this.#clock,
|
||||
counted: new Set(),
|
||||
loaded: conversationId === undefined,
|
||||
busy: false,
|
||||
settledAt: 0
|
||||
}
|
||||
this.#byKey.set(entry.key, entry)
|
||||
const unsubscribeChat = chat.subscribe((state) => this.#onChatState(entry, state))
|
||||
// What waits in a chat decides whether it may be released, and marks its row.
|
||||
const unsubscribeTurns = turns.subscribe(() => {
|
||||
if (!this.#destroyed) this.#publish()
|
||||
})
|
||||
entry.unsubscribe = () => {
|
||||
unsubscribeChat()
|
||||
unsubscribeTurns()
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
#onChatState(entry: Entry<H, A>, state: ChatState): void {
|
||||
if (this.#destroyed) return
|
||||
if (entry === this.#draft && state.conversationId !== undefined) {
|
||||
// The new chat's first turn named its conversation.
|
||||
this.#draft = undefined
|
||||
this.#entries.set(state.conversationId, entry)
|
||||
if (this.#selectedId === undefined) this.#selectedId = state.conversationId
|
||||
}
|
||||
const id = state.conversationId
|
||||
if (id === undefined) {
|
||||
// The chat gave its conversation back: a new chat's first message never ran, so the
|
||||
// conversation the id named was never created. Held under that id, the entry would
|
||||
// answer for a conversation that does not exist and mint a second one on the next
|
||||
// message, so it goes back to being the chat a new conversation starts on.
|
||||
if (entry !== this.#draft) this.#undoNewConversation(entry)
|
||||
return
|
||||
}
|
||||
const busy = isBusy(state.status)
|
||||
if (busy) this.#running.delete(id)
|
||||
else if (entry.busy) entry.settledAt = ++this.#clock
|
||||
entry.busy = busy
|
||||
const shown = id === this.#selectedId
|
||||
for (const message of state.messages) {
|
||||
if (message.role !== 'assistant' || message.pending || entry.counted.has(message.id)) continue
|
||||
entry.counted.add(message.id)
|
||||
if (!shown && entry.loaded && !state.loadingMessages) {
|
||||
this.#unread.set(id, (this.#unread.get(id) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
if (!state.loadingMessages) entry.loaded = true
|
||||
if (!busy) this.#evict()
|
||||
this.#publish()
|
||||
}
|
||||
|
||||
/** Takes an entry back out of the list of conversations, as the chat that starts one. */
|
||||
#undoNewConversation(entry: Entry<H, A>): void {
|
||||
// Whether the reader is looking at this chat, read before the id it was selected under
|
||||
// goes: it decides which of two chats with nothing to show stays in front of them.
|
||||
let wasShown = false
|
||||
for (const [key, held] of this.#entries) {
|
||||
if (held !== entry) continue
|
||||
this.#entries.delete(key)
|
||||
this.#unread.delete(key)
|
||||
this.#running.delete(key)
|
||||
if (this.#selectedId === key) {
|
||||
this.#selectedId = undefined
|
||||
wasShown = true
|
||||
}
|
||||
}
|
||||
if (!this.#draft) {
|
||||
this.#draft = entry
|
||||
this.#publish()
|
||||
return
|
||||
}
|
||||
// A new chat opened meanwhile, and only one chat can be the one a new conversation
|
||||
// starts on. The chat whose composer was written in is the one kept, since its panel
|
||||
// is the only place that text exists; where both were, the one the reader has in
|
||||
// front of them stays.
|
||||
const opened = this.#draft
|
||||
const written = this.#options.holdsDraft(entry.host)
|
||||
const openedWritten = this.#options.holdsDraft(opened.host)
|
||||
const kept = written === openedWritten ? (wasShown ? entry : opened) : written ? entry : opened
|
||||
const retired = kept === entry ? opened : entry
|
||||
this.#draft = kept
|
||||
// The other is released only once the send that withdrew this conversation has
|
||||
// reported what it could not do — the refusal reaches it after this — and the message
|
||||
// it hands back goes to the chat kept.
|
||||
this.#retiring.add(retired)
|
||||
setTimeout(() => {
|
||||
if (this.#destroyed || !this.#retiring.delete(retired)) return
|
||||
kept.turns.adopt(retired.turns.takeHeld())
|
||||
this.#release(retired)
|
||||
this.#publish()
|
||||
}, 0)
|
||||
this.#publish()
|
||||
}
|
||||
|
||||
#activity(id: string): ConversationActivity {
|
||||
const state = this.#entries.get(id)?.chat.getState()
|
||||
if (this.#running.has(id) || (state && isBusy(state.status))) return 'running'
|
||||
if (state && (state.status === 'error' || lastTurnFailed(state.messages))) return 'error'
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
/** Settled chats past the budget go, least recently shown first; their unread count stays. */
|
||||
#evict(): void {
|
||||
const settled = [...this.#entries.entries()].filter(
|
||||
([id, entry]) =>
|
||||
id !== this.#selectedId &&
|
||||
!isBusy(entry.chat.getState().status) &&
|
||||
entry.turns.releasable &&
|
||||
!this.#options.holdsDraft(entry.host)
|
||||
)
|
||||
settled.sort(([, a], [, b]) => b.lastShownAt - a.lastShownAt)
|
||||
for (const [id, entry] of settled.slice(this.#options.keepSettled ?? 5)) {
|
||||
this.#release(entry)
|
||||
this.#entries.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
#release(entry: Entry<H, A>): void {
|
||||
this.#byKey.delete(entry.key)
|
||||
entry.unsubscribe()
|
||||
this.#options.disposeHost(entry.host)
|
||||
entry.turns.dispose()
|
||||
entry.chat.destroy()
|
||||
}
|
||||
|
||||
#schedulePoll(): void {
|
||||
const every = this.#options.pollMs ?? 3000
|
||||
if (this.#running.size === 0 && !this.#recovering) {
|
||||
clearTimeout(this.#poll)
|
||||
this.#poll = undefined
|
||||
return
|
||||
}
|
||||
if (this.#poll || this.#polling) return
|
||||
this.#poll = setTimeout(
|
||||
() => void this.#relist(),
|
||||
this.#running.size === 0 ? every * RECOVERY_SLOWDOWN : every
|
||||
)
|
||||
}
|
||||
|
||||
/** One listing for every running row this pool follows no chat for. */
|
||||
async #relist(): Promise<void> {
|
||||
this.#poll = undefined
|
||||
this.#polling = true
|
||||
const since = this.listingStarted()
|
||||
const watched = [...this.#running.keys()]
|
||||
const rows: ListedConversation[] = []
|
||||
let failed = false
|
||||
try {
|
||||
// Page 1 holds the running rows but for a turn that has written nothing for a while,
|
||||
// behind conversations active since: the pages after it are read until every watched
|
||||
// row is found or the listing ends.
|
||||
for (let page = 1; page <= POLL_PAGES; page++) {
|
||||
const batch = await this.#options.listRecent(page)
|
||||
rows.push(...batch)
|
||||
const seen = new Set(rows.map((row) => row.id))
|
||||
// Recovering, this pool watches no row yet: the listing is read to its end (or to
|
||||
// the page budget), since a turn that is running can be on any of those pages.
|
||||
if (batch.length === 0) break
|
||||
if (watched.length > 0 && watched.every((id) => seen.has(id))) break
|
||||
}
|
||||
this.#pollFailures = 0
|
||||
} catch {
|
||||
failed = true
|
||||
// A listing that keeps failing would otherwise keep rows running for the life of the
|
||||
// page. After a few tries the rows go quiet, and the listing is read on at a slower
|
||||
// cadence so they come back once it answers again.
|
||||
if (++this.#pollFailures >= POLL_GIVE_UP) {
|
||||
this.#running.clear()
|
||||
this.#pollFailures = 0
|
||||
this.#recovering = true
|
||||
}
|
||||
} finally {
|
||||
this.#polling = false
|
||||
}
|
||||
if (this.#destroyed) return
|
||||
if (!failed) {
|
||||
this.#recovering = false
|
||||
// A watched row on no page read is gone from the listing, or has been quiet while
|
||||
// more conversations than those pages hold were active: either way it goes quiet
|
||||
// here, and opening it reads its own rows. A later listing that reported it stands.
|
||||
const seen = new Set(rows.map((row) => row.id))
|
||||
for (const id of watched) {
|
||||
if (!seen.has(id) && (this.#listedAt.get(id) ?? 0) <= since) this.#running.delete(id)
|
||||
}
|
||||
this.setListed(rows, since)
|
||||
return
|
||||
}
|
||||
this.#schedulePoll()
|
||||
this.#publish()
|
||||
}
|
||||
|
||||
#publish(): void {
|
||||
if (this.#destroyed) return
|
||||
const ids = new Set([...this.#entries.keys(), ...this.#running.keys(), ...this.#unread.keys()])
|
||||
const activity: Record<string, ConversationActivity> = {}
|
||||
for (const id of ids) {
|
||||
const value = this.#activity(id)
|
||||
if (value !== 'idle') activity[id] = value
|
||||
}
|
||||
const queued: Record<string, true> = {}
|
||||
for (const [id, entry] of this.#entries) {
|
||||
if (entry.turns.queued.text) queued[id] = true
|
||||
}
|
||||
// In the order the chats were created — `#byKey` insertion order — and never in the
|
||||
// order they were shown: a key that moves in the `{#each}` takes its panel out of the
|
||||
// DOM and back in, which loses the transcript's scroll and the composer's selection.
|
||||
const withPanel = new Set(this.#entries.values())
|
||||
if (this.#draft) withPanel.add(this.#draft)
|
||||
const mounted = [...this.#byKey.values()]
|
||||
.filter((entry) => withPanel.has(entry))
|
||||
.map((entry) => entry.key)
|
||||
this.#state = {
|
||||
selectedId: this.#selectedId,
|
||||
activity,
|
||||
unread: Object.fromEntries(this.#unread),
|
||||
queued,
|
||||
mounted,
|
||||
shownKey: this.#selectedEntry.key
|
||||
}
|
||||
for (const listener of this.#listeners) listener(this.#state)
|
||||
}
|
||||
}
|
||||
@@ -17,3 +17,34 @@ export const FRAME_CLASS: Record<ChatFrame, string> = {
|
||||
export function chatFlowKey(props: { path: string; identity?: string }): string {
|
||||
return props.identity || props.path
|
||||
}
|
||||
|
||||
const INPUTS_STORAGE_PREFIX = 'windmill_flow_chat_inputs_'
|
||||
|
||||
/**
|
||||
* The flow inputs the reader chose — the model among them. They belong to the flow and are
|
||||
* shared by every conversation in it, so they are held once per chat rather than once per
|
||||
* panel: two panels reading storage on their own would each keep the value it read.
|
||||
*/
|
||||
export function loadFlowChatInputs(props: {
|
||||
path: string
|
||||
identity?: string
|
||||
}): Record<string, any> {
|
||||
try {
|
||||
const stored = localStorage.getItem(`${INPUTS_STORAGE_PREFIX}${chatFlowKey(props)}`)
|
||||
return stored ? JSON.parse(stored) : {}
|
||||
} catch (e) {
|
||||
console.error('Failed to load inputs from localStorage:', e)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function saveFlowChatInputs(
|
||||
props: { path: string; identity?: string },
|
||||
values: Record<string, any>
|
||||
): void {
|
||||
try {
|
||||
localStorage.setItem(`${INPUTS_STORAGE_PREFIX}${chatFlowKey(props)}`, JSON.stringify(values))
|
||||
} catch (e) {
|
||||
console.error('Failed to save inputs to localStorage:', e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
import type { AttachmentUpload, Chat, ChatMessage, ChatState } from 'windmill-chat'
|
||||
import { untrack } from 'svelte'
|
||||
import type {
|
||||
AttachmentUpload,
|
||||
Chat,
|
||||
ChatMessage,
|
||||
ChatState,
|
||||
SendMessageOptions
|
||||
} from 'windmill-chat'
|
||||
import {
|
||||
emptyDraft,
|
||||
isBusy,
|
||||
turnFailed,
|
||||
type ConversationTurns,
|
||||
type Draft,
|
||||
type DraftSender
|
||||
} from './conversationTurns'
|
||||
import type {
|
||||
ChatSendRequestOptions,
|
||||
ChatViewHost
|
||||
@@ -43,19 +58,49 @@ export type FlowChatViewHostOptions = {
|
||||
revealOptions?: Pick<TypewriterRevealOptions, 'instant' | 'now' | 'schedule' | 'cancel'>
|
||||
}
|
||||
|
||||
function isBusy(status: ChatState['status']): boolean {
|
||||
return status === 'submitted' || status === 'streaming'
|
||||
}
|
||||
|
||||
/** The chat's own signal for a send stopped before it ran; nothing to tell the reader. */
|
||||
function isAbort(e: unknown): boolean {
|
||||
return e instanceof Error && e.name === 'AbortError'
|
||||
}
|
||||
|
||||
type Queue = { text: string; images: AttachedImage[]; blobs: AttachedBlob[] }
|
||||
/** What the composer attaches to a message, kept in the order it was attached. */
|
||||
export type ComposerAttachment =
|
||||
| { image: AttachedImage }
|
||||
| { blob: AttachedBlob }
|
||||
| { file: AttachedTextFile }
|
||||
|
||||
function emptyQueue(): Queue {
|
||||
return { text: '', images: [], blobs: [] }
|
||||
export function composerDraft(
|
||||
text: string,
|
||||
images: AttachedImage[] = [],
|
||||
blobs: AttachedBlob[] = [],
|
||||
files: AttachedTextFile[] = []
|
||||
): Draft<ComposerAttachment> {
|
||||
return {
|
||||
text,
|
||||
attachments: [
|
||||
...images.map((image) => ({ image })),
|
||||
...blobs.map((blob) => ({ blob })),
|
||||
...files.map((file) => ({ file }))
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/** This chat forwards files verbatim (`attachmentsAsBlobs`), so the text-file lane stays
|
||||
* empty — it is carried anyway, so a chat that decodes them loses nothing. */
|
||||
function splitAttachments(attachments: readonly ComposerAttachment[]): {
|
||||
images: AttachedImage[]
|
||||
blobs: AttachedBlob[]
|
||||
files: AttachedTextFile[]
|
||||
} {
|
||||
const images: AttachedImage[] = []
|
||||
const blobs: AttachedBlob[] = []
|
||||
const files: AttachedTextFile[] = []
|
||||
for (const attachment of attachments) {
|
||||
if ('image' in attachment) images.push(attachment.image)
|
||||
else if ('blob' in attachment) blobs.push(attachment.blob)
|
||||
else files.push(attachment.file)
|
||||
}
|
||||
return { images, blobs, files }
|
||||
}
|
||||
|
||||
/** A tool's arguments or result as the card shows them: parsed where the string is JSON. */
|
||||
@@ -68,29 +113,6 @@ function parseToolPayload(raw: string | undefined): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the turn the user message at `index` started failed: its last row before the
|
||||
* next user message reports `success: false`. The last row, not any row: a tool call can
|
||||
* fail and the agent still answer, and that turn completed.
|
||||
*/
|
||||
export function turnFailed(messages: readonly ChatMessage[], index: number): boolean {
|
||||
let last: ChatMessage | undefined
|
||||
for (let i = index + 1; i < messages.length; i++) {
|
||||
const message = messages[i]
|
||||
if (message.role === 'user') break
|
||||
last = message
|
||||
}
|
||||
return last?.success === false
|
||||
}
|
||||
|
||||
/** Whether the latest turn failed, per `turnFailed`. False before any turn. */
|
||||
function lastTurnFailed(messages: readonly ChatMessage[]): boolean {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === 'user') return turnFailed(messages, i)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* The step name says which AI agent step wrote a message, so it only tells the reader
|
||||
* anything once the conversation holds more than one. Counted over the transcript rather
|
||||
@@ -300,29 +322,44 @@ type RevealLanes = {
|
||||
* makes, so what it can offer is what the SDK's `Chat` can: a message in, an answer
|
||||
* streamed back, Stop. Every copilot-only field is answered with "no" (see ChatViewHost).
|
||||
*
|
||||
* The host owns its subscription to the chat, so a panel swapping chats mounts a new one.
|
||||
* One host per conversation, living as long as its chat. Its sends go through the
|
||||
* conversation's turns, which hold what waits for the running turn; the host builds each
|
||||
* send and puts what a turn hands back into the composer showing it.
|
||||
*/
|
||||
export class FlowChatViewHost implements ChatViewHost {
|
||||
export class FlowChatViewHost implements ChatViewHost, DraftSender<ComposerAttachment> {
|
||||
#chat: Chat
|
||||
#turns: ConversationTurns<ComposerAttachment>
|
||||
#options: FlowChatViewHostOptions
|
||||
#state = $state.raw<ChatState>() as ChatState
|
||||
#queued = $state.raw<Draft<ComposerAttachment>>(emptyDraft())
|
||||
#queuedLanes = $derived(splitAttachments(this.#queued.attachments))
|
||||
#unsubscribe: () => void
|
||||
|
||||
constructor(chat: Chat, options: FlowChatViewHostOptions = {}) {
|
||||
this.#chat = chat
|
||||
constructor(turns: ConversationTurns<ComposerAttachment>, options: FlowChatViewHostOptions = {}) {
|
||||
this.#turns = turns
|
||||
this.#chat = turns.chat
|
||||
this.#options = options
|
||||
this.#state = this.#chat.getState()
|
||||
const unsubscribeChat = this.#chat.subscribe((state) => this.#onState(state))
|
||||
const unsubscribeTurns = turns.subscribe(() => {
|
||||
this.#queued = turns.queued
|
||||
this.#takeReturned()
|
||||
})
|
||||
this.#unsubscribe = () => {
|
||||
unsubscribeChat()
|
||||
unsubscribeTurns()
|
||||
}
|
||||
}
|
||||
|
||||
/** Set by the panel showing this conversation; a message queued here is sent with what
|
||||
* the last panel to show it read. */
|
||||
setOptions(options: FlowChatViewHostOptions) {
|
||||
this.#options = options
|
||||
this.#state = chat.getState()
|
||||
this.#unsubscribe = chat.subscribe((state) => this.#onState(state))
|
||||
}
|
||||
|
||||
#disposed = false
|
||||
/** Stops following the chat, and drops what was queued: a flush still waiting on the
|
||||
* turn's release would otherwise start a run from a panel that is gone. A send still
|
||||
* uploading its attachments stops with the chat, which is the caller's to destroy; its
|
||||
* draft is not handed back, since the composer it came from is gone too. */
|
||||
dispose() {
|
||||
this.#disposed = true
|
||||
this.#queue = emptyQueue()
|
||||
this.#unsubscribe()
|
||||
for (const id of Object.keys(this.#reveals)) this.#dropReveal(id)
|
||||
}
|
||||
@@ -345,23 +382,9 @@ export class FlowChatViewHost implements ChatViewHost {
|
||||
if (previous.conversationId !== state.conversationId) {
|
||||
// A conversation opens at its end, whatever the reader was doing in the last one.
|
||||
this.#automaticScroll = true
|
||||
// The queue was typed into the conversation that just went away; a message sent
|
||||
// after the switch would ride out of the wrong one, so it goes back to the composer.
|
||||
this.dequeueMessage()
|
||||
return
|
||||
}
|
||||
if (!isBusy(previous.status) && isBusy(state.status)) this.#turnsStarted++
|
||||
if (isBusy(previous.status) && !isBusy(state.status)) {
|
||||
// The turn settled. What was typed during it goes out once the turn is released,
|
||||
// not now: the chat publishes `idle` from inside its own `sendMessage`, which still
|
||||
// counts the turn as open until it returns, and a send made before that would be
|
||||
// refused as a second turn. After a failure it goes back to the composer instead,
|
||||
// where the reader would rather look at the error than pile on. A failed flow
|
||||
// settles as `idle` too, with its error as the answer, so the messages decide.
|
||||
const succeeded = state.status === 'idle' && !lastTurnFailed(state.messages)
|
||||
if (succeeded) void this.#turnDone.then(this.flushQueuedMessage)
|
||||
else this.dequeueMessage()
|
||||
}
|
||||
}
|
||||
|
||||
// Smooth streaming. The chat appends each delta to the pending assistant message as it
|
||||
@@ -469,37 +492,27 @@ export class FlowChatViewHost implements ChatViewHost {
|
||||
instructions = ''
|
||||
// The user message lands in the transcript before `sendMessage` awaits anything.
|
||||
sendInFlight = false
|
||||
sendRequest = async (options: ChatSendRequestOptions = {}): Promise<boolean> =>
|
||||
this.#turns.send(composerDraft(options.instructions ?? '', options.images, options.blobs))
|
||||
|
||||
/**
|
||||
* `replayInputs` are a failed turn's own run arguments, read back from its job. They
|
||||
* stand in for the composer's current inputs, so a retry runs the turn that failed
|
||||
* rather than a new one wearing its text.
|
||||
*/
|
||||
sendRequest = async (
|
||||
options: ChatSendRequestOptions = {},
|
||||
replayInputs?: Record<string, any>
|
||||
): Promise<boolean> => {
|
||||
const text = options.instructions?.trim() ?? ''
|
||||
let images = options.images ?? []
|
||||
let blobs = options.blobs ?? []
|
||||
// The composer refuses an attachment-only send (requiresMessageText), so this is
|
||||
// the same rule at the other end: nothing runs without a message.
|
||||
if (!text) return false
|
||||
if (this.loading) {
|
||||
this.queueMessage(text, images, undefined, undefined, blobs)
|
||||
return true
|
||||
}
|
||||
if (this.#options.sendDisabled?.()) {
|
||||
// Refused, not dropped: the draft waits in the composer for sending to reopen.
|
||||
this.#aiChatInput?.prependText(text, images, [], blobs)
|
||||
return false
|
||||
}
|
||||
prepareSend(
|
||||
draft: Draft<ComposerAttachment>,
|
||||
replayInputs?: Record<string, unknown>
|
||||
): SendMessageOptions | undefined {
|
||||
// Refused, not dropped: the draft waits in the composer for sending to reopen.
|
||||
if (this.#options.sendDisabled?.()) return undefined
|
||||
let { images, blobs } = splitAttachments(draft.attachments)
|
||||
const target = this.#options.attachmentsTarget?.()
|
||||
// The inputs modal does not ask for this input, so a required one is enforced here. A
|
||||
// replay carries the files its run already has, in `replayInputs`, and attaches none.
|
||||
if (!replayInputs && target?.required && images.length === 0 && blobs.length === 0) {
|
||||
sendUserToast('This chat needs a file with each message. Attach one to send.', true)
|
||||
this.#aiChatInput?.prependText(text, images, [], blobs)
|
||||
return false
|
||||
return undefined
|
||||
}
|
||||
// A replay sends the arguments its run had, attachment references included.
|
||||
const inputs = replayInputs ?? { ...(this.#options.additionalInputs?.() ?? {}) }
|
||||
@@ -528,41 +541,24 @@ export class FlowChatViewHost implements ChatViewHost {
|
||||
}))
|
||||
: []
|
||||
this.#automaticScroll = true
|
||||
// A run that fails is reported through the chat's `onError` and as a failed message;
|
||||
// the promise itself only rejects when the chat refuses the turn outright — a turn
|
||||
// already running, an upload that failed, Stop pressed while it ran — and the draft
|
||||
// is then handed back rather than dropped. The composer took it before calling, so
|
||||
// nothing else would.
|
||||
const turn = this.#chat
|
||||
.sendMessage(text, {
|
||||
inputs: replayInputs ?? (this.#options.additionalInputs?.() ? inputs : undefined),
|
||||
attachments,
|
||||
attachmentsInput: target
|
||||
})
|
||||
.catch((e) => {
|
||||
if (this.#disposed) return
|
||||
if (attachments.length > 0 && !isAbort(e)) {
|
||||
sendUserToast(
|
||||
`Could not upload the attachments: ${e instanceof Error ? e.message : String(e)}`,
|
||||
true
|
||||
)
|
||||
}
|
||||
// What was queued behind it comes back too, after it: the chat publishes `idle`
|
||||
// when it withdraws the turn, and a queue left in place would be flushed as if
|
||||
// the turn had run.
|
||||
this.dequeueMessage()
|
||||
this.#aiChatInput?.prependText(text, images, [], blobs)
|
||||
})
|
||||
this.#turnDone = turn
|
||||
await turn
|
||||
return true
|
||||
return {
|
||||
inputs: replayInputs ?? (this.#options.additionalInputs?.() ? inputs : undefined),
|
||||
attachments,
|
||||
attachmentsInput: target
|
||||
}
|
||||
}
|
||||
/** Settles when the chat has released the last turn this host started. */
|
||||
#turnDone: Promise<unknown> = Promise.resolve()
|
||||
|
||||
sendFailed(error: unknown, draft: Draft<ComposerAttachment>): void {
|
||||
const uploaded = draft.attachments.length > 0 && this.#options.attachmentsTarget?.()
|
||||
if (uploaded && !isAbort(error)) {
|
||||
sendUserToast(
|
||||
`Could not upload the attachments: ${error instanceof Error ? error.message : String(error)}`,
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
const { messages, status } = this.#state
|
||||
const turn = isBusy(status) ? [...messages].reverse().find((m) => m.role === 'user') : undefined
|
||||
if (turn) {
|
||||
@@ -570,66 +566,72 @@ export class FlowChatViewHost implements ChatViewHost {
|
||||
[...this.#stoppedTurns, turn.id, turn.serverId].filter((id) => id !== undefined)
|
||||
)
|
||||
}
|
||||
void this.#chat.stop()
|
||||
this.#turns.stop()
|
||||
}
|
||||
// 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
|
||||
setAiChatInput: ChatViewHost['setAiChatInput'] = (aiChatInput) =>
|
||||
// Called from the composer's mount effect, and taking what a turn handed back reads the
|
||||
// draft it writes: tracked, that would rerun the effect and hand it back again.
|
||||
untrack(() => {
|
||||
this.#aiChatInput = aiChatInput
|
||||
this.#takeReturned()
|
||||
})
|
||||
|
||||
/** Whether a composer showing this conversation holds something not sent — text, an
|
||||
* attachment, a file still being read. Its panel stays mounted for as long as one does:
|
||||
* nothing else holds that draft. */
|
||||
holdsDraft(): boolean {
|
||||
return this.#composersWithDraft.size > 0
|
||||
}
|
||||
|
||||
/** Puts what a turn handed back into the composer, when one shows this conversation;
|
||||
* until then the conversation's turns hold it. */
|
||||
#takeReturned() {
|
||||
if (!this.#aiChatInput || this.#disposed) return
|
||||
const { text, attachments } = this.#turns.takeReturned()
|
||||
if (!text && attachments.length === 0) return
|
||||
const { images, blobs, files } = splitAttachments(attachments)
|
||||
this.#aiChatInput.prependText(text, images, files, blobs)
|
||||
}
|
||||
|
||||
// One message typed while the turn runs, sent whole with its attachments once the turn
|
||||
// settles. Enter again appends a line rather than replacing what waits.
|
||||
#queue = $state<Queue>(emptyQueue())
|
||||
get queuedMessage(): string {
|
||||
return this.#queue.text
|
||||
return this.#queued.text
|
||||
}
|
||||
queuedContext = undefined
|
||||
get queuedImages(): AttachedImage[] {
|
||||
return this.#queue.images
|
||||
return this.#queuedLanes.images
|
||||
}
|
||||
get queuedFiles(): AttachedTextFile[] {
|
||||
return this.#queuedLanes.files
|
||||
}
|
||||
queuedFiles: AttachedTextFile[] = []
|
||||
get queuedBlobs(): AttachedBlob[] {
|
||||
return this.#queue.blobs
|
||||
return this.#queuedLanes.blobs
|
||||
}
|
||||
queueMessage = (
|
||||
text: string,
|
||||
images: AttachedImage[] = [],
|
||||
_context?: unknown,
|
||||
_files?: unknown,
|
||||
files: AttachedTextFile[] = [],
|
||||
blobs: AttachedBlob[] = []
|
||||
) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed && images.length === 0 && blobs.length === 0) return
|
||||
const queue = this.#queue
|
||||
this.#queue = {
|
||||
text: !trimmed ? queue.text : queue.text ? `${queue.text}\n${trimmed}` : trimmed,
|
||||
images: [...queue.images, ...images],
|
||||
blobs: [...queue.blobs, ...blobs]
|
||||
}
|
||||
this.#turns.queue(composerDraft(text, images, blobs, files))
|
||||
}
|
||||
/** Put the queued draft back in the composer, attachments included. */
|
||||
dequeueMessage = () => {
|
||||
const { text, images, blobs } = this.#takeQueue()
|
||||
if (!text && images.length === 0 && blobs.length === 0) return
|
||||
this.#aiChatInput?.prependText(text, images, [], blobs)
|
||||
}
|
||||
flushQueuedMessage = () => {
|
||||
// Same rule as sendRequest, read before the queue is drained: a turn with no message
|
||||
// cannot run, and taking the queue for it would drop the attachments on the floor.
|
||||
if (!this.#queue.text || this.#disposed) return
|
||||
const { text, images, blobs } = this.#takeQueue()
|
||||
void this.sendRequest({ instructions: text, images, blobs })
|
||||
}
|
||||
#takeQueue(): Queue {
|
||||
const taken = this.#queue
|
||||
this.#queue = emptyQueue()
|
||||
return taken
|
||||
this.#turns.dequeue()
|
||||
}
|
||||
setComposerStaged = () => {}
|
||||
setComposerHasDraft = () => {}
|
||||
clearComposerStaged = () => {}
|
||||
/** Plain, not `$state`: read by the pool deciding what to release, never rendered. */
|
||||
readonly #composersWithDraft = new Set<string>()
|
||||
setComposerHasDraft = (key: string, hasDraft: boolean) => {
|
||||
if (hasDraft) this.#composersWithDraft.add(key)
|
||||
else this.#composersWithDraft.delete(key)
|
||||
}
|
||||
clearComposerStaged = (key: string) => {
|
||||
this.#composersWithDraft.delete(key)
|
||||
}
|
||||
attachmentBytesExcluding = () => 0
|
||||
|
||||
// Per-message actions
|
||||
@@ -689,7 +691,7 @@ export class FlowChatViewHost implements ChatViewHost {
|
||||
sendUserToast('That chat started another turn. Retry once it finishes.', true)
|
||||
return
|
||||
}
|
||||
void this.sendRequest({ instructions: message.content }, replayInputs)
|
||||
void this.#turns.send(composerDraft(message.content), replayInputs)
|
||||
}
|
||||
restartGeneration = () => {}
|
||||
handleUserQuestionAnswer = () => false
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Chat, ChatMessage, ChatState } from 'windmill-chat'
|
||||
import { FlowChatViewHost, toDisplayMessages } from './flowChatViewHost.svelte'
|
||||
import { TurnRunningError, type Chat, type ChatMessage, type ChatState } from 'windmill-chat'
|
||||
import {
|
||||
FlowChatViewHost,
|
||||
toDisplayMessages,
|
||||
type ComposerAttachment,
|
||||
type FlowChatViewHostOptions
|
||||
} from './flowChatViewHost.svelte'
|
||||
import { ConversationTurns } from './conversationTurns'
|
||||
|
||||
vi.mock('$lib/gen', () => ({
|
||||
JobService: { getJobArgs: vi.fn() }
|
||||
@@ -59,13 +65,15 @@ function fakeChat(initial: ChatState = idleState()) {
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
sendMessage: vi.fn(async () => {}),
|
||||
resumeTurn: vi.fn(async () => {}),
|
||||
stop: vi.fn(async () => {}),
|
||||
newConversation: vi.fn(),
|
||||
selectConversation: vi.fn(async () => {}),
|
||||
loadConversations: vi.fn(async () => []),
|
||||
deleteConversation: vi.fn(async () => {}),
|
||||
loadOlderMessages: vi.fn(async () => {}),
|
||||
renameConversation: vi.fn(async () => {}),
|
||||
loadOlderMessages: vi.fn(async () => {}),
|
||||
refreshMessages: vi.fn(async () => {}),
|
||||
destroy: vi.fn()
|
||||
} satisfies Chat
|
||||
return { chat, set }
|
||||
@@ -73,6 +81,14 @@ function fakeChat(initial: ChatState = idleState()) {
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
/** A host on its conversation's turns, the way the pool pairs them. */
|
||||
function hostOn(chat: Chat, options?: FlowChatViewHostOptions) {
|
||||
let host!: FlowChatViewHost
|
||||
const turns = new ConversationTurns<ComposerAttachment>(chat, () => host)
|
||||
host = new FlowChatViewHost(turns, options)
|
||||
return Object.assign(host, { turns })
|
||||
}
|
||||
|
||||
describe('toDisplayMessages', () => {
|
||||
it('maps user, assistant and tool rows, marking the user message of a failed turn', () => {
|
||||
const rows = [
|
||||
@@ -206,7 +222,7 @@ describe('toDisplayMessages', () => {
|
||||
expect(display[1]).not.toHaveProperty('streaming')
|
||||
|
||||
const { chat } = fakeChat(idleState({ messages: rows }))
|
||||
const host = new FlowChatViewHost(chat)
|
||||
const host = hostOn(chat)
|
||||
host.retryRequest(4)
|
||||
await vi.waitFor(() =>
|
||||
expect(chat.sendMessage).toHaveBeenCalledWith('second', expect.anything())
|
||||
@@ -370,7 +386,7 @@ describe('toDisplayMessages', () => {
|
||||
describe('FlowChatViewHost', () => {
|
||||
it('sends the text with the additional inputs and reports loading from the status', async () => {
|
||||
const { chat, set } = fakeChat()
|
||||
const host = new FlowChatViewHost(chat, { additionalInputs: () => ({ tone: 'brief' }) })
|
||||
const host = hostOn(chat, { additionalInputs: () => ({ tone: 'brief' }) })
|
||||
expect(host.loading).toBe(false)
|
||||
expect(await host.sendRequest({ instructions: ' hello ' })).toBe(true)
|
||||
expect(chat.sendMessage).toHaveBeenCalledWith('hello', {
|
||||
@@ -394,7 +410,7 @@ describe('FlowChatViewHost', () => {
|
||||
chat.sendMessage.mockImplementationOnce(
|
||||
() => new Promise<void>((resolve) => (releaseTurn = resolve))
|
||||
)
|
||||
const host = new FlowChatViewHost(chat)
|
||||
const host = hostOn(chat)
|
||||
void host.sendRequest({ instructions: 'start' })
|
||||
set({ status: 'streaming' })
|
||||
host.queueMessage('first')
|
||||
@@ -418,7 +434,7 @@ describe('FlowChatViewHost', () => {
|
||||
const { chat, set } = fakeChat(
|
||||
idleState({ status: 'streaming', messages: [message({ role: 'user', content: 'go' })] })
|
||||
)
|
||||
const host = new FlowChatViewHost(chat)
|
||||
const host = hostOn(chat)
|
||||
const prependText = vi.fn()
|
||||
host.setAiChatInput({ prependText } as any)
|
||||
host.queueMessage('later')
|
||||
@@ -438,7 +454,7 @@ describe('FlowChatViewHost', () => {
|
||||
it('hands the queue back instead of sending while sending is disabled', async () => {
|
||||
const { chat, set } = fakeChat(idleState({ status: 'streaming' }))
|
||||
let deploying = false
|
||||
const host = new FlowChatViewHost(chat, { sendDisabled: () => deploying })
|
||||
const host = hostOn(chat, { sendDisabled: () => deploying })
|
||||
const prependText = vi.fn()
|
||||
host.setAiChatInput({ prependText } as any)
|
||||
host.queueMessage('after deploy')
|
||||
@@ -458,12 +474,12 @@ describe('FlowChatViewHost', () => {
|
||||
chat.sendMessage.mockImplementationOnce(
|
||||
() => new Promise<void>((resolve) => (releaseTurn = resolve))
|
||||
)
|
||||
const host = new FlowChatViewHost(chat)
|
||||
const host = hostOn(chat)
|
||||
void host.sendRequest({ instructions: 'start' })
|
||||
set({ status: 'streaming' })
|
||||
host.queueMessage('never')
|
||||
set({ status: 'idle' })
|
||||
host.dispose()
|
||||
host.turns.dispose()
|
||||
releaseTurn()
|
||||
await flush()
|
||||
expect(chat.sendMessage).toHaveBeenCalledTimes(1)
|
||||
@@ -472,7 +488,7 @@ describe('FlowChatViewHost', () => {
|
||||
it('hands the text back when the chat refuses the turn', async () => {
|
||||
const { chat } = fakeChat()
|
||||
chat.sendMessage.mockRejectedValueOnce(new Error('a message is already being answered'))
|
||||
const host = new FlowChatViewHost(chat)
|
||||
const host = hostOn(chat)
|
||||
const prependText = vi.fn()
|
||||
host.setAiChatInput({ prependText } as any)
|
||||
await host.sendRequest({ instructions: 'kept' })
|
||||
@@ -480,9 +496,139 @@ describe('FlowChatViewHost', () => {
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('follows the turn a conversation is still answering and sends the refused message after it', async () => {
|
||||
const { chat, set } = fakeChat()
|
||||
const turn = { jobId: 'job-9', userSeq: 41 }
|
||||
chat.sendMessage.mockRejectedValueOnce(new TurnRunningError('still answering', turn))
|
||||
let releaseResumed = () => {}
|
||||
chat.resumeTurn.mockImplementationOnce(
|
||||
() => new Promise<void>((resolve) => (releaseResumed = resolve))
|
||||
)
|
||||
const host = hostOn(chat)
|
||||
await host.sendRequest({ instructions: 'after it' })
|
||||
expect(chat.resumeTurn).toHaveBeenCalledWith(turn)
|
||||
expect(host.queuedMessage).toBe('after it')
|
||||
set({ status: 'streaming' })
|
||||
set({ status: 'idle' })
|
||||
releaseResumed()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
// The refused send is the first call; the flush after the resumed turn is the second.
|
||||
expect(chat.sendMessage).toHaveBeenCalledTimes(2)
|
||||
expect(chat.sendMessage).toHaveBeenLastCalledWith('after it', {
|
||||
inputs: undefined,
|
||||
attachments: [],
|
||||
attachmentsInput: undefined
|
||||
})
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('hands back a refused message ahead of its queued correction when no composer shows it', async () => {
|
||||
const { chat, set } = fakeChat()
|
||||
let refuse = (_e: Error) => {}
|
||||
chat.sendMessage.mockImplementationOnce(
|
||||
() => new Promise<void>((_, reject) => (refuse = reject))
|
||||
)
|
||||
const host = hostOn(chat)
|
||||
const sent = host.sendRequest({ instructions: 'first' })
|
||||
set({ status: 'submitted' })
|
||||
host.queueMessage('correction')
|
||||
set({ status: 'idle' })
|
||||
refuse(new Error('upload failed (500)'))
|
||||
await sent
|
||||
const prependText = vi.fn()
|
||||
host.setAiChatInput({ prependText } as any)
|
||||
expect(prependText).toHaveBeenCalledWith('first\ncorrection', [], [], [])
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('hands a refused message back when the chat cannot follow the running turn', async () => {
|
||||
const { chat } = fakeChat(idleState({ history: 'local' }))
|
||||
chat.sendMessage.mockRejectedValueOnce(
|
||||
new TurnRunningError('still answering', { jobId: 'job-9', userSeq: 41 })
|
||||
)
|
||||
const host = hostOn(chat)
|
||||
const prependText = vi.fn()
|
||||
host.setAiChatInput({ prependText } as any)
|
||||
await host.sendRequest({ instructions: 'again' })
|
||||
await flush()
|
||||
expect(chat.resumeTurn).not.toHaveBeenCalled()
|
||||
expect(chat.sendMessage).toHaveBeenCalledTimes(1)
|
||||
expect(prependText).toHaveBeenCalledWith('again', [], [], [])
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('keeps a refused message ahead of what was queued while it waited', async () => {
|
||||
const { chat, set } = fakeChat()
|
||||
let refuse = (_e: Error) => {}
|
||||
chat.sendMessage.mockImplementationOnce(
|
||||
() => new Promise<void>((_, reject) => (refuse = reject))
|
||||
)
|
||||
// Like the chat, following a turn holds it at once.
|
||||
chat.resumeTurn.mockImplementationOnce(async () => set({ status: 'submitted' }))
|
||||
const host = hostOn(chat)
|
||||
const sent = host.sendRequest({ instructions: 'first' })
|
||||
set({ status: 'submitted' })
|
||||
host.queueMessage('correction')
|
||||
set({ status: 'idle' })
|
||||
refuse(new TurnRunningError('still answering', { jobId: 'job-9', userSeq: 41 }))
|
||||
await sent
|
||||
expect(host.queuedMessage).toBe('first\ncorrection')
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('hands back a message stopped before the chat refused it, rather than queueing it', async () => {
|
||||
const { chat } = fakeChat()
|
||||
let refuse = (_e: Error) => {}
|
||||
chat.sendMessage.mockImplementationOnce(
|
||||
() => new Promise<void>((_, reject) => (refuse = reject))
|
||||
)
|
||||
const host = hostOn(chat)
|
||||
const prependText = vi.fn()
|
||||
host.setAiChatInput({ prependText } as any)
|
||||
const sent = host.sendRequest({ instructions: 'never mind' })
|
||||
host.cancel()
|
||||
refuse(new TurnRunningError('still answering', { jobId: 'job-9', userSeq: 41 }))
|
||||
await sent
|
||||
expect(chat.resumeTurn).not.toHaveBeenCalled()
|
||||
expect(host.queuedMessage).toBe('')
|
||||
expect(prependText).toHaveBeenCalledWith('never mind', [], [], [])
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('keeps text handed back while no composer is mounted for the next one', () => {
|
||||
const { chat } = fakeChat(idleState({ status: 'streaming' }))
|
||||
const host = hostOn(chat)
|
||||
host.queueMessage('typed before leaving')
|
||||
host.cancel()
|
||||
// Held by the conversation's turns alone until a composer takes it, so the pool must
|
||||
// not release it.
|
||||
expect(host.turns.holdsText).toBe(true)
|
||||
const prependText = vi.fn()
|
||||
host.setAiChatInput({ prependText } as any)
|
||||
expect(prependText).toHaveBeenCalledWith('typed before leaving', [], [], [])
|
||||
expect(host.turns.holdsText).toBe(false)
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('reports a composer holding a draft, so its conversation keeps its panel', () => {
|
||||
const { chat } = fakeChat()
|
||||
const host = hostOn(chat)
|
||||
expect(host.holdsDraft()).toBe(false)
|
||||
// Two composers can show one conversation at once: the message being edited and the
|
||||
// one being written. The panel stays for as long as either holds something.
|
||||
host.setComposerHasDraft('composer', true)
|
||||
host.setComposerHasDraft('edit-box', true)
|
||||
expect(host.holdsDraft()).toBe(true)
|
||||
host.setComposerHasDraft('composer', false)
|
||||
expect(host.holdsDraft()).toBe(true)
|
||||
host.clearComposerStaged('edit-box')
|
||||
expect(host.holdsDraft()).toBe(false)
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('hands the queue back to the composer on Stop and on a failed turn', async () => {
|
||||
const { chat, set } = fakeChat(idleState({ status: 'streaming' }))
|
||||
const host = new FlowChatViewHost(chat)
|
||||
const host = hostOn(chat)
|
||||
const prependText = vi.fn()
|
||||
host.setAiChatInput({ prependText } as any)
|
||||
host.queueMessage('later')
|
||||
@@ -510,12 +656,12 @@ describe('FlowChatViewHost', () => {
|
||||
|
||||
it('takes attachments only where the flow has an input for them', () => {
|
||||
const { chat } = fakeChat()
|
||||
const none = new FlowChatViewHost(chat)
|
||||
const none = hostOn(chat)
|
||||
expect(none.supportsMessageAttachments).toBe(false)
|
||||
const list = new FlowChatViewHost(chat, { attachmentsTarget: () => listInput })
|
||||
const list = hostOn(chat, { attachmentsTarget: () => listInput })
|
||||
expect(list.supportsMessageAttachments).toBe(true)
|
||||
expect(list.maxMessageAttachments).toBeUndefined()
|
||||
const single = new FlowChatViewHost(chat, {
|
||||
const single = hostOn(chat, {
|
||||
attachmentsTarget: () => ({ name: 'file', multiple: false }),
|
||||
attachmentsUnavailable: () => 'no storage'
|
||||
})
|
||||
@@ -525,7 +671,7 @@ describe('FlowChatViewHost', () => {
|
||||
|
||||
it('hands the attachments to the chat, and drops a stored value for their input', async () => {
|
||||
const { chat } = fakeChat()
|
||||
const host = new FlowChatViewHost(chat, {
|
||||
const host = hostOn(chat, {
|
||||
additionalInputs: () => ({ tone: 'brief', files: [{ s3: 'stale' }] }),
|
||||
attachmentsTarget: () => listInput
|
||||
})
|
||||
@@ -542,7 +688,7 @@ describe('FlowChatViewHost', () => {
|
||||
// A queue merged over several turns reaches the host as one send.
|
||||
it('re-applies a single-file cap to a merged queue', async () => {
|
||||
const { chat } = fakeChat()
|
||||
const host = new FlowChatViewHost(chat, {
|
||||
const host = hostOn(chat, {
|
||||
attachmentsTarget: () => ({ name: 'file', multiple: false })
|
||||
})
|
||||
await host.sendRequest({ instructions: 'read', images: [image], blobs: [pdf] })
|
||||
@@ -552,7 +698,7 @@ describe('FlowChatViewHost', () => {
|
||||
|
||||
it('hands the draft back with its attachments when the upload is refused or stopped', async () => {
|
||||
const { chat } = fakeChat()
|
||||
const host = new FlowChatViewHost(chat, { attachmentsTarget: () => listInput })
|
||||
const host = hostOn(chat, { attachmentsTarget: () => listInput })
|
||||
const prependText = vi.fn()
|
||||
host.setAiChatInput({ prependText } as any)
|
||||
chat.sendMessage.mockRejectedValueOnce(new Error('POST upload failed (500)'))
|
||||
@@ -574,7 +720,7 @@ describe('FlowChatViewHost', () => {
|
||||
chat.sendMessage.mockImplementationOnce(
|
||||
() => new Promise<void>((_, reject) => (refuse = reject))
|
||||
)
|
||||
const host = new FlowChatViewHost(chat, { attachmentsTarget: () => listInput })
|
||||
const host = hostOn(chat, { attachmentsTarget: () => listInput })
|
||||
const prependText = vi.fn()
|
||||
host.setAiChatInput({ prependText } as any)
|
||||
void host.sendRequest({ instructions: 'A', blobs: [pdf] })
|
||||
@@ -603,7 +749,7 @@ describe('FlowChatViewHost', () => {
|
||||
user_message: 'read',
|
||||
user_attachments: [{ s3: 'chat/u1/contract.pdf', filename: 'contract.pdf' }]
|
||||
} as any)
|
||||
const host = new FlowChatViewHost(chat, {
|
||||
const host = hostOn(chat, {
|
||||
workspace: () => 'ws',
|
||||
attachmentsTarget: () => listInput
|
||||
})
|
||||
@@ -620,7 +766,7 @@ describe('FlowChatViewHost', () => {
|
||||
|
||||
it('refuses a message without a file when the flow requires one', async () => {
|
||||
const { chat } = fakeChat()
|
||||
const host = new FlowChatViewHost(chat, {
|
||||
const host = hostOn(chat, {
|
||||
attachmentsTarget: () => ({ ...listInput, required: true })
|
||||
})
|
||||
const prependText = vi.fn()
|
||||
@@ -635,7 +781,7 @@ describe('FlowChatViewHost', () => {
|
||||
|
||||
it('queues attachments with the text and sends them together', async () => {
|
||||
const { chat, set } = fakeChat(idleState({ status: 'streaming' }))
|
||||
const host = new FlowChatViewHost(chat, { attachmentsTarget: () => listInput })
|
||||
const host = hostOn(chat, { attachmentsTarget: () => listInput })
|
||||
host.queueMessage('look', [image], undefined, undefined, [pdf])
|
||||
expect(host.queuedImages).toEqual([image])
|
||||
expect(host.queuedBlobs).toEqual([pdf])
|
||||
@@ -661,7 +807,7 @@ describe('FlowChatViewHost', () => {
|
||||
messages: [message({ id: 'live', role: 'user', content: 'go' }), failedTool]
|
||||
})
|
||||
)
|
||||
const host = new FlowChatViewHost(chat)
|
||||
const host = hostOn(chat)
|
||||
host.cancel()
|
||||
expect(chat.stop).toHaveBeenCalled()
|
||||
set({ status: 'idle' })
|
||||
@@ -703,7 +849,7 @@ describe('FlowChatViewHost', () => {
|
||||
|
||||
it('stops following the chat once disposed', () => {
|
||||
const { chat, set } = fakeChat()
|
||||
const host = new FlowChatViewHost(chat)
|
||||
const host = hostOn(chat)
|
||||
host.dispose()
|
||||
set({ status: 'streaming' })
|
||||
expect(host.loading).toBe(false)
|
||||
@@ -721,7 +867,7 @@ describe('FlowChatViewHost', () => {
|
||||
it("replays the turn with the inputs its run had, not the composer's", async () => {
|
||||
const { chat } = fakeChat(failedTurn())
|
||||
getJobArgs.mockResolvedValueOnce({ user_message: 'go', tone: 'terse', model: 'old' } as any)
|
||||
const host = new FlowChatViewHost(chat, {
|
||||
const host = hostOn(chat, {
|
||||
workspace: () => 'ws',
|
||||
additionalInputs: () => ({ tone: 'brief', model: 'new' }),
|
||||
inputsShownInComposer: () => ['model']
|
||||
@@ -736,10 +882,46 @@ describe('FlowChatViewHost', () => {
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('keeps the replayed inputs when a turn elsewhere refuses the retry', async () => {
|
||||
const { chat, set } = fakeChat(failedTurn())
|
||||
getJobArgs.mockResolvedValueOnce({ user_message: 'go', tone: 'terse' } as any)
|
||||
const turn = { jobId: 'job-elsewhere', userSeq: 12 }
|
||||
chat.sendMessage.mockRejectedValueOnce(new TurnRunningError('still answering', turn))
|
||||
let releaseResumed = () => {}
|
||||
chat.resumeTurn.mockImplementationOnce(
|
||||
() => new Promise<void>((resolve) => (releaseResumed = resolve))
|
||||
)
|
||||
const host = hostOn(chat, {
|
||||
workspace: () => 'ws',
|
||||
additionalInputs: () => ({ tone: 'brief' })
|
||||
})
|
||||
await host.retryRequest(0)
|
||||
expect(chat.resumeTurn).toHaveBeenCalledWith(turn)
|
||||
// That turn answers, and the retry goes out behind it.
|
||||
set({ status: 'streaming' })
|
||||
set({
|
||||
status: 'idle',
|
||||
messages: [
|
||||
...failedTurn().messages,
|
||||
message({ role: 'user', content: 'from the other tab' }),
|
||||
message({ role: 'assistant', content: 'done' })
|
||||
]
|
||||
})
|
||||
releaseResumed()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
// On the arguments of the turn it replays, not on what the composer holds now.
|
||||
expect(chat.sendMessage).toHaveBeenCalledTimes(2)
|
||||
expect(chat.sendMessage).toHaveBeenLastCalledWith(
|
||||
'go',
|
||||
expect.objectContaining({ inputs: { tone: 'terse' } })
|
||||
)
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('falls back to a plain resend once the job is purged', async () => {
|
||||
const { chat } = fakeChat(failedTurn())
|
||||
getJobArgs.mockRejectedValueOnce(Object.assign(new Error('gone'), { status: 404 }))
|
||||
const host = new FlowChatViewHost(chat, {
|
||||
const host = hostOn(chat, {
|
||||
workspace: () => 'ws',
|
||||
additionalInputs: () => ({ tone: 'brief' })
|
||||
})
|
||||
@@ -755,7 +937,7 @@ describe('FlowChatViewHost', () => {
|
||||
it('does nothing but say so when the run cannot be read', async () => {
|
||||
const { chat } = fakeChat(failedTurn())
|
||||
getJobArgs.mockRejectedValueOnce(Object.assign(new Error('down'), { status: 500 }))
|
||||
const host = new FlowChatViewHost(chat, { workspace: () => 'ws' })
|
||||
const host = hostOn(chat, { workspace: () => 'ws' })
|
||||
await host.retryRequest(0)
|
||||
expect(chat.sendMessage).not.toHaveBeenCalled()
|
||||
expect(toast).toHaveBeenCalledWith('Could not read what that turn ran with. Try again.', true)
|
||||
@@ -766,7 +948,7 @@ describe('FlowChatViewHost', () => {
|
||||
const { chat, set } = fakeChat(failedTurn())
|
||||
let answer = (_: unknown) => {}
|
||||
getJobArgs.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)) as any)
|
||||
const host = new FlowChatViewHost(chat, { workspace: () => 'ws' })
|
||||
const host = hostOn(chat, { workspace: () => 'ws' })
|
||||
const retried = host.retryRequest(0)
|
||||
set({ status: 'streaming' })
|
||||
answer({ user_message: 'go' })
|
||||
@@ -785,7 +967,7 @@ describe('FlowChatViewHost', () => {
|
||||
const { chat, set } = fakeChat(failedTurn())
|
||||
let answer = (_: unknown) => {}
|
||||
getJobArgs.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)) as any)
|
||||
const host = new FlowChatViewHost(chat, { workspace: () => 'ws' })
|
||||
const host = hostOn(chat, { workspace: () => 'ws' })
|
||||
const retried = host.retryRequest(0)
|
||||
set({ status: 'streaming' })
|
||||
set({ status: 'idle' })
|
||||
@@ -826,7 +1008,7 @@ describe('FlowChatViewHost', () => {
|
||||
it('paces a streaming answer and shows it whole once it settles', () => {
|
||||
const { chat, set } = fakeChat(idleState({ status: 'streaming' }))
|
||||
const reveal = manualReveal()
|
||||
const host = new FlowChatViewHost(chat, { revealOptions: reveal.options })
|
||||
const host = hostOn(chat, { revealOptions: reveal.options })
|
||||
const streaming = message({ role: 'assistant', id: 'a1', content: '', pending: true })
|
||||
set({ messages: [{ ...streaming, content: 'The answer, in one burst of text.' }] })
|
||||
const shown = () => (host.displayMessages[0] as { content: string }).content
|
||||
@@ -848,7 +1030,7 @@ describe('FlowChatViewHost', () => {
|
||||
it('shows a paced row whole once a tool card follows it', () => {
|
||||
const { chat, set } = fakeChat(idleState({ status: 'streaming' }))
|
||||
const reveal = manualReveal()
|
||||
const host = new FlowChatViewHost(chat, { revealOptions: reveal.options })
|
||||
const host = hostOn(chat, { revealOptions: reveal.options })
|
||||
const answer = message({
|
||||
role: 'assistant',
|
||||
id: 'a1',
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
resetSessionPreviewTabs
|
||||
} from './sessionRuntime.svelte'
|
||||
import SessionStatusDot from './SessionStatusDot.svelte'
|
||||
import UnreadCountBadge from '$lib/components/common/badge/UnreadCountBadge.svelte'
|
||||
import { buildWorkspaceHierarchy } from '$lib/utils/workspaceHierarchy'
|
||||
import SessionFilterMenu from './SessionFilterMenu.svelte'
|
||||
import { Menu, Menubar, MenuItem } from '$lib/components/meltComponents'
|
||||
@@ -803,14 +804,7 @@
|
||||
{#if draft}
|
||||
<PencilLine class="w-3 h-3 text-tertiary" aria-label="Unsent draft" />
|
||||
{/if}
|
||||
{#if unread > 0}
|
||||
<span
|
||||
class="inline-flex items-center justify-center rounded-full bg-surface-accent-primary text-white font-medium leading-none min-w-4 h-4 px-1 text-[10px]"
|
||||
aria-label="{unread} unread message{unread === 1 ? '' : 's'}"
|
||||
>
|
||||
{unread > 9 ? '9+' : unread}
|
||||
</span>
|
||||
{/if}
|
||||
<UnreadCountBadge count={unread} class="min-w-4 h-4 text-[10px]" />
|
||||
</span>
|
||||
{/if}
|
||||
</MenuItem>
|
||||
@@ -1079,14 +1073,7 @@
|
||||
{#if draft}
|
||||
<PencilLine class="w-3 h-3 text-tertiary" aria-label="Unsent draft" />
|
||||
{/if}
|
||||
{#if unread > 0}
|
||||
<span
|
||||
class="inline-flex items-center justify-center rounded-full bg-surface-accent-primary text-white font-medium leading-none min-w-4 h-4 px-1 text-[10px]"
|
||||
aria-label="{unread} unread message{unread === 1 ? '' : 's'}"
|
||||
>
|
||||
{unread > 9 ? '9+' : unread}
|
||||
</span>
|
||||
{/if}
|
||||
<UnreadCountBadge count={unread} class="min-w-4 h-4 text-[10px]" />
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
@@ -8,8 +8,18 @@
|
||||
let {
|
||||
status,
|
||||
isFork,
|
||||
forkDetached = false
|
||||
}: { status: SessionChatStatus; isFork: boolean; forkDetached?: boolean } = $props()
|
||||
forkDetached = false,
|
||||
resting,
|
||||
restingTitle
|
||||
}: {
|
||||
status: SessionChatStatus
|
||||
isFork: boolean
|
||||
forkDetached?: boolean
|
||||
/** What the slot shows when there is no live signal. Sessions leave it unset and get
|
||||
* the workspace/fork mark below; another list passes its own resting mark. */
|
||||
resting?: import('svelte').Snippet
|
||||
restingTitle?: string
|
||||
} = $props()
|
||||
|
||||
const statusTooltip: Record<SessionChatStatus, string> = {
|
||||
idle: 'No chat activity',
|
||||
@@ -39,7 +49,7 @@
|
||||
: 'Root workspace session'
|
||||
)
|
||||
|
||||
const title = $derived(liveOverride ? statusTooltip[status] : persistentTitle)
|
||||
const title = $derived(liveOverride ? statusTooltip[status] : (restingTitle ?? persistentTitle))
|
||||
</script>
|
||||
|
||||
<span class="inline-flex items-center justify-center w-4 h-3 shrink-0" {title}>
|
||||
@@ -55,6 +65,8 @@
|
||||
<CircleHelp class="w-3 h-3 text-amber-500" />
|
||||
{:else if status === 'error'}
|
||||
<AlertTriangle class="w-3 h-3 text-red-500" />
|
||||
{:else if resting}
|
||||
{@render resting()}
|
||||
{:else if isFork}
|
||||
{#if forkDetached}
|
||||
<GitPullRequestClosed class="w-3 h-3 text-red-500" />
|
||||
|
||||
Reference in New Issue
Block a user