mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-14 16:02:57 +00:00
feat: list agents with runnables, let them be starred, and fill out their menu (#11651)
* feat: star agents and add runs, audit logs and copy path to their menu Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vo7HXMdgqXJhR7p8YyVgjB * fix: list an agent's runs as the previews they are Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vo7HXMdgqXJhR7p8YyVgjB * feat: list agents in the merged runnables stream so they sort with everything else Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vo7HXMdgqXJhR7p8YyVgjB * perf: index agent paths so the merged listing and owner counts skip other resources Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vo7HXMdgqXJhR7p8YyVgjB * feat: find agents in the ctrl+k search Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vo7HXMdgqXJhR7p8YyVgjB * feat: open an agent's version history from its menu Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vo7HXMdgqXJhR7p8YyVgjB --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
998e6877c1
commit
fe2571ea53
@@ -0,0 +1 @@
|
||||
DELETE FROM favorite WHERE favorite_kind = 'agent';
|
||||
@@ -0,0 +1,8 @@
|
||||
DO
|
||||
$do$
|
||||
BEGIN
|
||||
ALTER TYPE FAVORITE_KIND ADD VALUE 'agent';
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE NOTICE 'Couldn''t create FAVORITE_KIND::agent: %', SQLERRM;
|
||||
END
|
||||
$do$;
|
||||
@@ -0,0 +1 @@
|
||||
DROP INDEX IF EXISTS idx_resource_agent_owner_prefix;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Backs the agent branch of /runnables/list and /runnables/counts, which reach the
|
||||
-- `ai_agent` rows of `resource` by workspace and byte-ordered path prefix. Partial, so
|
||||
-- neither walks the workspace's other resources (state, cache, ...).
|
||||
-- Created CONCURRENTLY via the OVERRIDDEN_MIGRATIONS rewrite in windmill-api/src/db.rs.
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_agent_owner_prefix
|
||||
ON resource (workspace_id, path text_pattern_ops)
|
||||
WHERE resource_type = 'ai_agent';
|
||||
@@ -10,7 +10,7 @@ autoscaling_event_type: full_scaleout, scalein, scaleout
|
||||
aws_auth_resource_type: oidc, credentials
|
||||
delivery_mode: push, pull
|
||||
draft_type: script, flow, app
|
||||
favorite_kind: app, script, flow, raw_app, asset
|
||||
favorite_kind: app, script, flow, raw_app, asset, agent
|
||||
gcp_subscription_mode: create_update, existing
|
||||
http_method: get, post, put, delete, patch
|
||||
importer_kind: script, flow, app
|
||||
|
||||
@@ -9367,6 +9367,12 @@ paths:
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
- name: starred_only
|
||||
description: Only the `ai_agent` resources the caller starred
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
description: resource list
|
||||
@@ -12783,7 +12789,7 @@ paths:
|
||||
|
||||
/w/{workspace}/runnables/list:
|
||||
get:
|
||||
summary: list runnables (scripts, flows, apps) merged, ordered and keyset-paginated
|
||||
summary: list runnables (scripts, flows, apps, agents) merged, ordered and keyset-paginated
|
||||
operationId: listRunnables
|
||||
tags:
|
||||
- script
|
||||
@@ -12800,7 +12806,7 @@ paths:
|
||||
- $ref: "#/components/parameters/OrderDesc"
|
||||
- name: kinds
|
||||
in: query
|
||||
description: comma-separated subset of script,flow,app (default all)
|
||||
description: comma-separated subset of script,flow,app,agent (default all)
|
||||
schema:
|
||||
type: string
|
||||
- name: show_archived
|
||||
@@ -12874,7 +12880,7 @@ paths:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: kinds
|
||||
in: query
|
||||
description: comma-separated subset of script,flow,app (default all)
|
||||
description: comma-separated subset of script,flow,app,agent (default all)
|
||||
schema:
|
||||
type: string
|
||||
- name: include_without_main
|
||||
@@ -24541,7 +24547,7 @@ paths:
|
||||
type: string
|
||||
favorite_kind:
|
||||
type: string
|
||||
enum: [flow, app, script, raw_app, asset]
|
||||
enum: [flow, app, script, raw_app, asset, agent]
|
||||
responses:
|
||||
"200":
|
||||
description: star item
|
||||
@@ -24564,7 +24570,7 @@ paths:
|
||||
type: string
|
||||
favorite_kind:
|
||||
type: string
|
||||
enum: [flow, app, script, raw_app, asset]
|
||||
enum: [flow, app, script, raw_app, asset, agent]
|
||||
responses:
|
||||
"200":
|
||||
description: unstar item
|
||||
@@ -30236,9 +30242,10 @@ components:
|
||||
description: |
|
||||
A row in the merged runnables listing. `type` is the discriminator;
|
||||
kind-specific fields (hash/language/kind for scripts, execution_mode/
|
||||
version for apps) are present only for that kind. `edited_at` is the
|
||||
unified last-updated time (a script's created_at, a flow/app's edit
|
||||
time).
|
||||
version for apps, agent_memory/ws_specific for agents) are present only
|
||||
for that kind. An agent is an `ai_agent` resource whose description is
|
||||
reported as `summary`. `edited_at` is the unified last-updated time (a
|
||||
script's created_at, a flow/app/agent's edit time).
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
@@ -30246,6 +30253,7 @@ components:
|
||||
- script
|
||||
- flow
|
||||
- app
|
||||
- agent
|
||||
path:
|
||||
type: string
|
||||
summary:
|
||||
@@ -30313,6 +30321,11 @@ components:
|
||||
version:
|
||||
type: integer
|
||||
format: int64
|
||||
agent_memory:
|
||||
description: agent-only. The agent's `memory` setting.
|
||||
ws_specific:
|
||||
type: boolean
|
||||
description: agent-only. Whether the agent is workspace-specific, which keeps it out of deploys to other workspaces.
|
||||
required:
|
||||
- type
|
||||
- path
|
||||
|
||||
@@ -113,6 +113,9 @@ lazy_static::lazy_static! {
|
||||
(20260909163047, include_str!(
|
||||
"../../migrations/20260909163047_workspace_delete_cascade_indexes.up.sql"
|
||||
).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY").replace("DROP INDEX", "DROP INDEX CONCURRENTLY")),
|
||||
(20261009173546, include_str!(
|
||||
"../../migrations/20261009173546_agent_owner_prefix_index.up.sql"
|
||||
).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY")),
|
||||
// The widening fails every query the previous release's still-running
|
||||
// servers prepared on the column; `variables::widen_variable_value` does it
|
||||
// once they are gone.
|
||||
|
||||
@@ -32,6 +32,7 @@ pub enum FavoriteKind {
|
||||
#[allow(non_camel_case_types)]
|
||||
Raw_App,
|
||||
Asset,
|
||||
Agent,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct Favorite {
|
||||
|
||||
@@ -7,15 +7,17 @@
|
||||
*/
|
||||
|
||||
//! Unified, keyset-paginated listing of a workspace's runnables (scripts,
|
||||
//! flows, apps) merged into one globally-ordered stream. The homepage uses it
|
||||
//! so a chosen order (recently updated / oldest / name) is correct and complete
|
||||
//! across all three kinds at any workspace size, instead of client-sorting a
|
||||
//! flows, apps, and the `ai_agent` resources listed as agents) merged into one
|
||||
//! globally-ordered stream. The homepage uses it so a chosen order (recently
|
||||
//! updated / oldest / name) is correct and complete across every kind at any
|
||||
//! workspace size, instead of client-sorting a
|
||||
//! per-kind capped window.
|
||||
//!
|
||||
//! Efficiency: each kind is a UNION ALL branch ordered by an index on
|
||||
//! `(workspace_id, archived, <sort key>)` (created_at / edited_at, or the lowered
|
||||
//! summary-or-path expression for name orders); Postgres merges the ordered
|
||||
//! branches and stops at the page limit. Pagination is keyset — a
|
||||
//! branches and stops at the page limit. Agents, which are few, are instead
|
||||
//! reached through the partial `idx_resource_agent_owner_prefix` and sorted whole. Pagination is keyset — a
|
||||
//! `(sort_key, path, kind, tiebreak)` cursor, where `tiebreak` (a script's hash,
|
||||
//! 0 for flow/app) is a stable final key that keeps the order total even when rows
|
||||
//! tie on (sort_key, path, kind) — so deep pages don't re-scan. Visibility is
|
||||
@@ -51,7 +53,7 @@ struct ListRunnablesQuery {
|
||||
order_by: Option<String>,
|
||||
/// Descending when true (default true).
|
||||
order_desc: Option<bool>,
|
||||
/// Comma-separated subset of `script,flow,app`; omitted means all.
|
||||
/// Comma-separated subset of `script,flow,app,agent`; omitted means all.
|
||||
kinds: Option<String>,
|
||||
show_archived: Option<bool>,
|
||||
/// Include library scripts (no runnable main). Ignored for flows/apps.
|
||||
@@ -80,7 +82,7 @@ struct ListRunnablesQuery {
|
||||
#[derive(Serialize, sqlx::FromRow)]
|
||||
struct RunnableItem {
|
||||
#[serde(rename = "type")]
|
||||
kind: String, // 'script' | 'flow' | 'app'
|
||||
kind: String, // 'script' | 'flow' | 'app' | 'agent'
|
||||
path: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
summary: Option<String>,
|
||||
@@ -130,6 +132,11 @@ struct RunnableItem {
|
||||
id: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
version: Option<i64>,
|
||||
// agent-only: the `memory` setting, which decides whether the agent opens as a chat.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
agent_memory: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
ws_specific: Option<bool>,
|
||||
// sort keys, echoed into the cursor (not serialized to the client)
|
||||
#[serde(skip)]
|
||||
sort_time: chrono::DateTime<chrono::Utc>,
|
||||
@@ -247,7 +254,7 @@ fn scope_path_predicate(
|
||||
}
|
||||
}
|
||||
|
||||
/// The three UNION-ALL branch SELECTs, each projecting the shared `RunnableItem`
|
||||
/// The UNION-ALL branch SELECTs, each projecting the shared `RunnableItem`
|
||||
/// column set (NULL for columns that don't apply to that kind). `$1`=workspace,
|
||||
/// `$2`=username (favorites), `$3`=email (drafts). Kind-specific and per-request
|
||||
/// WHERE fragments are appended by the caller.
|
||||
@@ -255,6 +262,7 @@ struct Branches {
|
||||
script: String,
|
||||
flow: String,
|
||||
app: String,
|
||||
agent: String,
|
||||
}
|
||||
|
||||
fn branch_sqls() -> Branches {
|
||||
@@ -281,6 +289,7 @@ fn branch_sqls() -> Branches {
|
||||
o.codebase IS NOT NULL as use_codebase, \
|
||||
(o.lock_error_logs IS NOT NULL) as has_deploy_errors, NULL::bool as chat_input_enabled, \
|
||||
NULL::bool as raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \
|
||||
NULL::jsonb as agent_memory, NULL::bool as ws_specific, \
|
||||
o.created_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, o.hash as tiebreak \
|
||||
FROM script o \
|
||||
LEFT JOIN favorite ON favorite.favorite_kind = 'script' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = $2 \
|
||||
@@ -298,6 +307,7 @@ fn branch_sqls() -> Branches {
|
||||
NULL::bool as use_codebase, NULL::bool as has_deploy_errors, \
|
||||
(o.value->>'chat_input_enabled')::bool as chat_input_enabled, \
|
||||
NULL::bool as raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \
|
||||
NULL::jsonb as agent_memory, NULL::bool as ws_specific, \
|
||||
o.edited_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, 0::bigint as tiebreak \
|
||||
FROM flow o \
|
||||
LEFT JOIN favorite ON favorite.favorite_kind = 'flow' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = $2 \
|
||||
@@ -315,6 +325,7 @@ fn branch_sqls() -> Branches {
|
||||
NULL::bool as use_codebase, NULL::bool as has_deploy_errors, NULL::bool as chat_input_enabled, \
|
||||
av.raw_app, o.policy->>'execution_mode' as execution_mode, o.id, \
|
||||
o.versions[array_upper(o.versions, 1)] as version, \
|
||||
NULL::jsonb as agent_memory, NULL::bool as ws_specific, \
|
||||
COALESCE(av.created_at, 'epoch'::timestamptz) as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, 0::bigint as tiebreak \
|
||||
FROM app o \
|
||||
LEFT JOIN favorite ON favorite.favorite_kind = 'app' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = $2 \
|
||||
@@ -323,25 +334,68 @@ fn branch_sqls() -> Branches {
|
||||
draft_users = draft_users("d.typ IN ('app', 'raw_app')")
|
||||
);
|
||||
|
||||
Branches { script, flow, app }
|
||||
// An agent is an `ai_agent` resource; the subquery renames its description to the
|
||||
// `summary` the shared search and name sort read.
|
||||
let agent = format!(
|
||||
"SELECT 'agent' as kind, o.path, o.summary, o.workspace_id, o.extra_perms, \
|
||||
favorite.path IS NOT NULL as starred, false as archived, \
|
||||
draft.email IS NOT NULL as is_draft, NULL::bool as draft_only, NULL::text as draft_path, \
|
||||
{draft_users}, o.labels, folder_labels(o.workspace_id, o.path) as inherited_labels, \
|
||||
NULL::bool as ws_error_handler_muted, o.edited_at, \
|
||||
NULL::bigint as hash, NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \
|
||||
NULL::bool as use_codebase, NULL::bool as has_deploy_errors, NULL::bool as chat_input_enabled, \
|
||||
NULL::bool as raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \
|
||||
o.agent_memory, ws_specific.path IS NOT NULL as ws_specific, \
|
||||
COALESCE(o.edited_at, 'epoch'::timestamptz) as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, 0::bigint as tiebreak \
|
||||
FROM (SELECT workspace_id, path, description as summary, extra_perms, labels, edited_at, \
|
||||
value->'memory' as agent_memory \
|
||||
FROM resource WHERE resource_type = 'ai_agent') o \
|
||||
LEFT JOIN favorite ON favorite.favorite_kind = 'agent' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = $2 \
|
||||
LEFT JOIN draft ON draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'resource' AND draft.email = $3 \
|
||||
LEFT JOIN ws_specific ON ws_specific.path = o.path AND ws_specific.workspace_id = o.workspace_id AND ws_specific.item_kind = 'resource'",
|
||||
draft_users = draft_users("d.typ = 'resource'")
|
||||
);
|
||||
|
||||
Branches { script, flow, app, agent }
|
||||
}
|
||||
|
||||
/// The draft rows a kind lists and the table whose rows make a draft not draft-only.
|
||||
fn draft_kind_source(kind: &str) -> (&'static str, &'static str) {
|
||||
match kind {
|
||||
"script" => ("d.typ = 'script'", "script"),
|
||||
"flow" => ("d.typ = 'flow'", "flow"),
|
||||
// Typed by the draft itself, as the resource listing filters its draft-only rows.
|
||||
"agent" => (
|
||||
"d.typ = 'resource' AND d.value->>'resource_type' = 'ai_agent'",
|
||||
"resource",
|
||||
),
|
||||
_ => ("d.typ IN ('app', 'raw_app')", "app"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a draft records the path its editor has staged. Scripts and resources bind the
|
||||
/// Path widget to the item's own `path`, so it round-trips through the draft JSON's
|
||||
/// `path`; flows and apps write a separate `draft_path` only when it differs from the
|
||||
/// deployed one. See scripts.rs.
|
||||
fn draft_typed_path(kind: &str) -> &'static str {
|
||||
if kind == "script" || kind == "agent" {
|
||||
"path"
|
||||
} else {
|
||||
"draft_path"
|
||||
}
|
||||
}
|
||||
|
||||
/// The draft-only branch for a kind: the caller's drafts at paths carrying no
|
||||
/// deployed row, projected into the same column set as `branch_sqls` so they
|
||||
/// sort, search and paginate as ordinary rows. Same `$1`/`$2`/`$3` contract.
|
||||
fn draft_branch_sql(kind: &str) -> String {
|
||||
let (typ_pred, deployed) = match kind {
|
||||
"script" => ("d.typ = 'script'", "script"),
|
||||
"flow" => ("d.typ = 'flow'", "flow"),
|
||||
_ => ("d.typ IN ('app', 'raw_app')", "app"),
|
||||
};
|
||||
// Scripts bind the Path widget to `script.path`, so the typed path round-trips
|
||||
// through the draft JSON's own `path`; flows and apps write a separate
|
||||
// `draft_path` only when it differs from the deployed one. See scripts.rs.
|
||||
let typed_path = if kind == "script" {
|
||||
"path"
|
||||
let (typ_pred, deployed) = draft_kind_source(kind);
|
||||
let typed_path = draft_typed_path(kind);
|
||||
// A resource draft carries the description where the other kinds carry a summary.
|
||||
let summary_field = if kind == "agent" {
|
||||
"description"
|
||||
} else {
|
||||
"draft_path"
|
||||
"summary"
|
||||
};
|
||||
// `auto_kind` is only what the editor stamped into the draft — a `// pipeline`
|
||||
// annotation is not re-derived from the content here, unlike the per-kind
|
||||
@@ -351,7 +405,8 @@ fn draft_branch_sql(kind: &str) -> String {
|
||||
"script" => {
|
||||
"d.value->>'language' as language, d.value->>'kind' as script_kind, \
|
||||
d.value->>'auto_kind' as auto_kind, false as raw_app, \
|
||||
NULL::bool as chat_input_enabled"
|
||||
NULL::bool as chat_input_enabled, \
|
||||
NULL::jsonb as agent_memory, NULL::bool as ws_specific"
|
||||
}
|
||||
// Type-guarded rather than a bare `::bool` cast: draft JSON is stored
|
||||
// unvalidated, so a malformed value must yield NULL, not abort the list,
|
||||
@@ -360,11 +415,20 @@ fn draft_branch_sql(kind: &str) -> String {
|
||||
"NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \
|
||||
false as raw_app, \
|
||||
CASE WHEN json_typeof(d.value->'value'->'chat_input_enabled') = 'boolean' \
|
||||
THEN (d.value->'value'->>'chat_input_enabled')::bool END as chat_input_enabled"
|
||||
THEN (d.value->'value'->>'chat_input_enabled')::bool END as chat_input_enabled, \
|
||||
NULL::jsonb as agent_memory, NULL::bool as ws_specific"
|
||||
}
|
||||
"agent" => {
|
||||
"NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \
|
||||
false as raw_app, NULL::bool as chat_input_enabled, \
|
||||
(d.value->'args'->'memory')::jsonb as agent_memory, \
|
||||
CASE WHEN json_typeof(d.value->'wsSpecific') = 'boolean' \
|
||||
THEN (d.value->>'wsSpecific')::bool END as ws_specific"
|
||||
}
|
||||
_ => {
|
||||
"NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \
|
||||
(d.typ = 'raw_app') as raw_app, NULL::bool as chat_input_enabled"
|
||||
(d.typ = 'raw_app') as raw_app, NULL::bool as chat_input_enabled, \
|
||||
NULL::jsonb as agent_memory, NULL::bool as ws_specific"
|
||||
}
|
||||
};
|
||||
format!(
|
||||
@@ -378,11 +442,12 @@ fn draft_branch_sql(kind: &str) -> String {
|
||||
NULL::bigint as hash, o.language, o.script_kind, o.auto_kind, \
|
||||
NULL::bool as use_codebase, NULL::bool as has_deploy_errors, o.chat_input_enabled, \
|
||||
o.raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \
|
||||
o.agent_memory, o.ws_specific, \
|
||||
o.created_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.draft_path, o.path)) as sort_name, 0::bigint as tiebreak \
|
||||
FROM ( \
|
||||
SELECT DISTINCT ON (d.path) d.workspace_id, d.path, d.created_at, \
|
||||
d.email IS NULL as legacy, \
|
||||
COALESCE(d.value->>'summary', '') as summary, \
|
||||
COALESCE(d.value->>'{summary_field}', '') as summary, \
|
||||
NULLIF(NULLIF(d.value->>'{typed_path}', ''), d.path) as draft_path, \
|
||||
{kind_cols} \
|
||||
FROM draft d \
|
||||
@@ -397,6 +462,8 @@ fn draft_branch_sql(kind: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
const ALL_KINDS: [&str; 4] = ["script", "flow", "app", "agent"];
|
||||
|
||||
async fn list_runnables(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
@@ -416,16 +483,16 @@ async fn list_runnables(
|
||||
};
|
||||
|
||||
let mut kinds: Vec<&str> = match q.kinds.as_deref() {
|
||||
None | Some("") => vec!["script", "flow", "app"],
|
||||
None | Some("") => ALL_KINDS.to_vec(),
|
||||
Some(csv) => csv
|
||||
.split(',')
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| ["script", "flow", "app"].contains(s))
|
||||
.filter(|s| ALL_KINDS.contains(s))
|
||||
.collect(),
|
||||
};
|
||||
// Apps carry no `archived` column and are never listed as archived.
|
||||
// Apps and agents carry no `archived` column and are never listed as archived.
|
||||
if show_archived {
|
||||
kinds.retain(|k| *k != "app");
|
||||
kinds.retain(|k| *k != "app" && *k != "agent");
|
||||
}
|
||||
|
||||
let branches = branch_sqls();
|
||||
@@ -533,6 +600,11 @@ async fn list_runnables(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let agent_scope = if kinds.contains(&"agent") {
|
||||
scope_path_predicate(&authed, "resources", "o", 3, &mut binds)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Per-kind archived predicate (scripts/flows have the column; apps don't and
|
||||
// are excluded from the archived view).
|
||||
@@ -570,6 +642,10 @@ async fn list_runnables(
|
||||
if let Some(s) = &app_scope {
|
||||
app_extras.push(s.clone());
|
||||
}
|
||||
let mut agent_extras: Vec<String> = vec![];
|
||||
if let Some(s) = &agent_scope {
|
||||
agent_extras.push(s.clone());
|
||||
}
|
||||
|
||||
// Draft-only rows are the caller's own work in progress: never archived, so they
|
||||
// have no place in the archived view, and carrying no labels of their own they are
|
||||
@@ -589,6 +665,7 @@ async fn list_runnables(
|
||||
let scope = match kind {
|
||||
"script" => &script_scope,
|
||||
"flow" => &flow_scope,
|
||||
"agent" => &agent_scope,
|
||||
_ => &app_scope,
|
||||
};
|
||||
// The lib filter reads the same projected `auto_kind`, so a draft-only library
|
||||
@@ -649,6 +726,7 @@ async fn list_runnables(
|
||||
"script" => (&branches.script, &script_extras),
|
||||
"flow" => (&branches.flow, &flow_extras),
|
||||
"app" => (&branches.app, &app_extras),
|
||||
"agent" => (&branches.agent, &agent_extras),
|
||||
_ => return None,
|
||||
};
|
||||
Some(build_branch(
|
||||
@@ -708,7 +786,7 @@ async fn list_runnables(
|
||||
// No draft branch here: a draft-only path has no favorite row (the UI won't let
|
||||
// you star one), so it would scan the caller's whole draft slice per kind to
|
||||
// return nothing. The main stream below takes them unfiltered instead.
|
||||
let starred_branches: Vec<String> = ["script", "flow", "app"]
|
||||
let starred_branches: Vec<String> = ALL_KINDS
|
||||
.iter()
|
||||
.filter_map(|k| branch_for(k, Some(true), None, None))
|
||||
.collect();
|
||||
@@ -728,10 +806,10 @@ async fn list_runnables(
|
||||
// Main paged stream: non-starred rows (starred were pinned on the first page above).
|
||||
let main_fav = Some(false);
|
||||
let ns_branches: Vec<String> =
|
||||
["script", "flow", "app"]
|
||||
ALL_KINDS
|
||||
.iter()
|
||||
.filter_map(|k| branch_for(k, main_fav, keyset_sql.as_deref(), Some(per_page)))
|
||||
.chain(["script", "flow", "app"].iter().filter_map(|k| {
|
||||
.chain(ALL_KINDS.iter().filter_map(|k| {
|
||||
draft_branch_for(k, main_fav, keyset_sql.as_deref(), Some(per_page))
|
||||
}))
|
||||
.collect();
|
||||
@@ -762,7 +840,7 @@ async fn list_runnables(
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CountRunnablesQuery {
|
||||
/// Comma-separated subset of `script,flow,app`; omitted means all.
|
||||
/// Comma-separated subset of `script,flow,app,agent`; omitted means all.
|
||||
kinds: Option<String>,
|
||||
/// Include library scripts (no runnable main). Ignored for flows/apps.
|
||||
include_without_main: Option<bool>,
|
||||
@@ -789,7 +867,7 @@ struct OwnerCount {
|
||||
/// `owner/<rest>` is >= `owner || '/'` and, since '/' (0x2F) is immediately
|
||||
/// followed by '0' (0x30), < `owner || '0'`. `~>=~` / `~<~` are the
|
||||
/// text_pattern_ops operators, which is what lets `idx_<kind>_owner_prefix`
|
||||
/// answer the count with an index-only scan — the default opclass sorts by the
|
||||
/// (`idx_resource_agent_owner_prefix` for agents) answer the count with an index-only scan — the default opclass sorts by the
|
||||
/// database collation and cannot serve a byte-prefix range.
|
||||
fn owner_prefix_range(alias: &str) -> String {
|
||||
format!("{alias}.path ~>=~ (o.owner || '/') AND {alias}.path ~<~ (o.owner || '0')")
|
||||
@@ -818,13 +896,13 @@ async fn count_runnables_by_owner(
|
||||
Query(q): Query<CountRunnablesQuery>,
|
||||
) -> JsonResult<RunnableCountsResponse> {
|
||||
let kinds: Vec<&str> = match q.kinds.as_deref() {
|
||||
None | Some("") => vec!["script", "flow", "app"],
|
||||
None | Some("") => ALL_KINDS.to_vec(),
|
||||
// Deduplicated: every kind becomes its own count subquery, so a repeated
|
||||
// entry would both double that kind's count and multiply the scans.
|
||||
Some(csv) => {
|
||||
let mut ks: Vec<&str> = vec![];
|
||||
for k in csv.split(',').map(|s| s.trim()) {
|
||||
if ["script", "flow", "app"].contains(&k) && !ks.contains(&k) {
|
||||
if ALL_KINDS.contains(&k) && !ks.contains(&k) {
|
||||
ks.push(k);
|
||||
}
|
||||
}
|
||||
@@ -866,6 +944,13 @@ async fn count_runnables_by_owner(
|
||||
w.push(s);
|
||||
}
|
||||
}
|
||||
"agent" => {
|
||||
w.push(format!("{alias}.resource_type = 'ai_agent'"));
|
||||
if let Some(s) = scope_path_predicate(&authed, "resources", alias, base, binds)
|
||||
{
|
||||
w.push(s);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(s) = scope_path_predicate(&authed, "apps", alias, base, binds) {
|
||||
w.push(s);
|
||||
@@ -877,6 +962,7 @@ async fn count_runnables_by_owner(
|
||||
let table_of = |kind: &str| match kind {
|
||||
"script" => "script",
|
||||
"flow" => "flow",
|
||||
"agent" => "resource",
|
||||
_ => "app",
|
||||
};
|
||||
|
||||
@@ -1048,16 +1134,17 @@ async fn add_draft_counts(
|
||||
let branches: Vec<String> = kinds
|
||||
.iter()
|
||||
.map(|kind| {
|
||||
let (typ_pred, deployed, domain) = match *kind {
|
||||
"script" => ("d.typ = 'script'", "script", "scripts"),
|
||||
"flow" => ("d.typ = 'flow'", "flow", "flows"),
|
||||
_ => ("d.typ IN ('app', 'raw_app')", "app", "apps"),
|
||||
let (typ_pred, deployed) = draft_kind_source(kind);
|
||||
let domain = match *kind {
|
||||
"script" => "scripts",
|
||||
"flow" => "flows",
|
||||
"agent" => "resources",
|
||||
_ => "apps",
|
||||
};
|
||||
// The owner a draft counts under is where it says it will live, not the
|
||||
// `u/<caller>/draft_<uuid>` it is parked at — same path `/list` groups and
|
||||
// filters on. Scripts round-trip the typed path through the draft JSON's
|
||||
// own `path`; flows and apps write `draft_path`. See scripts.rs.
|
||||
let typed_path = if *kind == "script" { "path" } else { "draft_path" };
|
||||
// filters on.
|
||||
let typed_path = draft_typed_path(kind);
|
||||
let effective_path =
|
||||
format!("COALESCE(NULLIF(d.value->>'{typed_path}', ''), d.path) as path");
|
||||
let mut w = vec![
|
||||
|
||||
@@ -261,6 +261,8 @@ pub struct ListResourceQuery {
|
||||
/// When true, append per-user draft-only rows; picker callers leave it off
|
||||
/// to stay deployed-only. See list synthesis in scripts.rs.
|
||||
pub include_draft_only: Option<bool>,
|
||||
/// Only the `ai_agent` resources the authed user starred.
|
||||
pub starred_only: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, FromRow)]
|
||||
@@ -437,6 +439,14 @@ async fn list_resources(
|
||||
);
|
||||
}
|
||||
|
||||
if lq.starred_only.unwrap_or(false) {
|
||||
sqlb.and_where(
|
||||
"EXISTS(SELECT 1 FROM favorite WHERE favorite.workspace_id = resource.workspace_id \
|
||||
AND favorite.path = resource.path AND favorite.favorite_kind = 'agent' AND favorite.usr = ?)"
|
||||
.bind(&authed.username),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(label) = &lq.label {
|
||||
for l in label.split(',') {
|
||||
sqlb.and_where(
|
||||
@@ -472,6 +482,7 @@ async fn list_resources(
|
||||
&& lq.value.is_none()
|
||||
&& lq.broad_filter.is_none()
|
||||
&& lq.label.is_none()
|
||||
&& !lq.starred_only.unwrap_or(false)
|
||||
{
|
||||
let rt_filter: Option<Vec<&str>> = lq
|
||||
.resource_type
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import ChatFlowBadge from '$lib/components/flows/ChatFlowBadge.svelte'
|
||||
import { keepsManagedMemory } from '$lib/components/flows/agentFormFields'
|
||||
import { agentMenuItems, deleteAgent } from '$lib/components/flows/agentActions'
|
||||
import AgentHistory from '$lib/components/flows/AgentHistory.svelte'
|
||||
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
|
||||
import Row from './Row.svelte'
|
||||
import { selectMenuItems, type RowSelection } from './rowSelection'
|
||||
@@ -54,8 +55,16 @@
|
||||
let editHref = $derived(`/agents/edit/${agent.path}`)
|
||||
// A draft-only agent has nothing deployed for its page to run, so it opens in the editor.
|
||||
let rowHref = $derived(agent.draft_only ? editHref : `/agents/get/${agent.path}`)
|
||||
let history: AgentHistory | undefined = $state(undefined)
|
||||
</script>
|
||||
|
||||
<AgentHistory
|
||||
bind:this={history}
|
||||
path={agent.path}
|
||||
canRestore={agent.canWrite}
|
||||
onRestored={() => dispatch('change')}
|
||||
/>
|
||||
|
||||
{#snippet chatBadge()}
|
||||
<ChatFlowBadge title="Managed memory on: this agent opens as a conversation" />
|
||||
{/snippet}
|
||||
@@ -69,7 +78,7 @@
|
||||
path={agent.draft_path ?? agent.path}
|
||||
summary={agent.description}
|
||||
workspaceId={agent.workspace_id ?? $workspaceStore ?? ''}
|
||||
canFavorite={false}
|
||||
canFavorite={!agent.draft_only}
|
||||
{depth}
|
||||
{rowSelection}
|
||||
>
|
||||
@@ -110,8 +119,10 @@
|
||||
canWrite: agent.canWrite,
|
||||
draftOnly: Boolean(agent.draft_only),
|
||||
wsSpecific: agent.ws_specific,
|
||||
operator: $userStore?.operator,
|
||||
onPermissions: () => shareModal.openDrawer?.(agent.path, 'resource'),
|
||||
onDeploy: () => deploymentDrawer.openDrawer(agent.path, 'resource'),
|
||||
onHistory: () => history?.open(),
|
||||
onDelete: (event) => {
|
||||
const { path } = agent
|
||||
if (event?.shiftKey) remove(path)
|
||||
|
||||
@@ -260,7 +260,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if canFavorite && (kind == 'app' || kind == 'raw_app' || kind == 'script' || kind == 'flow')}
|
||||
{#if canFavorite && (kind == 'app' || kind == 'raw_app' || kind == 'script' || kind == 'flow' || kind == 'agent')}
|
||||
<div class="center-center h-full text-xs font-semibold text-secondary w-9">
|
||||
<Star {kind} {path} {workspaceId} {summary} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { Drawer, DrawerContent } from '$lib/components/common'
|
||||
import ResourceVersionHistory from '$lib/components/ResourceVersionHistory.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { markAgentWritten } from './agentEditorStore.svelte'
|
||||
|
||||
interface Props {
|
||||
path: string
|
||||
canRestore: boolean
|
||||
onRestored?: () => void
|
||||
}
|
||||
|
||||
let { path, canRestore, onRestored }: Props = $props()
|
||||
let drawer: Drawer | undefined = $state()
|
||||
|
||||
export function open() {
|
||||
drawer?.openDrawer()
|
||||
}
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={drawer} size="1200px">
|
||||
<DrawerContent title="Version history" on:close={() => drawer?.closeDrawer()} noPadding>
|
||||
<ResourceVersionHistory
|
||||
{path}
|
||||
{canRestore}
|
||||
onRestore={() => {
|
||||
drawer?.closeDrawer()
|
||||
// A restore writes the resource as a deploy does, so whatever shows this agent re-reads it.
|
||||
markAgentWritten($workspaceStore ?? undefined, path)
|
||||
onRestored?.()
|
||||
}}
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
@@ -1,8 +1,9 @@
|
||||
import { FileUp, Shield, Trash } from 'lucide-svelte'
|
||||
import { Copy, Eye, FileUp, History, List, Shield, Trash } from 'lucide-svelte'
|
||||
import { ResourceService } from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import type { Item } from '$lib/utils'
|
||||
import { copyToClipboard, type Item } from '$lib/utils'
|
||||
import { isDeployable } from '$lib/utils_deployable'
|
||||
import { goto } from '$lib/navigation'
|
||||
import type { WorkspaceDeployUISettings } from '$lib/gen'
|
||||
|
||||
/** Deletes a saved agent, saying how it went. Resolves whether it was deleted. */
|
||||
@@ -24,10 +25,13 @@ export function agentMenuItems(agent: {
|
||||
/** Never deployed: no resource yet to hold permissions or to deploy onward. */
|
||||
draftOnly?: boolean
|
||||
wsSpecific?: boolean
|
||||
/** Hides what an operator has no page for, as on a flow's menu. */
|
||||
operator?: boolean
|
||||
/** Undefined until loaded, when nothing is offered for deploy. */
|
||||
deployUiSettings: WorkspaceDeployUISettings | undefined
|
||||
onPermissions: () => void
|
||||
onDeploy: () => void
|
||||
onHistory: () => void
|
||||
onDelete: (event?: MouseEvent) => void
|
||||
}): Item[] {
|
||||
const deployable =
|
||||
@@ -35,6 +39,28 @@ export function agentMenuItems(agent: {
|
||||
!agent.draftOnly &&
|
||||
isDeployable('resource', agent.path, agent.deployUiSettings)
|
||||
return [
|
||||
// A draft-only agent has no runs, no audit trail, and only a placeholder path yet.
|
||||
...(agent.draftOnly
|
||||
? []
|
||||
: [
|
||||
{
|
||||
// An agent run is a flow preview, which the runs page hides by default. Chat turns
|
||||
// are filed under `<path>.chat` (agent_runs.rs) and listed by the agent's own chat.
|
||||
displayName: 'View runs',
|
||||
icon: List,
|
||||
action: () => goto(`/runs/${agent.path}?job_kinds=previews`)
|
||||
},
|
||||
...(agent.operator
|
||||
? []
|
||||
: [
|
||||
{
|
||||
displayName: 'Audit logs',
|
||||
icon: Eye,
|
||||
action: () => goto(`/audit_logs?resource=${agent.path}`)
|
||||
}
|
||||
]),
|
||||
{ displayName: 'Copy path', icon: Copy, action: () => copyToClipboard(agent.path) }
|
||||
]),
|
||||
{
|
||||
displayName: 'Permissions',
|
||||
icon: Shield,
|
||||
@@ -44,6 +70,9 @@ export function agentMenuItems(agent: {
|
||||
...(deployable
|
||||
? [{ displayName: 'Deploy to prod/staging', icon: FileUp, action: agent.onDeploy }]
|
||||
: []),
|
||||
...(agent.draftOnly || agent.operator
|
||||
? []
|
||||
: [{ displayName: 'Version history', icon: History, action: agent.onHistory }]),
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash,
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
type ListableApp,
|
||||
type Script,
|
||||
ScriptService,
|
||||
ResourceService,
|
||||
type Flow,
|
||||
type ListableRawApp,
|
||||
type ListableResource,
|
||||
@@ -175,12 +174,7 @@
|
||||
let itemKind = $derived(
|
||||
(filterValues.val.kind ?? 'all') as 'script' | 'flow' | 'app' | 'agent' | 'all'
|
||||
)
|
||||
// Agents are `ai_agent` resources, listed through the resource endpoint: the runnables
|
||||
// endpoint has no such kind, so it is asked for nothing on the agent view and for every
|
||||
// kind on the combined one.
|
||||
let showsRunnables = $derived(itemKind !== 'agent')
|
||||
let showsAgents = $derived(itemKind === 'all' || itemKind === 'agent')
|
||||
let runnableKinds = $derived(itemKind === 'all' || itemKind === 'agent' ? undefined : itemKind)
|
||||
let runnableKinds = $derived(itemKind === 'all' ? undefined : itemKind)
|
||||
let archived = $derived(!!filterValues.val.archived)
|
||||
let includeWithoutMain = $derived((filterValues.val.include_library ?? true) as boolean)
|
||||
let filterUserFolders = $derived(!!filterValues.val.only_user_folders)
|
||||
@@ -318,11 +312,7 @@
|
||||
let flows: TableFlow[] | undefined = $state()
|
||||
let apps: TableApp[] | undefined = $state()
|
||||
let raw_apps: TableRawApp[] | undefined = $state()
|
||||
// Starts empty rather than undefined: the runnables page decides when the list renders,
|
||||
// and agents join it when their own request lands.
|
||||
let agents: TableAgent[] = $state([])
|
||||
let agentOwnerCounts: Record<string, number> = $state({})
|
||||
let agentsGen = 0
|
||||
let agents: TableAgent[] | undefined = $state()
|
||||
// Monotonic fetch-order counter stamped onto each row as it arrives (see TableItem.ord).
|
||||
let fetchOrd = 0
|
||||
|
||||
@@ -353,7 +343,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function mapRunnable(it: RunnableItem): TableScript | TableFlow | TableApp {
|
||||
function mapRunnable(it: RunnableItem): TableScript | TableFlow | TableApp | TableAgent {
|
||||
const base = {
|
||||
...it,
|
||||
canWrite:
|
||||
@@ -367,84 +357,14 @@
|
||||
// combinedItems reads a script's time from `created_at`; the endpoint's
|
||||
// unified `edited_at` holds exactly that for scripts.
|
||||
if (it.type === 'script') return { ...base, created_at: it.edited_at } as unknown as TableScript
|
||||
// An agent row reads its resource's description, which the endpoint reports as `summary`.
|
||||
if (it.type === 'agent') return { ...base, description: it.summary } as unknown as TableAgent
|
||||
return base as unknown as TableFlow | TableApp
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace's saved agents, as the kind the home page shows them as. Read from the
|
||||
* resource listing, which the runnables' keyset order knows nothing of, so the order is applied
|
||||
* here and the rows are stamped before every runnable ordinal: the agents lead as one block and
|
||||
* a page of runnables keeps the server's order. Leading rather than trailing, or the list's
|
||||
* first window would never reach them past a page of runnables.
|
||||
*/
|
||||
async function loadAgents(): Promise<void> {
|
||||
const ws = $workspaceStore
|
||||
const gen = ++agentsGen
|
||||
if (!ws || !$userStore || !showsAgents || archived) {
|
||||
agents = []
|
||||
agentOwnerCounts = {}
|
||||
return
|
||||
}
|
||||
const rows: ListableResource[] = []
|
||||
try {
|
||||
// Paged even though search and scoping run over the whole set here: the listing caps a
|
||||
// page, and only the first carries the draft-only rows, so a short page ends it.
|
||||
const perPage = 1000
|
||||
for (let page = 1; ; page++) {
|
||||
const batch = await ResourceService.listResource({
|
||||
workspace: ws,
|
||||
resourceType: 'ai_agent',
|
||||
includeDraftOnly: true,
|
||||
page,
|
||||
perPage
|
||||
})
|
||||
rows.push(...batch)
|
||||
if (batch.length < perPage || gen !== agentsGen) break
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (gen !== agentsGen) return
|
||||
sendUserToast(`Failed to load agents: ${e?.body ?? e?.message ?? e}`, true)
|
||||
agents = []
|
||||
agentOwnerCounts = {}
|
||||
return
|
||||
}
|
||||
if (gen !== agentsGen) return
|
||||
// Counted before the owner scope: the chips and tree nodes list every owner.
|
||||
const counts: Record<string, number> = {}
|
||||
for (const r of rows) {
|
||||
const owner = effectivePath(r).split('/').slice(0, 2).join('/')
|
||||
counts[owner] = (counts[owner] ?? 0) + 1
|
||||
}
|
||||
agentOwnerCounts = counts
|
||||
const scoped = ownerFilter
|
||||
? rows.filter((r) => effectivePath(r).startsWith(ownerFilter + '/'))
|
||||
: rows
|
||||
const byTime = (r: ListableResource) => new Date(r.edited_at ?? 0).getTime()
|
||||
const sorted = [...scoped].sort((a, b) => {
|
||||
switch (sortOrder) {
|
||||
case 'updated_asc':
|
||||
return byTime(a) - byTime(b)
|
||||
case 'name_asc':
|
||||
return cmp(effectivePath(a), effectivePath(b))
|
||||
case 'name_desc':
|
||||
return cmp(effectivePath(b), effectivePath(a))
|
||||
default:
|
||||
return byTime(b) - byTime(a)
|
||||
}
|
||||
})
|
||||
agents = sorted.map((r, i) => ({
|
||||
...r,
|
||||
summary: r.description || undefined,
|
||||
canWrite: editRights.canEditItem('agent', r.path, (r.extra_perms ?? {}) as any),
|
||||
ord: AGENT_ORD_BASE + i
|
||||
}))
|
||||
}
|
||||
/** Below any ordinal a runnables page stamps (they count up from 0), so agents lead as a block. */
|
||||
const AGENT_ORD_BASE = -1_000_000
|
||||
|
||||
// The merged, server-ordered, keyset-paginated source. `reset` reloads from
|
||||
// the first page (order/filter change or workspace switch); otherwise it
|
||||
// appends the next page. All three kinds arrive interleaved and are split into
|
||||
// appends the next page. Every kind arrives interleaved and are split into
|
||||
// the existing per-kind arrays so the downstream pipeline is unchanged.
|
||||
async function loadRunnables(reset: boolean): Promise<void> {
|
||||
const ws = $workspaceStore
|
||||
@@ -458,25 +378,6 @@
|
||||
// arrays (mixing streams) or clobber the pending reset's generation.
|
||||
if (!reset && serverCursor === undefined) return
|
||||
if (scripts === undefined) loading = true
|
||||
// One load per reset: agents are fetched whole, so a load-more has nothing to append.
|
||||
const agentsLoad = reset ? loadAgents() : undefined
|
||||
if (!showsRunnables) {
|
||||
// Supersedes a page still in flight, which would otherwise land in the agent view.
|
||||
const gen = ++loadGen
|
||||
serverCursor = undefined
|
||||
hasMoreServer = false
|
||||
// The rows on screen stay until the agents replace them, as a reload keeps them, or the
|
||||
// view reads as empty in between.
|
||||
await agentsLoad
|
||||
if (gen !== loadGen) return
|
||||
scripts = []
|
||||
flows = []
|
||||
apps = []
|
||||
raw_apps = []
|
||||
pipelineMemberFolders = new Set()
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
if (reset) {
|
||||
serverCursor = undefined
|
||||
hasMoreServer = false
|
||||
@@ -519,18 +420,13 @@
|
||||
// A newer request superseded this one (e.g. order changed mid-flight); drop
|
||||
// this response so a stale page/cursor can't be mixed with the new order.
|
||||
if (gen !== loadGen) return
|
||||
// Landed together with the agents fetched beside it, or a workspace holding only agents
|
||||
// reads as empty until they arrive.
|
||||
if (agentsLoad) {
|
||||
await agentsLoad
|
||||
if (gen !== loadGen) return
|
||||
}
|
||||
serverCursor = res.next_cursor ?? undefined
|
||||
hasMoreServer = !!res.next_cursor
|
||||
|
||||
const s: TableScript[] = reset ? [] : [...(scripts ?? [])]
|
||||
const f: TableFlow[] = reset ? [] : [...(flows ?? [])]
|
||||
const a: TableApp[] = reset ? [] : [...(apps ?? [])]
|
||||
const ag: TableAgent[] = reset ? [] : [...(agents ?? [])]
|
||||
const memberFolders = reset ? new Set<string>() : new Set(pipelineMemberFolders)
|
||||
if (reset) fetchOrd = 0
|
||||
for (const it of res.items ?? []) {
|
||||
@@ -546,11 +442,14 @@
|
||||
f.push({ ...mapRunnable(it), ord: fetchOrd++ } as TableFlow)
|
||||
} else if (it.type === 'app') {
|
||||
a.push({ ...mapRunnable(it), ord: fetchOrd++ } as TableApp)
|
||||
} else if (it.type === 'agent') {
|
||||
ag.push({ ...mapRunnable(it), ord: fetchOrd++ } as TableAgent)
|
||||
}
|
||||
}
|
||||
scripts = s
|
||||
flows = f
|
||||
apps = a
|
||||
agents = ag
|
||||
raw_apps = []
|
||||
pipelineMemberFolders = memberFolders
|
||||
loading = false
|
||||
@@ -564,10 +463,13 @@
|
||||
// skipping ones already present. Lets a search reach matches outside the loaded
|
||||
// browse window without re-fetching the whole list.
|
||||
function mergeRunnables(newItems: RunnableItem[]) {
|
||||
const have = new Set([...(scripts ?? []), ...(flows ?? []), ...(apps ?? [])].map(itemKey))
|
||||
const have = new Set(
|
||||
[...(scripts ?? []), ...(flows ?? []), ...(apps ?? []), ...(agents ?? [])].map(itemKey)
|
||||
)
|
||||
const s = [...(scripts ?? [])]
|
||||
const f = [...(flows ?? [])]
|
||||
const a = [...(apps ?? [])]
|
||||
const ag = [...(agents ?? [])]
|
||||
const memberFolders = new Set(pipelineMemberFolders)
|
||||
let changed = false
|
||||
for (const it of newItems) {
|
||||
@@ -583,12 +485,14 @@
|
||||
if (it.type === 'script') s.push(mapped as TableScript)
|
||||
else if (it.type === 'flow') f.push(mapped as TableFlow)
|
||||
else if (it.type === 'app') a.push(mapped as TableApp)
|
||||
else if (it.type === 'agent') ag.push(mapped as TableAgent)
|
||||
changed = true
|
||||
}
|
||||
if (changed) {
|
||||
scripts = s
|
||||
flows = f
|
||||
apps = a
|
||||
agents = ag
|
||||
pipelineMemberFolders = memberFolders
|
||||
}
|
||||
}
|
||||
@@ -656,13 +560,6 @@
|
||||
// Track the prefix as open first — even a no-op call (re-expanding a cached node)
|
||||
// means it's on screen, so later reloads must refresh it.
|
||||
openOwners.add(owner)
|
||||
// The agent view has no runnables to page in: its rows are the loaded agents, which
|
||||
// `treeSource` adds itself. Rows an owner held from another kind go with the switch.
|
||||
if (!showsRunnables) {
|
||||
treeOwnerItems = treeOwnerItems.filter((x) => !effectivePath(x).startsWith(`${owner}/`))
|
||||
ownerLoad[owner] = { hasMore: false, loading: false, loaded: true, gen: treeGen }
|
||||
return
|
||||
}
|
||||
const st = ownerLoad[owner]
|
||||
// Only a load for the CURRENT generation blocks a new one. A load left in flight
|
||||
// by a superseded generation (treeGen bumped on a sort/filter reload) has already
|
||||
@@ -1105,7 +1002,6 @@
|
||||
[() => $workspaceStore, () => archived, () => itemKind, () => includeWithoutMain],
|
||||
async ([ws, showArchived, kind, withoutMain]) => {
|
||||
if (!ws || showArchived) return undefined
|
||||
if (kind === 'agent') return {}
|
||||
try {
|
||||
const res = await ScriptService.countRunnablesByOwner({
|
||||
workspace: ws,
|
||||
@@ -1121,16 +1017,7 @@
|
||||
}
|
||||
}
|
||||
)
|
||||
// The runnables endpoint never counts an agent, so the loaded agents are added per owner.
|
||||
let ownerCounts = $derived.by(() => {
|
||||
const runnables = ownerCountsRes.current
|
||||
if (runnables == undefined) return undefined
|
||||
const counts = { ...runnables }
|
||||
for (const [owner, n] of Object.entries(agentOwnerCounts)) {
|
||||
counts[owner] = (counts[owner] ?? 0) + n
|
||||
}
|
||||
return counts
|
||||
})
|
||||
let ownerCounts = $derived(ownerCountsRes.current)
|
||||
// The counts decide which owners the tree renders, so drawing it before they land
|
||||
// would show every workspace folder and then prune it away. Hold the skeleton
|
||||
// until the first response instead — it is fetched in parallel with the listing,
|
||||
@@ -1314,8 +1201,7 @@
|
||||
const kind = itemKind
|
||||
// Any term/scope change restarts search paging.
|
||||
searchCursor = undefined
|
||||
// Agents are all loaded, so the client-side search over them is already complete.
|
||||
if (term === '' || !ws || !$userStore || kind === 'agent') return
|
||||
if (term === '' || !ws || !$userStore) return
|
||||
const handle = setTimeout(async () => {
|
||||
let res: { items: RunnableItem[]; next_cursor?: string }
|
||||
try {
|
||||
@@ -1371,7 +1257,7 @@
|
||||
search: term,
|
||||
showArchived: showArchived ? true : undefined,
|
||||
includeWithoutMain: withoutMain ? true : undefined,
|
||||
kinds: kind !== 'all' && kind !== 'agent' ? kind : undefined,
|
||||
kinds: kind !== 'all' ? kind : undefined,
|
||||
pathStart: owner ? owner + '/' : undefined,
|
||||
includeDraftOnly: true,
|
||||
perPage: 1000,
|
||||
@@ -1423,7 +1309,7 @@
|
||||
type: 'raw_app' as 'raw_app',
|
||||
time: new Date(x.edited_at).getTime()
|
||||
})),
|
||||
...agents.map((x) => ({
|
||||
...(agents ?? []).map((x) => ({
|
||||
...x,
|
||||
type: 'agent' as 'agent',
|
||||
time: new Date(x.edited_at ?? 0).getTime()
|
||||
@@ -1487,12 +1373,10 @@
|
||||
// injected as a node and its rows come from the on-demand `treeOwnerItems` store,
|
||||
// so the tree never depends on which owners happen to be in the loaded window.
|
||||
// Otherwise (scoped/search/label) the tree just groups the global `items`.
|
||||
// Agents ride along in lazy mode too: the owner store is fed by the runnables endpoint,
|
||||
// which never returns one, while the resource listing already holds them all.
|
||||
let treeSource = $derived(
|
||||
treeLazyMode
|
||||
? [...treeOwnerItems, ...agents.map((x) => ({ ...x, type: 'agent' as 'agent' }))].filter(
|
||||
(x) => filterItemsPathsBaseOnUserFilters(x, filterUserFolders, filterUserFoldersType)
|
||||
? treeOwnerItems.filter((x) =>
|
||||
filterItemsPathsBaseOnUserFilters(x, filterUserFolders, filterUserFoldersType)
|
||||
)
|
||||
: items
|
||||
)
|
||||
|
||||
@@ -3,16 +3,19 @@
|
||||
import {
|
||||
AppService,
|
||||
FlowService,
|
||||
ResourceService,
|
||||
ScriptService,
|
||||
type Flow,
|
||||
type ListableApp,
|
||||
type ListableRawApp,
|
||||
type ListableResource,
|
||||
type Script,
|
||||
type SearchJobsIndexResponse
|
||||
} from '$lib/gen'
|
||||
import { clickOutside, isMac, scroll_into_view_if_needed_polyfill } from '$lib/utils'
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
BoxesIcon,
|
||||
CalendarIcon,
|
||||
Code2Icon,
|
||||
@@ -432,6 +435,9 @@
|
||||
case 'raw_app':
|
||||
path = `/apps_raw/get/${e.path}`
|
||||
break
|
||||
case 'agent':
|
||||
path = `/agents/get/${e.path}`
|
||||
break
|
||||
default:
|
||||
path = '/'
|
||||
}
|
||||
@@ -501,7 +507,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app'> = T & {
|
||||
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app' | 'agent'> = T & {
|
||||
search_id: string
|
||||
marked?: string
|
||||
type?: U
|
||||
@@ -518,7 +524,9 @@
|
||||
type TableApp = TableItem<ListableApp, 'app'>
|
||||
type TableRawApp = TableItem<ListableRawApp, 'raw_app'>
|
||||
|
||||
type TableAny = TableScript | TableFlow | TableApp | TableRawApp
|
||||
type TableAgent = TableItem<ListableResource & { summary?: string }, 'agent'>
|
||||
|
||||
type TableAny = TableScript | TableFlow | TableApp | TableRawApp | TableAgent
|
||||
|
||||
let combinedItems: TableAny[] | undefined = $state(undefined)
|
||||
|
||||
@@ -532,8 +540,18 @@
|
||||
withoutDescription: true
|
||||
})
|
||||
const apps = await AppService.listApps({ workspace: $workspaceStore! })
|
||||
const [agents, starredAgents] = await Promise.all(
|
||||
[false, true].map((starredOnly) =>
|
||||
ResourceService.listResource({
|
||||
workspace: $workspaceStore!,
|
||||
resourceType: 'ai_agent',
|
||||
starredOnly
|
||||
})
|
||||
)
|
||||
)
|
||||
const starredAgentPaths = new Set(starredAgents.map((x) => x.path))
|
||||
|
||||
let combinedItems: (TableScript | TableFlow | TableApp | TableRawApp)[] | undefined = [
|
||||
let combinedItems: TableAny[] | undefined = [
|
||||
...flows.map((x) => ({
|
||||
...x,
|
||||
type: 'flow' as 'flow',
|
||||
@@ -555,7 +573,17 @@
|
||||
type: 'app' as 'app',
|
||||
time: new Date(x.edited_at).getTime(),
|
||||
search_id: x.path
|
||||
}))
|
||||
})),
|
||||
...agents
|
||||
.filter((x) => !x.draft_only)
|
||||
.map((x) => ({
|
||||
...x,
|
||||
summary: x.description,
|
||||
type: 'agent' as 'agent',
|
||||
time: x.edited_at ? new Date(x.edited_at).getTime() : 0,
|
||||
starred: starredAgentPaths.has(x.path),
|
||||
search_id: x.path
|
||||
}))
|
||||
].sort((a, b) => (a.starred != b.starred ? (a.starred ? -1 : 1) : a.time - b.time > 0 ? -1 : 1))
|
||||
|
||||
return combinedItems
|
||||
@@ -571,6 +599,8 @@
|
||||
return LayoutDashboardIcon
|
||||
case 'raw_app':
|
||||
return LayoutDashboardIcon
|
||||
case 'agent':
|
||||
return Bot
|
||||
}
|
||||
}
|
||||
|
||||
@@ -729,7 +759,9 @@
|
||||
{#if tab === 'default'}
|
||||
{#if (itemMap[tab] ?? []).filter((e) => (combinedItems ?? []).includes(e)).length > 0}
|
||||
<div class="p-2">
|
||||
<div class="py-2 px-1 text-xs font-semibold text-primary"> Flows/Scripts/Apps </div>
|
||||
<div class="py-2 px-1 text-xs font-semibold text-primary">
|
||||
Flows/Scripts/Apps/Agents</div
|
||||
>
|
||||
{#each (itemMap[tab] ?? []).filter((e) => (combinedItems ?? []).includes(e)) as el}
|
||||
<QuickMenuItem
|
||||
onselect={(shift) => {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
flow: `/flows/get/${path}`,
|
||||
app: `/apps/get/${path}`,
|
||||
raw_app: `/apps_raw/get/${path}`,
|
||||
agent: `/agents/get/${path}`,
|
||||
asset: '#'
|
||||
}[kind]
|
||||
}
|
||||
@@ -71,7 +72,7 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { CodeXml, LayoutDashboard, Table2, Star, Trash2 } from 'lucide-svelte'
|
||||
import { Bot, CodeXml, LayoutDashboard, Table2, Star, Trash2 } from 'lucide-svelte'
|
||||
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
|
||||
import { Menu, MenuItem } from '$lib/components/meltComponents'
|
||||
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
|
||||
@@ -141,6 +142,8 @@
|
||||
<BarsStaggered size={16} class="text-teal-500" />
|
||||
{:else if favorite.kind == 'app' || favorite.kind == 'raw_app'}
|
||||
<LayoutDashboard size={16} class="text-orange-500" />
|
||||
{:else if favorite.kind == 'agent'}
|
||||
<Bot size={16} class="text-violet-500" />
|
||||
{:else if favorite.kind == 'asset'}
|
||||
<Table2 size={16} class="text-primary" />
|
||||
{/if}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
AssetService,
|
||||
FlowService,
|
||||
OpenAPI,
|
||||
ResourceService,
|
||||
ScriptService,
|
||||
SettingService,
|
||||
UserService,
|
||||
@@ -670,6 +671,11 @@
|
||||
workspace: $workspaceStore ?? '',
|
||||
starredOnly: true
|
||||
})
|
||||
const agents = await ResourceService.listResource({
|
||||
workspace: $workspaceStore ?? '',
|
||||
resourceType: 'ai_agent',
|
||||
starredOnly: true
|
||||
})
|
||||
const assets = await AssetService.listFavoriteAssets({ workspace: $workspaceStore ?? '' })
|
||||
favoriteManager.current = [
|
||||
...scripts.map((s) => ({
|
||||
@@ -690,6 +696,12 @@
|
||||
href: getFavoriteHref(f.path, 'app'),
|
||||
kind: 'app' as const
|
||||
})),
|
||||
...agents.map((a) => ({
|
||||
label: a.description || getFavoriteLabel(a.path, 'agent'),
|
||||
path: a.path,
|
||||
href: getFavoriteHref(a.path, 'agent'),
|
||||
kind: 'agent' as const
|
||||
})),
|
||||
...assets.map((a) => ({
|
||||
label: getFavoriteLabel(a.path, 'asset'),
|
||||
path: a.path,
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { Alert } from '$lib/components/common'
|
||||
import { keepsManagedMemory } from '$lib/components/flows/agentFormFields'
|
||||
import { agentMenuItems, deleteAgent } from '$lib/components/flows/agentActions'
|
||||
import AgentHistory from '$lib/components/flows/AgentHistory.svelte'
|
||||
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
@@ -40,6 +41,7 @@
|
||||
let configModal: AgentConfigModal | undefined = $state(undefined)
|
||||
let shareModal: ShareModal | undefined = $state(undefined)
|
||||
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state(undefined)
|
||||
let history: AgentHistory | undefined = $state(undefined)
|
||||
let deleteOpen = $state(false)
|
||||
let evalsModal: AgentEvalsModal | undefined = $state(undefined)
|
||||
let runForm: RunForm | undefined = $state(undefined)
|
||||
@@ -77,6 +79,7 @@
|
||||
deployUiSettings: deployUiSettings.current,
|
||||
onPermissions: () => shareModal?.openDrawer?.(path, 'resource'),
|
||||
onDeploy: () => deploymentDrawer?.openDrawer(path, 'resource'),
|
||||
onHistory: () => history?.open(),
|
||||
onDelete: () => (deleteOpen = true)
|
||||
}).map((item) => ({
|
||||
label: item.displayName,
|
||||
@@ -91,6 +94,7 @@
|
||||
|
||||
<ShareModal bind:this={shareModal} />
|
||||
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
|
||||
<AgentHistory bind:this={history} {path} canRestore={canEdit} onRestored={() => reloaded++} />
|
||||
<ConfirmationModal
|
||||
open={deleteOpen}
|
||||
title="Delete agent"
|
||||
|
||||
Reference in New Issue
Block a user