mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 00:03:00 +00:00
feat(agents): mockup of agents as a standalone frontend kind
Frontend-only exploration: saved AI agents (`ai_agent` resources) appear as their own kind, with pages of their own instead of the resources drawer. - home page lists agents as a kind, with an Agents filter and a create entry - /agents/edit/<path> hosts the existing agent editor with a Chat/Form toggle - /agents/get/<path> is the deployed agent's page: chat or single-shot form - /agent_chats lists every agent conversation, grouped by agent - a new agent starts from a minted draft path, named in the editor itself No backend change: a turn is still a preview of a one-step flow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
96db2d8431
commit
d4bd01ec04
@@ -53,12 +53,26 @@
|
||||
{#if item.icon}
|
||||
<item.icon size={14} color={item.iconColor} class="shrink-0" {...item.iconProps ?? {}} />
|
||||
{/if}
|
||||
<p
|
||||
title={item.disabled && item.tooltip ? undefined : item.displayName}
|
||||
class="truncate grow min-w-0 whitespace-nowrap text-left"
|
||||
>
|
||||
{item.displayName}
|
||||
</p>
|
||||
{#if item.subtitle}
|
||||
<div class="flex flex-col grow min-w-0 text-left">
|
||||
<p
|
||||
title={item.disabled && item.tooltip ? undefined : item.displayName}
|
||||
class="truncate whitespace-nowrap"
|
||||
>
|
||||
{item.displayName}
|
||||
</p>
|
||||
<p title={item.subtitle} class="truncate whitespace-nowrap text-2xs text-secondary">
|
||||
{item.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<p
|
||||
title={item.disabled && item.tooltip ? undefined : item.displayName}
|
||||
class="truncate grow min-w-0 whitespace-nowrap text-left"
|
||||
>
|
||||
{item.displayName}
|
||||
</p>
|
||||
{/if}
|
||||
{@render item.extra?.()}
|
||||
{#if item.shortcut || item.selected || item.toggle !== undefined}
|
||||
<!-- Single trailing group so `shortcut` and `selected` can coexist:
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
<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 { DraftService, 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 { createEventDispatcher } from 'svelte'
|
||||
import { Boxes, FileUp, FolderInput, Pen, Shield, Trash } from 'lucide-svelte'
|
||||
import Badge from '../badge/Badge.svelte'
|
||||
import Button from '../button/Button.svelte'
|
||||
import Row from './Row.svelte'
|
||||
|
||||
/**
|
||||
* A home-page row for a saved agent. The agent is an `ai_agent` resource; the row links to the
|
||||
* agent editor page rather than the resources drawer, and its menu carries the resource actions
|
||||
* (permissions, move, deploy, delete) since that is what the item is underneath.
|
||||
*/
|
||||
interface Props {
|
||||
agent: ListableResource & { canWrite: boolean }
|
||||
marked: string | undefined
|
||||
shareModal: ShareModal
|
||||
moveDrawer: MoveDrawer
|
||||
deploymentDrawer: DeployWorkspaceDrawer
|
||||
deleteConfirmedCallback?: (() => void) | undefined
|
||||
depth?: number
|
||||
menuOpen?: boolean
|
||||
showEditButton?: boolean
|
||||
keyboardSelected?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
agent,
|
||||
marked,
|
||||
shareModal,
|
||||
moveDrawer,
|
||||
deploymentDrawer,
|
||||
deleteConfirmedCallback = $bindable(),
|
||||
depth = 0,
|
||||
menuOpen = $bindable(false),
|
||||
showEditButton = true,
|
||||
keyboardSelected = false
|
||||
}: Props = $props()
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
async function deleteAgent(path: string): Promise<void> {
|
||||
try {
|
||||
if (agent.draft_only) {
|
||||
// Nothing deployed to delete: the row is the caller's own draft, which the draft
|
||||
// endpoint discards on a null value.
|
||||
await DraftService.updateDraft({
|
||||
workspace: $workspaceStore!,
|
||||
kind: 'resource',
|
||||
path,
|
||||
requestBody: { value: null, force: true }
|
||||
})
|
||||
} else {
|
||||
await ResourceService.deleteResource({ workspace: $workspaceStore!, path })
|
||||
}
|
||||
sendUserToast(`Deleted agent ${path}`)
|
||||
dispatch('change')
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not delete agent ${path}: ${err}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
let editHref = $derived(`${base}/agents/edit/${agent.path}`)
|
||||
// A draft-only agent has nothing deployed for a detail page to show, so it opens the editor.
|
||||
let rowHref = $derived(agent.draft_only ? editHref : `${base}/agents/get/${agent.path}`)
|
||||
</script>
|
||||
|
||||
<Row
|
||||
href={rowHref}
|
||||
kind="agent"
|
||||
{keyboardSelected}
|
||||
{marked}
|
||||
path={agent.path}
|
||||
summary={agent.is_draft ? `${agent.description || agent.path}*` : agent.description}
|
||||
workspaceId={agent.workspace_id ?? $workspaceStore ?? ''}
|
||||
canFavorite={false}
|
||||
{depth}
|
||||
>
|
||||
{#snippet badges()}
|
||||
<Badge color="violet" baseClass="border border-violet-200 dark:border-violet-800">Agent</Badge>
|
||||
<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')}
|
||||
/>
|
||||
{#if agent.labels?.length}
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#each agent.labels.slice(0, 3) as label (label)}
|
||||
<Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge>
|
||||
{/each}
|
||||
{#if agent.labels.length > 3}
|
||||
<Badge
|
||||
color="blue"
|
||||
small
|
||||
class="px-1"
|
||||
title={agent.labels
|
||||
.slice(3)
|
||||
.map((l) => 'Label: ' + l)
|
||||
.join('\n')}>+{agent.labels.length - 3}</Badge
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<InheritedLabels labels={agent.inherited_labels} />
|
||||
{/snippet}
|
||||
|
||||
{#snippet actions()}
|
||||
<span class="hidden md:inline-flex gap-x-1">
|
||||
{#if !$userStore?.operator && showEditButton && agent.canWrite}
|
||||
<div>
|
||||
<Button
|
||||
variant="subtle"
|
||||
wrapperClasses="w-20"
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: Pen }}
|
||||
href={editHref}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</span>
|
||||
<Dropdown
|
||||
items={async () => {
|
||||
const { path } = agent
|
||||
const canEdit = agent.canWrite && showEditButton
|
||||
return [
|
||||
{
|
||||
displayName: 'Open in resources',
|
||||
icon: Boxes,
|
||||
href: `${base}/resources#/resource/${path}`
|
||||
},
|
||||
{
|
||||
displayName: 'Permissions',
|
||||
icon: Shield,
|
||||
disabled: !canEdit,
|
||||
action: () => {
|
||||
shareModal.openDrawer?.(path, 'resource')
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Move/Rename',
|
||||
icon: FolderInput,
|
||||
disabled: !canEdit || 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: !canEdit,
|
||||
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'}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { FlowModule, InputTransform, OpenFlow } from '$lib/gen'
|
||||
import { agentStreamingEnabled } from './agentFormFields'
|
||||
import type { AgentTool } from './agentToolUtils'
|
||||
|
||||
/** The id of the one step an agent chat flow holds. */
|
||||
export const AGENT_CHAT_STEP_ID = '__wm_agent_root'
|
||||
|
||||
/** The step's configuration as a flow step carries it: the brain as input transforms, plus tools. */
|
||||
export interface AgentStepShape {
|
||||
input_transforms?: Record<string, InputTransform>
|
||||
tools?: AgentTool[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the chat can run this agent. It keys the agent's history on the conversation, which
|
||||
* the worker only reads back for `auto` memory: any other setting would show a transcript the
|
||||
* model never sees.
|
||||
*/
|
||||
export function agentChatReady(step: AgentStepShape | undefined): boolean {
|
||||
const memory = step?.input_transforms?.memory as { value?: { kind?: string } } | undefined
|
||||
return memory?.value?.kind === 'auto'
|
||||
}
|
||||
|
||||
/** `agentChatReady` over a saved agent's config, as a resource or draft holds it. */
|
||||
export function agentArgsChatReady(args: Record<string, any> | undefined): boolean {
|
||||
return (args?.memory as { kind?: string } | undefined)?.kind === 'auto'
|
||||
}
|
||||
|
||||
export const AGENT_CHAT_BLOCKED_REASON = 'Set memory to auto to chat with this agent.'
|
||||
|
||||
/** Whether a turn streams, from the same settings a flow's agent step reads. */
|
||||
export function agentChatStreams(step: AgentStepShape | undefined): boolean {
|
||||
return agentStreamingEnabled(step as Record<string, any> | undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* An agent as the one-step chat flow a turn previews. The step reads the message and the
|
||||
* attachments from the flow input, as a chat-enabled flow's agent step does, and the flow is
|
||||
* marked chat-enabled so the server files the run under a conversation.
|
||||
*/
|
||||
export function agentChatFlow(step: AgentStepShape | undefined): OpenFlow {
|
||||
const module: FlowModule = {
|
||||
id: AGENT_CHAT_STEP_ID,
|
||||
value: {
|
||||
type: 'aiagent',
|
||||
tools: Array.isArray(step?.tools) ? step.tools : [],
|
||||
input_transforms: {
|
||||
...(step?.input_transforms ?? {}),
|
||||
user_message: { type: 'javascript', expr: 'flow_input.user_message' },
|
||||
user_attachments: { type: 'javascript', expr: 'flow_input.user_attachments' }
|
||||
}
|
||||
} as any
|
||||
}
|
||||
return {
|
||||
summary: '',
|
||||
value: { modules: [module], chat_input_enabled: true } as OpenFlow['value'],
|
||||
schema: {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_message: {
|
||||
type: 'string',
|
||||
description: 'The message sent to the agent as the user turn.'
|
||||
},
|
||||
user_attachments: {
|
||||
type: 'array',
|
||||
description: 'Images or PDFs sent with the message. Needs S3 storage on the workspace.',
|
||||
items: { type: 'object', resourceType: 's3object' }
|
||||
}
|
||||
},
|
||||
required: ['user_message'],
|
||||
order: ['user_message', 'user_attachments']
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { sendUserToast } from '$lib/toast'
|
||||
import { canWrite } from '$lib/utils'
|
||||
import { userStore } from '$lib/stores'
|
||||
import { getUserExt } from '$lib/user'
|
||||
import { getUsernameForNamespace } from '$lib/userNamespace'
|
||||
import { random_adj } from '$lib/components/random_positive_adjetive'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { useTriggerDraftSync, type TriggerDraftSync } from '../triggers/useTriggerDraftSync.svelte'
|
||||
import { logReusableAgentUsage } from './agentTelemetry'
|
||||
@@ -83,6 +85,9 @@ export interface AgentResourceState {
|
||||
resource_type?: string
|
||||
labels?: string[]
|
||||
wsSpecific: boolean
|
||||
/** For an agent with nothing deployed, the path its first deploy creates. The draft itself
|
||||
* stays at its storage path (`path`), as a new script's does, so renaming never moves it. */
|
||||
draft_path?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,6 +183,9 @@ export interface AgentDraftOptions {
|
||||
/** The `ai_agent` resource being edited. */
|
||||
path: () => string | undefined
|
||||
workspace: () => string | undefined
|
||||
/** The path was minted for a new agent: a missing row is an empty agent to start from, not a
|
||||
* load failure. Nothing is written until the first edit. */
|
||||
isNew?: () => boolean
|
||||
}
|
||||
|
||||
export interface AgentDraftHandle {
|
||||
@@ -193,6 +201,8 @@ export interface AgentDraftHandle {
|
||||
/** Why this path cannot be edited here, if it cannot. Render it instead of the form. */
|
||||
readonly refusal: string | undefined
|
||||
readonly sync: TriggerDraftSync
|
||||
/** Where the last successful deploy wrote: `draft_path` for a first deploy, else the path. */
|
||||
readonly lastDeployedPath: string | undefined
|
||||
/** Write the current state to the resource and drop the draft. */
|
||||
deploy: () => Promise<boolean>
|
||||
}
|
||||
@@ -208,6 +218,7 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
|
||||
let loading = $state(true)
|
||||
let noDeployed = $state(false)
|
||||
let canWriteResource = $state(true)
|
||||
let lastDeployedPath = $state<string | undefined>(undefined)
|
||||
/** Guards the load against a path that changed under a slow response. */
|
||||
let loadedFor = $state<string | undefined>(undefined)
|
||||
/** Why the loaded path cannot be edited here, if it cannot. Gates the sync for as long as that
|
||||
@@ -323,10 +334,29 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
|
||||
await sync.maybeRestore()
|
||||
},
|
||||
(err) => {
|
||||
if (loadedFor !== key) return
|
||||
// A minted path with no row yet is a new agent. Memory starts on `auto`: the
|
||||
// editor's chat keys the history on the conversation and is off otherwise.
|
||||
if (opts.isNew?.() && (err as { status?: number })?.status === 404) {
|
||||
noDeployed = true
|
||||
deployed = undefined
|
||||
canWriteResource = true
|
||||
state = {
|
||||
path,
|
||||
description: '',
|
||||
args: { memory: { kind: 'auto', context_length: 10 } } as AIAgentConfig,
|
||||
resource_type: 'ai_agent',
|
||||
wsSpecific: false,
|
||||
// The friendly temporary name a new script or flow starts with, under the
|
||||
// author's own namespace; renamed from the editor's settings.
|
||||
draft_path: `u/${getUsernameForNamespace()}/${random_adj()}_agent`
|
||||
}
|
||||
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}`)
|
||||
}
|
||||
)
|
||||
@@ -342,12 +372,20 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
|
||||
sendUserToast(refused, true)
|
||||
return false
|
||||
}
|
||||
// A first deploy creates the agent where its author named it, not at the draft's storage
|
||||
// path, which is a generated `draft_<uuid>` nobody would link to.
|
||||
const target = noDeployed ? s.draft_path?.trim() : s.path
|
||||
if (!target) {
|
||||
sendUserToast('Pick a path for this agent before deploying.', true)
|
||||
return false
|
||||
}
|
||||
// The form stays editable while the request is in flight, so everything below works from a
|
||||
// snapshot taken now. Adopting the live state as `deployed` afterwards would count an edit
|
||||
// made during the request as saved, and the banner would clear on a value the server never
|
||||
// received; against the snapshot it stays a draft, which is what it is.
|
||||
const submitted = structuredClone($state.snapshot(s)) as AgentResourceState
|
||||
const written = await writeAgentResource(ws, submitted, noDeployed)
|
||||
const firstDeploy = noDeployed
|
||||
const written = await writeAgentResource(ws, { ...submitted, path: target }, noDeployed)
|
||||
if (!written.ok) {
|
||||
// A path that is no longer an agent tears this editor down; anything else is a plain error
|
||||
// the user can retry from the form as it stands.
|
||||
@@ -361,6 +399,14 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
|
||||
// The counter the step card's write-back used to report, from the surface that now owns the
|
||||
// write: a deploy here reaches every flow linking this agent.
|
||||
logReusableAgentUsage(noDeployed ? 'saved' : 'updated')
|
||||
lastDeployedPath = target
|
||||
if (firstDeploy) {
|
||||
// The agent now lives at `target`; the draft at the storage path has nothing left to
|
||||
// describe, whatever was typed during the request.
|
||||
sync.discard(opts.path()!, undefined)
|
||||
sendUserToast(`Saved agent ${target}`)
|
||||
return true
|
||||
}
|
||||
deployed = submitted
|
||||
noDeployed = false
|
||||
// Only when the form still holds exactly what was sent. `discard` resets the handle's cell to
|
||||
@@ -400,6 +446,9 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle {
|
||||
get refusal() {
|
||||
return refusal
|
||||
},
|
||||
get lastDeployedPath() {
|
||||
return lastDeployedPath
|
||||
},
|
||||
sync,
|
||||
deploy
|
||||
}
|
||||
|
||||
@@ -31,8 +31,16 @@
|
||||
} from '../agentResourceUtils'
|
||||
import { agentArgsToTransforms } from '../linkedAgentDrafts'
|
||||
import { AGENT_EDITOR_RUN_INPUTS, AGENT_TOOLS_ROW } from '../agentFormFields'
|
||||
import {
|
||||
AGENT_CHAT_BLOCKED_REASON,
|
||||
agentChatFlow,
|
||||
agentChatReady,
|
||||
agentChatStreams
|
||||
} from '../agentChatFlow'
|
||||
import { toolDisplayName, type AgentTool } from '../agentToolUtils'
|
||||
import { useAgentDraft } from '../agentDraft.svelte'
|
||||
import { runFlowPreview } from '../utils.svelte'
|
||||
import FlowChat from '../conversations/FlowChat.svelte'
|
||||
|
||||
interface Props {
|
||||
/** The `ai_agent` resource being edited. */
|
||||
@@ -44,6 +52,12 @@
|
||||
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>
|
||||
/** Which way of trying the agent the right pane shows: a conversation, or the single-shot
|
||||
* form for a run with explicit inputs. The chat needs `auto` memory
|
||||
* (`agentArgsChatReady`); asked for without it, the pane says why instead. */
|
||||
runPane?: 'chat' | 'form'
|
||||
/** The path was minted for a new agent, so a missing row starts an empty one. */
|
||||
isNew?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -52,7 +66,9 @@
|
||||
enableAi = false,
|
||||
toolId = undefined,
|
||||
onSelectTool = undefined,
|
||||
onSaved = undefined
|
||||
onSaved = undefined,
|
||||
runPane = 'chat',
|
||||
isNew = false
|
||||
}: Props = $props()
|
||||
|
||||
/** The one module the editor edits. Standalone (no `agent` key) so `initFlowState` loads a
|
||||
@@ -61,7 +77,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,
|
||||
@@ -291,6 +311,34 @@
|
||||
let testIsLoading = $state(false)
|
||||
let scriptProgress = $state(undefined)
|
||||
|
||||
let chatReady = $derived(agentChatReady(agentValue))
|
||||
let useStreaming = $derived(agentChatStreams(agentValue))
|
||||
|
||||
/** The draft as the one-step chat flow a turn previews. */
|
||||
function chatFlow(): OpenFlow {
|
||||
return agentChatFlow(agentValue)
|
||||
}
|
||||
|
||||
// What the chat reads of the flow shape (which inputs to promote, the composer's model
|
||||
// control), derived once per draft change rather than rebuilt on every render.
|
||||
let chatFlowShape = $derived(chatFlow())
|
||||
|
||||
async function runChatTurn(
|
||||
userMessage: string,
|
||||
conversationId: string,
|
||||
additionalInputs?: Record<string, any>
|
||||
): Promise<string | undefined> {
|
||||
return await runFlowPreview(
|
||||
{ user_message: userMessage, ...(additionalInputs ?? {}) },
|
||||
chatFlow(),
|
||||
path,
|
||||
undefined,
|
||||
conversationId,
|
||||
undefined,
|
||||
workspace
|
||||
)
|
||||
}
|
||||
|
||||
// Adding a tool goes straight into it: the editor has no graph to show the new node on, so the
|
||||
// tool it just created is the only place the click can land.
|
||||
async function addTool(detail: { kind: string; script?: any; flow?: any; inlineScript?: any }) {
|
||||
@@ -325,7 +373,7 @@
|
||||
// The path this editor opened, not the draft's live one: `deploy` refuses a renaming draft,
|
||||
// so the write always lands here, while the shared draft can be repointed by another tab
|
||||
// mid-request and would send the reconciliation after a resource nobody wrote.
|
||||
if (ok) await onSaved?.(path)
|
||||
if (ok) await onSaved?.(draft.lastDeployedPath ?? path)
|
||||
return ok
|
||||
})
|
||||
}
|
||||
@@ -350,7 +398,7 @@
|
||||
<!-- Resizable as the step panel's config and test are: a long system prompt and a long
|
||||
answer want opposite splits, and only the reader knows which they are on. -->
|
||||
<Splitpanes class="h-full">
|
||||
<Pane size={66} minSize={30}>
|
||||
<Pane size={55} minSize={30}>
|
||||
<div class="h-full min-h-0 overflow-auto">
|
||||
<PropPickerWrapper
|
||||
pickableProperties={stepPropPicker?.pickableProperties}
|
||||
@@ -384,10 +432,33 @@
|
||||
</Pane>
|
||||
<!-- Laid out as the script editor's preview column is: what a run takes above what it
|
||||
produced, both alongside what is being edited. -->
|
||||
<Pane size={34} minSize={20}>
|
||||
<Splitpanes horizontal class="h-full">
|
||||
<Pane size={40} minSize={15}>
|
||||
<div class="h-full overflow-auto">
|
||||
<Pane size={45} minSize={20}>
|
||||
<!-- Which side shows is the page's choice, made in its header; the pane only fills
|
||||
with it, so switching moves nothing here. -->
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
{#if runPane === 'chat' && chatReady}
|
||||
<!-- Test chats, as the flow editor's drawer files its own: each turn previews the
|
||||
draft rather than the deployed agent. -->
|
||||
<div class="flex-1 min-h-0 flex flex-col">
|
||||
<FlowChat
|
||||
{useStreaming}
|
||||
onRunFlow={runChatTurn}
|
||||
conversationKind="test"
|
||||
frame="none"
|
||||
{path}
|
||||
identity={path}
|
||||
inputSchema={chatFlowShape.schema}
|
||||
flowModules={chatFlowShape.value.modules}
|
||||
/>
|
||||
</div>
|
||||
{:else if runPane === 'chat'}
|
||||
<div class="flex-1 flex items-center justify-center p-6 text-xs text-tertiary">
|
||||
{AGENT_CHAT_BLOCKED_REASON}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- A column rather than a split: the inputs take the height they need, capped so
|
||||
the result always has room, and the result gets the rest. -->
|
||||
<div class="shrink-0 max-h-[45%] overflow-auto border-b">
|
||||
<ModulePreview
|
||||
mod={agentModule as FlowModule}
|
||||
schema={flowLocalAgentSchema(schema)}
|
||||
@@ -398,21 +469,21 @@
|
||||
bind:scriptProgress
|
||||
/>
|
||||
</div>
|
||||
</Pane>
|
||||
<Pane size={60} minSize={20}>
|
||||
<ModulePreviewResultViewer
|
||||
lang="deno"
|
||||
editor={undefined}
|
||||
diffEditor={undefined}
|
||||
mod={agentModule as FlowModule}
|
||||
{testJob}
|
||||
{testIsLoading}
|
||||
{scriptProgress}
|
||||
disableMock
|
||||
disableHistory
|
||||
/>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
<div class="flex-1 min-h-0">
|
||||
<ModulePreviewResultViewer
|
||||
lang="deno"
|
||||
editor={undefined}
|
||||
diffEditor={undefined}
|
||||
mod={agentModule as FlowModule}
|
||||
{testJob}
|
||||
{testIsLoading}
|
||||
{scriptProgress}
|
||||
disableMock
|
||||
disableHistory
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
<script lang="ts">
|
||||
import { setContext } from 'svelte'
|
||||
import { writable } from 'svelte/store'
|
||||
import type { Flow, FlowModule, Job, OpenFlow } from '$lib/gen'
|
||||
import { emptySchema, type StateStore } from '$lib/utils'
|
||||
import type { FlowInput } from '$lib/components/flows/types'
|
||||
import type { FlowEditorContext, FlowInputEditorState, FlowPanelDetachContext } from '../types'
|
||||
import type { PropPickerContext, FlowPropPickerConfig } from '$lib/components/prop_picker'
|
||||
import type { FlowCopilotContext } from '$lib/components/copilot/flow'
|
||||
import { initFlowState, type FlowState } from '../flowState'
|
||||
import { initHistory } from '$lib/history.svelte'
|
||||
import { StepsInputArgs } from '../stepsInputArgs.svelte'
|
||||
import { SelectionManager } from '$lib/components/graph/selectionUtils.svelte'
|
||||
import { ModulesTestStates } from '$lib/components/modulesTest.svelte'
|
||||
import ModulePreview from '$lib/components/ModulePreview.svelte'
|
||||
import ModulePreviewResultViewer from '$lib/components/ModulePreviewResultViewer.svelte'
|
||||
import { flowLocalAgentSchema, type AIAgentConfig } from '../agentResourceUtils'
|
||||
import { agentArgsToTransforms } from '../linkedAgentDrafts'
|
||||
import { AGENT_EDITOR_RUN_INPUTS } from '../agentFormFields'
|
||||
import type { AgentTool } from '../agentToolUtils'
|
||||
|
||||
/**
|
||||
* Runs one agent configuration as a step test, the way the agent editor's test pane does, with
|
||||
* nothing to edit around it: the config is whatever the caller hands over, deployed or not.
|
||||
* The module test machinery reads a flow editor's context, so this builds a private one around
|
||||
* a flow holding that single step.
|
||||
*/
|
||||
interface Props {
|
||||
/** The path the preview runs under. Must be a real workspace path (`u/`, `f/`). */
|
||||
path: string
|
||||
workspace: string | undefined
|
||||
config: AIAgentConfig
|
||||
}
|
||||
|
||||
let { path, workspace, config }: Props = $props()
|
||||
|
||||
const AGENT_ID = '__wm_agent_root'
|
||||
|
||||
const flowStore = $state({
|
||||
val: {
|
||||
summary: '',
|
||||
value: { modules: [] },
|
||||
extra_perms: {},
|
||||
schema: emptySchema()
|
||||
} as OpenFlow
|
||||
}) as StateStore<OpenFlow>
|
||||
const flowStateStore = $state({ val: {} }) as StateStore<FlowState>
|
||||
const previewArgs = $state({ val: {} })
|
||||
const stepsInputArgs = new StepsInputArgs()
|
||||
const modulesTestStates = new ModulesTestStates(() => {})
|
||||
const selectionManager = new SelectionManager()
|
||||
selectionManager.selectId(AGENT_ID)
|
||||
const history = initHistory(flowStore.val)
|
||||
const pathStore = writable(path)
|
||||
|
||||
setContext<FlowPanelDetachContext>('flowPanelDetach', {
|
||||
claim: () => () => {},
|
||||
modalOpen: () => false,
|
||||
close: () => {},
|
||||
enabled: () => false,
|
||||
preference: () => 'auto',
|
||||
setPreference: () => {}
|
||||
})
|
||||
|
||||
setContext<FlowEditorContext>('FlowEditorContext', {
|
||||
scriptEditorDrawer: writable(undefined),
|
||||
workspaceScriptSettingsDrawer: writable(undefined),
|
||||
flowEditorDrawer: writable(undefined),
|
||||
initialPathStore: writable(''),
|
||||
fakeInitialPath: '',
|
||||
flowInputsStore: writable<FlowInput>({}),
|
||||
customUi: {},
|
||||
insertButtonOpen: writable(false),
|
||||
executionCount: writable(0),
|
||||
flowInputEditorState: writable<FlowInputEditorState>({
|
||||
selectedTab: undefined,
|
||||
editPanelSize: undefined,
|
||||
payloadData: undefined
|
||||
}),
|
||||
currentEditor: writable(undefined),
|
||||
outputPickerOpenFns: {},
|
||||
preserveOnBehalfOf: writable(false),
|
||||
savedOnBehalfOfEmail: writable<string | undefined>(undefined),
|
||||
savedOnBehalfOfPermissionedAs: writable<string | undefined>(undefined),
|
||||
flowStore,
|
||||
flowStateStore,
|
||||
previewArgs,
|
||||
stepsInputArgs,
|
||||
modulesTestStates,
|
||||
selectionManager,
|
||||
history,
|
||||
pathStore,
|
||||
opWorkspace: () => workspace,
|
||||
agentEditorHost: () => path,
|
||||
saveDraft: () => {}
|
||||
} as FlowEditorContext)
|
||||
|
||||
setContext<PropPickerContext>('PropPickerContext', {
|
||||
flowPropPickerConfig: writable<FlowPropPickerConfig | undefined>(undefined),
|
||||
pickablePropertiesFiltered: writable(undefined),
|
||||
inModalPanel: () => true
|
||||
})
|
||||
|
||||
setContext<FlowCopilotContext>('FlowCopilotContext', {
|
||||
shouldUpdatePropertyType: writable({}),
|
||||
stepInputsLoading: writable(false),
|
||||
generatedExprs: writable({}),
|
||||
exprsToSet: writable({})
|
||||
})
|
||||
|
||||
// The config as the one module of the private flow, built once: the caller keys this pane on
|
||||
// what it hands over, so another config is another pane rather than a rebuild.
|
||||
flowStore.val.value.modules = [
|
||||
{
|
||||
id: AGENT_ID,
|
||||
value: {
|
||||
type: 'aiagent',
|
||||
tools: (Array.isArray(config.tools) ? config.tools : []) as AgentTool[] as any,
|
||||
input_transforms: agentArgsToTransforms(config) as any
|
||||
} as any
|
||||
} satisfies FlowModule
|
||||
]
|
||||
initFlowState(flowStore.val as Flow, flowStateStore, workspace, path).catch((err) =>
|
||||
console.error('agent run pane: could not infer tool schemas for', path, err)
|
||||
)
|
||||
|
||||
let agentModule = $derived(flowStore.val.value.modules?.[0])
|
||||
let schema = $derived(flowStateStore.val[AGENT_ID]?.schema ?? {})
|
||||
|
||||
let testJob: Job | undefined = $state(undefined)
|
||||
let testIsLoading = $state(false)
|
||||
let scriptProgress = $state(undefined)
|
||||
</script>
|
||||
|
||||
{#if agentModule}
|
||||
<!-- A column rather than a split: the inputs take the height they need, capped so the result
|
||||
always has room, and the result gets the rest. -->
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
<div class="shrink-0 max-h-[45%] overflow-auto border-b">
|
||||
<ModulePreview
|
||||
mod={agentModule as FlowModule}
|
||||
schema={flowLocalAgentSchema(schema)}
|
||||
pickableProperties={undefined}
|
||||
runInputKeys={AGENT_EDITOR_RUN_INPUTS}
|
||||
bind:testJob
|
||||
bind:testIsLoading
|
||||
bind:scriptProgress
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0">
|
||||
<ModulePreviewResultViewer
|
||||
lang="deno"
|
||||
editor={undefined}
|
||||
diffEditor={undefined}
|
||||
mod={agentModule as FlowModule}
|
||||
{testJob}
|
||||
{testIsLoading}
|
||||
{scriptProgress}
|
||||
disableMock
|
||||
disableHistory
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import SessionStatusDot from '$lib/components/sessions/SessionStatusDot.svelte'
|
||||
import type { SessionChatStatus } from '$lib/components/sessions/sessionRuntime.svelte'
|
||||
import UnreadCountBadge from '$lib/components/common/badge/UnreadCountBadge.svelte'
|
||||
import { PencilLine } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import type { Item } from '$lib/utils'
|
||||
|
||||
/**
|
||||
* One conversation in a list of them: the AI session sidebar's row shape, with the resting
|
||||
* mark a chat needs (a test run or one of the deployed flow's). Shared by the chat panel's
|
||||
* own sidebar and by any page that lists conversations beside a chat.
|
||||
*/
|
||||
interface Props {
|
||||
title: string
|
||||
status?: SessionChatStatus
|
||||
isTest?: boolean
|
||||
selected?: boolean
|
||||
/** Why the row cannot be opened, when something stops it. Disables it and says why. */
|
||||
lockedReason?: string
|
||||
unread?: number
|
||||
/** A message is waiting to be sent in this chat. */
|
||||
queued?: boolean
|
||||
/** Keeps the row's menu visible while one of its actions is under way. */
|
||||
busy?: boolean
|
||||
onSelect: () => void
|
||||
/** The row's own actions, shown in a menu on hover. */
|
||||
actions?: () => Item[]
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
status = 'idle',
|
||||
isTest = false,
|
||||
selected = false,
|
||||
lockedReason = undefined,
|
||||
unread = 0,
|
||||
queued = false,
|
||||
busy = false,
|
||||
onSelect,
|
||||
actions = undefined
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="subtle"
|
||||
onClick={onSelect}
|
||||
{selected}
|
||||
disabled={!!lockedReason}
|
||||
title={lockedReason}
|
||||
btnClasses="transition-all duration-150 group gap-2"
|
||||
>
|
||||
<!-- Says what the chat is doing where there is something to say, and which kind of chat it
|
||||
is otherwise. -->
|
||||
<SessionStatusDot
|
||||
{status}
|
||||
isFork={false}
|
||||
restingTitle={isTest ? 'Test chat, run from the flow editor' : 'Chat on the deployed flow'}
|
||||
>
|
||||
{#snippet resting()}
|
||||
<span
|
||||
class="w-[6px] h-[6px] rounded-full {isTest
|
||||
? 'border border-gray-400 dark:border-gray-500'
|
||||
: 'bg-gray-300 dark:bg-gray-600'}"
|
||||
></span>
|
||||
{/snippet}
|
||||
</SessionStatusDot>
|
||||
<span
|
||||
class={twMerge('flex-1 text-left truncate', unread > 0 ? 'font-semibold text-primary' : '')}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{#if queued || unread > 0}
|
||||
<span class="shrink-0 inline-flex items-center gap-1">
|
||||
{#if queued}
|
||||
<PencilLine class="w-3 h-3 text-tertiary" aria-label="Message waiting to send" />
|
||||
{/if}
|
||||
<UnreadCountBadge count={unread} />
|
||||
</span>
|
||||
{/if}
|
||||
<!-- Hidden while the row is disabled: it sits inside the row's button, and a disabled button
|
||||
swallows every click in its subtree, so a visible menu here would be an affordance that
|
||||
does nothing. -->
|
||||
{#if actions && !lockedReason}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class={twMerge(
|
||||
'ml-2 transition-all duration-100 opacity-0 group-hover:opacity-100',
|
||||
busy ? 'opacity-100' : ''
|
||||
)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownV2 items={actions} size="xs" />
|
||||
</div>
|
||||
{/if}
|
||||
</Button>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import { Loader2, MessageSquare, SlidersHorizontal } from 'lucide-svelte'
|
||||
import type { Snippet } from 'svelte'
|
||||
import { FlowChatManager } from './FlowChatManager.svelte'
|
||||
import { FlowChatViewHost } from './flowChatViewHost.svelte'
|
||||
import AIChatDisplay from '$lib/components/copilot/chat/AIChatDisplay.svelte'
|
||||
@@ -39,6 +40,10 @@
|
||||
/** The flow's description, shown under the empty transcript's prompt. */
|
||||
description?: string
|
||||
wideLayout?: boolean
|
||||
/** Rendered above the composer — see `FlowChatProps`. */
|
||||
inputPreface?: Snippet
|
||||
/** Replaces this component's own empty-transcript prompt — see `FlowChatProps`. */
|
||||
emptyHintOverride?: Snippet
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -49,7 +54,9 @@
|
||||
path,
|
||||
identity = undefined,
|
||||
description = undefined,
|
||||
wideLayout = false
|
||||
wideLayout = false,
|
||||
inputPreface = undefined,
|
||||
emptyHintOverride = undefined
|
||||
}: Props = $props()
|
||||
|
||||
// Derive helperScript for dynamic inputs from schema
|
||||
@@ -285,7 +292,7 @@
|
||||
<div
|
||||
class="flex flex-col h-full flex-1 min-w-0"
|
||||
class:min-h-96={chatHost.displayMessages.length > 0}
|
||||
class:min-h-64={chatHost.displayMessages.length === 0}
|
||||
class:min-h-64={chatHost.displayMessages.length === 0 && !emptyHintOverride}
|
||||
>
|
||||
<AIChatDisplay
|
||||
messages={chatHost.displayMessages}
|
||||
@@ -298,7 +305,8 @@
|
||||
hideHeader
|
||||
hideModeSelector
|
||||
{wideLayout}
|
||||
{emptyHint}
|
||||
emptyHint={emptyHintOverride ?? emptyHint}
|
||||
{inputPreface}
|
||||
footerSettings={modalSchema || modelWiring ? footerSettings : undefined}
|
||||
placeholder="Send a message to run the flow"
|
||||
disabled={deploymentInProgress || !!modelGap || !!manager.wrongKindReason}
|
||||
|
||||
@@ -33,7 +33,11 @@
|
||||
wideLayout = false,
|
||||
frame = 'top',
|
||||
conversationKind = 'deployed',
|
||||
parallelTurns = false
|
||||
parallelTurns = false,
|
||||
conversationId = undefined,
|
||||
hideSidebar = false,
|
||||
inputPreface = undefined,
|
||||
emptyHint = undefined
|
||||
}: FlowChatProps = $props()
|
||||
|
||||
const flowEditorContext = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
@@ -69,8 +73,16 @@
|
||||
// conversation, and tracked, the first send of a fresh chat would select the conversation
|
||||
// it just created and so abort its own turn. Waits for a workspace, which an embedded
|
||||
// editor resolves from its session rather than having on the first run.
|
||||
// A caller naming a conversation is followed instead, each time it names another one; `null`
|
||||
// asks for a fresh one.
|
||||
$effect(() => {
|
||||
if (workspace) untrack(() => manager.selectLatestConversation())
|
||||
if (!workspace) return
|
||||
const chosen = conversationId
|
||||
untrack(() => {
|
||||
if (chosen === null) manager.createConversation({ clearMessages: true })
|
||||
else if (chosen) manager.selectConversation(chosen)
|
||||
else manager.selectLatestConversation()
|
||||
})
|
||||
})
|
||||
|
||||
// Ends whatever is still running when the panel goes away, which is the only thing that
|
||||
@@ -107,7 +119,9 @@
|
||||
|
||||
<!-- The column's max width and side padding come from AIChatDisplay itself. -->
|
||||
<div class="flex overflow-hidden flex-1 {FRAME_CLASS[frame]}">
|
||||
<FlowConversationsSidebar {manager} {description} />
|
||||
{#if !hideSidebar}
|
||||
<FlowConversationsSidebar {manager} {description} />
|
||||
{/if}
|
||||
<!-- pb-3 on the chat alone, not on the row: the transcript and composer stop short of
|
||||
the panel edge the way the session chat does, while the sidebar and the border
|
||||
dividing it from the chat still reach the bottom. -->
|
||||
@@ -121,6 +135,8 @@
|
||||
{identity}
|
||||
{description}
|
||||
{wideLayout}
|
||||
{inputPreface}
|
||||
emptyHintOverride={emptyHint}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,11 +11,10 @@
|
||||
PanelLeftOpen,
|
||||
Info
|
||||
} from 'lucide-svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import SessionStatusDot from '$lib/components/sessions/SessionStatusDot.svelte'
|
||||
import ConversationRow from './ConversationRow.svelte'
|
||||
import type { SessionChatStatus } from '$lib/components/sessions/sessionRuntime.svelte'
|
||||
import UnreadCountBadge from '$lib/components/common/badge/UnreadCountBadge.svelte'
|
||||
import { PencilLine } from 'lucide-svelte'
|
||||
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
|
||||
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
|
||||
import { emptyString, type Item } from '$lib/utils'
|
||||
@@ -312,53 +311,20 @@
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="subtle"
|
||||
onClick={() => manager.selectConversation(conversation.id, conversation.isDraft)}
|
||||
<!-- The dot sits in the slot New chat's icon occupies above, so the column lines
|
||||
up. -->
|
||||
<ConversationRow
|
||||
title={getConversationTitle(conversation)}
|
||||
status={dotStatus(conversation.id)}
|
||||
isTest={conversation.is_test}
|
||||
selected={manager.selectedConversationId === conversation.id}
|
||||
disabled={!!rowLocked(conversation)}
|
||||
title={rowLocked(conversation)}
|
||||
btnClasses="transition-all duration-150 group gap-2"
|
||||
>
|
||||
<!-- In the slot New chat's icon occupies above, so the column lines up. Says
|
||||
what the chat is doing where there is something to say, and which kind of
|
||||
chat it is otherwise. -->
|
||||
{@render statusDot(conversation)}
|
||||
{@const unread = manager.unreadCount(conversation.id)}
|
||||
<span
|
||||
class={twMerge(
|
||||
'flex-1 text-left truncate',
|
||||
unread > 0 ? 'font-semibold text-primary' : ''
|
||||
)}
|
||||
>
|
||||
{getConversationTitle(conversation)}
|
||||
</span>
|
||||
{#if manager.conversationStatus(conversation.id) === 'queued' || unread > 0}
|
||||
<span class="shrink-0 inline-flex items-center gap-1">
|
||||
{#if manager.conversationStatus(conversation.id) === 'queued'}
|
||||
<PencilLine class="w-3 h-3 text-tertiary" aria-label="Message waiting to send" />
|
||||
{/if}
|
||||
<UnreadCountBadge count={unread} />
|
||||
</span>
|
||||
{/if}
|
||||
<!-- Hidden while the row is disabled: it sits inside the row's button, and a
|
||||
disabled button swallows every click in its subtree, so a visible menu
|
||||
here would be an affordance that does nothing. -->
|
||||
{#if !rowLocked(conversation)}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class={twMerge(
|
||||
'ml-2 transition-all duration-100 opacity-0 group-hover:opacity-100',
|
||||
manager.deletingConversationId === conversation.id ? 'opacity-100' : ''
|
||||
)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownV2 items={() => rowActions(conversation)} size="xs" />
|
||||
</div>
|
||||
{/if}
|
||||
</Button>
|
||||
lockedReason={rowLocked(conversation)}
|
||||
unread={manager.unreadCount(conversation.id)}
|
||||
queued={manager.conversationStatus(conversation.id) === 'queued'}
|
||||
busy={manager.deletingConversationId === conversation.id}
|
||||
onSelect={() => manager.selectConversation(conversation.id, conversation.isDraft)}
|
||||
actions={() => rowActions(conversation)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Snippet } from 'svelte'
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import type { ConversationKind } from './FlowChatManager.svelte'
|
||||
|
||||
@@ -41,6 +42,18 @@ export interface FlowChatProps {
|
||||
/** Whether a turn may run while another chat's is still going. Off where one run
|
||||
* owns the surface — the editor's panel shows it on the graph. */
|
||||
parallelTurns?: boolean
|
||||
/** Open on this conversation rather than on the latest one, or on a fresh one for `null`.
|
||||
* Followed as it changes, so a list outside the panel can drive which chat it shows. */
|
||||
conversationId?: string | null
|
||||
/** Leave out the panel's own conversation list, for a surface that lists them itself and
|
||||
* drives the chat through `conversationId`. */
|
||||
hideSidebar?: boolean
|
||||
/** Rendered above the composer, as the session chat's own preface is: what a turn will run
|
||||
* against, before the first message fixes it. */
|
||||
inputPreface?: Snippet
|
||||
/** Replaces the empty transcript's prompt. An empty snippet leaves the composer at the top
|
||||
* of the panel, as a new session has it. */
|
||||
emptyHint?: Snippet
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import Tab from '$lib/components/common/tabs/Tab.svelte'
|
||||
import {
|
||||
Plus,
|
||||
Bot,
|
||||
Code2,
|
||||
LayoutDashboard,
|
||||
ChevronDown,
|
||||
@@ -120,6 +121,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}/agents/add`)
|
||||
},
|
||||
...(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,20 @@
|
||||
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
|
||||
{showEditButton}
|
||||
{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,
|
||||
CheckSquare,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
@@ -63,7 +66,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
|
||||
}
|
||||
|
||||
@@ -102,7 +105,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' },
|
||||
@@ -167,7 +171,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)
|
||||
@@ -201,7 +213,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
|
||||
@@ -219,6 +231,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
|
||||
@@ -304,10 +317,14 @@
|
||||
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 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)
|
||||
|
||||
@@ -348,6 +365,57 @@
|
||||
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 = []
|
||||
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 = []
|
||||
return
|
||||
}
|
||||
if (gen !== agentsGen) return
|
||||
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
|
||||
@@ -364,6 +432,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
|
||||
@@ -386,7 +468,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.
|
||||
@@ -599,7 +681,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,
|
||||
@@ -715,7 +797,7 @@
|
||||
}
|
||||
|
||||
function filterItemsPathsBaseOnUserFilters(
|
||||
item: TableScript | TableFlow | TableApp | TableRawApp,
|
||||
item: TableScript | TableFlow | TableApp | TableRawApp | TableAgent,
|
||||
filterUserFolders: boolean,
|
||||
filterUserFoldersType: 'only f/*' | 'u/username and f/*' | undefined
|
||||
) {
|
||||
@@ -972,7 +1054,7 @@
|
||||
try {
|
||||
const res = await ScriptService.countRunnablesByOwner({
|
||||
workspace: ws,
|
||||
kinds: kind !== 'all' ? kind : undefined,
|
||||
kinds: kind !== 'all' && kind !== 'agent' ? kind : undefined,
|
||||
includeWithoutMain: withoutMain ? true : undefined,
|
||||
// Same scope as the listing, so a badge counts the rows behind it.
|
||||
includeDraftOnly: true
|
||||
@@ -1160,7 +1242,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 {
|
||||
@@ -1216,7 +1299,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,
|
||||
@@ -1267,6 +1350,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)
|
||||
)
|
||||
@@ -1327,10 +1415,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
|
||||
)
|
||||
@@ -1663,7 +1753,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(
|
||||
@@ -1799,6 +1890,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
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
AlertCircle,
|
||||
Database,
|
||||
Pyramid,
|
||||
MessagesSquare,
|
||||
MailIcon,
|
||||
ChevronDown,
|
||||
ChevronRight
|
||||
@@ -236,6 +237,13 @@
|
||||
aiId: 'sidebar-menu-link-assets',
|
||||
aiDescription: 'Button to navigate to assets'
|
||||
},
|
||||
{
|
||||
label: 'Agent chats',
|
||||
href: `${base}/agent_chats`,
|
||||
icon: MessagesSquare,
|
||||
aiId: 'sidebar-menu-link-agent-chats',
|
||||
aiDescription: 'Button to navigate to the conversations with the saved agents'
|
||||
},
|
||||
{
|
||||
label: 'Folders',
|
||||
href: `${base}/folders`,
|
||||
|
||||
@@ -1669,6 +1669,8 @@ export function conditionalMelt(node: HTMLElement, meltItem: AnyMeltElement | un
|
||||
|
||||
export type Item = {
|
||||
displayName: string
|
||||
/** A second, dimmer line under `displayName`, always shown (e.g. an item's path). */
|
||||
subtitle?: string
|
||||
action?: (e: MouseEvent) => void
|
||||
icon?: any
|
||||
iconColor?: string
|
||||
|
||||
@@ -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 ?? ''
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
<script lang="ts">
|
||||
import { resource } from 'runed'
|
||||
import { Bot, ChevronDown, ExternalLink, Pen, Plus, Trash2 } from 'lucide-svelte'
|
||||
import { base } from '$lib/base'
|
||||
import { Button, Skeleton } from '$lib/components/common'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
|
||||
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
|
||||
import FlowChat from '$lib/components/flows/conversations/FlowChat.svelte'
|
||||
import ConversationRow from '$lib/components/flows/conversations/ConversationRow.svelte'
|
||||
import { runFlowPreview } from '$lib/components/flows/utils.svelte'
|
||||
import { agentArgsToTransforms } from '$lib/components/flows/linkedAgentDrafts'
|
||||
import {
|
||||
AGENT_CHAT_BLOCKED_REASON,
|
||||
agentArgsChatReady,
|
||||
agentChatFlow,
|
||||
agentChatReady,
|
||||
agentChatStreams,
|
||||
type AgentStepShape
|
||||
} from '$lib/components/flows/agentChatFlow'
|
||||
import type { AIAgentConfig } from '$lib/components/flows/agentResourceUtils'
|
||||
import type { AgentTool } from '$lib/components/flows/agentToolUtils'
|
||||
import {
|
||||
FlowConversationsService,
|
||||
ResourceService,
|
||||
type FlowConversation,
|
||||
type ListableResource
|
||||
} from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import type { Item } from '$lib/utils'
|
||||
|
||||
/**
|
||||
* Every conversation with a saved agent, listed by agent the way the sidebar lists AI sessions
|
||||
* by workspace, with the chat itself laid out as a session is: a title row, and the agent it
|
||||
* talks to shown above the composer until the first message fixes it, then beside the title.
|
||||
* The chat runs the deployed agent, as the agent's own page does; there is no form here.
|
||||
*/
|
||||
let ws = $derived($workspaceStore)
|
||||
|
||||
type AgentGroup = { agent: ListableResource; conversations: FlowConversation[] }
|
||||
|
||||
// Conversations are stored by flow path, and an agent's chats run under its resource path,
|
||||
// so grouping is a join on that path. Every agent is kept for the picker; only those someone
|
||||
// has talked to get a section.
|
||||
let catalog = resource(
|
||||
() => ws,
|
||||
async (
|
||||
ws
|
||||
): Promise<
|
||||
{ agents: ListableResource[]; groups: AgentGroup[]; chatReady: Set<string> } | undefined
|
||||
> => {
|
||||
if (!ws) return undefined
|
||||
const [agents, conversations] = await Promise.all([
|
||||
ResourceService.listResource({ workspace: ws, resourceType: 'ai_agent' }),
|
||||
FlowConversationsService.listFlowConversations({ workspace: ws, kind: 'all', perPage: 100 })
|
||||
])
|
||||
// The list leaves a deployed row's value out, and whether an agent can chat is in it.
|
||||
// Read per agent; a draft-only row carries its value and has no deployed one to read.
|
||||
const values = await Promise.all(
|
||||
agents.map((a) =>
|
||||
a.value != null
|
||||
? Promise.resolve(a.value)
|
||||
: ResourceService.getResourceValue({ workspace: ws, path: a.path }).catch(
|
||||
() => undefined
|
||||
)
|
||||
)
|
||||
)
|
||||
const chatReady = new Set(
|
||||
agents
|
||||
.filter((_, i) => agentArgsChatReady(values[i] as Record<string, any> | undefined))
|
||||
.map((a) => a.path)
|
||||
)
|
||||
const byPath = new Map<string, FlowConversation[]>()
|
||||
for (const c of conversations) {
|
||||
byPath.set(c.flow_path, [...(byPath.get(c.flow_path) ?? []), c])
|
||||
}
|
||||
const sorted = [...agents].sort((a, b) => a.path.localeCompare(b.path))
|
||||
return {
|
||||
agents: sorted,
|
||||
chatReady,
|
||||
groups: sorted
|
||||
.filter((agent) => byPath.has(agent.path))
|
||||
.map((agent) => ({
|
||||
agent,
|
||||
conversations: (byPath.get(agent.path) ?? []).sort((a, b) =>
|
||||
b.updated_at.localeCompare(a.updated_at)
|
||||
)
|
||||
}))
|
||||
}
|
||||
}
|
||||
)
|
||||
let agents = $derived(catalog.current?.agents ?? [])
|
||||
/** The agents a new chat can talk to: their memory is `auto`, which the chat needs. */
|
||||
let chatAgents = $derived(agents.filter((a) => catalog.current?.chatReady.has(a.path)))
|
||||
let groups = $derived(catalog.current?.groups ?? [])
|
||||
|
||||
let selectedAgent = $state<string | undefined>(undefined)
|
||||
let selectedConversation = $state<string | undefined>(undefined)
|
||||
/** A chat being started. Its agent is picked above the composer, as a session's workspace
|
||||
* is, and fixed by the first message. */
|
||||
let newChat = $state(false)
|
||||
let pickedAgent = $state<string | undefined>(undefined)
|
||||
let chatStarted = $derived(newChat && selectedConversation !== undefined)
|
||||
|
||||
let currentAgent = $derived(newChat ? pickedAgent : (selectedAgent ?? groups[0]?.agent.path))
|
||||
let currentResource = $derived(agents.find((a) => a.path === currentAgent))
|
||||
let currentGroup = $derived(groups.find((g) => g.agent.path === currentAgent))
|
||||
// With nothing picked, the agent's latest chat: what the panel opens on its own, named here
|
||||
// so the title and the highlighted row agree with it.
|
||||
let currentConversationId = $derived(
|
||||
newChat ? selectedConversation : (selectedConversation ?? currentGroup?.conversations[0]?.id)
|
||||
)
|
||||
let currentConversation = $derived(
|
||||
groups.flatMap((g) => g.conversations).find((c) => c.id === currentConversationId)
|
||||
)
|
||||
let title = $derived(
|
||||
newChat && !chatStarted ? 'Untitled chat' : (currentConversation?.title ?? 'Untitled chat')
|
||||
)
|
||||
|
||||
function open(agentPath: string, conversationId?: string) {
|
||||
newChat = false
|
||||
selectedAgent = agentPath
|
||||
selectedConversation = conversationId
|
||||
}
|
||||
|
||||
// Starts on the agent last looked at, as a session starts on the active workspace.
|
||||
function startNewChat() {
|
||||
// Read before the switch: once in a new chat, the current agent is the picked one.
|
||||
const lastLookedAt = currentAgent
|
||||
newChat = true
|
||||
pickedAgent = chatAgents.find((a) => a.path === lastLookedAt)?.path ?? chatAgents[0]?.path
|
||||
selectedConversation = undefined
|
||||
}
|
||||
|
||||
// The deployed config of the agent being talked to, in the shape a flow step carries it.
|
||||
let agentResource = resource(
|
||||
() => ({ ws, path: currentAgent }),
|
||||
async ({ ws, path }) => {
|
||||
if (!ws || !path) return undefined
|
||||
return await ResourceService.getResource({ workspace: ws, path })
|
||||
}
|
||||
)
|
||||
let config = $derived((agentResource.current?.value ?? {}) as AIAgentConfig)
|
||||
let chatStep = $derived<AgentStepShape>({
|
||||
input_transforms: agentArgsToTransforms(config) as AgentStepShape['input_transforms'],
|
||||
tools: (Array.isArray(config.tools) ? config.tools : []) as AgentTool[]
|
||||
})
|
||||
let chatReady = $derived(agentResource.current !== undefined && agentChatReady(chatStep))
|
||||
let useStreaming = $derived(agentChatStreams(chatStep))
|
||||
let chatFlowShape = $derived(agentChatFlow(chatStep))
|
||||
|
||||
async function runChatTurn(
|
||||
userMessage: string,
|
||||
conversationId: string,
|
||||
additionalInputs?: Record<string, any>
|
||||
): Promise<string | undefined> {
|
||||
const path = currentAgent
|
||||
if (!path) return undefined
|
||||
const jobId = await runFlowPreview(
|
||||
{ user_message: userMessage, ...(additionalInputs ?? {}) },
|
||||
agentChatFlow(chatStep),
|
||||
path,
|
||||
undefined,
|
||||
conversationId,
|
||||
undefined,
|
||||
ws
|
||||
)
|
||||
// A first turn creates the conversation this list does not have yet. The chat keeps its
|
||||
// own state; only the list's highlight follows it, so nothing reloads under the turn.
|
||||
selectedAgent = path
|
||||
selectedConversation = conversationId
|
||||
void catalog.refetch()
|
||||
return jobId
|
||||
}
|
||||
|
||||
function conversationTitle(conversation: FlowConversation): string {
|
||||
return conversation.title || `Conversation ${conversation.created_at.slice(0, 10)}`
|
||||
}
|
||||
|
||||
let deletingId = $state<string | undefined>(undefined)
|
||||
|
||||
async function renameConversation(conversation: FlowConversation) {
|
||||
const title = window.prompt('Chat name', conversationTitle(conversation))
|
||||
if (!ws || title == null) return
|
||||
await FlowConversationsService.updateFlowConversation({
|
||||
workspace: ws,
|
||||
conversationId: conversation.id,
|
||||
requestBody: { title }
|
||||
})
|
||||
void catalog.refetch()
|
||||
}
|
||||
|
||||
async function deleteConversation(conversation: FlowConversation) {
|
||||
if (!ws) return
|
||||
deletingId = conversation.id
|
||||
try {
|
||||
await FlowConversationsService.deleteFlowConversation({
|
||||
workspace: ws,
|
||||
conversationId: conversation.id
|
||||
})
|
||||
if (selectedConversation === conversation.id) selectedConversation = undefined
|
||||
await catalog.refetch()
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not delete the chat: ${err}`, true)
|
||||
} finally {
|
||||
deletingId = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function rowActions(conversation: FlowConversation): Item[] {
|
||||
return [
|
||||
{ displayName: 'Rename', icon: Pen, action: () => renameConversation(conversation) },
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash2,
|
||||
type: 'delete',
|
||||
disabled: deletingId === conversation.id,
|
||||
action: () => deleteConversation(conversation)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
let agentMenu = $derived(
|
||||
chatAgents.map((a) => ({
|
||||
displayName: a.description || a.path,
|
||||
// The summary alone does not tell two agents apart ("AI judge" is common); the path does.
|
||||
subtitle: a.description ? a.path : undefined,
|
||||
action: () => (pickedAgent = a.path)
|
||||
}))
|
||||
)
|
||||
</script>
|
||||
|
||||
<!-- The chip naming the agent, as the workspace chip names a session's workspace: a picker
|
||||
before the first message, inert once the chat has one. -->
|
||||
{#snippet agentChip(interactive: boolean)}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
title={currentResource?.path}
|
||||
tabindex={interactive ? undefined : -1}
|
||||
endIcon={interactive ? { icon: ChevronDown } : undefined}
|
||||
btnClasses="min-w-0 rounded-md text-2xs justify-start bg-surface-secondary {interactive
|
||||
? ''
|
||||
: 'cursor-default active:opacity-100 hover:bg-surface-secondary'}"
|
||||
wrapperClasses="max-w-[16rem]"
|
||||
>
|
||||
<span class="truncate min-w-0 flex-1 text-left">
|
||||
{currentResource?.description || currentResource?.path || 'Pick an agent'}
|
||||
</span>
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
<!-- Nothing where the flow chat would prompt to start: the composer says it, as a session's. -->
|
||||
{#snippet noHint()}{/snippet}
|
||||
|
||||
{#snippet talkingTo()}
|
||||
<div class="flex flex-row items-center gap-1 py-0.5 px-1 text-2xs text-secondary">
|
||||
<span class="shrink-0">Talking to</span>
|
||||
<DropdownV2 fixedHeight={false} placement="bottom-start" enableFlyTransition items={agentMenu}>
|
||||
{#snippet buttonReplacement()}
|
||||
{@render agentChip(true)}
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<main class="h-screen w-full flex min-h-0">
|
||||
<!-- Laid out as the sidebar's session picker is: the new-item button on top, then a plain
|
||||
section title per agent with its chats as text rows, groups spaced apart. -->
|
||||
<aside class="w-64 shrink-0 border-r flex flex-col min-h-0">
|
||||
<div class="flex flex-col gap-1 px-2 pt-3 pb-2 shrink-0">
|
||||
<MenuButton
|
||||
stopPropagationOnClick={true}
|
||||
on:click={startNewChat}
|
||||
isCollapsed={false}
|
||||
icon={Plus}
|
||||
label="New chat"
|
||||
class="!text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 overflow-y-auto flex flex-col gap-0.5 px-2 pt-2 pb-3">
|
||||
{#if catalog.loading && groups.length === 0}
|
||||
<div class="p-2"><Skeleton layout={[[1], 0.5, [1], 0.5, [1]]} /></div>
|
||||
{:else if groups.length === 0}
|
||||
<div class="p-2 text-xs text-tertiary">No conversation yet.</div>
|
||||
{:else}
|
||||
{#each groups as group, groupIdx (group.agent.path)}
|
||||
<!-- The session picker's group header, as a link to the agent: it looks the same at
|
||||
rest and only answers the pointer. -->
|
||||
<div
|
||||
class="flex flex-row items-center gap-1 pl-1 pr-0.5 pt-2 pb-1 min-w-0 {groupIdx > 0
|
||||
? 'mt-4'
|
||||
: ''}"
|
||||
>
|
||||
<a
|
||||
href="{base}/agents/get/{group.agent.path}"
|
||||
title="Open {group.agent.path}"
|
||||
class="group/title flex items-center gap-1.5 min-w-0 text-secondary text-xs hover:text-primary"
|
||||
>
|
||||
<Bot size={14} class="shrink-0" />
|
||||
<span class="truncate group-hover/title:underline">
|
||||
{group.agent.description || group.agent.path}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
{#each group.conversations as conversation (conversation.id)}
|
||||
<div class="pl-1">
|
||||
<ConversationRow
|
||||
title={conversationTitle(conversation)}
|
||||
isTest={conversation.is_test}
|
||||
selected={currentConversationId === conversation.id}
|
||||
busy={deletingId === conversation.id}
|
||||
onSelect={() => open(group.agent.path, conversation.id)}
|
||||
actions={() => rowActions(conversation)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="flex-1 min-w-0 flex flex-col min-h-0 pb-2">
|
||||
{#if currentResource}
|
||||
<header class="flex flex-row items-center gap-1 pl-4 pr-4 py-2 shrink-0">
|
||||
<span class="text-sm font-semibold text-primary truncate">{title}</span>
|
||||
{#if !newChat || chatStarted}
|
||||
<!-- Fixed by the first message, so it moves up beside the title as a session's
|
||||
workspace does. -->
|
||||
<div class="flex items-center gap-1 min-w-0 ml-2 text-2xs text-tertiary">
|
||||
<span class="shrink-0">Talking to</span>
|
||||
{@render agentChip(false)}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grow"></div>
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
iconOnly
|
||||
startIcon={{ icon: ExternalLink }}
|
||||
title="Open agent"
|
||||
href="{base}/agents/get/{currentResource.path}"
|
||||
/>
|
||||
</header>
|
||||
<div class="flex-1 min-h-0 w-full flex flex-col {newChat && !chatStarted ? 'pt-8' : ''}">
|
||||
{#if chatReady}
|
||||
<!-- Before the first message the chat takes only the height its composer needs, so
|
||||
it sits at the top as a new session's does, with the agent's description under
|
||||
it rather than a prompt to start. -->
|
||||
{#key `${newChat}:${currentResource.path}`}
|
||||
<div
|
||||
class={newChat && !chatStarted
|
||||
? 'shrink-0 flex flex-col'
|
||||
: 'flex-1 min-h-0 flex flex-col'}
|
||||
>
|
||||
<FlowChat
|
||||
{useStreaming}
|
||||
onRunFlow={runChatTurn}
|
||||
conversationKind="test"
|
||||
frame="none"
|
||||
wideLayout
|
||||
path={currentResource.path}
|
||||
identity={currentResource.path}
|
||||
description={currentResource.description}
|
||||
inputSchema={chatFlowShape.schema}
|
||||
flowModules={chatFlowShape.value.modules}
|
||||
conversationId={newChat ? null : currentConversationId}
|
||||
inputPreface={newChat && !chatStarted ? talkingTo : undefined}
|
||||
emptyHint={noHint}
|
||||
hideSidebar
|
||||
/>
|
||||
</div>
|
||||
{/key}
|
||||
{#if newChat && !chatStarted && currentResource.description}
|
||||
<div class="w-full max-w-3xl mx-auto px-7 pt-4 text-xs text-tertiary">
|
||||
<GfmMarkdown md={currentResource.description} noPadding prose="sm" />
|
||||
</div>
|
||||
{/if}
|
||||
{:else if agentResource.current !== undefined}
|
||||
<div class="flex-1 flex flex-col items-center justify-center gap-3 p-6">
|
||||
{#if newChat && !chatStarted}
|
||||
{@render talkingTo()}
|
||||
{/if}
|
||||
<span class="text-xs text-tertiary">{AGENT_CHAT_BLOCKED_REASON}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if !catalog.loading && (newChat || agents.length === 0)}
|
||||
<div class="flex-1 flex items-center justify-center text-xs text-tertiary">
|
||||
{agents.length === 0
|
||||
? 'No saved agent in this workspace. Create one from the home page to chat with it.'
|
||||
: 'No agent can chat yet: set its memory to auto from its editor.'}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</main>
|
||||
@@ -0,0 +1,5 @@
|
||||
<!--
|
||||
Never rendered — the sibling +page.ts redirects at the load phase, before
|
||||
this component can mount. Kept as an empty file because SvelteKit only
|
||||
registers a route when a +page.svelte exists.
|
||||
-->
|
||||
@@ -0,0 +1,6 @@
|
||||
import { makeDraftAddLoad } from '$lib/draftAddRedirect'
|
||||
import type { PageLoad } from './$types'
|
||||
|
||||
export const prerender = false
|
||||
|
||||
export const load: PageLoad = makeDraftAddLoad('agents/edit')
|
||||
@@ -0,0 +1,356 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state'
|
||||
import { resource } from 'runed'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bot,
|
||||
Braces,
|
||||
FormInput,
|
||||
History,
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
Save,
|
||||
Settings
|
||||
} from 'lucide-svelte'
|
||||
import EditableInput from '$lib/components/common/EditableInput.svelte'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
import Section from '$lib/components/Section.svelte'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import {
|
||||
AGENT_CHAT_BLOCKED_REASON,
|
||||
agentArgsChatReady
|
||||
} from '$lib/components/flows/agentChatFlow'
|
||||
import { base } from '$lib/base'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { Badge, Button, Drawer, DrawerContent } from '$lib/components/common'
|
||||
import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte'
|
||||
import AutosaveIndicator from '$lib/components/AutosaveIndicator.svelte'
|
||||
import ResourceVersionHistory from '$lib/components/ResourceVersionHistory.svelte'
|
||||
import AgentEditorHost from '$lib/components/flows/content/AgentEditorHost.svelte'
|
||||
import { agentWriteCount, markAgentWritten } from '$lib/components/flows/agentEditorStore.svelte'
|
||||
import { ResourceService } from '$lib/gen'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
|
||||
/**
|
||||
* The standalone agent editor: the same host the flow step's dialog and the resources page
|
||||
* open, on a page of its own. It edits the `ai_agent` resource at the route's path through
|
||||
* the per-user resource draft and runs it from the host's test pane.
|
||||
*/
|
||||
let path = $derived(page.params.path ?? '')
|
||||
let ws = $derived($workspaceStore)
|
||||
let host = $state<ReturnType<typeof AgentEditorHost> | undefined>(undefined)
|
||||
let toolId = $state<string | undefined>(undefined)
|
||||
let versionDrawer: Drawer | undefined = $state(undefined)
|
||||
let saving = $state(false)
|
||||
// A version restore writes the resource behind the host, which still holds the baseline it
|
||||
// loaded and any draft over it; deploying from that would write the pre-restore value back.
|
||||
// Remounting reloads both, as the dialog form of this editor does by closing.
|
||||
let restores = $state(0)
|
||||
|
||||
// Counted per agent so a deploy refetches the version it just minted.
|
||||
let writes = $derived(agentWriteCount(ws, path))
|
||||
let versionResource = resource(
|
||||
() => ({ ws, 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 }
|
||||
}
|
||||
)
|
||||
let version = $derived.by(() => {
|
||||
const loaded = versionResource.current
|
||||
return loaded !== undefined &&
|
||||
loaded.ws === ws &&
|
||||
loaded.path === path &&
|
||||
loaded.writes === writes
|
||||
? loaded.version
|
||||
: undefined
|
||||
})
|
||||
|
||||
let draft = $derived(host?.draftHandle())
|
||||
let refused = $derived(draft?.refusal != null)
|
||||
let readOnly = $derived(draft ? !draft.canWrite : false)
|
||||
let draftOnly = $derived(draft?.noDeployed ?? false)
|
||||
|
||||
/** Chat first; the form stays for a run with explicit inputs. Chat needs `auto` memory,
|
||||
* read off the draft so the toggle follows the memory field as it is edited. */
|
||||
let runPane = $state<'chat' | 'form'>('chat')
|
||||
let chatReady = $derived(agentArgsChatReady(draft?.state?.args))
|
||||
|
||||
/** Minted by `/agents/add`, as `/scripts/add` mints a script's: the editor starts empty. */
|
||||
let isNew = $derived(page.url.searchParams.get('new_draft') === 'true')
|
||||
// A draft-only agent is shown at the path its first deploy will create, not at its storage path.
|
||||
let shownPath = $derived(draftOnly ? (draft?.state?.draft_path ?? '') : path)
|
||||
let pathEditable = $derived(draftOnly && !readOnly && !!draft?.state)
|
||||
let settingsOpen = $state(false)
|
||||
|
||||
// A deploy lands on the deployed agent's page, as a flow's does: what was just written is what
|
||||
// that page shows and runs.
|
||||
async function onDeploy() {
|
||||
saving = true
|
||||
let deployed = false
|
||||
try {
|
||||
deployed = (await host?.deploy()) ?? false
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
if (deployed) await goto(`${base}/agents/get/${draft?.lastDeployedPath ?? path}`)
|
||||
}
|
||||
|
||||
function onSaved(savedPath: string) {
|
||||
if (ws) markAgentWritten(ws, savedPath)
|
||||
}
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<Button
|
||||
href="{base}/"
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
iconOnly
|
||||
startIcon={{ icon: ArrowLeft }}
|
||||
title="Back to home"
|
||||
/>
|
||||
<Bot size={18} class="text-violet-500 shrink-0" />
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<!-- Laid out as the script and flow editors' header is: the path above with its pen, the
|
||||
summary line editable in place. -->
|
||||
<div class="inline-block max-w-full align-top group px-2 py-0.5 leading-tight min-w-0">
|
||||
<div class="flex items-center max-w-full text-2xs text-secondary font-mono min-w-0">
|
||||
<span class="truncate">{shownPath}</span>
|
||||
<!-- A deployed agent is renamed with Move, which keeps the flows linking it in view;
|
||||
only a first deploy picks its path here. -->
|
||||
{#if pathEditable}
|
||||
<Popover
|
||||
placement="bottom-start"
|
||||
contentClasses="p-4"
|
||||
usePointerDownOutside
|
||||
disableFocusTrap
|
||||
closeOnOtherPopoverOpen
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
iconOnly
|
||||
startIcon={{ icon: Pencil }}
|
||||
title="Edit path"
|
||||
aria-label="Edit path"
|
||||
btnClasses="opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="w-[480px]">{@render pathField(true)}</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="max-w-full -mt-[2px]" title={draft?.state?.description}>
|
||||
<EditableInput
|
||||
value={draft?.state?.description ?? ''}
|
||||
placeholder="Add a summary..."
|
||||
editable={!readOnly && !!draft?.state}
|
||||
commitOnInput
|
||||
size="sm"
|
||||
onSave={(v) => {
|
||||
if (draft?.state) draft.state.description = v.trim()
|
||||
}}
|
||||
textClass="text-xs font-semibold text-emphasis leading-tight"
|
||||
class="max-w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if version != undefined && !refused}
|
||||
<Badge color="gray" class="shrink-0" title="The version runs are recorded against">
|
||||
v{version}
|
||||
</Badge>
|
||||
{/if}
|
||||
<!-- The draft autosave state, as the flow and script editors show it. -->
|
||||
{#if ws && !refused}
|
||||
<AutosaveIndicator
|
||||
workspace={ws}
|
||||
itemKind="resource"
|
||||
{path}
|
||||
{draftOnly}
|
||||
onResetToDeployed={() => draft?.sync.resetToDeployed(path)}
|
||||
/>
|
||||
{/if}
|
||||
{#if readOnly}
|
||||
<Badge color="gray" class="shrink-0" title="You do not have write access to this agent">
|
||||
Read only
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-xs text-tertiary hidden xl:inline truncate">
|
||||
Changes here update the saved agent, and every flow that links to it.
|
||||
</span>
|
||||
<div class="grow"></div>
|
||||
{#if !refused}
|
||||
<ToggleButtonGroup
|
||||
selected={runPane}
|
||||
onSelected={(v) => (runPane = v as 'chat' | 'form')}
|
||||
noWFull
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
value="chat"
|
||||
label="Chat"
|
||||
icon={MessageSquare}
|
||||
size="md"
|
||||
disabled={!chatReady}
|
||||
tooltip={chatReady ? undefined : AGENT_CHAT_BLOCKED_REASON}
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton value="form" label="Form" icon={FormInput} size="md" {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
<div class="grow"></div>
|
||||
<!-- Nothing deployed means no resource to open as JSON and no history to list. -->
|
||||
{#if !draftOnly}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
startIcon={{ icon: Braces }}
|
||||
iconOnly
|
||||
title="Edit as JSON in the resources page"
|
||||
href="{base}/resources#/resource/{path}"
|
||||
/>
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
startIcon={{ icon: History }}
|
||||
iconOnly
|
||||
title="Version history"
|
||||
on:click={() => versionDrawer?.openDrawer()}
|
||||
/>
|
||||
{/if}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
startIcon={{ icon: Settings }}
|
||||
title="Settings"
|
||||
on:click={() => (settingsOpen = true)}
|
||||
>
|
||||
Settings
|
||||
</Button>
|
||||
<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>
|
||||
|
||||
<div class="shrink-0">
|
||||
<LocalDraftBanner
|
||||
show={draft?.sync.hasDraft ?? false}
|
||||
reserveSpace={false}
|
||||
getDeployed={() => draft?.deployed}
|
||||
getCurrent={() => draft?.state}
|
||||
onDiscard={() => draft?.sync.resetToDeployed(path)}
|
||||
title="Deployed <> Unsaved agent changes"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-0">
|
||||
{#key `${ws}:${path}:${restores}`}
|
||||
<AgentEditorHost
|
||||
bind:this={host}
|
||||
{path}
|
||||
workspace={ws}
|
||||
enableAi={$copilotInfo.enabled}
|
||||
{toolId}
|
||||
onSelectTool={(id) => (toolId = id)}
|
||||
{onSaved}
|
||||
{runPane}
|
||||
{isNew}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The path field, shared by the header's pen and the settings drawer. A deployed agent's path
|
||||
is shown, not edited: renaming it is Move, from its page. -->
|
||||
{#snippet pathField(autofocus: boolean)}
|
||||
{#if pathEditable}
|
||||
<Path
|
||||
{autofocus}
|
||||
bind:path={
|
||||
() => draft?.state?.draft_path ?? '',
|
||||
(v) => {
|
||||
if (draft?.state) draft.state.draft_path = v
|
||||
}
|
||||
}
|
||||
initialPath={draft?.state?.draft_path ?? ''}
|
||||
namePlaceholder="agent"
|
||||
kind="resource"
|
||||
size="sm"
|
||||
drawerOffset={4000}
|
||||
/>
|
||||
{:else}
|
||||
<span class="text-xs font-mono text-secondary">{shownPath}</span>
|
||||
<p class="text-2xs text-tertiary mt-1">
|
||||
Rename a deployed agent with Move, from its page, so the flows linking it follow.
|
||||
</p>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<!-- Laid out as the script editor's settings: summary, path, then what the item is for. -->
|
||||
<Drawer placement="right" bind:open={settingsOpen} size="800px">
|
||||
<DrawerContent title="Settings" on:close={() => (settingsOpen = false)}>
|
||||
<Section label="Metadata">
|
||||
<div class="flex flex-col gap-6">
|
||||
<Label label="Summary">
|
||||
<TextInput
|
||||
bind:value={
|
||||
() => draft?.state?.description ?? '',
|
||||
(v) => {
|
||||
if (draft?.state) draft.state.description = v
|
||||
}
|
||||
}
|
||||
disabled={readOnly || !draft?.state}
|
||||
inputProps={{ placeholder: 'Short summary to be displayed when listed' }}
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Path">
|
||||
{#snippet header()}
|
||||
<Tooltip
|
||||
documentationLink="https://www.windmill.dev/docs/core_concepts/roles_and_permissions#path"
|
||||
>
|
||||
The unique identifier of the agent in the workspace that defines permissions
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
{@render pathField(false)}
|
||||
</Label>
|
||||
</div>
|
||||
</Section>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Drawer bind:this={versionDrawer} size="1200px">
|
||||
<DrawerContent title="Version history" on:close={() => versionDrawer?.closeDrawer()} noPadding>
|
||||
<ResourceVersionHistory
|
||||
{path}
|
||||
workspace={ws}
|
||||
canRestore={!readOnly}
|
||||
onRestore={() => {
|
||||
versionDrawer?.closeDrawer()
|
||||
if (ws) markAgentWritten(ws, path)
|
||||
restores++
|
||||
}}
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
@@ -0,0 +1,416 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state'
|
||||
import { resource } from 'runed'
|
||||
import { get } from 'svelte/store'
|
||||
import { Splitpanes, Pane } from 'svelte-splitpanes'
|
||||
import { Boxes, Bot, FileUp, FolderInput, History, Pen, Shield, Trash } from 'lucide-svelte'
|
||||
import { base } from '$lib/base'
|
||||
import { goto } from '$lib/navigation'
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
Tab,
|
||||
TabContent,
|
||||
Tabs
|
||||
} from '$lib/components/common'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import MoveDrawer from '$lib/components/MoveDrawer.svelte'
|
||||
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import ResourceVersionHistory from '$lib/components/ResourceVersionHistory.svelte'
|
||||
import HighlightCode from '$lib/components/HighlightCode.svelte'
|
||||
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
|
||||
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
|
||||
import AgentRunPane from '$lib/components/flows/content/AgentRunPane.svelte'
|
||||
import FlowChat from '$lib/components/flows/conversations/FlowChat.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { FormInput, MessageSquare } from 'lucide-svelte'
|
||||
import { runFlowPreview } from '$lib/components/flows/utils.svelte'
|
||||
import { agentArgsToTransforms } from '$lib/components/flows/linkedAgentDrafts'
|
||||
import {
|
||||
AGENT_CHAT_BLOCKED_REASON,
|
||||
agentChatFlow,
|
||||
agentChatReady,
|
||||
agentChatStreams,
|
||||
type AgentStepShape
|
||||
} from '$lib/components/flows/agentChatFlow'
|
||||
import { agentWriteCount, markAgentWritten } from '$lib/components/flows/agentEditorStore.svelte'
|
||||
import {
|
||||
AGENT_BRAIN_LABELS,
|
||||
agentEditorRefusal,
|
||||
summarizeAgentBrain,
|
||||
type AIAgentConfig
|
||||
} from '$lib/components/flows/agentResourceUtils'
|
||||
import { toolDisplayName, type AgentTool } from '$lib/components/flows/agentToolUtils'
|
||||
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
|
||||
import { ResourceService, type Resource } from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { canWrite } from '$lib/utils'
|
||||
import { isDeployable } from '$lib/utils_deployable'
|
||||
|
||||
/**
|
||||
* The deployed agent, as the flow and script pages show theirs: what it is, and a form to run
|
||||
* it. Everything here reads the deployed resource; the editor page is where the draft lives.
|
||||
*/
|
||||
let path = $derived(page.params.path ?? '')
|
||||
let ws = $derived($workspaceStore)
|
||||
let writes = $derived(agentWriteCount(ws, path))
|
||||
|
||||
let agentResource = resource(
|
||||
() => ({ ws, path, writes }),
|
||||
async ({ ws, path }) => {
|
||||
if (!ws || !path) return undefined
|
||||
const [r, history, deployUi] = await Promise.all([
|
||||
ResourceService.getResource({ workspace: ws, path }),
|
||||
ResourceService.getResourceHistory({ workspace: ws, path }).catch(() => undefined),
|
||||
getDeployUiSettings()
|
||||
])
|
||||
return {
|
||||
resource: r,
|
||||
version: history?.versions?.[0]?.version,
|
||||
deployable: isDeployable('resource', path, deployUi)
|
||||
}
|
||||
}
|
||||
)
|
||||
let loaded = $derived(agentResource.current)
|
||||
let agent = $derived(loaded?.resource as Resource | undefined)
|
||||
let refusal = $derived(agent ? agentEditorRefusal(path, agent.resource_type) : undefined)
|
||||
let config = $derived((agent?.value ?? {}) as AIAgentConfig)
|
||||
let brain = $derived(summarizeAgentBrain(config))
|
||||
let tools = $derived(Array.isArray(config.tools) ? (config.tools as AgentTool[]) : [])
|
||||
let systemPrompt = $derived(
|
||||
typeof config.system_prompt === 'string' ? config.system_prompt : undefined
|
||||
)
|
||||
let can_write = $derived(
|
||||
agent ? canWrite(agent.path, agent.extra_perms ?? {}, get(userStore) ?? undefined) : false
|
||||
)
|
||||
let loadError = $derived(agentResource.error ? String(agentResource.error) : undefined)
|
||||
|
||||
let rightTab = $state<'agent' | 'export'>('agent')
|
||||
|
||||
/** Chat first, as in the editor; the form stays for a run with explicit inputs. */
|
||||
let leftPane = $state<'chat' | 'form'>('chat')
|
||||
// The deployed config in the shape a flow step carries it, which is what the chat runs.
|
||||
let chatStep = $derived<AgentStepShape>({
|
||||
input_transforms: agentArgsToTransforms(config) as AgentStepShape['input_transforms'],
|
||||
tools
|
||||
})
|
||||
let chatReady = $derived(agentChatReady(chatStep))
|
||||
let chatBlockedReason = $derived(chatReady ? undefined : AGENT_CHAT_BLOCKED_REASON)
|
||||
let useStreaming = $derived(agentChatStreams(chatStep))
|
||||
let chatFlowShape = $derived(agentChatFlow(chatStep))
|
||||
|
||||
// A turn previews the deployed config under the agent's path. There is no deployed run for an
|
||||
// agent on the server, so these conversations are filed as the preview files them: test ones,
|
||||
// shared with the editor's list.
|
||||
async function runChatTurn(
|
||||
userMessage: string,
|
||||
conversationId: string,
|
||||
additionalInputs?: Record<string, any>
|
||||
): Promise<string | undefined> {
|
||||
return await runFlowPreview(
|
||||
{ user_message: userMessage, ...(additionalInputs ?? {}) },
|
||||
agentChatFlow(chatStep),
|
||||
path,
|
||||
undefined,
|
||||
conversationId,
|
||||
undefined,
|
||||
ws
|
||||
)
|
||||
}
|
||||
|
||||
/** What kind of tool a roster entry is: an MCP server, a web search, a script, a flow... */
|
||||
function toolKind(tool: AgentTool): string {
|
||||
const value = tool?.value as Record<string, any> | undefined
|
||||
return value?.tool_type ?? value?.type ?? 'tool'
|
||||
}
|
||||
|
||||
let shareModal: ShareModal | undefined = $state(undefined)
|
||||
let moveDrawer: MoveDrawer | undefined = $state(undefined)
|
||||
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state(undefined)
|
||||
let versionDrawer: Drawer | undefined = $state(undefined)
|
||||
let deleteOpen = $state(false)
|
||||
|
||||
async function deleteAgent() {
|
||||
if (!ws) return
|
||||
try {
|
||||
await ResourceService.deleteResource({ workspace: ws, path })
|
||||
sendUserToast(`Deleted agent ${path}`)
|
||||
await goto(`${base}/?kind=agent`)
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not delete agent ${path}: ${err}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
let menuItems = $derived.by(() => {
|
||||
if (!agent || $userStore?.operator) return []
|
||||
return [
|
||||
{
|
||||
displayName: 'Permissions',
|
||||
icon: Shield,
|
||||
disabled: !can_write,
|
||||
action: () => shareModal?.openDrawer?.(path, 'resource')
|
||||
},
|
||||
{
|
||||
displayName: 'Move/Rename',
|
||||
icon: FolderInput,
|
||||
disabled: !can_write,
|
||||
action: () => moveDrawer?.openDrawer(path, agent?.description, 'resource')
|
||||
},
|
||||
{
|
||||
displayName: 'Version history',
|
||||
icon: History,
|
||||
action: () => versionDrawer?.openDrawer()
|
||||
},
|
||||
{
|
||||
displayName: 'Open in resources',
|
||||
icon: Boxes,
|
||||
href: `${base}/resources#/resource/${path}`
|
||||
},
|
||||
...(loaded?.deployable && !agent.ws_specific
|
||||
? [
|
||||
{
|
||||
displayName: 'Deploy to prod/staging',
|
||||
icon: FileUp,
|
||||
action: () => deploymentDrawer?.openDrawer(path, 'resource')
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Delete',
|
||||
icon: Trash,
|
||||
type: 'delete' as const,
|
||||
disabled: !can_write,
|
||||
action: () => (deleteOpen = true)
|
||||
}
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<ShareModal bind:this={shareModal} />
|
||||
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
|
||||
<MoveDrawer
|
||||
bind:this={moveDrawer}
|
||||
on:update={async (e) => {
|
||||
await goto(`${base}/agents/get/${e.detail}?workspace=${ws}`)
|
||||
}}
|
||||
/>
|
||||
<ConfirmationModal
|
||||
open={deleteOpen}
|
||||
title="Delete agent"
|
||||
confirmationText="Delete"
|
||||
trashbin
|
||||
on:canceled={() => (deleteOpen = false)}
|
||||
on:confirmed={() => {
|
||||
deleteOpen = false
|
||||
deleteAgent()
|
||||
}}
|
||||
>
|
||||
<span>Every flow linking {path} will fail at its agent step once it is gone.</span>
|
||||
</ConfirmationModal>
|
||||
<Drawer bind:this={versionDrawer} size="1200px">
|
||||
<DrawerContent title="Version history" on:close={() => versionDrawer?.closeDrawer()} noPadding>
|
||||
<ResourceVersionHistory
|
||||
{path}
|
||||
workspace={ws}
|
||||
canRestore={can_write}
|
||||
onRestore={() => {
|
||||
versionDrawer?.closeDrawer()
|
||||
if (ws) markAgentWritten(ws, path)
|
||||
}}
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<main class="h-screen w-full flex flex-col">
|
||||
<div class="border-b">
|
||||
<div
|
||||
class="flex w-full flex-wrap md:flex-nowrap justify-end gap-x-2 gap-y-4 items-center min-h-12"
|
||||
>
|
||||
<div class="grow px-4 inline-flex items-center gap-3 min-w-0">
|
||||
<Bot size={20} class="text-violet-500 shrink-0" />
|
||||
<div class="min-w-0 flex flex-col">
|
||||
<span class="text-sm font-semibold text-emphasis truncate">
|
||||
{agent?.description || path}
|
||||
</span>
|
||||
{#if agent?.description}
|
||||
<span class="text-2xs text-tertiary truncate">{path}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if loaded?.version != undefined}
|
||||
<Badge color="gray" class="shrink-0" title="The version runs are recorded against">
|
||||
v{loaded.version}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if agent?.labels?.length}
|
||||
<div class="hidden md:flex items-center gap-0.5">
|
||||
{#each agent.labels as label (label)}
|
||||
<Badge color="blue" small class="px-1">{label}</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<InheritedLabels labels={agent?.inherited_labels} />
|
||||
</div>
|
||||
{#if agent && !refusal}
|
||||
<ToggleButtonGroup
|
||||
selected={leftPane}
|
||||
onSelected={(v) => (leftPane = v as 'chat' | 'form')}
|
||||
noWFull
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
value="chat"
|
||||
label="Chat"
|
||||
icon={MessageSquare}
|
||||
size="md"
|
||||
disabled={!chatReady}
|
||||
tooltip={chatBlockedReason}
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton value="form" label="Form" icon={FormInput} size="md" {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
<div class="grow"></div>
|
||||
{/if}
|
||||
<div class="flex gap-1 items-center pr-4">
|
||||
{#if menuItems.length > 0}
|
||||
<DropdownV2 items={menuItems} placement="bottom-end" size="md" />
|
||||
{/if}
|
||||
{#if can_write && !$userStore?.operator}
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: Pen }}
|
||||
href="{base}/agents/edit/{path}"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loadError}
|
||||
<div class="p-6 max-w-lg">
|
||||
<Alert type="error" title="Could not load agent">{loadError}</Alert>
|
||||
</div>
|
||||
{:else if refusal}
|
||||
<div class="p-6 max-w-lg">
|
||||
<Alert type="error" title="Not an agent">{refusal}</Alert>
|
||||
</div>
|
||||
{:else if agent}
|
||||
<div class="grow min-h-0 w-full">
|
||||
<Splitpanes>
|
||||
<Pane size={65} minSize={40}>
|
||||
<div class="h-full flex flex-col min-h-0">
|
||||
<!-- In chat the description belongs to the chat, which shows it under the empty
|
||||
transcript and in the sidebar. -->
|
||||
{#if agent.description && leftPane === 'form'}
|
||||
<div class="px-4 pt-4 shrink-0">
|
||||
<div class="p-4 rounded-md bg-surface-secondary">
|
||||
<GfmMarkdown md={agent.description} noPadding />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grow min-h-0 flex flex-col">
|
||||
{#if leftPane === 'chat' && chatReady}
|
||||
<FlowChat
|
||||
{useStreaming}
|
||||
onRunFlow={runChatTurn}
|
||||
conversationKind="test"
|
||||
frame="none"
|
||||
{path}
|
||||
identity={path}
|
||||
description={agent.description}
|
||||
inputSchema={chatFlowShape.schema}
|
||||
flowModules={chatFlowShape.value.modules}
|
||||
/>
|
||||
{:else if leftPane === 'chat'}
|
||||
<div class="flex-1 flex items-center justify-center p-6 text-xs text-tertiary">
|
||||
{chatBlockedReason}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Keyed on the write count: the pane builds its step once from the config
|
||||
it is given, so a restore or a deploy from another tab gets a fresh one. -->
|
||||
{#key `${ws}:${path}:${writes}`}
|
||||
<AgentRunPane {path} workspace={ws} {config} />
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Pane>
|
||||
<Pane size={35} minSize={20}>
|
||||
<div class="flex flex-col h-full">
|
||||
<Tabs bind:selected={rightTab} wrapperClass="flex-none w-full">
|
||||
<Tab value="agent" label="Agent" />
|
||||
<Tab value="export" label="Export" />
|
||||
{#snippet content()}
|
||||
<div class="min-h-0 grow overflow-auto">
|
||||
<TabContent value="agent" class="p-4 flex flex-col gap-6">
|
||||
<section class="flex flex-col gap-2">
|
||||
<h3 class="text-xs font-semibold uppercase text-tertiary">Model</h3>
|
||||
<dl class="flex flex-col gap-1">
|
||||
{#each brain.filter((p) => p.label !== AGENT_BRAIN_LABELS['system_prompt']) as param (param.label)}
|
||||
<div class="flex items-baseline gap-2 text-xs">
|
||||
<dt class="text-tertiary shrink-0 w-32">{param.label}</dt>
|
||||
<dd class="text-secondary truncate" title={param.value}
|
||||
>{param.value}</dd
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
</section>
|
||||
<section class="flex flex-col gap-2">
|
||||
<h3 class="text-xs font-semibold uppercase text-tertiary">System message</h3>
|
||||
{#if systemPrompt}
|
||||
<pre
|
||||
class="text-xs text-secondary whitespace-pre-wrap rounded-md border border-light bg-surface-tertiary p-3 max-h-80 overflow-auto"
|
||||
>{systemPrompt}</pre
|
||||
>
|
||||
{:else}
|
||||
<span class="text-xs text-tertiary">None</span>
|
||||
{/if}
|
||||
</section>
|
||||
<section class="flex flex-col gap-2">
|
||||
<h3 class="text-xs font-semibold uppercase text-tertiary">
|
||||
Tools ({tools.length})
|
||||
</h3>
|
||||
{#if tools.length === 0}
|
||||
<span class="text-xs text-tertiary">No tools.</span>
|
||||
{:else}
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each tools as tool (tool.id)}
|
||||
<li class="flex items-center gap-2 text-xs">
|
||||
<span class="text-secondary truncate">
|
||||
{toolDisplayName(tool) ?? tool.id}
|
||||
</span>
|
||||
<Badge color="gray" small>{toolKind(tool)}</Badge>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
</TabContent>
|
||||
<TabContent value="export" class="p-2">
|
||||
<HighlightCode
|
||||
language="json"
|
||||
code={JSON.stringify(agent.value ?? {}, null, 2)}
|
||||
/>
|
||||
</TabContent>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Tabs>
|
||||
</div>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-6 text-xs text-tertiary">Loading agent...</div>
|
||||
{/if}
|
||||
</main>
|
||||
Reference in New Issue
Block a user