mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
feat: run turns in several flow chat conversations at once
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c297ed0052
commit
d14a78ceb8
+17
@@ -48,8 +48,25 @@ _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.
|
||||
_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
|
||||
|
||||
@@ -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},
|
||||
};
|
||||
|
||||
@@ -68,7 +68,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?;
|
||||
|
||||
@@ -110,9 +110,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(
|
||||
|
||||
@@ -14923,6 +14923,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:
|
||||
@@ -15473,6 +15478,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:
|
||||
@@ -28363,6 +28373,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
|
||||
|
||||
@@ -65,7 +65,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
|
||||
@@ -100,7 +102,70 @@ 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(),
|
||||
))
|
||||
}
|
||||
|
||||
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_as::<_, (Uuid, Uuid, i64)>(
|
||||
"SELECT c.id, u.job_id, u.created_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)",
|
||||
)
|
||||
.bind(conversation_ids)
|
||||
.fetch_all(executor)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(conversation_id, job_id, user_seq)| {
|
||||
(conversation_id, RunningTurn { job_id, user_seq })
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// `memory_id` is the caller's to choose, so a preview run could name a deployed
|
||||
|
||||
@@ -233,6 +233,23 @@ own (`'deployed'`, the server's default) or both (`'all'`); each `Conversation`
|
||||
stops following the current answer; the flow keeps running and, with server history,
|
||||
its answer is there when you come back.
|
||||
|
||||
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
|
||||
await chat.selectConversation(id)
|
||||
const running = chat.getState().conversations.find((c) => c.id === id)?.runningTurn
|
||||
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
|
||||
|
||||
+61
-9
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,18 +145,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()
|
||||
}
|
||||
|
||||
@@ -249,7 +300,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
|
||||
|
||||
+127
-57
@@ -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'
|
||||
@@ -35,6 +37,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
|
||||
@@ -42,8 +46,6 @@ interface Turn {
|
||||
/** Id of the turn's user message; the answer is whatever follows it. */
|
||||
userMessageId: string
|
||||
jobId?: string
|
||||
/** The flow job and its step jobs; a persisted answer carries one of them as `job_id`. */
|
||||
jobIds?: Set<string>
|
||||
/** Id of the streaming assistant message; cleared when a tool call ends the round. */
|
||||
assistantId?: string
|
||||
streamedText: boolean
|
||||
@@ -64,6 +66,8 @@ 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()
|
||||
|
||||
constructor(options: ChatOptions) {
|
||||
this.#config = resolveConfig(options)
|
||||
@@ -125,9 +129,10 @@ class ChatImpl implements Chat {
|
||||
(c) => c.id === conversationId
|
||||
) ?? { id: conversationId, title: conversationTitle(content), createdAt: timestamp, updatedAt: timestamp }
|
||||
const touched = { ...conversation, updatedAt: timestamp }
|
||||
const listed = this.#state.conversations
|
||||
this.#set({
|
||||
conversationId,
|
||||
conversations: [touched, ...this.#state.conversations.filter((c) => c.id !== conversationId)],
|
||||
conversations: [touched, ...listed.filter((c) => c.id !== conversationId)],
|
||||
messages: [
|
||||
...this.#state.messages,
|
||||
{ id: turn.userMessageId, role: 'user', content, success: true, createdAt: timestamp, pending: true }
|
||||
@@ -143,6 +148,77 @@ 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 {
|
||||
// stop() and a conversation switch abort the turn and settle the state themselves.
|
||||
if (turn.controller.signal.aborted || isAbortError(e)) return
|
||||
if (e instanceof TurnRunningError) {
|
||||
// Nothing of this message reached the server: it goes, and the caller decides
|
||||
// whether to follow the running turn and send it again.
|
||||
if (this.#turnActive(turn)) {
|
||||
this.#set({
|
||||
messages: this.#state.messages.filter((m) => m.id !== turn.userMessageId),
|
||||
conversations: listed,
|
||||
status: 'idle'
|
||||
})
|
||||
}
|
||||
throw e
|
||||
}
|
||||
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 || this.#state.history !== 'server') return
|
||||
const turn: Turn = {
|
||||
controller: new AbortController(),
|
||||
conversationId,
|
||||
userMessageId: '',
|
||||
jobId,
|
||||
streamedText: false
|
||||
}
|
||||
this.#turn = turn
|
||||
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
|
||||
// The stream replays the turn from its start, so the rows it already wrote go and
|
||||
// come back as it replays them. 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.
|
||||
let messages = this.#state.messages.filter((m) => m.seq !== undefined && m.seq <= userSeq)
|
||||
let user = messages.find((m) => m.seq === userSeq)
|
||||
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> {
|
||||
try {
|
||||
const stopPolling = this.#state.history === 'server' ? this.#startPolling(turn) : () => {}
|
||||
let result: unknown
|
||||
try {
|
||||
@@ -193,8 +269,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({
|
||||
@@ -323,9 +405,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 {
|
||||
@@ -451,8 +543,6 @@ class ChatImpl implements Chat {
|
||||
async #finishTurn(turn: Turn, result: unknown, isNew: boolean): Promise<void> {
|
||||
if (!this.#turnActive(turn)) return
|
||||
if (this.#state.history === 'server') {
|
||||
turn.jobIds = await this.#turnJobIds(turn)
|
||||
if (!this.#turnActive(turn)) return
|
||||
const reconciled = await this.#reconcileTurn(turn)
|
||||
if (!this.#turnActive(turn)) return
|
||||
if (reconciled) {
|
||||
@@ -503,11 +593,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
|
||||
@@ -534,48 +620,24 @@ 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. Every row created after the user message is the
|
||||
* turn's: the server refuses a turn while the conversation's previous run is still
|
||||
* queued, so no other run of this conversation writes meanwhile. 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 ownJob = (m: ChatMessage) =>
|
||||
turn.jobIds === undefined || (m.jobId === undefined ? m.role === 'tool' : turn.jobIds.has(m.jobId))
|
||||
const userSeq = messages[from]?.seq
|
||||
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') return
|
||||
if (userSeq !== undefined ? m.seq <= userSeq : i <= from) return
|
||||
if (latest === undefined || m.seq > latest.seq!) latest = m
|
||||
}
|
||||
})
|
||||
return latest?.role === 'assistant'
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
return ids
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) throw e
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
#failTurn(turn: Turn, e: unknown): void {
|
||||
if (!this.#turnActive(turn)) return
|
||||
const error = toError(e)
|
||||
@@ -601,11 +663,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
|
||||
@@ -618,11 +676,20 @@ class ChatImpl implements Chat {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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): Promise<void> {
|
||||
const rows = await this.#api.listMessages(conversationId, {
|
||||
afterSeq: this.#lastSeq(),
|
||||
perPage: 100
|
||||
})
|
||||
const rows = await this.#rowsAfterLastSeq(conversationId)
|
||||
if (this.#turn || this.#state.conversationId !== conversationId) return
|
||||
this.#mergeRows(rows)
|
||||
this.#set({ messages: finalized(this.#state.messages) })
|
||||
@@ -766,7 +833,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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+71
-32
@@ -1,8 +1,12 @@
|
||||
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. */
|
||||
@@ -18,6 +22,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 +35,76 @@ 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 })) {
|
||||
failures = 0
|
||||
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()
|
||||
reopen = true
|
||||
break
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
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 }
|
||||
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'
|
||||
@@ -61,6 +62,7 @@ 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,
|
||||
|
||||
+20
-1
@@ -1,3 +1,5 @@
|
||||
import type { RunningTurn } from './api'
|
||||
|
||||
export type ChatRole = 'user' | 'assistant' | 'tool' | 'system'
|
||||
|
||||
/**
|
||||
@@ -57,6 +59,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 {
|
||||
@@ -127,8 +134,20 @@ export interface Chat {
|
||||
getState(): ChatState
|
||||
/** Calls `listener` now and on every change; returns the unsubscribe function (Svelte store contract). */
|
||||
subscribe(listener: (state: ChatState) => void): () => void
|
||||
/** Sends a message in the current conversation, starting one when there is none. Resolves when the answer is complete. */
|
||||
/**
|
||||
* Sends a message in the current conversation, starting one when there is none. Resolves
|
||||
* when the answer is complete. Rejects with `TurnRunningError`, and shows nothing of the
|
||||
* message, when the conversation is still answering one sent elsewhere: follow that turn
|
||||
* with `resumeTurn`, then send again.
|
||||
*/
|
||||
sendMessage(text: string, options?: { inputs?: Record<string, unknown> }): 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
|
||||
|
||||
+70
-31
@@ -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'
|
||||
@@ -724,49 +725,87 @@ 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,
|
||||
// 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('/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')
|
||||
expect(state.conversations).toEqual([])
|
||||
})
|
||||
|
||||
test('resuming a turn whose message is off the first page replays it without duplicating rows', async () => {
|
||||
const { fetch, calls } = fetchMock(
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/jobs_u/get/job-2')
|
||||
? json({ flow_status: { modules: [{ job: 'step-2' }] } })
|
||||
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')
|
||||
expect(calls.some((c) => c.url.pathname.includes('/jobs_u/get/'))).toBe(false)
|
||||
})
|
||||
|
||||
test('a stream that keeps failing hands the turn to polling the job', async () => {
|
||||
const { fetch } = fetchMock(
|
||||
run,
|
||||
(c) => (c.url.pathname === streamPath ? text('bad gateway', 502) : undefined),
|
||||
(c) =>
|
||||
c.url.pathname.endsWith('/get_result_maybe/job-1')
|
||||
? json({ completed: true, success: true, result: { windmill_chat_answer: 'polled' } })
|
||||
: undefined,
|
||||
// Read 1 is stop()'s sync; the stopped job's answer lands after the second user row.
|
||||
(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(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))
|
||||
const first = chat.sendMessage('first')
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
await chat.stop()
|
||||
await first
|
||||
await chat.sendMessage('second')
|
||||
expect(chat.getState().messages.map((m) => [m.role, m.content, m.serverId])).toEqual([
|
||||
['user', 'first', 'row-71'],
|
||||
['user', 'second', 'row-72'],
|
||||
['assistant', 'first answer, late', 'row-73'],
|
||||
['assistant', 'second answer', 'row-74']
|
||||
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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ApiError,
|
||||
type Job,
|
||||
JobService,
|
||||
type RestartedFrom,
|
||||
@@ -214,9 +215,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
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* How many messages have arrived somewhere since it was last read.
|
||||
*
|
||||
* Sits in the row's own flow by default, which is where a list uses it; `class` pins it
|
||||
* to a corner for a caller that has one icon standing for the whole list. Nothing renders
|
||||
* at zero — an absent badge is what "nothing new" looks like.
|
||||
*/
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
interface Props {
|
||||
count: number
|
||||
/** What the count is of, for the label a screen reader reads. */
|
||||
noun?: string
|
||||
/** Positioning for a caller that pins it to a corner rather than letting it sit in
|
||||
* the row — the collapsed rail, where the count belongs to an icon button. */
|
||||
class?: string
|
||||
/** The 12px form, for a corner where the row-sized badge would crowd the icon. */
|
||||
small?: boolean
|
||||
}
|
||||
|
||||
let { count, noun = 'message', class: className = '', small = false }: Props = $props()
|
||||
</script>
|
||||
|
||||
{#if count > 0}
|
||||
<span
|
||||
class={twMerge(
|
||||
'unread-badge inline-flex items-center justify-center rounded-full bg-surface-accent-primary text-white font-medium',
|
||||
small ? 'min-w-3 h-3 px-0.5 text-[8px]' : 'min-w-3.5 h-3.5 px-1 text-[9px]',
|
||||
// After the size, not before: tailwind-merge counts a text size as resetting
|
||||
// line-height, so a `leading-*` ahead of one is dropped from the result.
|
||||
'leading-none',
|
||||
className
|
||||
)}
|
||||
aria-label="{count} unread {noun}{count === 1 ? '' : 's'}"
|
||||
>
|
||||
{count > 9 ? '9+' : count}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/*
|
||||
* Centring a digit by flex centres its *line box*, which is the font's em box — and a
|
||||
* digit's ink does not sit in the middle of that. Inter reserves descender space a
|
||||
* figure never uses, so the glyph lands fractionally low; at these sizes that reads as
|
||||
* the badge being a pixel off. Trimming the box to cap-height and baseline makes the
|
||||
* ink itself what gets centred. Dropped silently where it is unsupported, which leaves
|
||||
* the same near-miss as before rather than anything worse.
|
||||
*/
|
||||
.unread-badge {
|
||||
text-box: trim-both cap alphabetic;
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { enterpriseLicense, workspaceStore } 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 type { FlowEditorContext } from '../types'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { ApiError, type FlowModule } from '$lib/gen'
|
||||
import { FlowChatPool, type FlowChatPoolState } from './flowChatPool'
|
||||
import { FlowChatViewHost } from './flowChatViewHost.svelte'
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -53,24 +62,39 @@
|
||||
// The editor may act on a workspace other than the nav store's (AI-session live editor).
|
||||
const workspace = $derived(flowEditorContext?.opWorkspace?.() ?? $workspaceStore)
|
||||
|
||||
let chat = $state<Chat | undefined>(undefined)
|
||||
let chatState = $state<ChatState | undefined>(undefined)
|
||||
// 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> | undefined>(undefined)
|
||||
let poolState = $state<FlowChatPoolState | undefined>(undefined)
|
||||
let sidebar = $state<FlowConversationsSidebar | undefined>(undefined)
|
||||
|
||||
$effect(() => {
|
||||
const ws = workspace
|
||||
const flowPath = path
|
||||
if (!ws || !flowPath) return
|
||||
const created = createChat({
|
||||
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 flow did not start')
|
||||
// The server creates the conversation with the run, so the sidebar can list
|
||||
// it now, whatever becomes of the turn.
|
||||
@@ -78,15 +102,42 @@
|
||||
return jobId
|
||||
},
|
||||
onError: (error) => sendUserToast('Failed to run flow: ' + error.message, true)
|
||||
}
|
||||
const api = new WindmillChatApi({ baseUrl, workspace: ws })
|
||||
const createdList = createChat(options)
|
||||
const createdPool = new FlowChatPool<FlowChatViewHost>({
|
||||
createChat: () => createChat(options),
|
||||
createHost: (chat) => new FlowChatViewHost(chat),
|
||||
disposeHost: (host) => host.dispose(),
|
||||
hasQueued: (host) => host.queuedMessage !== '',
|
||||
resumeTurn: (host, turn) => host.resumeTurn(turn),
|
||||
isRunFinished: async (jobId) => (await api.getCompletedResult(jobId)).completed
|
||||
})
|
||||
const unsubscribe = created.subscribe((s) => (chatState = s))
|
||||
chat = created
|
||||
const unsubscribeList = createdList.subscribe((s) => {
|
||||
listState = s
|
||||
createdPool.setListed(s.conversations)
|
||||
})
|
||||
const unsubscribePool = createdPool.subscribe((s) => (poolState = s))
|
||||
listChat = createdList
|
||||
pool = createdPool
|
||||
return () => {
|
||||
unsubscribe()
|
||||
created.destroy()
|
||||
unsubscribeList()
|
||||
unsubscribePool()
|
||||
createdPool.destroy()
|
||||
createdList.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
// A new chat keeps its chat once its first turn names the conversation, so the panel is
|
||||
// only remounted when the reader moves to another one.
|
||||
const shown = $derived.by(() => {
|
||||
void poolState?.selectedId
|
||||
return pool?.selected
|
||||
})
|
||||
const shownIsTest = $derived(
|
||||
listState?.conversations.find((c) => c.id === poolState?.selectedId)?.isTest
|
||||
)
|
||||
|
||||
// Derive additional inputs schema (excluding user_message) for chat mode
|
||||
const additionalInputsSchema = $derived.by(() => {
|
||||
const props = inputSchema?.properties ?? {}
|
||||
@@ -103,21 +154,22 @@
|
||||
</script>
|
||||
|
||||
<div class="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden flex-1">
|
||||
{#if chat && chatState}
|
||||
{#if listChat && listState && pool && poolState && shown}
|
||||
{#if !hideSidebar}
|
||||
<FlowConversationsSidebar
|
||||
bind:this={sidebar}
|
||||
{chat}
|
||||
{chatState}
|
||||
{listChat}
|
||||
{pool}
|
||||
{poolState}
|
||||
defaultKind={conversationKind}
|
||||
canFilterKind={conversationKind !== 'deployed'}
|
||||
/>
|
||||
{/if}
|
||||
<!-- 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}
|
||||
{#key shown}
|
||||
<FlowChatInterface
|
||||
{chat}
|
||||
chat={shown.chat}
|
||||
chatHost={shown.host}
|
||||
isTest={shownIsTest}
|
||||
{deploymentInProgress}
|
||||
{additionalInputsSchema}
|
||||
{flowModules}
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
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 type { FlowModule } from '$lib/gen'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
@@ -23,6 +23,11 @@
|
||||
|
||||
interface Props {
|
||||
chat: Chat
|
||||
/** The conversation's host, which outlives this panel: FlowChat remounts the panel per
|
||||
* conversation, under `{#key}`, so a later value of either prop never reaches it. */
|
||||
chatHost: FlowChatViewHost
|
||||
/** Whether the shown conversation is a test chat, once the list has said. */
|
||||
isTest?: boolean
|
||||
deploymentInProgress?: boolean
|
||||
additionalInputsSchema?: Record<string, any>
|
||||
/** The flow's modules, read for the provider wiring of its AI agent steps. */
|
||||
@@ -38,6 +43,8 @@
|
||||
|
||||
let {
|
||||
chat,
|
||||
chatHost: chatHostProp,
|
||||
isTest = undefined,
|
||||
deploymentInProgress = false,
|
||||
additionalInputsSchema,
|
||||
flowModules,
|
||||
@@ -146,30 +153,22 @@
|
||||
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),
|
||||
workspace: () => workspace,
|
||||
sendDisabled: () => deploymentInProgress || !!modelGap || !!wrongKindReason
|
||||
}
|
||||
)
|
||||
const chatHost = untrack(() => chatHostProp)
|
||||
chatHost.setOptions({
|
||||
additionalInputs: () => (additionalInputsSchema ? { ...runInputs } : undefined),
|
||||
workspace: () => workspace,
|
||||
sendDisabled: () => deploymentInProgress || !!modelGap || !!wrongKindReason
|
||||
})
|
||||
setChatViewHost(chatHost)
|
||||
|
||||
// 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 { 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>
|
||||
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
|
||||
@@ -79,6 +92,7 @@
|
||||
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 +117,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 +132,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 +162,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 +190,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 +258,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 +267,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 +276,6 @@
|
||||
<ToggleButtonGroup
|
||||
selected={kind}
|
||||
onSelected={(next) => setKind(next as ConversationKind)}
|
||||
disabled={turnInFlight}
|
||||
noWFull
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
@@ -274,11 +304,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 +337,6 @@
|
||||
onClick={(e) => {
|
||||
e?.stopPropagation()
|
||||
draft = false
|
||||
chat.newConversation()
|
||||
}}
|
||||
title="Discard draft"
|
||||
destructive
|
||||
@@ -314,7 +351,7 @@
|
||||
<InfiniteList
|
||||
bind:this={list}
|
||||
bind:items
|
||||
selectedItemId={chatState.conversationId}
|
||||
selectedItemId={poolState.selectedId}
|
||||
noBorder={true}
|
||||
rounded={false}
|
||||
preventXOverflow={true}
|
||||
@@ -348,19 +385,41 @@
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
{@const unread = poolState.unread[conversation.id] ?? 0}
|
||||
{@const queued = !!pool.get(conversation.id)?.host.queuedMessage}
|
||||
<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,126 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Chat, ChatMessage, ChatState, Conversation } from 'windmill-chat'
|
||||
import { FlowChatPool } from './flowChatPool'
|
||||
|
||||
/** 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(),
|
||||
selectConversation: vi.fn(async (id: string) => set({ conversationId: id })),
|
||||
loadConversations: vi.fn(async () => []),
|
||||
deleteConversation: vi.fn(async () => {}),
|
||||
renameConversation: vi.fn(async () => {}),
|
||||
loadOlderMessages: 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 }
|
||||
}
|
||||
|
||||
function pool(
|
||||
options: { keepSettled?: number; isRunFinished?: (jobId: string) => Promise<boolean> } = {}
|
||||
) {
|
||||
const chats: ReturnType<typeof fakeChat>[] = []
|
||||
const hosts = { resumeTurn: vi.fn(), dispose: vi.fn() }
|
||||
const created = new FlowChatPool<{ chat: Chat }>({
|
||||
createChat: () => {
|
||||
const fake = fakeChat()
|
||||
chats.push(fake)
|
||||
return fake.chat
|
||||
},
|
||||
createHost: (chat) => ({ chat }),
|
||||
disposeHost: hosts.dispose,
|
||||
hasQueued: () => false,
|
||||
resumeTurn: (host, turn) => hosts.resumeTurn(host.chat, turn),
|
||||
isRunFinished: options.isRunFinished ?? (async () => false),
|
||||
keepSettled: options.keepSettled,
|
||||
pollMs: 10
|
||||
})
|
||||
const chatOf = (id: string) => chats.find((c) => c.chat.getState().conversationId === id)!
|
||||
return { pool: created, chatOf, hosts }
|
||||
}
|
||||
|
||||
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({ 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('polls a turn another page started until its run ends, and follows it when selected', async () => {
|
||||
let finished = false
|
||||
const { pool: p, chatOf, hosts } = pool({ isRunFinished: async () => finished })
|
||||
const turn = { jobId: 'job-1', userSeq: 7 }
|
||||
p.setListed([
|
||||
conversation('a', { runningTurn: turn }),
|
||||
conversation('b', { runningTurn: turn })
|
||||
])
|
||||
expect(p.getState().activity).toEqual({ a: 'running', b: 'running' })
|
||||
|
||||
p.select('a')
|
||||
expect(hosts.resumeTurn).toHaveBeenCalledWith(chatOf('a').chat, turn)
|
||||
|
||||
finished = true
|
||||
await vi.waitFor(() => expect(p.getState().activity).toEqual({}))
|
||||
p.destroy()
|
||||
})
|
||||
|
||||
it('releases settled chats past the budget, never one still running', () => {
|
||||
const { pool: p, chatOf, hosts } = 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(p.get('old')).toBeUndefined()
|
||||
expect(p.get('recent')).toBeDefined()
|
||||
expect(p.get('busy')).toBeDefined()
|
||||
expect(hosts.dispose).toHaveBeenCalledTimes(1)
|
||||
p.destroy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,290 @@
|
||||
import type { Chat, ChatMessage, ChatState, Conversation, RunningTurn } 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'
|
||||
}
|
||||
|
||||
/** What a conversation's row says about it. */
|
||||
export type ConversationActivity = 'running' | 'error' | 'idle'
|
||||
|
||||
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>
|
||||
}
|
||||
|
||||
export interface PooledChat<H> {
|
||||
chat: Chat
|
||||
host: H
|
||||
}
|
||||
|
||||
export interface FlowChatPoolOptions<H> {
|
||||
/** A chat on the flow with no conversation selected. */
|
||||
createChat(): Chat
|
||||
createHost(chat: Chat): H
|
||||
disposeHost(host: H): void
|
||||
/** Whether a message typed during the turn waits in the host to go out. */
|
||||
hasQueued(host: H): boolean
|
||||
/** Follows a turn another page started, through the host so what it queues waits for it. */
|
||||
resumeTurn(host: H, turn: RunningTurn): void
|
||||
/** Whether a run has ended, for a running conversation this page holds no chat for. */
|
||||
isRunFinished(jobId: string): Promise<boolean>
|
||||
/** Settled conversations kept in memory beside the shown and the busy ones. */
|
||||
keepSettled?: number
|
||||
pollMs?: number
|
||||
}
|
||||
|
||||
interface Entry<H> extends PooledChat<H> {
|
||||
unsubscribe: () => void
|
||||
lastShownAt: number
|
||||
/** Assistant messages already counted, so a message counts once as it settles. */
|
||||
counted: Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
readonly #options: FlowChatPoolOptions<H>
|
||||
readonly #entries = new Map<string, Entry<H>>()
|
||||
/** The chat a new conversation starts on; it joins `#entries` once its first turn names it. */
|
||||
#draft: Entry<H> | undefined
|
||||
/** Turns running in conversations this pool is not following, as the list reported them. */
|
||||
readonly #running = new Map<string, RunningTurn>()
|
||||
readonly #unread = new Map<string, number>()
|
||||
readonly #listeners = new Set<(state: FlowChatPoolState) => void>()
|
||||
#selectedId: string | undefined
|
||||
#state: FlowChatPoolState = { selectedId: undefined, activity: {}, unread: {} }
|
||||
#poll: ReturnType<typeof setInterval> | undefined
|
||||
#clock = 0
|
||||
#destroyed = false
|
||||
|
||||
constructor(options: FlowChatPoolOptions<H>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/** The chat shown now. */
|
||||
get selected(): PooledChat<H> {
|
||||
// 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 of a conversation, when this pool holds one. */
|
||||
get(conversationId: string): PooledChat<H> | undefined {
|
||||
return this.#entries.get(conversationId)
|
||||
}
|
||||
|
||||
/** 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.#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 && !isBusy(entry.chat.getState().status)) {
|
||||
this.#running.delete(conversationId)
|
||||
this.#options.resumeTurn(entry.host, turn)
|
||||
}
|
||||
this.#evict()
|
||||
this.#publish()
|
||||
}
|
||||
|
||||
/**
|
||||
* What the conversation list last said. A conversation it reports running that no chat
|
||||
* here is following gets its run polled, so its row stops saying so when the run ends.
|
||||
*/
|
||||
setListed = (conversations: readonly Conversation[]): void => {
|
||||
for (const conversation of conversations) {
|
||||
const followed = this.#entries.get(conversation.id)
|
||||
if (conversation.runningTurn && !(followed && isBusy(followed.chat.getState().status))) {
|
||||
this.#running.set(conversation.id, conversation.runningTurn)
|
||||
} else {
|
||||
this.#running.delete(conversation.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
|
||||
clearInterval(this.#poll)
|
||||
for (const entry of this.#entries.values()) this.#release(entry)
|
||||
if (this.#draft) this.#release(this.#draft)
|
||||
this.#entries.clear()
|
||||
this.#draft = undefined
|
||||
this.#listeners.clear()
|
||||
}
|
||||
|
||||
#track(conversationId: string | undefined): Entry<H> {
|
||||
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 entry: Entry<H> = {
|
||||
chat,
|
||||
host: this.#options.createHost(chat),
|
||||
unsubscribe: () => {},
|
||||
lastShownAt: ++this.#clock,
|
||||
counted: new Set()
|
||||
}
|
||||
entry.unsubscribe = chat.subscribe((state) => this.#onChatState(entry, state))
|
||||
return entry
|
||||
}
|
||||
|
||||
#onChatState(entry: Entry<H>, 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) return
|
||||
if (isBusy(state.status)) this.#running.delete(id)
|
||||
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 && !state.loadingMessages) this.#unread.set(id, (this.#unread.get(id) ?? 0) + 1)
|
||||
}
|
||||
if (!isBusy(state.status)) this.#evict()
|
||||
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) &&
|
||||
!this.#options.hasQueued(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>): void {
|
||||
entry.unsubscribe()
|
||||
this.#options.disposeHost(entry.host)
|
||||
entry.chat.destroy()
|
||||
}
|
||||
|
||||
#schedulePoll(): void {
|
||||
if (this.#running.size === 0) {
|
||||
clearInterval(this.#poll)
|
||||
this.#poll = undefined
|
||||
return
|
||||
}
|
||||
if (this.#poll) return
|
||||
this.#poll = setInterval(() => void this.#pollRuns(), this.#options.pollMs ?? 3000)
|
||||
}
|
||||
|
||||
async #pollRuns(): Promise<void> {
|
||||
await Promise.all(
|
||||
[...this.#running].map(async ([id, turn]) => {
|
||||
const finished = await this.#options.isRunFinished(turn.jobId).catch(() => false)
|
||||
if (finished && this.#running.get(id) === turn) this.#running.delete(id)
|
||||
})
|
||||
)
|
||||
if (this.#destroyed) 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
|
||||
}
|
||||
this.#state = {
|
||||
selectedId: this.#selectedId,
|
||||
activity,
|
||||
unread: Object.fromEntries(this.#unread)
|
||||
}
|
||||
for (const listener of this.#listeners) listener(this.#state)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
import type { Chat, ChatMessage, ChatState } from 'windmill-chat'
|
||||
import {
|
||||
TurnRunningError,
|
||||
type Chat,
|
||||
type ChatMessage,
|
||||
type ChatState,
|
||||
type RunningTurn
|
||||
} from 'windmill-chat'
|
||||
import { isBusy, lastTurnFailed, turnFailed } from './flowChatPool'
|
||||
import type {
|
||||
ChatSendRequestOptions,
|
||||
ChatViewHost
|
||||
@@ -22,10 +29,6 @@ export type FlowChatViewHostOptions = {
|
||||
sendDisabled?: () => boolean
|
||||
}
|
||||
|
||||
function isBusy(status: ChatState['status']): boolean {
|
||||
return status === 'submitted' || status === 'streaming'
|
||||
}
|
||||
|
||||
/** A tool's arguments or result as the card shows them: parsed where the string is JSON. */
|
||||
function parseToolPayload(raw: string | undefined): unknown {
|
||||
if (raw === undefined || raw === '') return undefined
|
||||
@@ -36,29 +39,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
|
||||
}
|
||||
|
||||
export function toDisplayMessages(messages: readonly ChatMessage[]): DisplayMessage[] {
|
||||
let userIndex = 0
|
||||
return messages.map((message, i): DisplayMessage => {
|
||||
@@ -114,7 +94,8 @@ export function toDisplayMessages(messages: readonly ChatMessage[]): DisplayMess
|
||||
* 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: it outlasts the panel showing it,
|
||||
* so a message queued in a conversation still goes out once the reader has moved on.
|
||||
*/
|
||||
export class FlowChatViewHost implements ChatViewHost {
|
||||
#chat: Chat
|
||||
@@ -129,6 +110,17 @@ export class FlowChatViewHost implements ChatViewHost {
|
||||
this.#unsubscribe = chat.subscribe((state) => this.#onState(state))
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/** Follows a turn this chat did not start, so what is queued behind it waits for it. */
|
||||
resumeTurn = (turn: RunningTurn) => {
|
||||
this.#turnDone = this.#chat.resumeTurn(turn)
|
||||
}
|
||||
|
||||
#disposed = false
|
||||
/** Stops following the chat, and drops what was queued: a flush still waiting on the
|
||||
* turn's release would otherwise start a run from a panel that is gone. The chat itself
|
||||
@@ -212,16 +204,25 @@ export class FlowChatViewHost implements ChatViewHost {
|
||||
}
|
||||
if (this.#options.sendDisabled?.()) {
|
||||
// Refused, not dropped: the text waits in the composer for sending to reopen.
|
||||
this.#aiChatInput?.prependText(text)
|
||||
this.#returnText(text)
|
||||
return false
|
||||
}
|
||||
this.#automaticScroll = true
|
||||
// A run that fails is reported through the chat's `onError` and as a failed message;
|
||||
// the promise itself only rejects when the chat refuses the turn outright, and the
|
||||
// text is then handed back rather than dropped.
|
||||
// text is then handed back rather than dropped. Refused because the conversation is
|
||||
// still answering a message sent elsewhere: that turn is followed here, and this one
|
||||
// waits behind it as if it had been typed during it.
|
||||
const turn = this.#chat
|
||||
.sendMessage(text, { inputs: this.#options.additionalInputs?.() })
|
||||
.catch(() => this.#aiChatInput?.prependText(text))
|
||||
.catch((e) => {
|
||||
if (e instanceof TurnRunningError && !this.#disposed) {
|
||||
this.queueMessage(text)
|
||||
this.resumeTurn(e.turn)
|
||||
} else {
|
||||
this.#returnText(text)
|
||||
}
|
||||
})
|
||||
this.#turnDone = turn
|
||||
await turn
|
||||
return true
|
||||
@@ -239,6 +240,16 @@ export class FlowChatViewHost implements ChatViewHost {
|
||||
#aiChatInput: Parameters<ChatViewHost['setAiChatInput']>[0] = null
|
||||
setAiChatInput: ChatViewHost['setAiChatInput'] = (aiChatInput) => {
|
||||
this.#aiChatInput = aiChatInput
|
||||
if (aiChatInput && this.#returned) {
|
||||
aiChatInput.prependText(this.#returned)
|
||||
this.#returned = ''
|
||||
}
|
||||
}
|
||||
/** Text handed back while no composer shows this conversation, for the next one that does. */
|
||||
#returned = ''
|
||||
#returnText(text: string) {
|
||||
if (this.#aiChatInput) this.#aiChatInput.prependText(text)
|
||||
else this.#returned = this.#returned ? `${this.#returned}\n${text}` : text
|
||||
}
|
||||
|
||||
// One message typed while the turn runs, sent whole once it settles. Enter again
|
||||
@@ -260,7 +271,7 @@ export class FlowChatViewHost implements ChatViewHost {
|
||||
const text = this.#queued
|
||||
if (!text) return
|
||||
this.#queued = ''
|
||||
this.#aiChatInput?.prependText(text)
|
||||
this.#returnText(text)
|
||||
}
|
||||
flushQueuedMessage = () => {
|
||||
const text = this.#queued
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Chat, ChatMessage, ChatState } from 'windmill-chat'
|
||||
import { TurnRunningError, type Chat, type ChatMessage, type ChatState } from 'windmill-chat'
|
||||
import { FlowChatViewHost, toDisplayMessages } from './flowChatViewHost.svelte'
|
||||
|
||||
function message(partial: Partial<ChatMessage> & Pick<ChatMessage, 'role'>): ChatMessage {
|
||||
@@ -39,11 +39,13 @@ 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 () => {}),
|
||||
renameConversation: vi.fn(async () => {}),
|
||||
loadOlderMessages: vi.fn(async () => {}),
|
||||
destroy: vi.fn()
|
||||
} satisfies Chat
|
||||
@@ -239,6 +241,37 @@ 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 = new FlowChatViewHost(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))
|
||||
expect(chat.sendMessage).toHaveBeenLastCalledWith('after it', { inputs: undefined })
|
||||
host.dispose()
|
||||
})
|
||||
|
||||
it('keeps text handed back while no composer is mounted for the next one', () => {
|
||||
const { chat } = fakeChat(idleState({ status: 'streaming' }))
|
||||
const host = new FlowChatViewHost(chat)
|
||||
host.queueMessage('typed before leaving')
|
||||
host.cancel()
|
||||
const prependText = vi.fn()
|
||||
host.setAiChatInput({ prependText } as any)
|
||||
expect(prependText).toHaveBeenCalledWith('typed before leaving')
|
||||
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)
|
||||
|
||||
@@ -51,6 +51,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'
|
||||
@@ -801,14 +802,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} />
|
||||
</span>
|
||||
{/if}
|
||||
</MenuItem>
|
||||
@@ -1075,14 +1069,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} />
|
||||
</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,9 @@
|
||||
: '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 +67,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