mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-03 08:02:19 +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> * fix: keep runnables out of the agents view and anchor a new agent once saved Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: autosave a new agent's first edit and list agents past one page Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: add agent detail and editor pages Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: explain when an agent cannot be run and drop the broken agent move Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: keep query params and a unique path when creating an agent Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: lead the home list with agents and keep rows while the agent view loads Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: treat a loading agent as undeployed when leaving the editor page Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: agent detail page config panel, run page on form runs, chat badge Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: agent configuration modal and draft paths like other new items Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: keep a new agent draft-free until the first input, land agents with runnables Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: place AI agent after apps in the new menu and describe chat and flow use Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: new agents start with managed memory, editor form says how to turn it off Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: clearer managed memory hint in the agent editor form Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * refactor: share the agent editor's pane notice as a PaneNotice component Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: name a new agent after a path no resource holds Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: tell repeated tool names apart and hide permissions on draft-only agents Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: agent configuration beside the model in the chat composer and above the form Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: center the agent run form, configuration beside its Run button Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: icon-only agent configuration button beside Run Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: agents run on behalf of their deployer through a run-by-path endpoint Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: agents keep their run-as identity in their value, preserved by the CLI The identity an agent runs as lives in `value.on_behalf_of` instead of a column. Every write of an agent resolves it server-side as a flow's is: the writer's own, unless an admin or wm_deployers member asks to keep it, and a folder default on create. Retyping a resource into an agent resolves its value the same way. Export leaves it out; the run endpoint reads it and drops it from the step's inputs. The CLI follows the app model: a pushed agent never takes its identity from the tracked file, an unchanged agent compares equal to the deployed one, and an admin or deployer push claims the deployed identity back. The owner-change pre-check lists agents. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: deploying an agent draft from review keeps its deployed identity As for an app: an agent draft carries no identity, so the review page claims the deployed one back, which the backend honours for an admin or wm_deployers member. The draft diff leaves the deployed identity out, since a draft never holds one. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: deploying an agent to another workspace offers the run-as choice An agent deployed to prod/staging, merged from a fork or promoted with `wmill workspace merge` gets the same identity choice as a flow or an app: the target's current one, the deployer, or a picked user, sent as a principal the way a trigger's is. The source workspace's principal is never copied, and a difference in identity alone is not a change in the workspace compare or its diff. The frontend consumes the published windmill-utils-internal, so its deploy provider carries the same rewrite until that version ships. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: agent identity on fork, agent-scoped job reads, and the run license gate A fork re-points an agent's identity at its creator when they may not preserve someone else's, and at the creator when it names nobody in the fork, as it does an app's. A token scoped to `jobs:run:agents:<path>` reads back the runs it starts, chat turns included. The agent run endpoint checks the enterprise license like every other run entrypoint. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: a CLI push decides agent identity handling by the tracked file's type An agent retyped into another resource by a push kept neither its value's `on_behalf_of` as the file stated it nor clear of the old agent's identity. The file's type now decides, and only a deployed agent's identity is claimed back. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * refactor: agents run as the caller, with a note on what a shared one needs Drops the agent run-as identity: its storage in the agent value, the backfill, the resolution on every write, and its handling in export, the CLI, draft and cross-workspace deploys, forks and the workspace compare. The run endpoint runs as the caller, so an operator or reader runs an agent with their own access. The editor tells the author of a folder agent that anyone running it needs access to its AI resource and to what its tools use. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs: agent editor comments describe runs as the caller Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * refactor: agent editor pane notes use Alert Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * refactor: agent editor pane notes render as Alert Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: agent editor notes as regular Alerts, not banners Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: agent path as its own editor level, and evals on the agent page The path leaves the agent form: the editor dialog opens it as a level, as it does evals, and the editor page in a drawer. The agent page gains Evals, in a dialog. The page supplies the run form, keeping it out of the editor the flow editor reaches. The draft edit gate no longer throws when a focused, changed field is removed: the `change` that removal fires lands mid-teardown, so the gate opens just after instead. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: agent settings as the resource editor's header fields, behind a cog The agent's own settings (path, labels, workspace specific, description) open from a cog as the flow and script editors' do, laid out as the top of the resource editor. The folder note is gone. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: agent settings fields and cog, completing the rename Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: evals open as a dialog over the agent editor page The editor page opens an agent's evals over itself, as the agent page does, with the unsaved edits offered to a run; the editor dialog keeps evals as a level of its own. The two pages share the dialog. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: the unreachable model provider note reads like the unreachable agent one Same warning level and wording as the note above it, with the path inline rather than in parentheses that lost their spaces. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: the unreachable model provider note offers editing the agent too Editing the agent to use a provider the reader can access is often the simpler way out; offered when the reader can write the agent, with Unlink and asking for access. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: provider note lists asking for access as its own option Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: shorter provider note, without the header's buttons repeated Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: evals only for those who can edit the agent Evaluating builds datasets and runs against the agent, which is authoring: the agent page and the editor offer it only with write access to the agent. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: agent page actions ordered as the script and flow pages The menu leads and Edit comes last, as DetailPageHeader lays them out. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: the agent editor dialog's levels slide in built on the first visit Warmed, as the evals pane's levels are, so settings and evals are mounted before the first navigation rather than inside its transition. Evals are only in the strip where they can be opened. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: the agent's settings report path errors, and hold nothing a reader can edit The path field reads its error back, so it shows it and keeps deploy blocked on it. Labels are shown rather than editable without write access. Evals wait for the load to know they can be opened, and the page layout builds no levels it never shows. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * refactor: one builder for an agent's run flow, and the agent page on the shared header The run endpoint and evals build the agent's one-step flow through the same function. The agent page uses DetailPageHeader, whose error handler, tag and trigger context are now optional, and whose menu items keep their disabled state. The home row and the page share the agent's menu and delete. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: the agent page's menu is built from its current path Deploy settings are the workspace's, so they load once and the menu is derived from them rather than fetched per path, where a superseded fetch could land after a navigation. The shared run-flow builder lives with agent runs rather than evals. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: the scoped-read comment names the run-flow builder as it is Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
562 lines
22 KiB
Svelte
562 lines
22 KiB
Svelte
<script lang="ts">
|
|
import {
|
|
ChevronRight,
|
|
FlaskConical,
|
|
Settings,
|
|
FormInput,
|
|
History,
|
|
MessageSquare,
|
|
Save
|
|
} from 'lucide-svelte'
|
|
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
|
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
|
import { onDestroy, untrack } from 'svelte'
|
|
import { resource } from 'runed'
|
|
import Modal, { type ModalTrailSegment } from '$lib/components/common/modal/Modal.svelte'
|
|
import PagedContent from '$lib/components/common/modal/PagedContent.svelte'
|
|
import { Badge, Button, Drawer, DrawerContent } from '$lib/components/common'
|
|
import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte'
|
|
import ResourceVersionHistory from '$lib/components/ResourceVersionHistory.svelte'
|
|
import { clearPageDrawerAnchor } from '$lib/components/sessions/pageDrawerSession'
|
|
import { RESOURCES_PATH } from '$lib/components/sessions/previewPaths'
|
|
import EvalsPane from '$lib/components/aiEvals/EvalsPane.svelte'
|
|
import type { EvalsLocation } from '$lib/components/aiEvals/evalUtils'
|
|
import { ResourceService, type AgentDraft } from '$lib/gen'
|
|
import {
|
|
agentEditorTarget,
|
|
agentWriteCount,
|
|
type AgentEditorTarget,
|
|
closeAgentEditor,
|
|
markAgentWritten,
|
|
openAgentEditor,
|
|
showAgentEditorTool,
|
|
showAgentEditorView
|
|
} from '../agentEditorStore.svelte'
|
|
import { agentConfigToInputTransforms } from '../agentResourceUtils'
|
|
import { publishLinkedAgentTools } from '../flowState'
|
|
import { linkedModulesForAgent, linkedToolsScope } from '../linkedAgentToolsStore.svelte'
|
|
import AgentEditorHost from './AgentEditorHost.svelte'
|
|
import AgentSettings from './AgentSettings.svelte'
|
|
import AgentEvalsModal from './AgentEvalsModal.svelte'
|
|
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
|
|
|
const operatingWorkspace = useOperatingWorkspace()
|
|
|
|
interface Props {
|
|
enableAi?: boolean
|
|
/** Which targets this mount is responsible for. The target is module-global while several
|
|
* flow editors can be alive at once (a session retains every tab it has visited), so without
|
|
* this each of them would build a whole editor — fetching, inferring tool schemas, and
|
|
* running its own two-way sync against the one draft row. Required rather than defaulted: a
|
|
* mount that guesses wrong renders nothing, and silence is a poor way to find that out. */
|
|
owns: (target: AgentEditorTarget) => boolean
|
|
/** A deploy moved the agent from `from` to `to`. What names the old path belongs to the surface
|
|
* that opened the editor: a flow's own steps, a page's URL. */
|
|
onRenamed?: (from: string, to: string) => void
|
|
/** `page` fills its container with the editor's header and body instead of opening a dialog. */
|
|
layout?: 'modal' | 'page'
|
|
/** Where closing leads, for a layout that has no dialog to dismiss. `deployed` is false for an
|
|
* agent that exists only as a draft, which has no page of its own to return to. */
|
|
onClose?: (deployed: boolean) => void
|
|
/** Ran after every successful deploy, with the path written. */
|
|
onDeployed?: (path: string) => void
|
|
}
|
|
|
|
let {
|
|
enableAi = false,
|
|
owns,
|
|
onRenamed = undefined,
|
|
layout = 'modal',
|
|
onClose = undefined,
|
|
onDeployed = undefined
|
|
}: Props = $props()
|
|
|
|
// Every target names the surface that opened it, and only a flow step or a resource row can:
|
|
// an agent used as a tool of the agent being edited stays part of it, with no way in this editor
|
|
// to link it to a saved agent of its own. So `owns` decides alone — nothing here has to adopt a
|
|
// target opened from inside another editor.
|
|
let target = $derived.by(() => {
|
|
const t = agentEditorTarget()
|
|
return t && owns(t) ? t : undefined
|
|
})
|
|
// The last agent this dialog showed. The version pane keeps working after `onRestore` returns —
|
|
// it reloads its list keyed on the path it was given — while the restore handler has already
|
|
// closed the dialog and dropped the target. Reading the live one there throws, and the throw
|
|
// lands in the global unhandled-rejection handler as a bare toast with no stack.
|
|
let lastShownAgent = $state<string | undefined>(undefined)
|
|
$effect(() => {
|
|
const p = target?.path
|
|
if (p) untrack(() => (lastShownAgent = p))
|
|
})
|
|
|
|
let ws = $derived(target?.workspace ?? $operatingWorkspace)
|
|
let host = $state<ReturnType<typeof AgentEditorHost> | undefined>(undefined)
|
|
let versionDrawer: Drawer | undefined = $state(undefined)
|
|
let settingsDrawer: Drawer | undefined = $state(undefined)
|
|
let evalsModal: AgentEvalsModal | undefined = $state(undefined)
|
|
let saving = $state(false)
|
|
|
|
// Counted per agent, so a deploy that leaves the editor on the same agent still refetches the
|
|
// version it just minted. Shared, so the step card behind the dialog refetches on the same signal.
|
|
let writes = $derived(agentWriteCount(ws, target?.path))
|
|
let versionResource = resource(
|
|
() => ({ ws, path: target?.path, writes }),
|
|
async ({ ws, path, writes }) => {
|
|
if (!ws || !path) return { ws, path, writes }
|
|
const history = await ResourceService.getResourceHistory({ workspace: ws, path })
|
|
return { ws, path, writes, version: history.versions?.[0]?.version }
|
|
}
|
|
)
|
|
// `writes` as well as the identity: runed neither aborts nor tags a superseded request, so a
|
|
// response from before a deploy can land after the one for it and relabel the badge with the
|
|
// version that deploy replaced.
|
|
let version = $derived.by(() => {
|
|
const loaded = versionResource.current
|
|
return loaded !== undefined &&
|
|
loaded.ws === ws &&
|
|
loaded.path === target?.path &&
|
|
loaded.writes === writes
|
|
? loaded.version
|
|
: undefined
|
|
})
|
|
|
|
let draft = $derived(host?.draftHandle())
|
|
let testPane = $derived(host?.testPaneHandle())
|
|
// The load refused this path, so there is no agent to deploy, evaluate or show a history of:
|
|
// the dialog carries only the refusal the host renders.
|
|
let refused = $derived(draft?.refusal != null)
|
|
let inEvals = $derived(target?.view === 'evals' && !refused)
|
|
// A level of its own in the dialog; the page layout opens the same settings in a drawer instead.
|
|
let inSettings = $derived(target?.view === 'settings' && !refused)
|
|
let readOnly = $derived(draft ? !draft.canWrite : false)
|
|
let draftOnly = $derived(draft?.noDeployed ?? false)
|
|
// Evals run against the deployed agent, and a draft-only one has none: the backend's
|
|
// `require_agent` would reject every run. Evaluating is authoring, so it is for those who can
|
|
// edit the agent. Unknown until the load answers, and warming the pane loads it.
|
|
let canEvaluate = $derived(
|
|
draft != undefined && !draft.loading && !draftOnly && !readOnly && !refused
|
|
)
|
|
// A never-deployed agent is stored at a minted `draft_<uuid>` path, so it is named by the path
|
|
// its first deploy will create, as the home list names it.
|
|
let shownPath = $derived((draftOnly && draft?.state?.path) || target?.path)
|
|
|
|
// Where the evals pane is within itself, so its levels extend this dialog's trail rather than
|
|
// opening a dialog of their own. Cleared on the way in: the pane reports a level once it is on
|
|
// one, and never that it is back at its root.
|
|
let evalsLocation = $state<EvalsLocation | undefined>(undefined)
|
|
let evalsKey = $derived(`${ws}:${target?.path}:${target?.view}`)
|
|
$effect(() => {
|
|
evalsKey
|
|
untrack(() => (evalsLocation = undefined))
|
|
})
|
|
|
|
const AGENT_DESCRIPTION = 'Changes here update the saved agent, and every flow that links to it.'
|
|
|
|
let root = $derived<ModalTrailSegment>({
|
|
label: shownPath ?? 'Agent',
|
|
onclick: inEvals || inSettings ? () => showAgentEditorView(undefined) : undefined
|
|
})
|
|
let trail = $derived<ModalTrailSegment[]>(
|
|
inEvals
|
|
? [
|
|
root,
|
|
{ label: 'Evals', onclick: evalsLocation ? evalsLocation.back : undefined },
|
|
...(evalsLocation ? [{ label: evalsLocation.label }] : [])
|
|
]
|
|
: inSettings
|
|
? [root, { label: 'Settings' }]
|
|
: [root]
|
|
)
|
|
// The root's alone: below it the header's second line is the way back, and what a level is for
|
|
// belongs to that level rather than to the dialog's own name.
|
|
let description = $derived(inEvals || inSettings || refused ? undefined : AGENT_DESCRIPTION)
|
|
|
|
// The dialog's levels are its pages; a page has none, so evals open over it as a dialog.
|
|
function openEvals() {
|
|
if (layout === 'modal') showAgentEditorView('evals')
|
|
else evalsModal?.openModal()
|
|
}
|
|
|
|
function openSettings() {
|
|
if (layout === 'modal') showAgentEditorView('settings')
|
|
else settingsDrawer?.openDrawer()
|
|
}
|
|
|
|
/** The unsaved edits, in the shape the server builds from a deployed config
|
|
* (`ai_evals/run.rs` `config_to_draft`), so a draft run's hash can be recognised as equal to
|
|
* the version it is later deployed as. */
|
|
function editedConfig(): AgentDraft {
|
|
const args = draft?.state?.args
|
|
return {
|
|
input_transforms: agentConfigToInputTransforms(args ?? {}) as Record<string, unknown>,
|
|
tools: (args?.tools ?? []) as unknown[] as Record<string, unknown>[]
|
|
}
|
|
}
|
|
|
|
/** A resources-page row anchors itself in the hash on the way in, so closing has to clear it or
|
|
* the URL keeps claiming the agent is open and a refresh reopens it. Anchored to that page, so
|
|
* this is a no-op when the editor was opened from a flow. */
|
|
function close() {
|
|
// Unknown until the load answers, and a detail page that does not exist is the worse guess.
|
|
const deployed = draft != undefined && !draft.loading && !draftOnly && !refused
|
|
closeAgentEditor()
|
|
if (layout === 'modal') void clearPageDrawerAnchor(RESOURCES_PATH)
|
|
onClose?.(deployed)
|
|
}
|
|
|
|
// The target is module-global and outlives this component: navigating away takes the mount that
|
|
// owns it without passing through `close()`, and what it leaves behind reopens the dialog on the
|
|
// way back and tells the flow editor a dialog is over its graph. Only its own, so a mount going
|
|
// down never drops a target another one is showing.
|
|
onDestroy(() => {
|
|
const t = agentEditorTarget()
|
|
if (t && owns(t)) closeAgentEditor()
|
|
})
|
|
|
|
/** What the flow behind a write has to be told about it, captured before the request rather than
|
|
* read after: the dialog stays closable while one is in flight and can be pointed at another
|
|
* agent by the time it lands, and the reactive `ws`/`target` would then name that one. */
|
|
type WriteTarget = { ws: string | undefined; path: string; host: AgentEditorTarget['host'] }
|
|
function currentWriteTarget(): WriteTarget | undefined {
|
|
const t = target
|
|
return t ? { ws, path: t.path, host: t.host } : undefined
|
|
}
|
|
/** Reconciling is best-effort: it refreshes what a write changed, and the write itself has
|
|
* already landed. A rejection here must not surface as an unhandled one — the global handler
|
|
* turns those into a bare toast with no stack, which says nothing about where it came from. */
|
|
function reconcileQuietly(at: WriteTarget | undefined, path: string) {
|
|
try {
|
|
void Promise.resolve(reconcile(at, path)).catch((err) => {
|
|
console.error('agent editor: could not refresh what links to', path, err)
|
|
})
|
|
} catch (err) {
|
|
console.error('agent editor: could not refresh what links to', path, err)
|
|
}
|
|
}
|
|
|
|
function reconcile(at: WriteTarget | undefined, path: string) {
|
|
if (!at?.ws) return
|
|
markAgentWritten(at.ws, path)
|
|
if (!at.host) return
|
|
// The host graph resolves a linked agent's tool nodes from the resource, so it has to re-read
|
|
// what the write just changed. Every step of that flow linking this agent, not only the one
|
|
// the editor was opened from: they all show tools the write may have moved. Looked up under
|
|
// the path the editor opened, since a rename is about to move those steps off it.
|
|
const scope = linkedToolsScope(at.ws, at.host.flowPath)
|
|
const moduleIds = new Set(linkedModulesForAgent(scope, at.path))
|
|
moduleIds.add(at.host.moduleId)
|
|
return Promise.all(
|
|
// With the draft: a deploy leaves none, but a version restore leaves the draft standing and
|
|
// it is still what a test of the host step would run.
|
|
[...moduleIds].map((moduleId) => publishLinkedAgentTools(path, at.ws, scope, moduleId, true))
|
|
)
|
|
}
|
|
|
|
let deployingFor = $state<WriteTarget | undefined>(undefined)
|
|
|
|
async function onDeploy() {
|
|
saving = true
|
|
deployingFor = currentWriteTarget()
|
|
try {
|
|
await host?.deploy()
|
|
} finally {
|
|
saving = false
|
|
deployingFor = undefined
|
|
}
|
|
}
|
|
|
|
/** What a successful deploy has to reconcile. `savedPath` is the path it wrote, which a rename
|
|
* moves off the one the editor opened. */
|
|
async function onSaved(savedPath: string) {
|
|
const at = deployingFor ?? currentWriteTarget()
|
|
// Before the rename is announced: it finds the steps to refresh under the old path.
|
|
const reconciled = reconcile(at, savedPath)
|
|
// A new agent's first deploy is announced like a rename even at the same path: the surface
|
|
// has anchored nothing for it yet, and reopening drops `isNew` now that it exists.
|
|
if (at && (savedPath !== at.path || target?.isNew)) {
|
|
onRenamed?.(at.path, savedPath)
|
|
// The dialog is keyed on the path, so this reloads it on the renamed agent. Only while it
|
|
// still shows the one deployed: it can be closed or pointed elsewhere mid-request.
|
|
if (target?.path === at.path) {
|
|
openAgentEditor({ path: savedPath, workspace: target.workspace, host: target.host })
|
|
}
|
|
}
|
|
await reconciled
|
|
onDeployed?.(savedPath)
|
|
}
|
|
</script>
|
|
|
|
{#if target}
|
|
{#key `${ws}:${target.path}`}
|
|
<!-- Bound rather than held open: the store is what decides whether this is mounted, so every
|
|
way out has to reach it. The close button and the backdrop only set `open`, and a
|
|
dialog left closed over a target still set could never be opened again.
|
|
|
|
`paginated` throughout: the body is one strip of pages (the agent form, then evals), so
|
|
the header keeps its height and only the pages move. -->
|
|
{#if layout === 'modal'}
|
|
<Modal
|
|
bind:open={() => true, (open) => !open && close()}
|
|
kind="X"
|
|
fillHeight
|
|
enterConfirms={false}
|
|
paginated
|
|
title={shownPath ?? target.path}
|
|
{trail}
|
|
{description}
|
|
{titleBadge}
|
|
{levelBadge}
|
|
{settings}
|
|
class="w-[92vw] sm:w-[92vw] max-w-[1500px] sm:max-w-[1500px] h-[88vh]"
|
|
>
|
|
{@render body()}
|
|
</Modal>
|
|
{:else}
|
|
<div class="h-full min-h-0 flex flex-col">
|
|
<div class="flex items-center gap-2 px-4 py-2 border-b shrink-0">
|
|
<div class="min-w-0 flex flex-col">
|
|
<div class="flex items-center gap-1 min-w-0">
|
|
{#each trail as segment, i (i)}
|
|
{#if i > 0}
|
|
<ChevronRight size={14} class="text-tertiary shrink-0" />
|
|
{/if}
|
|
{#if segment.onclick}
|
|
<Button variant="subtle" unifiedSize="sm" onClick={segment.onclick}>
|
|
{segment.label}
|
|
</Button>
|
|
{:else}
|
|
<span class="text-sm font-semibold text-emphasis truncate">{segment.label}</span>
|
|
{/if}
|
|
{#if i === 0}
|
|
{@render titleBadge()}
|
|
{/if}
|
|
{/each}
|
|
{@render levelBadge()}
|
|
</div>
|
|
{#if description}
|
|
<span class="text-2xs text-tertiary truncate">{description}</span>
|
|
{/if}
|
|
</div>
|
|
<div class="grow"></div>
|
|
{@render settings()}
|
|
</div>
|
|
<div class="flex-1 min-h-0 px-4 sm:px-6">
|
|
{@render body()}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/key}
|
|
|
|
{#snippet titleBadge()}
|
|
<!-- Against the agent's own name wherever it appears, as the linked-agent card in the
|
|
step panel has it. -->
|
|
{#if version != undefined && !refused}
|
|
<Badge color="gray" class="shrink-0" title="The version runs are recorded against">
|
|
v{version}
|
|
</Badge>
|
|
{/if}
|
|
{/snippet}
|
|
{#snippet levelBadge()}
|
|
<!-- Marks evals, not the agent, so it sits against that level's own name. Dropped a
|
|
level deeper, where it would read as marking the run rather than the feature it
|
|
belongs to. -->
|
|
{#if inEvals && !evalsLocation}
|
|
<Badge color="blue" small class="shrink-0 !py-0 leading-4">Beta</Badge>
|
|
{/if}
|
|
{/snippet}
|
|
<!-- Evals carries none of the editor's actions: nothing there edits the agent, and the run
|
|
dialog states for itself whether a run is against the deployed version or the edits. -->
|
|
{#snippet settings()}
|
|
<div class="flex flex-row items-center gap-2 shrink-0">
|
|
{#if !inEvals && !refused}
|
|
<!-- Switches the editor's right-hand pane, and is drawn only once the agent has
|
|
loaded and the pane has picked its first mode. -->
|
|
{#if testPane?.mode && !inSettings}
|
|
<ToggleButtonGroup
|
|
bind:selected={
|
|
() => testPane?.mode,
|
|
(mode) => {
|
|
if (testPane) testPane.mode = mode
|
|
}
|
|
}
|
|
noWFull
|
|
>
|
|
{#snippet children({ item })}
|
|
<ToggleButton
|
|
size="sm"
|
|
value="chat"
|
|
label="Chat"
|
|
icon={MessageSquare}
|
|
tooltip="Chat with the agent: each message runs it, and it remembers the conversation"
|
|
{item}
|
|
/>
|
|
<ToggleButton
|
|
size="sm"
|
|
value="form"
|
|
label="Form"
|
|
icon={FormInput}
|
|
tooltip="Run the agent once, on the inputs in a form"
|
|
{item}
|
|
/>
|
|
{/snippet}
|
|
</ToggleButtonGroup>
|
|
{/if}
|
|
{#if readOnly}
|
|
<Badge color="gray" class="shrink-0" title="You do not have write access to this agent">
|
|
Read only
|
|
</Badge>
|
|
{/if}
|
|
{#if !inSettings}
|
|
<Button
|
|
unifiedSize="sm"
|
|
variant="default"
|
|
startIcon={{ icon: Settings }}
|
|
iconOnly
|
|
title="Settings"
|
|
on:click={openSettings}
|
|
/>
|
|
{/if}
|
|
{#if canEvaluate}
|
|
<Button
|
|
unifiedSize="sm"
|
|
variant="default"
|
|
startIcon={{ icon: FlaskConical }}
|
|
title="Run this agent against a dataset of cases"
|
|
on:click={openEvals}
|
|
>
|
|
Evals
|
|
</Button>
|
|
{/if}
|
|
<Button
|
|
unifiedSize="sm"
|
|
variant="default"
|
|
startIcon={{ icon: History }}
|
|
iconOnly
|
|
title="Version history"
|
|
on:click={() => versionDrawer?.openDrawer()}
|
|
/>
|
|
<Button
|
|
unifiedSize="sm"
|
|
variant="accent"
|
|
startIcon={{ icon: Save }}
|
|
loading={saving}
|
|
disabled={readOnly}
|
|
title={readOnly ? 'You do not have write access to this agent' : undefined}
|
|
on:click={onDeploy}
|
|
>
|
|
Deploy
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
{/snippet}
|
|
{#snippet body()}
|
|
<div class="h-full min-h-0 flex flex-col">
|
|
<!-- Full-bleed, as a banner is everywhere else: the body's own horizontal padding is
|
|
cancelled so it spans it. -->
|
|
<div class="-mx-4 sm:-mx-6 shrink-0 {inEvals ? 'hidden' : ''}">
|
|
<LocalDraftBanner
|
|
show={draft?.sync.hasDraft ?? false}
|
|
reserveSpace={draft?.sync.hasBaseline ?? false}
|
|
getDeployed={() => draft?.deployed}
|
|
getCurrent={() => draft?.state}
|
|
onDiscard={() => draft?.sync.resetToDeployed(target?.path ?? '')}
|
|
title="Deployed <> Unsaved agent changes"
|
|
/>
|
|
</div>
|
|
<!-- One strip of pages, so a level slides in from the right the way evals' own levels do.
|
|
No `onNavigate`: the arrow keys belong to whichever pane is on screen, and evals answers
|
|
them for its own levels. Warmed, as those levels are: a page built on its first visit
|
|
lands inside the transition and arrives empty. The page layout has no levels: it opens
|
|
settings in a drawer and evals in a dialog, so warming them there would build them twice. -->
|
|
<PagedContent
|
|
warm
|
|
class="flex-1 min-h-0"
|
|
current={inEvals ? 'evals' : inSettings ? 'settings' : 'agent'}
|
|
pages={[
|
|
{ key: 'agent', content: agentPage },
|
|
...(layout === 'modal' ? [{ key: 'settings', content: settingsPage }] : []),
|
|
...(layout === 'modal' && canEvaluate ? [{ key: 'evals', content: evalsPage }] : [])
|
|
]}
|
|
/>
|
|
</div>
|
|
{/snippet}
|
|
|
|
<!-- Mounted under the evals level too: it holds the draft the header's banner and Deploy act on,
|
|
and the config a draft run is offered on. The target is read optionally: a deploy that
|
|
navigates away clears it while these are still rendered. -->
|
|
{#snippet agentPage()}
|
|
<AgentEditorHost
|
|
bind:this={host}
|
|
path={target?.path ?? ''}
|
|
workspace={ws}
|
|
{enableAi}
|
|
toolId={target?.toolId}
|
|
onSelectTool={(id) => showAgentEditorTool(id)}
|
|
{onSaved}
|
|
isNew={target?.isNew}
|
|
/>
|
|
{/snippet}
|
|
|
|
{#snippet settingsFields()}
|
|
{#if draft}
|
|
<AgentSettings
|
|
{draft}
|
|
path={target?.path ?? ''}
|
|
workspace={ws}
|
|
bind:error={() => host?.pathError(), (error) => host?.setPathError(error)}
|
|
/>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{#snippet settingsPage()}
|
|
<div class="max-w-2xl py-6">{@render settingsFields()}</div>
|
|
{/snippet}
|
|
|
|
{#snippet evalsPage()}
|
|
<EvalsPane
|
|
agentPath={target?.path ?? ''}
|
|
opWorkspace={ws}
|
|
editedConfig={draft?.sync.hasDraft ? editedConfig : undefined}
|
|
bind:location={evalsLocation}
|
|
active={inEvals}
|
|
/>
|
|
{/snippet}
|
|
|
|
{#if layout === 'page' && ws}
|
|
<AgentEvalsModal
|
|
bind:this={evalsModal}
|
|
agentPath={target?.path ?? ''}
|
|
workspace={ws}
|
|
editedConfig={draft?.sync.hasDraft ? editedConfig : undefined}
|
|
/>
|
|
{/if}
|
|
|
|
<Drawer bind:this={settingsDrawer} size="600px">
|
|
<DrawerContent title="Settings" on:close={() => settingsDrawer?.closeDrawer()}>
|
|
{@render settingsFields()}
|
|
</DrawerContent>
|
|
</Drawer>
|
|
|
|
<Drawer bind:this={versionDrawer} size="1200px">
|
|
<DrawerContent title="Version history" on:close={() => versionDrawer?.closeDrawer()} noPadding>
|
|
<ResourceVersionHistory
|
|
path={target?.path ?? lastShownAgent ?? ''}
|
|
workspace={ws}
|
|
canRestore={!readOnly}
|
|
onRestore={() => {
|
|
versionDrawer?.closeDrawer()
|
|
// A restore writes the resource as a deploy does, so the flow behind has to be told
|
|
// the same way. Captured before closing, which drops the target this reads.
|
|
const at = currentWriteTarget()
|
|
// Close the editor too, as the generic resource editor does on a restore: it holds
|
|
// a baseline captured before the restore, and any local draft on top of it, so
|
|
// deploying from it afterwards would write the pre-restore value straight back over
|
|
// the version just restored.
|
|
close()
|
|
if (at) reconcileQuietly(at, at.path)
|
|
}}
|
|
/>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
{/if}
|