mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
feat: tell test flow conversations from deployed ones and rename a chat (#11179)
* feat: mark test flow conversations apart from deployed ones and allow renaming a chat Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix: keep the conversation kind across refreshes and reject NUL titles Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix: ignore conversation lists for a kind no longer selected Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: start a fresh conversation listing when the kind changes on a later page Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: refuse sending into a conversation of the other kind Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: refuse cross-kind conversation continuations on the server Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep the conversation filter unavailable while an answer runs Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
381d4470ef
commit
4eab995cf7
+8
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by\n FROM flow_conversation\n WHERE id = $1 AND workspace_id = $2\n FOR UPDATE",
|
||||
"query": "SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test\n FROM flow_conversation\n WHERE id = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -37,6 +37,11 @@
|
||||
"ordinal": 6,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "is_test",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -52,8 +57,9 @@
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6f32c1feed096ff706ae359ad6a3ca33b3f82ca38289dfa4a69aa95041027d57"
|
||||
"hash": "48c8522a4fed219c5011f4ba63c81cfe028a8b2a32bd790840cef65c452a8c31"
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE flow_conversation SET title = $1, updated_at = updated_at\n WHERE id = $2 AND workspace_id = $3\n RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5b9c9eb64051f291fed4be9bc0b0cc0aef2e7bde732899976eddac36a2da7658"
|
||||
}
|
||||
+10
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by, title)\n VALUES ($1, $2, $3, $4, $5)\n ON CONFLICT (id) DO NOTHING\n RETURNING id, workspace_id, flow_path, title, created_at, updated_at, created_by",
|
||||
"query": "INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by, title, is_test)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT (id) DO NOTHING\n RETURNING id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -37,6 +37,11 @@
|
||||
"ordinal": 6,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "is_test",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -45,7 +50,8 @@
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
"Varchar",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
@@ -55,8 +61,9 @@
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c1e3ed3ecc3bcb98f60ba8196d33fee4a74f61b061e5025ecb75882208b3ba8f"
|
||||
"hash": "6d259b8cce5da5fecefe4ce322789b6b2cc43b51f2056c677d58f39c31fb26cb"
|
||||
}
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by\n FROM flow_conversation\n WHERE id = $1 AND workspace_id = $2",
|
||||
"query": "SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test\n FROM flow_conversation\n WHERE id = $1 AND workspace_id = $2\n FOR UPDATE",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -37,6 +37,11 @@
|
||||
"ordinal": 6,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "is_test",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -52,8 +57,9 @@
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c383cc023714b361d10c10e8fef1fc148ab1da942951ee9ffdddaecee76a6be9"
|
||||
"hash": "dd84f9dfb238d18cb74f9e43228345427131bf8008eba6920021d6a449791534"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE flow_conversation DROP COLUMN is_test;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- A chat run from the flow editor's test panel is stored exactly like one from the
|
||||
-- deployed flow, so the two were indistinguishable once written. Marking them lets the
|
||||
-- lists tell a trial apart from a real conversation.
|
||||
ALTER TABLE flow_conversation ADD COLUMN is_test BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- Existing rows: a conversation whose messages came from a flowpreview run was a test.
|
||||
-- Derived once here because the job is purged on retention, after which the origin of an
|
||||
-- old conversation is unknowable.
|
||||
--
|
||||
-- Walked to the root job rather than matched directly: an existing message row never holds
|
||||
-- the flow job itself. The rows point at the step that produced them — the AI agent's job
|
||||
-- for an answer, the tool's own job for a tool call — whose kind is never 'flowpreview'.
|
||||
--
|
||||
-- `root_job` first, matching `get_root_job_id` (windmill-worker/src/common.rs): only it
|
||||
-- reaches the top of the run. `flow_innermost_root_job` stops at the closest flow scope by
|
||||
-- design, so an agent inside a subflow would land on that subflow's 'flow' row and the
|
||||
-- conversation would read as deployed.
|
||||
UPDATE flow_conversation c
|
||||
SET is_test = true
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM flow_conversation_message m
|
||||
JOIN v2_job j ON j.id = m.job_id
|
||||
JOIN v2_job root
|
||||
ON root.id = coalesce(j.root_job, j.flow_innermost_root_job, j.parent_job, j.id)
|
||||
WHERE m.conversation_id = c.id AND root.kind = 'flowpreview'
|
||||
);
|
||||
@@ -99,7 +99,7 @@ email_trigger: path(char), local_part(char), workspaced_local_part(bool), script
|
||||
favorite: usr(char), workspace_id(char), path(char), favorite_kind(favorite_kind)
|
||||
flow: workspace_id(char), path(char), summary(text), description(text), value(jsonb), edited_by(char), edited_at(ts), archived(bool), schema(json), extra_perms(jsonb), dependency_job(uuid), draft_only(bool), tag(char), ws_error_handler_muted(bool), dedicated_worker(bool), timeout(int), visible_to_runner_only(bool), concurrency_key(char), versions(bigint[]), on_behalf_of(varchar), on_behalf_of_email(text), lock_error_logs(text), labels(text[])
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
flow_conversation: id(uuid), workspace_id(char), flow_path(char), title(char), created_at(ts), updated_at(ts), created_by(char)
|
||||
flow_conversation: id(uuid), workspace_id(char), flow_path(char), title(char), created_at(ts), updated_at(ts), created_by(char), is_test(bool)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
flow_conversation_message: id(uuid), conversation_id(uuid), message_type(message_type), content(text), job_id(uuid), created_at(ts), created_seq(int8), step_name(char), success(bool)
|
||||
FK: (conversation_id) -> flow_conversation(id) | (job_id) -> v2_job(id)
|
||||
|
||||
@@ -258,6 +258,7 @@ async fn test_new_turns_wait_for_conversation_cleanup_and_recreate(
|
||||
"test-user",
|
||||
"hi again",
|
||||
conv_id,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
windmill_common::flow_conversations::add_message_to_conversation_tx(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
extract::{Path, Query},
|
||||
routing::{delete, get},
|
||||
routing::{delete, get, post},
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -15,13 +15,14 @@ use windmill_common::{
|
||||
db::{UserDB, DB},
|
||||
error::{JsonResult, Result},
|
||||
flow_conversations::MessageType,
|
||||
utils::{not_found_if_none, paginate, Pagination},
|
||||
utils::{not_found_if_none, paginate, truncate_with_ellipsis, Pagination},
|
||||
};
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/list", get(list_conversations))
|
||||
.route("/delete/{conversation_id}", delete(delete_conversation))
|
||||
.route("/update/{conversation_id}", post(update_conversation))
|
||||
.route("/{conversation_id}/messages", get(list_messages))
|
||||
}
|
||||
|
||||
@@ -38,9 +39,22 @@ pub struct FlowConversationMessage {
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
/// Which conversations a listing holds. A test chat was started from the editor's test
|
||||
/// panel; a deployed one from the flow itself.
|
||||
#[derive(Deserialize, Default, Clone, Copy)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ConversationKind {
|
||||
Test,
|
||||
/// The default: a deployed flow's chat should not surface someone's trial runs.
|
||||
#[default]
|
||||
Deployed,
|
||||
All,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListConversationsQuery {
|
||||
pub flow_path: Option<String>,
|
||||
pub kind: Option<ConversationKind>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -67,6 +81,7 @@ async fn list_conversations(
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"is_test",
|
||||
])
|
||||
.and_where_eq("workspace_id", "?".bind(&w_id));
|
||||
|
||||
@@ -74,6 +89,16 @@ async fn list_conversations(
|
||||
sqlb.and_where_eq("flow_path", "?".bind(flow_path));
|
||||
}
|
||||
|
||||
match query.kind.unwrap_or_default() {
|
||||
ConversationKind::Test => {
|
||||
sqlb.and_where_eq("is_test", "true");
|
||||
}
|
||||
ConversationKind::Deployed => {
|
||||
sqlb.and_where_eq("is_test", "false");
|
||||
}
|
||||
ConversationKind::All => {}
|
||||
}
|
||||
|
||||
sqlb.order_by("updated_at", true)
|
||||
.limit(per_page as i64)
|
||||
.offset(offset as i64);
|
||||
@@ -101,7 +126,7 @@ async fn delete_conversation(
|
||||
// Verify the conversation exists and belongs to the user
|
||||
let conversation = sqlx::query_as!(
|
||||
FlowConversation,
|
||||
"SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by
|
||||
"SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test
|
||||
FROM flow_conversation
|
||||
WHERE id = $1 AND workspace_id = $2",
|
||||
conversation_id,
|
||||
@@ -148,6 +173,50 @@ async fn delete_conversation(
|
||||
Ok(format!("Conversation {} deleted", conversation_id))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateConversation {
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
async fn update_conversation(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, conversation_id)): Path<(String, Uuid)>,
|
||||
Json(update): Json<UpdateConversation>,
|
||||
) -> Result<String> {
|
||||
// Postgres refuses a NUL in a text column, so it must not reach the query as a 500.
|
||||
if update.title.contains('\0') {
|
||||
return Err(windmill_common::error::Error::BadRequest(
|
||||
"title cannot contain a NUL character".to_string(),
|
||||
));
|
||||
}
|
||||
// The column is VARCHAR(255) and the helper appends an ellipsis to what it cuts, so the
|
||||
// bound it takes is three short of the column's. A longer title would otherwise reach
|
||||
// Postgres as a 22001 and come back a 500.
|
||||
let title = truncate_with_ellipsis(update.title.trim(), 252);
|
||||
|
||||
let mut tx = user_db.clone().begin(&authed).await?;
|
||||
|
||||
// `updated_at` is kept: the list is ordered by it, and a rename must not move the
|
||||
// chat to the top the way a new turn does.
|
||||
let updated = sqlx::query_scalar!(
|
||||
"UPDATE flow_conversation SET title = $1, updated_at = updated_at
|
||||
WHERE id = $2 AND workspace_id = $3
|
||||
RETURNING id",
|
||||
title,
|
||||
conversation_id,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
not_found_if_none(updated, "Conversation", conversation_id.to_string())?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(format!("Conversation {} updated", conversation_id))
|
||||
}
|
||||
|
||||
async fn list_messages(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
|
||||
@@ -669,6 +669,7 @@ pub async fn handle_chat_conversation_messages(
|
||||
run_query: &RunJobQuery,
|
||||
user_message_raw: Option<&Box<serde_json::value::RawValue>>,
|
||||
job_id: Uuid,
|
||||
is_test: bool,
|
||||
) -> error::Result<()> {
|
||||
// Names the query parameter rather than the field: it is not a flow argument, and
|
||||
// supplying it as one is the first thing tried on reading `memory_id is required`.
|
||||
@@ -701,6 +702,7 @@ pub async fn handle_chat_conversation_messages(
|
||||
&authed.username,
|
||||
&user_message,
|
||||
memory_id,
|
||||
is_test,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -836,6 +838,7 @@ pub async fn run_flow<'c>(
|
||||
&run_query,
|
||||
args.args.get("user_message"),
|
||||
uuid,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -12464,6 +12464,15 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: kind
|
||||
description: which conversations to list - the flow editor's test chats, the deployed flow's own (the default), or both
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- test
|
||||
- deployed
|
||||
- all
|
||||
responses:
|
||||
"200":
|
||||
description: flow conversations list
|
||||
@@ -12474,6 +12483,40 @@ paths:
|
||||
items:
|
||||
$ref: "#/components/schemas/FlowConversation"
|
||||
|
||||
/w/{workspace}/flow_conversations/update/{conversation_id}:
|
||||
post:
|
||||
summary: rename flow conversation
|
||||
operationId: updateFlowConversation
|
||||
tags:
|
||||
- flow_conversations
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: conversation_id
|
||||
description: conversation id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [title]
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
description: the chat's name
|
||||
responses:
|
||||
"200":
|
||||
description: flow conversation updated
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/flow_conversations/delete/{conversation_id}:
|
||||
delete:
|
||||
summary: delete flow conversation
|
||||
@@ -28301,7 +28344,7 @@ components:
|
||||
FlowConversation:
|
||||
type: object
|
||||
required:
|
||||
[id, workspace_id, flow_path, created_at, updated_at, created_by]
|
||||
[id, workspace_id, flow_path, created_at, updated_at, created_by, is_test]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
@@ -28328,6 +28371,9 @@ components:
|
||||
created_by:
|
||||
type: string
|
||||
description: Username who created the conversation
|
||||
is_test:
|
||||
type: boolean
|
||||
description: Started from the flow editor's test panel rather than a deployed run
|
||||
|
||||
FlowConversationMessage:
|
||||
type: object
|
||||
|
||||
@@ -9554,6 +9554,8 @@ async fn run_preview_flow_job(
|
||||
&run_query,
|
||||
user_message.as_ref(),
|
||||
uuid,
|
||||
// Run from the editor's test panel: a trial, not a real conversation.
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -26,8 +26,12 @@ pub struct FlowConversation {
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub created_by: String,
|
||||
/// Started from the flow editor's test panel rather than a deployed run.
|
||||
pub is_test: bool,
|
||||
}
|
||||
|
||||
/// `is_test` is written on insert. An existing conversation of the other kind refuses the
|
||||
/// turn, so preview and deployed runs never share one.
|
||||
pub async fn get_or_create_conversation_with_id(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
w_id: &str,
|
||||
@@ -35,9 +39,10 @@ pub async fn get_or_create_conversation_with_id(
|
||||
username: &str,
|
||||
title: &str,
|
||||
conversation_id: Uuid,
|
||||
is_test: bool,
|
||||
) -> Result<FlowConversation> {
|
||||
if let Some(existing) = lock_conversation(tx, w_id, conversation_id).await? {
|
||||
return Ok(existing);
|
||||
return same_kind(existing, is_test);
|
||||
}
|
||||
|
||||
// Truncate title to 25 characters max
|
||||
@@ -47,15 +52,16 @@ pub async fn get_or_create_conversation_with_id(
|
||||
// wins, the others wait on it, do nothing, and read the row it created.
|
||||
let created = sqlx::query_as!(
|
||||
FlowConversation,
|
||||
"INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by, title)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
"INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by, title, is_test)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
RETURNING id, workspace_id, flow_path, title, created_at, updated_at, created_by",
|
||||
RETURNING id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test",
|
||||
conversation_id,
|
||||
w_id,
|
||||
flow_path,
|
||||
username,
|
||||
title
|
||||
title,
|
||||
is_test
|
||||
)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
@@ -63,13 +69,29 @@ pub async fn get_or_create_conversation_with_id(
|
||||
return Ok(conversation);
|
||||
}
|
||||
|
||||
lock_conversation(tx, w_id, conversation_id)
|
||||
// The concurrent first turn that won the insert may have been of the other kind.
|
||||
let existing = lock_conversation(tx, w_id, conversation_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::error::Error::BadRequest(format!(
|
||||
"conversation {conversation_id} belongs to another workspace"
|
||||
))
|
||||
})
|
||||
})?;
|
||||
same_kind(existing, is_test)
|
||||
}
|
||||
|
||||
/// `memory_id` is the caller's to choose, so a preview run could name a deployed
|
||||
/// conversation and the reverse. A conversation's kind is fixed at creation and nothing
|
||||
/// would show the mixing afterwards, so the turn is refused before it starts.
|
||||
fn same_kind(existing: FlowConversation, is_test: bool) -> Result<FlowConversation> {
|
||||
if existing.is_test == is_test {
|
||||
return Ok(existing);
|
||||
}
|
||||
Err(crate::error::Error::BadRequest(if existing.is_test {
|
||||
"this conversation was started from the flow editor's test panel; start a new conversation to run the deployed flow".to_string()
|
||||
} else {
|
||||
"this conversation belongs to the deployed flow; start a new conversation to test from the flow editor".to_string()
|
||||
}))
|
||||
}
|
||||
|
||||
/// Locked, so a turn orders against retention collecting the conversation
|
||||
@@ -83,7 +105,7 @@ async fn lock_conversation(
|
||||
) -> Result<Option<FlowConversation>> {
|
||||
Ok(sqlx::query_as!(
|
||||
FlowConversation,
|
||||
"SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by
|
||||
"SELECT id, workspace_id, flow_path, title, created_at, updated_at, created_by, is_test
|
||||
FROM flow_conversation
|
||||
WHERE id = $1 AND workspace_id = $2
|
||||
FOR UPDATE",
|
||||
|
||||
+5
-2
@@ -225,8 +225,11 @@ answer, an `assistant` message with `success: false`. `status: 'error'` (with `e
|
||||
set) means the turn could not run or be followed at all, such as a refused request.
|
||||
|
||||
Methods: `sendMessage(text, { inputs? })`, `stop()`, `newConversation()`,
|
||||
`selectConversation(id)`, `loadConversations({ page?, perPage? })`,
|
||||
`deleteConversation(id)`, `loadOlderMessages()`, `destroy()`. Switching conversations
|
||||
`selectConversation(id)`, `loadConversations({ page?, perPage?, kind? })`,
|
||||
`deleteConversation(id)`, `renameConversation(id, title)`, `loadOlderMessages()`,
|
||||
`destroy()`. `kind` lists the flow editor's test chats (`'test'`), the deployed flow's
|
||||
own (`'deployed'`, the server's default) or both (`'all'`); each `Conversation` carries
|
||||
`isTest`. A rename keeps the conversation's place in the list. Switching conversations
|
||||
stops following the current answer; the flow keeps running and, with server history,
|
||||
its answer is there when you come back.
|
||||
|
||||
|
||||
+20
-2
@@ -28,8 +28,16 @@ export interface FlowConversation {
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
/** Started from the flow editor's test panel rather than a deployed run. */
|
||||
is_test: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Which conversations a listing holds: the flow editor's test chats, the deployed flow's
|
||||
* own (the server's default), or both.
|
||||
*/
|
||||
export type ConversationKind = 'test' | 'deployed' | 'all'
|
||||
|
||||
export interface FlowConversationMessage {
|
||||
id: string
|
||||
conversation_id: string
|
||||
@@ -167,15 +175,25 @@ export class WindmillChatApi {
|
||||
|
||||
async listConversations(
|
||||
flowPath: string,
|
||||
options: { page?: number; perPage?: number; signal?: AbortSignal } = {}
|
||||
options: { page?: number; perPage?: number; kind?: ConversationKind; signal?: AbortSignal } = {}
|
||||
): Promise<FlowConversation[]> {
|
||||
const extra: Record<string, string> = { flow_path: flowPath }
|
||||
if (options.kind !== undefined) extra.kind = options.kind
|
||||
const res = await this.#request('flow_conversations/list', {
|
||||
query: pagination(options, { flow_path: flowPath }),
|
||||
query: pagination(options, extra),
|
||||
signal: options.signal
|
||||
})
|
||||
return (await res.json()) as FlowConversation[]
|
||||
}
|
||||
|
||||
/** Sets a conversation's title. Its place in the list is kept: only a turn moves one. */
|
||||
async renameConversation(conversationId: string, title: string): Promise<void> {
|
||||
await this.#request(`flow_conversations/update/${encodeURIComponent(conversationId)}`, {
|
||||
method: 'POST',
|
||||
body: { title }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Without `afterSeq`: one page counted from the newest message, returned oldest first.
|
||||
* With `afterSeq`: the messages created after that cursor, oldest first.
|
||||
|
||||
@@ -53,7 +53,8 @@ export function useWindmillRuntime(options: WindmillRuntimeOptions): AssistantRu
|
||||
threads: chat.conversations.map((c) => ({ status: 'regular' as const, id: c.id, title: c.title })),
|
||||
onSwitchToNewThread: () => chat.newConversation(),
|
||||
onSwitchToThread: (id) => chat.selectConversation(id),
|
||||
onDelete: (id) => chat.deleteConversation(id)
|
||||
onDelete: (id) => chat.deleteConversation(id),
|
||||
onRename: (id, title) => chat.renameConversation(id, title)
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
|
||||
+36
-4
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
WindmillApiError,
|
||||
WindmillChatApi,
|
||||
type ConversationKind,
|
||||
type FlowConversation,
|
||||
type FlowConversationMessage
|
||||
} from './api'
|
||||
@@ -18,6 +19,7 @@ import type {
|
||||
} from './types'
|
||||
import {
|
||||
conversationTitle,
|
||||
truncateTitle,
|
||||
errorResultMessage,
|
||||
extractChatAnswer,
|
||||
isAbortError,
|
||||
@@ -59,6 +61,8 @@ class ChatImpl implements Chat {
|
||||
#state: ChatState
|
||||
#turn: Turn | undefined
|
||||
#page = 1
|
||||
/** 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
|
||||
|
||||
constructor(options: ChatOptions) {
|
||||
@@ -229,15 +233,21 @@ class ChatImpl implements Chat {
|
||||
}
|
||||
|
||||
loadConversations = async (
|
||||
options: { page?: number; perPage?: number } = {}
|
||||
options: { page?: number; perPage?: number; kind?: ConversationKind } = {}
|
||||
): Promise<Conversation[]> => {
|
||||
const page = options.page ?? 1
|
||||
// A different kind is a different listing: its first rows replace the held ones, on
|
||||
// whichever page they were asked for.
|
||||
const kindChanged = 'kind' in options && options.kind !== this.#conversationKind
|
||||
if ('kind' in options) this.#conversationKind = options.kind
|
||||
const kind = this.#conversationKind
|
||||
let conversations: Conversation[]
|
||||
if (this.#state.history === 'server') {
|
||||
try {
|
||||
const rows = await this.#api.listConversations(this.#config.flowPath, {
|
||||
page,
|
||||
perPage: options.perPage ?? this.#config.pageSize
|
||||
perPage: options.perPage ?? this.#config.pageSize,
|
||||
kind
|
||||
})
|
||||
conversations = rows.map(fromConversation)
|
||||
} catch (e) {
|
||||
@@ -247,10 +257,13 @@ class ChatImpl implements Chat {
|
||||
} else {
|
||||
conversations = this.#state.history === 'local' ? this.#local.listConversations() : []
|
||||
}
|
||||
// Another kind was asked for while this list was on its way: its rows are not the
|
||||
// listing any more, whichever response lands last.
|
||||
if (kind !== this.#conversationKind) return conversations
|
||||
const known = new Set(this.#state.conversations.map((c) => c.id))
|
||||
this.#set({
|
||||
conversations:
|
||||
page === 1
|
||||
page === 1 || kindChanged
|
||||
? conversations
|
||||
: [...this.#state.conversations, ...conversations.filter((c) => !known.has(c.id))]
|
||||
})
|
||||
@@ -273,6 +286,24 @@ class ChatImpl implements Chat {
|
||||
this.#set({ conversations: this.#state.conversations.filter((c) => c.id !== conversationId) })
|
||||
}
|
||||
|
||||
renameConversation = async (conversationId: string, title: string): Promise<void> => {
|
||||
// Cut here as the server cuts, so the title shown is the one stored.
|
||||
const trimmed = truncateTitle(title.trim())
|
||||
if (!trimmed) return
|
||||
if (this.#state.history === 'server') {
|
||||
await this.#api.renameConversation(conversationId, trimmed)
|
||||
} else if (this.#state.history === 'local') {
|
||||
this.#local.renameConversation(conversationId, trimmed)
|
||||
}
|
||||
// Patched in place: the server keeps `updated_at` on a rename, so the list order the
|
||||
// next load returns is the one shown now.
|
||||
this.#set({
|
||||
conversations: this.#state.conversations.map((c) =>
|
||||
c.id === conversationId ? { ...c, title: trimmed } : c
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
loadOlderMessages = async (): Promise<void> => {
|
||||
const conversationId = this.#state.conversationId
|
||||
if (
|
||||
@@ -734,7 +765,8 @@ function fromConversation(row: FlowConversation): Conversation {
|
||||
id: row.id,
|
||||
title: row.title ?? undefined,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
updatedAt: row.updated_at,
|
||||
isTest: row.is_test
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ export interface LocalHistory {
|
||||
listConversations(): Conversation[]
|
||||
getMessages(conversationId: string): ChatMessage[]
|
||||
upsertConversation(conversation: Conversation): void
|
||||
/** Changes a stored conversation's title in place; unlike `upsertConversation`, its position is kept. */
|
||||
renameConversation(conversationId: string, title: string): void
|
||||
saveMessages(conversationId: string, messages: ChatMessage[]): void
|
||||
deleteConversation(conversationId: string): void
|
||||
}
|
||||
@@ -55,6 +57,11 @@ export function createLocalHistory(storage: StorageLike | undefined, key: string
|
||||
}
|
||||
write(s)
|
||||
},
|
||||
renameConversation(id, title) {
|
||||
const s = read()
|
||||
s.conversations = s.conversations.map((c) => (c.id === id ? { ...c, title } : c))
|
||||
write(s)
|
||||
},
|
||||
saveMessages(id, messages) {
|
||||
const s = read()
|
||||
s.messages[id] = messages.map((m) => ({ ...m, pending: false }))
|
||||
|
||||
@@ -5,6 +5,7 @@ export {
|
||||
WindmillApiError,
|
||||
readServerSentEvents,
|
||||
type WindmillChatApiOptions,
|
||||
type ConversationKind,
|
||||
type FlowConversation,
|
||||
type FlowConversationMessage,
|
||||
type JobUpdateEvent,
|
||||
|
||||
@@ -11,6 +11,7 @@ export type UseWindmillChat = ChatState &
|
||||
| 'selectConversation'
|
||||
| 'loadConversations'
|
||||
| 'deleteConversation'
|
||||
| 'renameConversation'
|
||||
| 'loadOlderMessages'
|
||||
> & { chat: Chat }
|
||||
|
||||
@@ -65,6 +66,7 @@ export function useWindmillChat(options: ChatOptions): UseWindmillChat {
|
||||
selectConversation: chat.selectConversation,
|
||||
loadConversations: chat.loadConversations,
|
||||
deleteConversation: chat.deleteConversation,
|
||||
renameConversation: chat.renameConversation,
|
||||
loadOlderMessages: chat.loadOlderMessages
|
||||
}),
|
||||
[state, chat]
|
||||
|
||||
+16
-1
@@ -52,6 +52,11 @@ export interface Conversation {
|
||||
title: string | undefined
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
/**
|
||||
* Started from the flow editor's test panel rather than a deployed run. Known once the
|
||||
* server has listed the conversation; unset for one only this client has seen.
|
||||
*/
|
||||
isTest?: boolean
|
||||
}
|
||||
|
||||
export interface ChatState {
|
||||
@@ -128,8 +133,18 @@ export interface Chat {
|
||||
stop(): Promise<void>
|
||||
newConversation(): void
|
||||
selectConversation(conversationId: string): Promise<void>
|
||||
loadConversations(options?: { page?: number; perPage?: number }): Promise<Conversation[]>
|
||||
/**
|
||||
* `kind` narrows server history to the flow editor's test chats, the deployed flow's
|
||||
* own (the server's default), or both. Local history has no test chats and ignores it.
|
||||
*/
|
||||
loadConversations(options?: {
|
||||
page?: number
|
||||
perPage?: number
|
||||
kind?: 'test' | 'deployed' | 'all'
|
||||
}): Promise<Conversation[]>
|
||||
deleteConversation(conversationId: string): Promise<void>
|
||||
/** Sets a conversation's title. The list keeps its order: only a turn moves a conversation. */
|
||||
renameConversation(conversationId: string, title: string): Promise<void>
|
||||
loadOlderMessages(): Promise<void>
|
||||
/** Stops background work (stream, polling) and writes local history out. The chat stays usable. */
|
||||
destroy(): void
|
||||
|
||||
@@ -69,6 +69,12 @@ export function conversationTitle(firstMessage: string): string {
|
||||
return chars.length > 25 ? `${chars.slice(0, 25).join('')}...` : firstMessage
|
||||
}
|
||||
|
||||
/** The server's bound on a typed title: 252 characters plus an ellipsis fits its 255-char column. */
|
||||
export function truncateTitle(title: string): string {
|
||||
const chars = Array.from(title)
|
||||
return chars.length > 252 ? `${chars.slice(0, 252).join('')}...` : title
|
||||
}
|
||||
|
||||
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) return reject(abortError())
|
||||
|
||||
@@ -414,6 +414,112 @@ describe('createChat with server history', () => {
|
||||
expect(chat.getState().conversations.map((c) => c.id)).toEqual(['c1', 'c2'])
|
||||
})
|
||||
|
||||
test('lists one kind of conversation and carries which kind each one is', async () => {
|
||||
const row = (id: string, is_test: boolean) => ({
|
||||
id,
|
||||
workspace_id: 'ws',
|
||||
flow_path: FLOW,
|
||||
title: id,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
created_by: 'admin',
|
||||
is_test
|
||||
})
|
||||
const { fetch, calls } = fetchMock((c) =>
|
||||
c.url.pathname === '/api/w/ws/flow_conversations/list'
|
||||
? json(c.url.searchParams.get('kind') === 'test' ? [row('t1', true)] : [row('d1', false)])
|
||||
: undefined
|
||||
)
|
||||
const chat = createChat(options({}, fetch))
|
||||
await chat.loadConversations()
|
||||
expect(calls[0].url.searchParams.has('kind')).toBe(false)
|
||||
expect(chat.getState().conversations.map((c) => [c.id, c.isTest])).toEqual([['d1', false]])
|
||||
await chat.loadConversations({ kind: 'test' })
|
||||
expect(calls[1].url.searchParams.get('kind')).toBe('test')
|
||||
expect(chat.getState().conversations.map((c) => [c.id, c.isTest])).toEqual([['t1', true]])
|
||||
})
|
||||
|
||||
test('a list for a kind no longer asked for does not replace the newer one', async () => {
|
||||
const row = (id: string, is_test: boolean) => ({
|
||||
id,
|
||||
workspace_id: 'ws',
|
||||
flow_path: FLOW,
|
||||
title: id,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
created_by: 'admin',
|
||||
is_test
|
||||
})
|
||||
const { fetch } = fetchMock((c) => {
|
||||
if (c.url.pathname !== '/api/w/ws/flow_conversations/list') return undefined
|
||||
if (c.url.searchParams.get('kind') === 'test') {
|
||||
return new Promise<Response>((r) => setTimeout(() => r(json([row('t1', true)])), 50))
|
||||
}
|
||||
return json([row('d1', false)])
|
||||
})
|
||||
const chat = createChat(options({}, fetch))
|
||||
const slow = chat.loadConversations({ kind: 'test' })
|
||||
await chat.loadConversations({ kind: 'deployed' })
|
||||
await slow
|
||||
expect(chat.getState().conversations.map((c) => c.id)).toEqual(['d1'])
|
||||
// Another kind asked for on a later page starts its own listing rather than appending.
|
||||
await chat.loadConversations({ page: 2, kind: 'test' })
|
||||
expect(chat.getState().conversations.map((c) => c.id)).toEqual(['t1'])
|
||||
})
|
||||
|
||||
test('the refresh after a new turn lists the kind last asked for', async () => {
|
||||
const { fetch, calls } = fetchMock(
|
||||
run,
|
||||
(c) =>
|
||||
c.url.pathname === streamPath
|
||||
? sse([{ type: 'update', completed: true, only_result: { output: 'Hello', messages: [] } }])
|
||||
: undefined,
|
||||
(c) =>
|
||||
c.method === 'GET' && c.url.pathname.endsWith('/messages')
|
||||
? json([messageRow(11, 'user', 'hi'), messageRow(12, 'assistant', 'Hello', { job_id: 'agent-job' })])
|
||||
: undefined,
|
||||
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined)
|
||||
)
|
||||
const chat = createChat(options({}, fetch))
|
||||
await chat.loadConversations({ kind: 'test' })
|
||||
await chat.sendMessage('hi')
|
||||
const lists = calls.filter((c) => c.url.pathname === '/api/w/ws/flow_conversations/list')
|
||||
expect(lists.length).toBeGreaterThan(1)
|
||||
expect(lists.every((c) => c.url.searchParams.get('kind') === 'test')).toBe(true)
|
||||
})
|
||||
|
||||
test('renaming a conversation keeps its place in the list', async () => {
|
||||
const row = (id: string) => ({
|
||||
id,
|
||||
workspace_id: 'ws',
|
||||
flow_path: FLOW,
|
||||
title: id,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
created_by: 'admin',
|
||||
is_test: false
|
||||
})
|
||||
const { fetch, calls } = fetchMock(
|
||||
(c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([row('c1'), row('c2')]) : undefined),
|
||||
(c) =>
|
||||
c.method === 'POST' && c.url.pathname === '/api/w/ws/flow_conversations/update/c2'
|
||||
? text('Conversation c2 updated')
|
||||
: undefined
|
||||
)
|
||||
const chat = createChat(options({}, fetch))
|
||||
await chat.loadConversations()
|
||||
await chat.renameConversation('c2', ' Budget review ')
|
||||
expect(calls[1].body).toEqual({ title: 'Budget review' })
|
||||
expect(chat.getState().conversations.map((c) => [c.id, c.title])).toEqual([
|
||||
['c1', 'c1'],
|
||||
['c2', 'Budget review']
|
||||
])
|
||||
// Cut as the server cuts, so what is shown is what is stored.
|
||||
await chat.renameConversation('c2', 'x'.repeat(300))
|
||||
expect(chat.getState().conversations[1].title).toBe('x'.repeat(252) + '...')
|
||||
expect(calls[2].body).toEqual({ title: 'x'.repeat(252) + '...' })
|
||||
})
|
||||
|
||||
test('a turn started right after stop() is not touched by the stop sync', async () => {
|
||||
let jobs = 0
|
||||
const { fetch } = fetchMock(
|
||||
@@ -726,6 +832,27 @@ describe('createChat with server history', () => {
|
||||
expect((await again.loadConversations()).map((c) => c.id)).toEqual([newer, older])
|
||||
})
|
||||
|
||||
test('renaming a local conversation persists the title without reordering history', async () => {
|
||||
const storage = memoryStorage()
|
||||
const { fetch, calls } = fetchMock(run, (c) =>
|
||||
c.url.pathname === streamPath ? sse([{ type: 'update', completed: true, only_result: 'ok' }]) : undefined
|
||||
)
|
||||
const chat = createChat(options({ token: 'tok', storage }, fetch))
|
||||
await chat.sendMessage('older')
|
||||
const older = chat.getState().conversationId!
|
||||
chat.newConversation()
|
||||
await chat.sendMessage('newer')
|
||||
const newer = chat.getState().conversationId!
|
||||
const before = calls.length
|
||||
await chat.renameConversation(older, 'Renamed')
|
||||
expect(calls.length).toBe(before)
|
||||
const again = createChat(options({ token: 'tok', storage }, fetch))
|
||||
expect((await again.loadConversations()).map((c) => [c.id, c.title])).toEqual([
|
||||
[newer, 'newer'],
|
||||
[older, 'Renamed']
|
||||
])
|
||||
})
|
||||
|
||||
test('destroying the chat mid-turn leaves it idle', async () => {
|
||||
const { fetch } = fetchMock(run, (c) =>
|
||||
c.url.pathname === streamPath
|
||||
|
||||
@@ -470,7 +470,7 @@
|
||||
)
|
||||
return jobId ?? ''
|
||||
}}
|
||||
hideSidebar={true}
|
||||
conversationKind="test"
|
||||
path={$pathStore}
|
||||
inputSchema={flowStore.val.schema}
|
||||
flowModules={flowStore.val.value?.modules}
|
||||
|
||||
@@ -849,7 +849,7 @@
|
||||
<FlowChat
|
||||
onRunFlow={runFlowWithMessage}
|
||||
path={$pathStore}
|
||||
hideSidebar={true}
|
||||
conversationKind="test"
|
||||
inputSchema={flowStore.val.schema}
|
||||
flowModules={flowStore.val.value?.modules}
|
||||
/>
|
||||
|
||||
@@ -28,6 +28,13 @@
|
||||
/** The flow's description, shown under the empty transcript's prompt. */
|
||||
description?: string
|
||||
wideLayout?: boolean
|
||||
/**
|
||||
* What this surface's own runs are: the editor runs previews and lists its test
|
||||
* chats, the flow page runs the deployed flow and lists only its users' chats.
|
||||
* The sidebar offers the kind filter everywhere but on the deployed flow, whose
|
||||
* users have no test chats to look at.
|
||||
*/
|
||||
conversationKind?: 'test' | 'deployed'
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -38,7 +45,8 @@
|
||||
inputSchema = undefined,
|
||||
flowModules = undefined,
|
||||
description = undefined,
|
||||
wideLayout = false
|
||||
wideLayout = false,
|
||||
conversationKind = 'deployed'
|
||||
}: Props = $props()
|
||||
|
||||
const flowEditorContext = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
@@ -97,7 +105,13 @@
|
||||
<div class="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden flex-1">
|
||||
{#if chat && chatState}
|
||||
{#if !hideSidebar}
|
||||
<FlowConversationsSidebar bind:this={sidebar} {chat} {chatState} />
|
||||
<FlowConversationsSidebar
|
||||
bind:this={sidebar}
|
||||
{chat}
|
||||
{chatState}
|
||||
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. -->
|
||||
@@ -111,6 +125,7 @@
|
||||
{workspace}
|
||||
{description}
|
||||
{wideLayout}
|
||||
{conversationKind}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
/** The flow's description, shown under the empty transcript's prompt. */
|
||||
description?: string
|
||||
wideLayout?: boolean
|
||||
/** What this surface's runs create: previews in the editor, deployed runs on the flow page. */
|
||||
conversationKind?: 'test' | 'deployed'
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -42,7 +44,8 @@
|
||||
path,
|
||||
workspace = undefined,
|
||||
description = undefined,
|
||||
wideLayout = false
|
||||
wideLayout = false,
|
||||
conversationKind = 'deployed'
|
||||
}: Props = $props()
|
||||
|
||||
// Derive helperScript for dynamic inputs from schema
|
||||
@@ -150,10 +153,22 @@
|
||||
{
|
||||
additionalInputs: () => (additionalInputsSchema ? { ...runInputs } : undefined),
|
||||
workspace: () => workspace,
|
||||
sendDisabled: () => deploymentInProgress || !!modelGap
|
||||
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
|
||||
? '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
|
||||
@@ -287,8 +302,10 @@
|
||||
{emptyHint}
|
||||
footerSettings={modalSchema || showModelButton ? footerSettings : undefined}
|
||||
placeholder="Send a message to run the flow"
|
||||
disabled={deploymentInProgress || !!modelGap}
|
||||
disabledMessage={deploymentInProgress ? 'Deployment in progress' : (modelGap ?? '')}
|
||||
disabled={deploymentInProgress || !!modelGap || !!wrongKindReason}
|
||||
disabledMessage={deploymentInProgress
|
||||
? 'Deployment in progress'
|
||||
: (modelGap ?? wrongKindReason ?? '')}
|
||||
loadPastChat={() => {}}
|
||||
deletePastChat={() => {}}
|
||||
saveAndClear={() => {}}
|
||||
|
||||
@@ -1,20 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import { MessageCircle, Plus, Trash2, PanelLeftClose, PanelLeftOpen } from 'lucide-svelte'
|
||||
import {
|
||||
MessageCircle,
|
||||
Plus,
|
||||
Trash2,
|
||||
Pen,
|
||||
Filter,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen
|
||||
} from 'lucide-svelte'
|
||||
import CountBadge from '$lib/components/common/badge/CountBadge.svelte'
|
||||
import InfiniteList from '$lib/components/InfiniteList.svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import type { Item } from '$lib/utils'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { fade } from 'svelte/transition'
|
||||
import { untrack } from 'svelte'
|
||||
import type { Chat, ChatState, Conversation } from 'windmill-chat'
|
||||
import { tick, untrack } from 'svelte'
|
||||
import type { Chat, ChatState, Conversation, ConversationKind } from 'windmill-chat'
|
||||
|
||||
interface Props {
|
||||
chat: Chat
|
||||
chatState: ChatState
|
||||
/**
|
||||
* 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
|
||||
* started, so nobody's trial runs are mixed into them.
|
||||
*/
|
||||
defaultKind?: ConversationKind
|
||||
/**
|
||||
* Whether the filter is offered. Only the editor does: a deployed flow has no test
|
||||
* chats of its own to show, and offering to list someone's trial runs there would
|
||||
* put editor scratch in front of the flow's users.
|
||||
*/
|
||||
canFilterKind?: boolean
|
||||
}
|
||||
|
||||
let { chat, chatState }: Props = $props()
|
||||
let { chat, chatState, defaultKind = 'deployed', canFilterKind = false }: Props = $props()
|
||||
|
||||
let expanded = $state(false)
|
||||
let list = $state<InfiniteList | undefined>(undefined)
|
||||
@@ -23,13 +49,32 @@
|
||||
// A conversation exists on the server only once its first turn ran, so "New chat"
|
||||
// shows a draft row until then.
|
||||
let draft = $state(false)
|
||||
// The prop seeds the filter; the filter is then the user's.
|
||||
let kind = $state<ConversationKind>(untrack(() => defaultKind))
|
||||
|
||||
// The chat being renamed, and the text typed so far. One at a time: the input is the
|
||||
// row's own label, so a second one would have nowhere to go.
|
||||
let renamingId = $state<string | undefined>(undefined)
|
||||
let renameDraft = $state('')
|
||||
let renameInput = $state<TextInput | undefined>(undefined)
|
||||
|
||||
const turnInFlight = $derived(
|
||||
chatState.status === 'submitted' || chatState.status === 'streaming'
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
const l = list
|
||||
const c = chat
|
||||
if (!l) return
|
||||
untrack(() => {
|
||||
l.setLoader((page, perPage) => c.loadConversations({ page, perPage }))
|
||||
// Every load goes through here, the first one and infinite scroll included. A
|
||||
// response for a kind no longer selected keeps the rows shown: the load for the
|
||||
// selected kind brings its own, whichever of the two lands last.
|
||||
l.setLoader(async (page, perPage) => {
|
||||
const requested = kind
|
||||
const rows = await c.loadConversations({ page, perPage, kind: requested })
|
||||
return requested === kind ? rows : items
|
||||
})
|
||||
l.setDeleteItemFn(async (id: string) => {
|
||||
deletingId = id
|
||||
try {
|
||||
@@ -46,10 +91,15 @@
|
||||
})
|
||||
})
|
||||
|
||||
/** The container reports a started turn: a conversation's first one creates its server entry. */
|
||||
/**
|
||||
* The container reports a started turn: a conversation's first one creates its server
|
||||
* entry. A new conversation is of this surface's own kind, so a filter that would not
|
||||
* list it goes back to that kind rather than hiding the chat that was just started.
|
||||
*/
|
||||
export async function conversationStarted(conversationId: string) {
|
||||
if (items.some((c) => c.id === conversationId)) return
|
||||
draft = false
|
||||
if (kind !== 'all' && kind !== defaultKind) kind = defaultKind
|
||||
await list?.loadData('forceRefresh')
|
||||
}
|
||||
|
||||
@@ -60,6 +110,70 @@
|
||||
draft = true
|
||||
}
|
||||
|
||||
const KIND_LABELS: Record<ConversationKind, string> = {
|
||||
test: 'Test',
|
||||
deployed: 'Deployed',
|
||||
all: 'All'
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
kind = next
|
||||
const open = items.find((c) => c.id === chatState.conversationId)
|
||||
const stillListed = open === undefined || next === 'all' || (next === 'test') === open.isTest
|
||||
if (!stillListed) chat.newConversation()
|
||||
await list?.loadData('forceRefresh')
|
||||
}
|
||||
|
||||
async function startRename(conversation: Conversation) {
|
||||
renamingId = conversation.id
|
||||
renameDraft = getConversationTitle(conversation)
|
||||
// The field replaces the row, so it exists only after this render.
|
||||
await tick()
|
||||
renameInput?.focus()
|
||||
renameInput?.select()
|
||||
}
|
||||
|
||||
async function commitRename() {
|
||||
const id = renamingId
|
||||
renamingId = undefined
|
||||
if (!id) return
|
||||
const title = renameDraft.trim()
|
||||
const current = items.find((c) => c.id === id)
|
||||
if (!current || title === '' || title === current.title) return
|
||||
try {
|
||||
await chat.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
|
||||
items = items.map((c) => (c.id === id ? { ...c, title: stored } : c))
|
||||
} catch (error) {
|
||||
console.error('Failed to rename conversation:', error)
|
||||
sendUserToast('Failed to rename conversation', true)
|
||||
}
|
||||
}
|
||||
|
||||
function rowActions(conversation: Conversation): Item[] {
|
||||
return [
|
||||
{ displayName: 'Rename', icon: Pen, action: () => startRename(conversation) },
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash2,
|
||||
type: 'delete',
|
||||
disabled: deletingId === conversation.id,
|
||||
action: () => list?.deleteItem(conversation.id)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function getConversationTitle(conversation: Conversation): string {
|
||||
return conversation.title || `Conversation ${conversation.createdAt.slice(0, 10)}`
|
||||
}
|
||||
@@ -87,17 +201,68 @@
|
||||
>
|
||||
<div transition:fade={{ duration: 100 }}> Conversations </div>
|
||||
</Button>
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: Plus, classes: 'ml-[2px]' }}
|
||||
onClick={newChat}
|
||||
title="Start new conversation"
|
||||
iconOnly={!expanded}
|
||||
btnClasses={'justify-start transition-all duration-150 whitespace-nowrap'}
|
||||
>
|
||||
<div transition:fade={{ duration: 100 }}> New chat </div>
|
||||
</Button>
|
||||
<!-- Side by side while there is width for both; stacked once collapsed, where the
|
||||
rail fits one icon across. -->
|
||||
<div class={expanded ? 'flex flex-row gap-1 items-center' : 'flex flex-col gap-2'}>
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: Plus, classes: 'ml-[2px]' }}
|
||||
onClick={newChat}
|
||||
title="Start new conversation"
|
||||
iconOnly={!expanded}
|
||||
wrapperClasses={expanded ? 'grow min-w-0' : ''}
|
||||
btnClasses={'w-full justify-start transition-all duration-150 whitespace-nowrap'}
|
||||
>
|
||||
<div transition:fade={{ duration: 100 }}> New chat </div>
|
||||
</Button>
|
||||
{#if 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}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<!-- Icon-only next to the wider New chat: which kind is listed is named in
|
||||
the title and by the group inside. -->
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
unifiedSize="md"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: Filter }}
|
||||
disabled={turnInFlight}
|
||||
title={turnInFlight
|
||||
? 'Wait for the current answer to change which chats are listed'
|
||||
: `Filter conversations · ${KIND_LABELS[kind]}`}
|
||||
iconOnly
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="p-3">
|
||||
<ToggleButtonGroup
|
||||
selected={kind}
|
||||
onSelected={(next) => setKind(next as ConversationKind)}
|
||||
disabled={turnInFlight}
|
||||
noWFull
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton size="sm" value="test" label={KIND_LABELS.test} {item} />
|
||||
<ToggleButton size="sm" value="deployed" label={KIND_LABELS.deployed} {item} />
|
||||
<ToggleButton size="sm" value="all" label={KIND_LABELS.all} {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
<p class="text-2xs text-tertiary mt-1.5 max-w-[190px]">
|
||||
Test chats are the ones run from the flow editor's test panel, kept apart from the
|
||||
conversations the deployed flow's users started.
|
||||
</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -157,38 +322,58 @@
|
||||
{#snippet customRow({ item: conversation })}
|
||||
{#if expanded}
|
||||
<div class={twMerge('w-full pb-1')} transition:fade={{ duration: 100, delay: 30 }}>
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
draft = false
|
||||
chat.selectConversation(conversation.id)
|
||||
}}
|
||||
selected={chatState.conversationId === conversation.id}
|
||||
btnClasses="transition-all duration-150 group"
|
||||
>
|
||||
<span class="flex-1 text-left truncate">
|
||||
{getConversationTitle(conversation)}
|
||||
</span>
|
||||
{#if renamingId === conversation.id}
|
||||
<!-- While renaming, the field replaces the row rather than sitting inside its
|
||||
button: a text input nested in a button is a nested interactive control,
|
||||
and every keystroke would have to be kept from reaching the row. -->
|
||||
<div class="flex flex-row items-center h-8 px-2 rounded-md bg-surface-selected">
|
||||
<TextInput
|
||||
bind:this={renameInput}
|
||||
bind:value={renameDraft}
|
||||
class="min-w-0 flex-1"
|
||||
size="sm"
|
||||
inputProps={{
|
||||
'aria-label': 'Chat name',
|
||||
onblur: commitRename,
|
||||
onkeydown: (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
commitRename()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
renamingId = undefined
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<Button
|
||||
wrapperClasses={twMerge(
|
||||
'ml-2 transition-all duration-100 opacity-0 group-hover:opacity-100',
|
||||
deletingId === conversation.id ? 'opacity-100' : ' '
|
||||
)}
|
||||
disabled={deletingId === conversation.id}
|
||||
onClick={(e) => {
|
||||
e?.stopPropagation()
|
||||
list?.deleteItem(conversation.id)
|
||||
}}
|
||||
title="Delete conversation"
|
||||
destructive
|
||||
unifiedSize="xs"
|
||||
unifiedSize="md"
|
||||
variant="subtle"
|
||||
loading={deletingId === conversation.id}
|
||||
iconOnly
|
||||
startIcon={{ icon: Trash2 }}
|
||||
/>
|
||||
</Button>
|
||||
onClick={() => {
|
||||
draft = false
|
||||
chat.selectConversation(conversation.id)
|
||||
}}
|
||||
selected={chatState.conversationId === conversation.id}
|
||||
btnClasses="transition-all duration-150 group"
|
||||
>
|
||||
<span class="flex-1 text-left truncate">
|
||||
{getConversationTitle(conversation)}
|
||||
</span>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class={twMerge(
|
||||
'ml-2 transition-all duration-100 opacity-0 group-hover:opacity-100',
|
||||
deletingId === conversation.id ? 'opacity-100' : ''
|
||||
)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownV2 items={() => rowActions(conversation)} size="xs" />
|
||||
</div>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
Reference in New Issue
Block a user