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/summarized_schema.txt b/backend/summarized_schema.txt index ac07dc06d6..4ff8468017 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 f9586529ac..998845c458 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -9280,6 +9280,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 @@ -24454,7 +24460,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 @@ -24477,7 +24483,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 @@ -32381,6 +32387,9 @@ components: On a draft-only row, the path its editor has staged when it differs from the storage path (e.g. a never-deployed item parked at `u/{user}/draft_{uuid}`). + starred: + type: boolean + description: For an `ai_agent` resource only, whether the caller starred it. required: - path - resource_type 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-store/src/resources.rs b/backend/windmill-store/src/resources.rs index eeea790009..1a8035bab6 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -225,6 +225,10 @@ pub struct ListedResource { #[serde(skip_serializing_if = "Option::is_none")] #[sqlx(default)] pub draft_path: Option, + /// Whether the authed user starred this `ai_agent`; unset on every other type. + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(default)] + pub starred: Option, } #[derive(Deserialize)] @@ -261,6 +265,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)] @@ -374,6 +380,12 @@ async fn list_resources( AND draft.email = ?) as is_draft" .bind(&authed.email), ) + .field( + &"CASE WHEN resource.resource_type = 'ai_agent' THEN 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 = ?) END as starred" + .bind(&authed.username), + ) .left() .join("variable") .on("variable.path = resource.path AND variable.workspace_id = resource.workspace_id") @@ -437,6 +449,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 +492,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 @@ -560,7 +581,7 @@ async fn list_resources( // Synthesized rows are the authed user's draft. is_draft: Some(true), }; - rows.push(ListedResource { resource, agent_memory, draft_path }); + rows.push(ListedResource { resource, agent_memory, draft_path, starred: None }); } } diff --git a/frontend/src/lib/components/common/table/AgentRow.svelte b/frontend/src/lib/components/common/table/AgentRow.svelte index 54142a4822..71228b66e4 100644 --- a/frontend/src/lib/components/common/table/AgentRow.svelte +++ b/frontend/src/lib/components/common/table/AgentRow.svelte @@ -70,7 +70,7 @@ path={agent.draft_path ?? agent.path} summary={agent.description} workspaceId={agent.workspace_id ?? $workspaceStore ?? ''} - canFavorite={false} + canFavorite={!agent.draft_only} {depth} {rowSelection} > @@ -111,6 +111,7 @@ 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'), onDelete: (event) => { 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/agentActions.ts b/frontend/src/lib/components/flows/agentActions.ts index daaec527df..3c28e59013 100644 --- a/frontend/src/lib/components/flows/agentActions.ts +++ b/frontend/src/lib/components/flows/agentActions.ts @@ -1,8 +1,10 @@ -import { FileUp, Shield, Trash } from 'lucide-svelte' +import { Copy, Eye, FileUp, 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 { base } from '$lib/base' +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,6 +26,8 @@ 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 @@ -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 + ? [] + : [ + { + // Chat turns are filed under `.chat` (agent_runs.rs) and listed by the agent's + // own chat, so this is the form runs. + displayName: 'View runs', + icon: List, + action: () => goto(`${base}/runs/${agent.path}`) + }, + ...(agent.operator + ? [] + : [ + { + displayName: 'Audit logs', + icon: Eye, + action: () => goto(`${base}/audit_logs?resource=${agent.path}`) + } + ]), + { displayName: 'Copy path', icon: Copy, action: () => copyToClipboard(agent.path) } + ]), { displayName: 'Permissions', icon: Shield, diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index 8e6ab769be..27f12a8f31 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -900,13 +900,15 @@ // each row carries its server fetch ordinal (`ord`), which already reflects the // chosen order, the (path, kind) tiebreaks, full-precision timestamps, the database // collation, and starred-first pinning. Sorting by it can never disagree with the - // server, so a later page never jumps above shown rows on "load more". Depends on + // server, so a later page never jumps above shown rows on "load more". Agents lead the + // runnables (see loadAgents) everywhere but among the starred, which all come first. Depends on // `sortOrder` only so its identity changes when the order does (re-grouping the tree, // whose leaves also sort by ord); the actual reordering comes from the reload that // restamps ord. let compareItems = $derived.by(() => { sortOrder - return (a: { ord?: number }, b: { ord?: number }): number => (a.ord ?? 0) - (b.ord ?? 0) + return (a: { ord?: number; starred?: boolean }, b: { ord?: number; starred?: boolean }) => + Number(!!b.starred) - Number(!!a.starred) || (a.ord ?? 0) - (b.ord ?? 0) }) const opts: uFuzzy.Options = { 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 @@