mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-21 00:03:31 +00:00
feat: list agents on the home page and create them from the new menu
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6cd70a8e08
commit
252a086e97
@@ -467,12 +467,10 @@ async fn list_resources(
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null);
|
||||
// ResourceEditor's `ResourceState`: { path, description, args, labels?, wsSpecific, resource_type? }
|
||||
let path = v
|
||||
.get("path")
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if path.is_empty() || !allowed(&path) {
|
||||
// Listed at the draft's key rather than its `path`: an editor keys the draft on the path
|
||||
// it opened, which is the only one a draft read can find, while `path` moves on rename.
|
||||
let path = row.path;
|
||||
if !allowed(&path) {
|
||||
continue;
|
||||
}
|
||||
let description = v
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$lib/base'
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import type MoveDrawer from '$lib/components/MoveDrawer.svelte'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import DraftBadge from '$lib/components/DraftBadge.svelte'
|
||||
import type ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
|
||||
import { ResourceService, type ListableResource } from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { isDeployable } from '$lib/utils_deployable'
|
||||
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
|
||||
import { RESOURCES_PATH } from '$lib/components/sessions/previewPaths'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { FileUp, FolderInput, Shield, Trash } from 'lucide-svelte'
|
||||
import Row from './Row.svelte'
|
||||
|
||||
/**
|
||||
* A home-page row for a saved agent. The agent is an `ai_agent` resource, so the row opens the
|
||||
* agent editor the resources page hosts, and its menu carries the resource actions.
|
||||
*/
|
||||
interface Props {
|
||||
agent: ListableResource & { canWrite: boolean }
|
||||
marked: string | undefined
|
||||
shareModal: ShareModal
|
||||
moveDrawer: MoveDrawer
|
||||
deploymentDrawer: DeployWorkspaceDrawer
|
||||
deleteConfirmedCallback?: (() => void) | undefined
|
||||
depth?: number
|
||||
menuOpen?: boolean
|
||||
keyboardSelected?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
agent,
|
||||
marked,
|
||||
shareModal,
|
||||
moveDrawer,
|
||||
deploymentDrawer,
|
||||
deleteConfirmedCallback = $bindable(),
|
||||
depth = 0,
|
||||
menuOpen = $bindable(false),
|
||||
keyboardSelected = false
|
||||
}: Props = $props()
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
async function deleteAgent(path: string): Promise<void> {
|
||||
try {
|
||||
await ResourceService.deleteResource({ workspace: $workspaceStore!, path })
|
||||
sendUserToast(`Deleted agent ${path}`)
|
||||
dispatch('change')
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not delete agent ${path}: ${err}`, true)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Row
|
||||
href="{base}{RESOURCES_PATH}#/resource/{agent.path}"
|
||||
kind="agent"
|
||||
{keyboardSelected}
|
||||
{marked}
|
||||
path={agent.path}
|
||||
summary={agent.description}
|
||||
workspaceId={agent.workspace_id ?? $workspaceStore ?? ''}
|
||||
canFavorite={false}
|
||||
{depth}
|
||||
>
|
||||
{#snippet badges()}
|
||||
<SharedBadge canWrite={agent.canWrite} extraPerms={agent.extra_perms} />
|
||||
<DraftBadge
|
||||
is_draft={agent.is_draft}
|
||||
draft_only={agent.draft_only}
|
||||
currentUsername={$userStore?.username}
|
||||
workspace={$workspaceStore ?? undefined}
|
||||
itemKind="resource"
|
||||
path={agent.path}
|
||||
onMigrated={() => dispatch('change')}
|
||||
/>
|
||||
<InheritedLabels labels={agent.inherited_labels} />
|
||||
{/snippet}
|
||||
|
||||
{#snippet actions()}
|
||||
<Dropdown
|
||||
items={async () => {
|
||||
const { path } = agent
|
||||
return [
|
||||
{
|
||||
displayName: 'Permissions',
|
||||
icon: Shield,
|
||||
disabled: !agent.canWrite,
|
||||
action: () => {
|
||||
shareModal.openDrawer?.(path, 'resource')
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Move/Rename',
|
||||
icon: FolderInput,
|
||||
disabled: !agent.canWrite || Boolean(agent.draft_only),
|
||||
action: () => {
|
||||
moveDrawer.openDrawer(path, agent.description, 'resource')
|
||||
}
|
||||
},
|
||||
...(!agent.ws_specific &&
|
||||
!agent.draft_only &&
|
||||
isDeployable('resource', path, await getDeployUiSettings())
|
||||
? [
|
||||
{
|
||||
displayName: 'Deploy to prod/staging',
|
||||
icon: FileUp,
|
||||
action: () => {
|
||||
deploymentDrawer.openDrawer(path, 'resource')
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash,
|
||||
type: 'delete' as const,
|
||||
disabled: !agent.canWrite,
|
||||
action: (event) => {
|
||||
if (event?.shiftKey) {
|
||||
deleteAgent(path)
|
||||
} else {
|
||||
deleteConfirmedCallback = () => {
|
||||
deleteAgent(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}}
|
||||
on:open={() => {
|
||||
menuOpen = true
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
</Row>
|
||||
@@ -39,6 +39,7 @@
|
||||
| 'flow'
|
||||
| 'app'
|
||||
| 'raw_app'
|
||||
| 'agent'
|
||||
| 'resource'
|
||||
| 'variable'
|
||||
| 'resource_type'
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import AzureIcon from '$lib/components/icons/AzureIcon.svelte'
|
||||
import GoogleCloudIcon from '$lib/components/icons/GoogleCloudIcon.svelte'
|
||||
import {
|
||||
Bot,
|
||||
Boxes,
|
||||
Calendar,
|
||||
Code2,
|
||||
@@ -29,6 +30,7 @@
|
||||
| 'app'
|
||||
| 'raw_app'
|
||||
| 'raw_app_file'
|
||||
| 'agent'
|
||||
| 'resource'
|
||||
| 'variable'
|
||||
| 'resource_type'
|
||||
@@ -99,6 +101,8 @@
|
||||
<FileIcon name={path ?? ''} {size} />
|
||||
{:else if effectiveKind === 'script'}
|
||||
<Code2 {size} class="text-blue-500" />
|
||||
{:else if effectiveKind === 'agent'}
|
||||
<Bot {size} class="text-violet-500" />
|
||||
{:else if effectiveKind === 'variable'}
|
||||
<DollarSign {size} class="text-gray-400" />
|
||||
{:else if effectiveKind === 'resource'}
|
||||
|
||||
@@ -181,6 +181,8 @@ export interface AgentDraftOptions {
|
||||
/** The `ai_agent` resource being edited. */
|
||||
path: () => string | undefined
|
||||
workspace: () => string | undefined
|
||||
/** A missing row is a new agent to start empty, not a load failure. */
|
||||
isNew?: () => boolean
|
||||
}
|
||||
|
||||
export interface AgentDraftHandle {
|
||||
@@ -327,10 +329,26 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
|
||||
await sync.maybeRestore()
|
||||
},
|
||||
(err) => {
|
||||
if (loadedFor !== key) return
|
||||
// Nothing is written until the first edit: the sync saves only on user input, and
|
||||
// the first deploy creates the resource at whatever path the form then holds.
|
||||
if (opts.isNew?.() && (err as { status?: number })?.status === 404) {
|
||||
noDeployed = true
|
||||
deployed = undefined
|
||||
canWriteResource = true
|
||||
state = {
|
||||
path,
|
||||
description: '',
|
||||
args: {},
|
||||
resource_type: 'ai_agent',
|
||||
wsSpecific: false
|
||||
}
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
// A failed load knows neither the resource's type nor its value, so it refuses:
|
||||
// clearing `loading` alone would let the sync restore a persisted draft into a form
|
||||
// that would then deploy over a resource nobody read.
|
||||
if (loadedFor !== key) return
|
||||
refuse(`Could not load agent ${path}: ${err}`)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -18,6 +18,9 @@ export interface AgentEditorTarget {
|
||||
/** Where to re-resolve a graph's tool nodes after a deploy, when opened from a flow step. Only a
|
||||
* real flow sets it: the agent editor offers no way to open a second editor from inside itself. */
|
||||
host?: { flowPath: string; moduleId: string }
|
||||
/** The path was minted for an agent that does not exist yet: a missing row is an empty agent to
|
||||
* start from rather than a load failure. */
|
||||
isNew?: boolean
|
||||
}
|
||||
|
||||
let target = $state<AgentEditorTarget | undefined>(undefined)
|
||||
|
||||
@@ -61,6 +61,8 @@
|
||||
onSelectTool?: (toolId: string | undefined) => void
|
||||
/** Ran after a successful deploy, with the path actually written, which a rename can move. */
|
||||
onSaved?: (path: string) => void | Promise<void>
|
||||
/** The path was minted for a new agent, so a missing row starts an empty one. */
|
||||
isNew?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -69,7 +71,8 @@
|
||||
enableAi = false,
|
||||
toolId = undefined,
|
||||
onSelectTool = undefined,
|
||||
onSaved = undefined
|
||||
onSaved = undefined,
|
||||
isNew = false
|
||||
}: Props = $props()
|
||||
|
||||
/** The one module the editor edits. Standalone (no `agent` key) so `initFlowState` loads a
|
||||
@@ -78,7 +81,11 @@
|
||||
* schema to the root's. */
|
||||
const AGENT_ID = '__wm_agent_root'
|
||||
|
||||
const draft = useAgentDraft({ path: () => path, workspace: () => workspace })
|
||||
const draft = useAgentDraft({
|
||||
path: () => path,
|
||||
workspace: () => workspace,
|
||||
isNew: () => isNew
|
||||
})
|
||||
|
||||
/** Read access only. Everything that could write is blocked, down to the draft itself: an
|
||||
* autosave the server rejects would look like a save and lose the edit. Running the agent,
|
||||
@@ -447,6 +454,7 @@
|
||||
}
|
||||
bind:error={pathError}
|
||||
initialPath={path}
|
||||
checkInitialPathExistence={draft.noDeployed}
|
||||
namePlaceholder="agent"
|
||||
kind="resource"
|
||||
workspaceOverride={workspace}
|
||||
|
||||
@@ -386,6 +386,7 @@
|
||||
toolId={target.toolId}
|
||||
onSelectTool={(id) => showAgentEditorTool(id)}
|
||||
{onSaved}
|
||||
isNew={target.isNew}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import Tab from '$lib/components/common/tabs/Tab.svelte'
|
||||
import {
|
||||
Plus,
|
||||
Bot,
|
||||
Code2,
|
||||
LayoutDashboard,
|
||||
ChevronDown,
|
||||
@@ -29,6 +30,7 @@
|
||||
import YAML from 'yaml'
|
||||
import type { Snippet } from 'svelte'
|
||||
import { logFeatureUsage } from '$lib/utils/featureUsage'
|
||||
import { NEW_AGENT_HASH, RESOURCES_PATH } from '$lib/components/sessions/previewPaths'
|
||||
|
||||
interface Props {
|
||||
/** Replaces the default `New` button, e.g. with an inline text link. */
|
||||
@@ -120,6 +122,21 @@
|
||||
}
|
||||
] as Option[])
|
||||
: []),
|
||||
{
|
||||
key: 'agent',
|
||||
label: 'AI agent',
|
||||
icon: Bot,
|
||||
accent: 'purple',
|
||||
tagline: 'A reusable agent with tools',
|
||||
description:
|
||||
'Pick a model, write the system prompt and give the agent tools: scripts, flows, MCP servers or other agents. Test it from the editor and link it from any flow.',
|
||||
bullets: [
|
||||
'Any provider and model',
|
||||
'Tools from scripts, flows and MCP',
|
||||
'Shared across flows'
|
||||
],
|
||||
onSelect: () => goto(`${base}${RESOURCES_PATH}${NEW_AGENT_HASH}`)
|
||||
},
|
||||
...(HOME_SHOW_CREATE_APP
|
||||
? ([
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import AppRow from '../common/table/AppRow.svelte'
|
||||
import FlowRow from '../common/table/FlowRow.svelte'
|
||||
import RawAppRow from '../common/table/RawAppRow.svelte'
|
||||
import AgentRow from '../common/table/AgentRow.svelte'
|
||||
import ScriptRow from '../common/table/ScriptRow.svelte'
|
||||
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { Alert } from '$lib/components/common'
|
||||
@@ -44,9 +45,10 @@
|
||||
// A raw app the listing returns is an `app` row carrying `raw_app`, and is
|
||||
// selectable like any other app. The separate `raw_app` type is the legacy
|
||||
// listing shape, which renders through RawAppRow — no selection control there,
|
||||
// so it must not enter the selection either.
|
||||
// so it must not enter the selection either. An agent is a resource underneath,
|
||||
// which none of the bulk actions handle.
|
||||
let bulkItem = $derived(
|
||||
homeSelection?.available && item.type !== 'raw_app'
|
||||
homeSelection?.available && item.type !== 'raw_app' && item.type !== 'agent'
|
||||
? toBulkItem(item, $userStore, $workspaceStore)
|
||||
: undefined
|
||||
)
|
||||
@@ -132,6 +134,19 @@
|
||||
bind:menuOpen
|
||||
{keyboardSelected}
|
||||
/>
|
||||
{:else if item.type == 'agent'}
|
||||
<AgentRow
|
||||
bind:deleteConfirmedCallback
|
||||
marked={item.marked}
|
||||
on:change={() => dispatch('reload')}
|
||||
agent={item}
|
||||
{shareModal}
|
||||
{moveDrawer}
|
||||
{deploymentDrawer}
|
||||
{depth}
|
||||
bind:menuOpen
|
||||
{keyboardSelected}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if menuOpen}
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
type ListableApp,
|
||||
type Script,
|
||||
ScriptService,
|
||||
ResourceService,
|
||||
type Flow,
|
||||
type ListableRawApp,
|
||||
type ListableResource,
|
||||
type RunnableItem
|
||||
} from '$lib/gen'
|
||||
import { resource } from 'runed'
|
||||
@@ -20,6 +22,7 @@
|
||||
import type uFuzzy from '@leeoniya/ufuzzy'
|
||||
import {
|
||||
ArrowDownUp,
|
||||
Bot,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Code2,
|
||||
@@ -62,7 +65,7 @@
|
||||
import BulkActionsBar from './BulkActionsBar.svelte'
|
||||
import { HomeSelection, setHomeSelection, toBulkItem } from './homeSelection.svelte'
|
||||
interface Props {
|
||||
subtab?: 'flow' | 'script' | 'app'
|
||||
subtab?: 'flow' | 'script' | 'app' | 'agent'
|
||||
showEditButtons?: boolean
|
||||
}
|
||||
|
||||
@@ -101,7 +104,8 @@
|
||||
options: [
|
||||
{ value: 'script', label: 'Script' },
|
||||
...(HOME_SEARCH_SHOW_FLOW ? [{ value: 'flow', label: 'Flow' }] : []),
|
||||
{ value: 'app', label: 'App' }
|
||||
{ value: 'app', label: 'App' },
|
||||
{ value: 'agent', label: 'Agent' }
|
||||
]
|
||||
},
|
||||
archived: { type: 'boolean' as const, label: 'Only archived' },
|
||||
@@ -166,7 +170,15 @@
|
||||
// depends on these (search, kind, archived, library, user-folder scope), so changing a
|
||||
// searchbar chip reloads the server stream exactly as toggling the old controls did.
|
||||
let filter = $derived((filterValues.val._default_ ?? '') as string)
|
||||
let itemKind = $derived((filterValues.val.kind ?? 'all') as 'script' | 'flow' | 'app' | 'all')
|
||||
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 archived = $derived(!!filterValues.val.archived)
|
||||
let includeWithoutMain = $derived((filterValues.val.include_library ?? true) as boolean)
|
||||
let filterUserFolders = $derived(!!filterValues.val.only_user_folders)
|
||||
@@ -200,7 +212,7 @@
|
||||
if (el) untrack(() => el.open())
|
||||
})
|
||||
|
||||
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app'> = T & {
|
||||
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app' | 'agent'> = T & {
|
||||
canWrite: boolean
|
||||
marked?: string
|
||||
type?: U
|
||||
@@ -218,6 +230,7 @@
|
||||
type TableFlow = TableItem<Flow, 'flow'>
|
||||
type TableApp = TableItem<ListableApp, 'app'>
|
||||
type TableRawApp = TableItem<ListableRawApp, 'raw_app'>
|
||||
type TableAgent = TableItem<ListableResource & { summary?: string }, 'agent'>
|
||||
|
||||
// Folders that are data pipelines, surfaced as their own "Pipeline" entry
|
||||
// (the member scripts are folded into it, not listed individually). Two
|
||||
@@ -303,10 +316,15 @@
|
||||
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
|
||||
// Monotonic fetch-order counter stamped onto each row as it arrives (see TableItem.ord).
|
||||
let fetchOrd = 0
|
||||
|
||||
let filteredItems: (TableScript | TableFlow | TableApp | TableRawApp)[] = $state([])
|
||||
let filteredItems: (TableScript | TableFlow | TableApp | TableRawApp | TableAgent)[] = $state([])
|
||||
|
||||
let loading = $state(true)
|
||||
|
||||
@@ -347,6 +365,66 @@
|
||||
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 pages nothing and orders nothing, so the order is applied here and
|
||||
* the rows are stamped after every runnable ordinal: a page of runnables keeps the server's
|
||||
* order and the agents follow it as one block.
|
||||
*/
|
||||
async function loadAgents(): Promise<void> {
|
||||
const ws = $workspaceStore
|
||||
const gen = ++agentsGen
|
||||
if (!ws || !$userStore || !showsAgents || archived) {
|
||||
agents = []
|
||||
agentOwnerCounts = {}
|
||||
return
|
||||
}
|
||||
let rows: ListableResource[]
|
||||
try {
|
||||
rows = await ResourceService.listResource({
|
||||
workspace: ws,
|
||||
resourceType: 'ai_agent',
|
||||
includeDraftOnly: true
|
||||
})
|
||||
} 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 = r.path.split('/').slice(0, 2).join('/')
|
||||
counts[owner] = (counts[owner] ?? 0) + 1
|
||||
}
|
||||
agentOwnerCounts = counts
|
||||
const scoped = ownerFilter ? rows.filter((r) => r.path.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(a.path, b.path)
|
||||
case 'name_desc':
|
||||
return cmp(b.path, a.path)
|
||||
default:
|
||||
return byTime(b) - byTime(a)
|
||||
}
|
||||
})
|
||||
agents = sorted.map((r, i) => ({
|
||||
...r,
|
||||
summary: r.description || undefined,
|
||||
canWrite: canWrite(r.path, (r.extra_perms ?? {}) as any, $userStore) && !$userStore?.operator,
|
||||
ord: AGENT_ORD_BASE + i
|
||||
}))
|
||||
}
|
||||
/** Above any ordinal a runnables page can reach, so agents sort after them 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
|
||||
@@ -363,6 +441,20 @@
|
||||
// arrays (mixing streams) or clobber the pending reset's generation.
|
||||
if (!reset && serverCursor === undefined) return
|
||||
if (scripts === undefined) loading = true
|
||||
// One request per reset: the resource listing is not paged, so a load-more has
|
||||
// nothing to append for agents.
|
||||
if (reset) void loadAgents()
|
||||
if (!showsRunnables) {
|
||||
scripts = []
|
||||
flows = []
|
||||
apps = []
|
||||
raw_apps = []
|
||||
pipelineMemberFolders = new Set()
|
||||
serverCursor = undefined
|
||||
hasMoreServer = false
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
if (reset) {
|
||||
serverCursor = undefined
|
||||
hasMoreServer = false
|
||||
@@ -385,7 +477,7 @@
|
||||
orderDesc,
|
||||
showArchived: archived ? true : undefined,
|
||||
includeWithoutMain: includeWithoutMain ? true : undefined,
|
||||
kinds: itemKind !== 'all' ? itemKind : undefined,
|
||||
kinds: runnableKinds,
|
||||
// Selecting an owner/folder scopes the paged stream to it server-side,
|
||||
// so a folder's full contents load on demand rather than relying on the
|
||||
// folder happening to be within the loaded browse window.
|
||||
@@ -598,7 +690,7 @@
|
||||
orderDesc,
|
||||
showArchived: archived ? true : undefined,
|
||||
includeWithoutMain: includeWithoutMain ? true : undefined,
|
||||
kinds: itemKind !== 'all' ? itemKind : undefined,
|
||||
kinds: runnableKinds,
|
||||
pathStart: prefix,
|
||||
includeDraftOnly: true,
|
||||
perPage: OWNER_PAGE_SIZE,
|
||||
@@ -714,7 +806,7 @@
|
||||
}
|
||||
|
||||
function filterItemsPathsBaseOnUserFilters(
|
||||
item: TableScript | TableFlow | TableApp | TableRawApp,
|
||||
item: TableScript | TableFlow | TableApp | TableRawApp | TableAgent,
|
||||
filterUserFolders: boolean,
|
||||
filterUserFoldersType: 'only f/*' | 'u/username and f/*' | undefined
|
||||
) {
|
||||
@@ -968,6 +1060,7 @@
|
||||
[() => $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,
|
||||
@@ -983,7 +1076,16 @@
|
||||
}
|
||||
}
|
||||
)
|
||||
let ownerCounts = $derived(ownerCountsRes.current)
|
||||
// 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
|
||||
})
|
||||
// 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,
|
||||
@@ -1159,7 +1261,8 @@
|
||||
const kind = itemKind
|
||||
// Any term/scope change restarts search paging.
|
||||
searchCursor = undefined
|
||||
if (term === '' || !ws || !$userStore) return
|
||||
// Agents are all loaded, so the client-side search over them is already complete.
|
||||
if (term === '' || !ws || !$userStore || kind === 'agent') return
|
||||
const handle = setTimeout(async () => {
|
||||
let res: { items: RunnableItem[]; next_cursor?: string }
|
||||
try {
|
||||
@@ -1215,7 +1318,7 @@
|
||||
search: term,
|
||||
showArchived: showArchived ? true : undefined,
|
||||
includeWithoutMain: withoutMain ? true : undefined,
|
||||
kinds: kind !== 'all' ? kind : undefined,
|
||||
kinds: kind !== 'all' && kind !== 'agent' ? kind : undefined,
|
||||
pathStart: owner ? owner + '/' : undefined,
|
||||
includeDraftOnly: true,
|
||||
perPage: 1000,
|
||||
@@ -1266,6 +1369,11 @@
|
||||
...x,
|
||||
type: 'raw_app' as 'raw_app',
|
||||
time: new Date(x.edited_at).getTime()
|
||||
})),
|
||||
...agents.map((x) => ({
|
||||
...x,
|
||||
type: 'agent' as 'agent',
|
||||
time: new Date(x.edited_at ?? 0).getTime()
|
||||
}))
|
||||
].sort(compareItems)
|
||||
)
|
||||
@@ -1326,10 +1434,12 @@
|
||||
// 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.filter((x) =>
|
||||
filterItemsPathsBaseOnUserFilters(x, filterUserFolders, filterUserFoldersType)
|
||||
? [...treeOwnerItems, ...agents.map((x) => ({ ...x, type: 'agent' as 'agent' }))].filter(
|
||||
(x) => filterItemsPathsBaseOnUserFilters(x, filterUserFolders, filterUserFoldersType)
|
||||
)
|
||||
: items
|
||||
)
|
||||
@@ -1662,7 +1772,8 @@
|
||||
homeSelection.active &&
|
||||
selectedIndex >= 0 &&
|
||||
selectedIndex < displayedItems.length &&
|
||||
displayedItems[selectedIndex].type !== 'raw_app'
|
||||
displayedItems[selectedIndex].type !== 'raw_app' &&
|
||||
displayedItems[selectedIndex].type !== 'agent'
|
||||
) {
|
||||
e.preventDefault()
|
||||
homeSelection.toggle(
|
||||
@@ -1798,6 +1909,14 @@
|
||||
size="md"
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value="agent"
|
||||
label="Agents"
|
||||
icon={Bot}
|
||||
selectedColor="#8b5cf6"
|
||||
size="md"
|
||||
{item}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ListableApp, Script, Flow, ListableRawApp } from '$lib/gen'
|
||||
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app'> = T & {
|
||||
import type { ListableApp, Script, Flow, ListableRawApp, ListableResource } from '$lib/gen'
|
||||
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app' | 'agent'> = T & {
|
||||
canWrite: boolean
|
||||
marked?: string
|
||||
type?: U
|
||||
@@ -14,8 +14,11 @@ type TableScript = TableItem<Script, 'script'>
|
||||
type TableFlow = TableItem<Flow, 'flow'>
|
||||
type TableApp = TableItem<ListableApp, 'app'>
|
||||
type TableRawApp = TableItem<ListableRawApp, 'raw_app'>
|
||||
/** A saved agent: an `ai_agent` resource the home page lists as a kind of its own. Its
|
||||
* description stands in as the summary every other kind carries. */
|
||||
type TableAgent = TableItem<ListableResource & { summary?: string }, 'agent'>
|
||||
|
||||
export type ItemType = TableScript | TableFlow | TableApp | TableRawApp
|
||||
export type ItemType = TableScript | TableFlow | TableApp | TableRawApp | TableAgent
|
||||
|
||||
export interface FolderItem {
|
||||
folderName: string
|
||||
@@ -182,6 +185,8 @@ function getModifiedAt(item: ItemType): number {
|
||||
return new Date(item.edited_at).getTime() || 0
|
||||
} else if (item.type === 'raw_app') {
|
||||
return new Date(item.edited_at).getTime() || 0
|
||||
} else if (item.type === 'agent') {
|
||||
return new Date(item.edited_at ?? 0).getTime() || 0
|
||||
}
|
||||
|
||||
return 0
|
||||
|
||||
@@ -11,6 +11,8 @@ export const RUNS_PATH = '/runs'
|
||||
export const SCHEDULES_PATH = '/schedules'
|
||||
export const VARIABLES_PATH = '/variables'
|
||||
export const RESOURCES_PATH = '/resources'
|
||||
/** On the resources page, opens the agent editor on a new, not yet saved agent. */
|
||||
export const NEW_AGENT_HASH = '#/new_agent'
|
||||
export const ASSETS_PATH = '/assets'
|
||||
export const AUDIT_LOGS_PATH = '/audit_logs'
|
||||
export const WORKSPACE_SETTINGS_PATH = '/workspace_settings'
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
let tab = $state<Tab>('workspace')
|
||||
|
||||
let subtab: 'flow' | 'script' | 'app' = $state('script')
|
||||
let subtab: 'flow' | 'script' | 'app' | 'agent' = $state('script')
|
||||
|
||||
const searchParams = useSearchParams(z.object({ search: z.string().nullable() }))
|
||||
const getFilter = () => searchParams.search ?? ''
|
||||
|
||||
@@ -88,7 +88,9 @@
|
||||
handOffPageDrawer,
|
||||
setPageDrawerAnchor
|
||||
} from '$lib/components/sessions/pageDrawerSession'
|
||||
import { RESOURCES_PATH } from '$lib/components/sessions/previewPaths'
|
||||
import { NEW_AGENT_HASH, RESOURCES_PATH } from '$lib/components/sessions/previewPaths'
|
||||
import { getUsernameForNamespace } from '$lib/userNamespace'
|
||||
import { random_adj } from '$lib/components/random_positive_adjetive'
|
||||
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
|
||||
import ExploreAssetButton, {
|
||||
assetCanBeExplored
|
||||
@@ -671,6 +673,18 @@
|
||||
let handledHash = ''
|
||||
$effect(() => {
|
||||
const hash = page.url.hash
|
||||
if (hash === NEW_AGENT_HASH) {
|
||||
if (hash === handledHash) return
|
||||
handledHash = hash
|
||||
untrack(() => {
|
||||
resourceEditor?.close?.({ keepAnchor: true })
|
||||
openAgentEditor({
|
||||
path: `u/${getUsernameForNamespace()}/${random_adj()}_agent`,
|
||||
isNew: true
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!hash.startsWith('#/resource/')) {
|
||||
// Navigating away from a drawer target must clear the tracker, or
|
||||
// re-targeting the same item later would be skipped as already handled.
|
||||
|
||||
Reference in New Issue
Block a user