From fe2571ea53603625f48601aa800e9f700322d5b8 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Wed, 14 Oct 2026 10:59:01 +0200 Subject: [PATCH] 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 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 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 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 Claude-Session: https://claude.ai/code/session_01Vo7HXMdgqXJhR7p8YyVgjB * feat: find agents in the ctrl+k search Co-Authored-By: Claude Opus 5.5 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 Claude-Session: https://claude.ai/code/session_01Vo7HXMdgqXJhR7p8YyVgjB --------- Co-authored-by: Claude Opus 5.5 --- ...009164611_add_agent_favorite_kind.down.sql | 1 + ...61009164611_add_agent_favorite_kind.up.sql | 8 + ...09173546_agent_owner_prefix_index.down.sql | 1 + ...1009173546_agent_owner_prefix_index.up.sql | 7 + backend/summarized_schema.txt | 2 +- backend/windmill-api/openapi.yaml | 29 ++- backend/windmill-api/src/db.rs | 3 + backend/windmill-api/src/favorite.rs | 1 + backend/windmill-api/src/runnables.rs | 169 +++++++++++++----- backend/windmill-store/src/resources.rs | 11 ++ .../components/common/table/AgentRow.svelte | 13 +- .../lib/components/common/table/Row.svelte | 2 +- .../lib/components/flows/AgentHistory.svelte | 34 ++++ .../src/lib/components/flows/agentActions.ts | 33 +++- .../src/lib/components/home/ItemsList.svelte | 160 +++-------------- .../search/GlobalSearchModal.svelte | 42 ++++- .../components/sidebar/FavoriteMenu.svelte | 5 +- .../src/routes/(root)/(logged)/+layout.svelte | 12 ++ .../agents/get/[...path]/+page.svelte | 4 + 19 files changed, 339 insertions(+), 198 deletions(-) create mode 100644 backend/migrations/20261009164611_add_agent_favorite_kind.down.sql create mode 100644 backend/migrations/20261009164611_add_agent_favorite_kind.up.sql create mode 100644 backend/migrations/20261009173546_agent_owner_prefix_index.down.sql create mode 100644 backend/migrations/20261009173546_agent_owner_prefix_index.up.sql create mode 100644 frontend/src/lib/components/flows/AgentHistory.svelte diff --git a/backend/migrations/20261009164611_add_agent_favorite_kind.down.sql b/backend/migrations/20261009164611_add_agent_favorite_kind.down.sql new file mode 100644 index 0000000000..c50ff19ab5 --- /dev/null +++ b/backend/migrations/20261009164611_add_agent_favorite_kind.down.sql @@ -0,0 +1 @@ +DELETE FROM favorite WHERE favorite_kind = 'agent'; diff --git a/backend/migrations/20261009164611_add_agent_favorite_kind.up.sql b/backend/migrations/20261009164611_add_agent_favorite_kind.up.sql new file mode 100644 index 0000000000..c0959d2527 --- /dev/null +++ b/backend/migrations/20261009164611_add_agent_favorite_kind.up.sql @@ -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$; diff --git a/backend/migrations/20261009173546_agent_owner_prefix_index.down.sql b/backend/migrations/20261009173546_agent_owner_prefix_index.down.sql new file mode 100644 index 0000000000..d3e9318571 --- /dev/null +++ b/backend/migrations/20261009173546_agent_owner_prefix_index.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS idx_resource_agent_owner_prefix; diff --git a/backend/migrations/20261009173546_agent_owner_prefix_index.up.sql b/backend/migrations/20261009173546_agent_owner_prefix_index.up.sql new file mode 100644 index 0000000000..be1fd5813f --- /dev/null +++ b/backend/migrations/20261009173546_agent_owner_prefix_index.up.sql @@ -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'; diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index fa1c049746..6afe7bf30c 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -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 diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index d124d34a65..d9064b2943 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -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 diff --git a/backend/windmill-api/src/db.rs b/backend/windmill-api/src/db.rs index db99a07c92..f269235460 100644 --- a/backend/windmill-api/src/db.rs +++ b/backend/windmill-api/src/db.rs @@ -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. diff --git a/backend/windmill-api/src/favorite.rs b/backend/windmill-api/src/favorite.rs index d3e3456e0f..86f3377b39 100644 --- a/backend/windmill-api/src/favorite.rs +++ b/backend/windmill-api/src/favorite.rs @@ -32,6 +32,7 @@ pub enum FavoriteKind { #[allow(non_camel_case_types)] Raw_App, Asset, + Agent, } #[derive(Deserialize)] pub struct Favorite { diff --git a/backend/windmill-api/src/runnables.rs b/backend/windmill-api/src/runnables.rs index 7e64b48a1a..754221dac0 100644 --- a/backend/windmill-api/src/runnables.rs +++ b/backend/windmill-api/src/runnables.rs @@ -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, )` (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, /// Descending when true (default true). order_desc: Option, - /// Comma-separated subset of `script,flow,app`; omitted means all. + /// Comma-separated subset of `script,flow,app,agent`; omitted means all. kinds: Option, show_archived: Option, /// 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, @@ -130,6 +132,11 @@ struct RunnableItem { id: Option, #[serde(skip_serializing_if = "Option::is_none")] version: Option, + // 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(skip_serializing_if = "Option::is_none")] + ws_specific: Option, // sort keys, echoed into the cursor (not serialized to the client) #[serde(skip)] sort_time: chrono::DateTime, @@ -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, @@ -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 = 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 = ["script", "flow", "app"] + let starred_branches: Vec = 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 = - ["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, /// Include library scripts (no runnable main). Ignored for flows/apps. include_without_main: Option, @@ -789,7 +867,7 @@ struct OwnerCount { /// `owner/` is >= `owner || '/'` and, since '/' (0x2F) is immediately /// followed by '0' (0x30), < `owner || '0'`. `~>=~` / `~<~` are the /// text_pattern_ops operators, which is what lets `idx__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, ) -> JsonResult { 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 = 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//draft_` 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![ diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index eeea790009..19ba8c529b 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -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, + /// Only the `ai_agent` resources the authed user starred. + pub starred_only: Option, } #[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> = lq .resource_type diff --git a/frontend/src/lib/components/common/table/AgentRow.svelte b/frontend/src/lib/components/common/table/AgentRow.svelte index aca6ae7f9c..05c15a582a 100644 --- a/frontend/src/lib/components/common/table/AgentRow.svelte +++ b/frontend/src/lib/components/common/table/AgentRow.svelte @@ -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) + dispatch('change')} +/> + {#snippet chatBadge()} {/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) diff --git a/frontend/src/lib/components/common/table/Row.svelte b/frontend/src/lib/components/common/table/Row.svelte index 76cd30640a..111618145f 100644 --- a/frontend/src/lib/components/common/table/Row.svelte +++ b/frontend/src/lib/components/common/table/Row.svelte @@ -260,7 +260,7 @@ {/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')}
diff --git a/frontend/src/lib/components/flows/AgentHistory.svelte b/frontend/src/lib/components/flows/AgentHistory.svelte new file mode 100644 index 0000000000..c6deff1e59 --- /dev/null +++ b/frontend/src/lib/components/flows/AgentHistory.svelte @@ -0,0 +1,34 @@ + + + + drawer?.closeDrawer()} noPadding> + { + drawer?.closeDrawer() + // A restore writes the resource as a deploy does, so whatever shows this agent re-reads it. + markAgentWritten($workspaceStore ?? undefined, path) + onRestored?.() + }} + /> + + diff --git a/frontend/src/lib/components/flows/agentActions.ts b/frontend/src/lib/components/flows/agentActions.ts index daaec527df..da31a3665f 100644 --- a/frontend/src/lib/components/flows/agentActions.ts +++ b/frontend/src/lib/components/flows/agentActions.ts @@ -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 `.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, diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index ee22e1a43a..7e71cd5a02 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -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 = $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 { - 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 = {} - 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 { 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() : 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 ) diff --git a/frontend/src/lib/components/search/GlobalSearchModal.svelte b/frontend/src/lib/components/search/GlobalSearchModal.svelte index b94e0faefa..c2c4a15bd8 100644 --- a/frontend/src/lib/components/search/GlobalSearchModal.svelte +++ b/frontend/src/lib/components/search/GlobalSearchModal.svelte @@ -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 & { + type TableItem = T & { search_id: string marked?: string type?: U @@ -518,7 +524,9 @@ type TableApp = TableItem type TableRawApp = TableItem - type TableAny = TableScript | TableFlow | TableApp | TableRawApp + type TableAgent = TableItem + + 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}
-
Flows/Scripts/Apps
+
+ Flows/Scripts/Apps/Agents
{#each (itemMap[tab] ?? []).filter((e) => (combinedItems ?? []).includes(e)) as el} { diff --git a/frontend/src/lib/components/sidebar/FavoriteMenu.svelte b/frontend/src/lib/components/sidebar/FavoriteMenu.svelte index 1c3191f0cb..c3d14d4c4d 100644 --- a/frontend/src/lib/components/sidebar/FavoriteMenu.svelte +++ b/frontend/src/lib/components/sidebar/FavoriteMenu.svelte @@ -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 @@