mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-22 00:02:38 +00:00
* feat(frontend): group the AI agent step form and edit saved agents in a modal Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: edit a saved AI agent through its own resource draft Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor: drop the agent fork-for-edit session now that edits live in a draft Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: edit ai_agent resources from the resources page with the agent editor Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: send a standalone agent's brain from the module when testing a step Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: keep the agent draft faithful to the resource it deploys to Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore: add the sqlx cache entry for the eval subject rename Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor: share the module insert between the graph and the agent editor Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(frontend): open evals inside the agent editor, actions in its header Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(frontend): add tools from the agent editor and lighten its test pane Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): open an ai_agent deep link in the agent editor Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): drop the failed result badge on a step that never ran Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(frontend): head the agent editor's levels with a back control Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(frontend): drop connect and fill inputs from the agent editor Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(frontend): lighten the agent editor's run panel Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): stop a nested agent tool's config reading as AI-filled Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(frontend): offer only AI or static on an agent tool's inputs Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(frontend): give a saved agent's tool editor a static-only surface Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(frontend): open an agent tool in a drawer beside the agent Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(frontend): hide unset agent config in the run form Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(frontend): share the input forms' pickers and s3 lookup Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(frontend): drop a dead agent-editor export and fix two stale comments Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): reach an ai_agent's resource-level settings and copilot Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): open an ai_agent's resource view as JSON, not the generic form Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): address review findings on the agent editor's draft and streaming Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): close the agent editor on a version restore, as the resource editor does Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): stop the provider picker auto-writing a kind, and clear review nits Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(frontend): drop the fork-for-edit leftovers from the agent card Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): mount the agent editor in the dev flow editor and guard the deep-link race Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): deploy the agent config that was submitted, and refuse one no run could use Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(frontend): build the agent editor's rows from the design-system button Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): keep a draft-only agent's draft, and let a blank MCP summary deploy Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): guard read-only agents, incomplete MCP tools and duplicate editor mounts Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: read-only agent editor, linked-card refresh, atomic eval rename * fix: eval rename needs the privileged pool, per-workspace write access * fix(frontend): drop the agent editor target when its mount goes away * refactor: drop the agent rename work from this PR, unban the bindable defaults * fix(frontend): refuse a renaming deploy and drop the copilot from static-only fields * fix(frontend): mirror the worker's streaming rule and scope agent writes to their target * fix(frontend): read runtime streaming as off and reset the drawer's json view * fix(frontend): read an unsettled output_type as non-streaming too * fix(frontend): let the showing modal claim an agent opened from inside it * fix(frontend): keep in-flight edits, tool replacements and every linked step in sync * fix(frontend): keep attachments in the run form and bind the agent ref to its tools * fix(frontend): preview the agent as authored and re-evaluate step args on run * fix(frontend): scope agent-editor ownership to the flow's workspace * fix(frontend): drop the tool drill-in where there is no graph to select on * fix(frontend): require a provider kind and keep one resource editor open at a time * fix(frontend): keep legacy nulls, static-only text literal, and the handover anchor * test(worker): pin the agent streaming default * fix(frontend): let an AI-fillable input be switched to static * fix(frontend): report agent editor background failures instead of floating them * fix(frontend): keep the version pane's path alive while the editor closes * fix(frontend): clear the anchor-keep flag at the start of each drawer session * fix(frontend): preview the agent without its synthetic path, refresh the baseline on external writes * refactor(frontend): drop the unverifiable baseline refresh, state the synthetic-path rule * fix(frontend): keep the synthetic path out of agent tool test runs too * refactor(frontend): host the agent editor under the agent's own path * fix(frontend): mark an agent editor's host explicitly instead of inferring it from the path * fix(frontend): discard linked-agent responses from before a deploy * fix(frontend): keep a flow mount from claiming an agent editor's nested target * feat(frontend): keep an agent used as a tool inside the agent being edited * fix(frontend): reserve the agent editor's root module id * docs(frontend): record why the agent editor previews under the agent's path * fix(frontend): refuse to open or deploy a resource that is not an agent * docs(frontend): put the scope-migration comment on the function it describes * fix(frontend): refuse an agent path whose resource type is not proven * fix(frontend): recheck the resource type before deploying, and keep expressions off static-only inputs * fix(frontend): lazy-load the agent editor and slide its levels like the evals pane * refactor: drop unreachable non-list tools check from agent deploy Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): clear text-only agent fields on image output, reserve the root id Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): keep the agent editor usable for a non-list tools value Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): stop the parked eval run list from taking arrow keys Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): report a non-list tools value on deploy instead of throwing Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): keep temperature editable for image output Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): skip non-object tool entries when rendering an agent Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): guard tool entry reads instead of copying the tool array Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): key tool rows by position so duplicate ids render Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
687 lines
20 KiB
Svelte
687 lines
20 KiB
Svelte
<script lang="ts">
|
|
import {
|
|
type Job,
|
|
JobService,
|
|
type RestartedFrom,
|
|
type OpenFlow,
|
|
type ScriptLang
|
|
} from '$lib/gen'
|
|
import { workspaceStore } from '$lib/stores'
|
|
import { Badge, Button } from './common'
|
|
import { createEventDispatcher, getContext, untrack } from 'svelte'
|
|
import type { FlowEditorContext } from './flows/types'
|
|
import { runFlowPreview } from './flows/utils.svelte'
|
|
import { processSecretArgs } from './secretArgUtils'
|
|
import SchemaForm from './SchemaForm.svelte'
|
|
import SchemaFormWithArgPicker from './SchemaFormWithArgPicker.svelte'
|
|
import FlowStatusViewer from '../components/FlowStatusViewer.svelte'
|
|
import FlowProgressBar from './flows/FlowProgressBar.svelte'
|
|
import FlowExecutionStatus from './runs/FlowExecutionStatus.svelte'
|
|
import JobDetailHeader from './runs/JobDetailHeader.svelte'
|
|
import {
|
|
AlertTriangle,
|
|
Circle,
|
|
CornerDownLeft,
|
|
Download,
|
|
Loader2,
|
|
Play,
|
|
RefreshCw,
|
|
X
|
|
} from 'lucide-svelte'
|
|
import { sendUserToast, type StateStore } from '$lib/utils'
|
|
import { dfs } from './flows/dfs'
|
|
import { sliceModules } from './flows/flowStateUtils.svelte'
|
|
import InputSelectedBadge from './schema/InputSelectedBadge.svelte'
|
|
import Toggle from './Toggle.svelte'
|
|
import JsonInputs from './JsonInputs.svelte'
|
|
import { argsToJsonPayload } from '$lib/schema'
|
|
import type { Schema } from '$lib/common'
|
|
import FlowHistoryJobPicker from './FlowHistoryJobPicker.svelte'
|
|
import type { DurationStatus, GraphModuleState } from './graph'
|
|
import { getStepHistoryLoaderContext } from './stepHistoryLoader.svelte'
|
|
import FlowChat from './flows/conversations/FlowChat.svelte'
|
|
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
|
|
import FlowRestartButton from './FlowRestartButton.svelte'
|
|
import { useNestedRestartState } from './useNestedRestartState.svelte'
|
|
import { buildFlowRecording, downloadRecordingJson } from './recording/runRecording'
|
|
import { agentStreamingEnabled } from './flows/agentFormFields'
|
|
|
|
interface Props {
|
|
previewMode: 'upTo' | 'whole'
|
|
open: boolean
|
|
preventEscape?: boolean
|
|
jobId?: string | undefined
|
|
job?: Job | undefined
|
|
initial?: boolean
|
|
selectedJobStep?: string | undefined
|
|
selectedJobStepIsTopLevel?: boolean | undefined
|
|
selectedJobStepType?: 'single' | 'forloop' | 'branchall'
|
|
rightColumnSelect?: 'timeline' | 'node_status' | 'node_definition' | 'user_states'
|
|
branchOrIterationN?: number
|
|
scrollTop?: number
|
|
localModuleStates?: Record<string, GraphModuleState>
|
|
localDurationStatuses?: Record<string, DurationStatus>
|
|
onRunPreview?: (jobId?: string) => void
|
|
render?: boolean
|
|
onJobDone?: () => void
|
|
upToId?: string | undefined
|
|
customUi?: {
|
|
tagLabel?: string | undefined
|
|
}
|
|
suspendStatus: StateStore<Record<string, { job: Job; nb: number }>>
|
|
}
|
|
|
|
let {
|
|
previewMode = $bindable(),
|
|
open,
|
|
preventEscape = $bindable(false),
|
|
jobId = $bindable(undefined),
|
|
job = $bindable(undefined),
|
|
initial = $bindable(false),
|
|
selectedJobStep = $bindable(undefined),
|
|
selectedJobStepIsTopLevel = $bindable(undefined),
|
|
selectedJobStepType = $bindable('single'),
|
|
rightColumnSelect = $bindable('timeline'),
|
|
branchOrIterationN = $bindable(0),
|
|
scrollTop = $bindable(0),
|
|
localModuleStates = $bindable({}),
|
|
localDurationStatuses = $bindable({}),
|
|
onRunPreview,
|
|
render = false,
|
|
onJobDone,
|
|
upToId = undefined,
|
|
suspendStatus
|
|
}: Props = $props()
|
|
|
|
let previewExpandedSubflows: Record<
|
|
string,
|
|
{ modules: import('$lib/gen').FlowModule[]; groups?: any[] }
|
|
> = $state({})
|
|
const restart = useNestedRestartState({
|
|
selectedJobStep: () => selectedJobStep,
|
|
job: () => job,
|
|
graphModuleStates: () => localModuleStates,
|
|
expandedSubflows: () => previewExpandedSubflows
|
|
})
|
|
// `selectedJobStepType` and `selectedJobStepIsTopLevel` are still exposed as
|
|
// `$bindable` props to legacy parents (FlowPreviewButtons binds them but
|
|
// doesn't read them). Sync them out of the composable here so the prop
|
|
// surface stays unchanged.
|
|
$effect(() => {
|
|
selectedJobStepIsTopLevel = restart.selectedJobStepIsTopLevel
|
|
})
|
|
$effect(() => {
|
|
selectedJobStepType = restart.selectedJobStepType
|
|
})
|
|
let isRunning: boolean = $state(false)
|
|
let jsonView: boolean = $state(false)
|
|
let jsonEditor: JsonInputs | undefined = $state(undefined)
|
|
let schemaHeight = $state(0)
|
|
let isValid: boolean = $state(true)
|
|
let isOwner: boolean = $state(false)
|
|
|
|
export async function test(conversationId?: string): Promise<string | undefined> {
|
|
renderCount++
|
|
return await runPreview(previewArgs.val, undefined, conversationId)
|
|
}
|
|
|
|
const {
|
|
selectionManager,
|
|
previewArgs,
|
|
flowStateStore,
|
|
flowStore,
|
|
pathStore,
|
|
initialPathStore,
|
|
fakeInitialPath,
|
|
customUi,
|
|
executionCount,
|
|
devTempScriptRefs,
|
|
opWorkspace
|
|
} = $state(getContext<FlowEditorContext>('FlowEditorContext'))
|
|
// Acting workspace when previewing inside an AI session; else the nav workspace.
|
|
let opWs = $derived(opWorkspace?.() ?? $workspaceStore)
|
|
const dispatch = createEventDispatcher()
|
|
|
|
let renderCount: number = $state(0)
|
|
let schemaFormWithArgPicker: SchemaFormWithArgPicker | undefined = $state(undefined)
|
|
let currentJobId: string | undefined = $state(undefined)
|
|
let stepHistoryLoader = getStepHistoryLoaderContext()
|
|
let flowProgressBar: FlowProgressBar | undefined = $state(undefined)
|
|
|
|
// Recording: nothing is captured live — once the run completes, its id is
|
|
// remembered and the recording is built from the completed jobs on download.
|
|
let lastRecordingJobId: string | undefined = $state(undefined)
|
|
let recordedFlow: OpenFlow | undefined = undefined
|
|
let downloadingRecording: boolean = $state(false)
|
|
let recordingMode: boolean = $state(false)
|
|
|
|
export function setRecordingMode(on: boolean) {
|
|
recordingMode = on
|
|
}
|
|
|
|
let loadingHistory = $state(false)
|
|
|
|
let shouldUseStreaming = $derived.by(() => {
|
|
const modules = flowStore.val.value?.modules
|
|
const lastModule = modules && modules.length > 0 ? modules[modules.length - 1] : undefined
|
|
if (lastModule?.value?.type !== 'aiagent') return false
|
|
return agentStreamingEnabled(lastModule.value)
|
|
})
|
|
|
|
function extractFlow(previewMode: 'upTo' | 'whole'): OpenFlow {
|
|
if (previewMode === 'whole') {
|
|
return flowStore.val
|
|
} else {
|
|
const flow = stateSnapshot(flowStore).val as OpenFlow
|
|
const idOrders = dfs(flow.value.modules, (x) => x.id)
|
|
let upToIndex = idOrders.indexOf(upToId ?? selectionManager.getSelectedId() ?? '')
|
|
|
|
if (upToIndex != -1) {
|
|
flow.value.modules = sliceModules(flow.value.modules, upToIndex, idOrders)
|
|
}
|
|
return flow
|
|
}
|
|
}
|
|
|
|
let lastPreviewFlow: undefined | string = $state(undefined)
|
|
export async function runPreview(
|
|
args: Record<string, any>,
|
|
restartedFrom: RestartedFrom | undefined,
|
|
conversationId?: string | undefined
|
|
) {
|
|
let newJobId: string | undefined = undefined
|
|
if (stepHistoryLoader?.flowJobInitial !== false) {
|
|
stepHistoryLoader?.setFlowJobInitial(false)
|
|
}
|
|
try {
|
|
lastPreviewFlow = JSON.stringify(flowStore.val)
|
|
flowProgressBar?.reset()
|
|
const newFlow = extractFlow(previewMode)
|
|
if (recordingMode) {
|
|
// Any completed run in recording mode becomes downloadable, so pair
|
|
// the flow as it is for *this* run — not a stale earlier capture.
|
|
// Deep-cloned: extractFlow('whole') returns the live store object,
|
|
// and edits made before download must not rewrite the run's definition.
|
|
recordedFlow = JSON.parse(JSON.stringify(newFlow))
|
|
}
|
|
args = await processSecretArgs(args, flowStore.val.schema as any, opWs)
|
|
newJobId = await runFlowPreview(
|
|
args,
|
|
newFlow,
|
|
$pathStore,
|
|
restartedFrom,
|
|
conversationId,
|
|
devTempScriptRefs?.(),
|
|
opWorkspace?.()
|
|
)
|
|
jobId = newJobId
|
|
isRunning = true
|
|
if (inputSelected) {
|
|
savedArgs = $state.snapshot(previewArgs.val)
|
|
inputSelected = undefined
|
|
}
|
|
onRunPreview?.(newJobId)
|
|
} catch (e) {
|
|
sendUserToast('Could not run preview', true, undefined, e.toString())
|
|
isRunning = false
|
|
jobId = undefined
|
|
}
|
|
schemaFormWithArgPicker?.refreshHistory()
|
|
return newJobId
|
|
}
|
|
|
|
function onKeyDown(event: KeyboardEvent) {
|
|
if (open) {
|
|
switch (event.key) {
|
|
case 'Enter':
|
|
if (event.ctrlKey || event.metaKey) {
|
|
event.preventDefault()
|
|
runPreview(previewArgs.val, undefined)
|
|
}
|
|
break
|
|
|
|
case 'Escape':
|
|
if (preventEscape) {
|
|
selectInput(undefined)
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
let savedArgs = $state(previewArgs.val)
|
|
let inputSelected: 'captures' | 'history' | 'saved' | undefined = $state(undefined)
|
|
async function selectInput(input, type?: 'captures' | 'history' | 'saved' | undefined) {
|
|
if (!input) {
|
|
previewArgs.val = savedArgs
|
|
inputSelected = undefined
|
|
setTimeout(() => {
|
|
preventEscape = false
|
|
}, 100)
|
|
} else {
|
|
previewArgs.val = input
|
|
inputSelected = type
|
|
preventEscape = true
|
|
}
|
|
// Deselecting restores the args the same way selecting replaced them, so both branches
|
|
// owe the editor an overwrite — it holds a payload for the input being left behind.
|
|
jsonEditor?.setCode(
|
|
argsToJsonPayload(flowStore.val.schema as Schema | undefined, previewArgs.val)
|
|
)
|
|
}
|
|
|
|
export function refresh() {
|
|
renderCount++
|
|
}
|
|
|
|
async function recordAndTest() {
|
|
lastRecordingJobId = undefined
|
|
await runPreview(previewArgs.val, undefined)
|
|
}
|
|
|
|
async function downloadRecording() {
|
|
if (!lastRecordingJobId || downloadingRecording) return
|
|
downloadingRecording = true
|
|
try {
|
|
// A run started before recording mode was enabled captured no flow —
|
|
// fall back to the flow as it is now.
|
|
const recording = await buildFlowRecording(
|
|
opWs!,
|
|
lastRecordingJobId,
|
|
$pathStore,
|
|
recordedFlow ?? extractFlow(previewMode)
|
|
)
|
|
downloadRecordingJson(recording, `flow-recording-${$pathStore.replace(/\//g, '-')}`)
|
|
} catch (e: any) {
|
|
sendUserToast('Could not build the recording', true, undefined, e?.toString())
|
|
} finally {
|
|
downloadingRecording = false
|
|
}
|
|
}
|
|
|
|
let scrollableDiv: HTMLDivElement | undefined = $state(undefined)
|
|
function handleScroll() {
|
|
let newScroll = scrollableDiv?.scrollTop ?? 0
|
|
if (newScroll != 0 && render) {
|
|
scrollTop = newScroll
|
|
}
|
|
}
|
|
|
|
function onScrollableDivChange() {
|
|
if (scrollTop != 0 && scrollableDiv) {
|
|
scrollableDiv.scrollTop = scrollTop
|
|
}
|
|
}
|
|
$effect(() => {
|
|
scrollableDiv && render && untrack(() => onScrollableDivChange())
|
|
})
|
|
|
|
export async function cancelTest() {
|
|
isRunning = false
|
|
try {
|
|
jobId &&
|
|
(await JobService.cancelQueuedJob({
|
|
workspace: opWs ?? '',
|
|
id: jobId,
|
|
requestBody: {}
|
|
}))
|
|
} catch {}
|
|
}
|
|
|
|
export function getLocalModuleStates() {
|
|
return localModuleStates
|
|
}
|
|
|
|
export function getLocalDurationStatuses() {
|
|
return localDurationStatuses
|
|
}
|
|
|
|
export function getSuspendStatus() {
|
|
return suspendStatus
|
|
}
|
|
|
|
export function getIsRunning() {
|
|
return isRunning
|
|
}
|
|
|
|
export function getIsOwner() {
|
|
return isOwner
|
|
}
|
|
|
|
export function getJob() {
|
|
return job
|
|
}
|
|
|
|
export function flowHasChanged() {
|
|
return !!lastPreviewFlow && JSON.stringify(flowStore.val) != lastPreviewFlow
|
|
}
|
|
</script>
|
|
|
|
<svelte:window onkeydown={onKeyDown} />
|
|
|
|
<div class="flex flex-col space-y-2 h-screen bg-surface py-2 w-full" id="flow-preview-content">
|
|
{#if render}
|
|
<div class="flex flex-row w-full items-center gap-x-2 px-4">
|
|
<div class="w-8">
|
|
<Button
|
|
on:click={() => dispatch('close')}
|
|
startIcon={{ icon: X }}
|
|
iconOnly
|
|
unifiedSize="md"
|
|
variant="default"
|
|
btnClasses="hover:bg-surface-hover bg-surface-secondaryw-8 h-8 rounded-full p-0"
|
|
/>
|
|
</div>
|
|
|
|
{#if isRunning}
|
|
<div class="mx-auto flex items-center gap-2">
|
|
<Button
|
|
variant="accent"
|
|
destructive
|
|
on:click={async () => {
|
|
cancelTest()
|
|
}}
|
|
unifiedSize="md"
|
|
btnClasses="w-full max-w-lg"
|
|
loading={true}
|
|
clickableWhileLoading
|
|
>
|
|
Cancel
|
|
</Button>
|
|
{#if recordingMode}
|
|
<span class="text-xs text-red-500 font-medium flex items-center gap-1">
|
|
<Circle size={8} fill="currentColor" /> Recording
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<div class="grow justify-center flex flex-row gap-2 items-center">
|
|
{#if jobId !== undefined && selectedJobStep !== undefined && restart.topLevelRestartable}
|
|
<!--
|
|
Nested-step restart is intentionally not surfaced from the editor
|
|
preview: the chain needs `flow_job_id` resolved at every level,
|
|
which only the backend can do (by walking the original
|
|
execution). Users who want nested restart should use the run
|
|
page, which goes through the dedicated restart API.
|
|
-->
|
|
<FlowRestartButton
|
|
{jobId}
|
|
{selectedJobStep}
|
|
selectedJobStepType={restart.selectedJobStepType}
|
|
restartBranchNames={restart.restartBranchNames}
|
|
presetIterationN={restart.topLevelLoopIteration}
|
|
iterationCounts={restart.iterationCounts}
|
|
onRestart={(stepId, branchOrIterationN) => {
|
|
runPreview(previewArgs.val, {
|
|
flow_job_id: jobId,
|
|
step_id: stepId,
|
|
branch_or_iteration_n: branchOrIterationN
|
|
})
|
|
}}
|
|
/>
|
|
{/if}
|
|
{#if !flowStore.val.value?.chat_input_enabled}
|
|
<Button
|
|
variant="accent"
|
|
startIcon={{ icon: isRunning ? RefreshCw : Play }}
|
|
size="sm"
|
|
btnClasses="w-full max-w-lg"
|
|
on:click={() =>
|
|
recordingMode ? recordAndTest() : runPreview(previewArgs.val, undefined)}
|
|
id="flow-editor-test-flow-drawer"
|
|
shortCut={{ Icon: CornerDownLeft }}
|
|
>
|
|
{#if previewMode == 'upTo'}
|
|
Test up to
|
|
<Badge baseClass="ml-1" color="indigo">
|
|
{selectionManager.getSelectedId()}
|
|
</Badge>
|
|
{:else if recordingMode}
|
|
Test flow and record
|
|
{:else}
|
|
Test flow
|
|
{/if}
|
|
</Button>
|
|
{/if}
|
|
{#if lastRecordingJobId && recordingMode}
|
|
<Button
|
|
variant="subtle"
|
|
unifiedSize="sm"
|
|
title="Download recording"
|
|
on:click={downloadRecording}
|
|
loading={downloadingRecording}
|
|
startIcon={{ icon: Download }}
|
|
>
|
|
Download recording
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
<div
|
|
bind:this={scrollableDiv}
|
|
class="overflow-y-auto grow flex flex-col pt-4 px-4"
|
|
onscroll={(e) => handleScroll()}
|
|
>
|
|
{#if render}
|
|
{#if flowStore.val.value?.chat_input_enabled}
|
|
<div class="flex flex-row justify-center w-full mb-6">
|
|
<FlowChat
|
|
useStreaming={shouldUseStreaming}
|
|
onRunFlow={async (userMessage, conversationId, additionalInputs) => {
|
|
await runPreview(
|
|
{ user_message: userMessage, ...(additionalInputs ?? {}) },
|
|
undefined,
|
|
conversationId
|
|
)
|
|
return jobId ?? ''
|
|
}}
|
|
hideSidebar={true}
|
|
path={$pathStore}
|
|
inputSchema={flowStore.val.schema}
|
|
/>
|
|
</div>
|
|
{:else}
|
|
<div>
|
|
<SchemaFormWithArgPicker
|
|
bind:this={schemaFormWithArgPicker}
|
|
runnableId={$initialPathStore}
|
|
stablePathForCaptures={$initialPathStore || fakeInitialPath}
|
|
runnableType={'FlowPath'}
|
|
workspace={opWs}
|
|
previewArgs={previewArgs.val}
|
|
on:openTriggers
|
|
on:select={(e) => {
|
|
selectInput(e.detail.payload, e.detail?.type)
|
|
}}
|
|
{isValid}
|
|
{jsonView}
|
|
>
|
|
<div class="w-full flex flex-row justify-between">
|
|
<InputSelectedBadge
|
|
onReject={() => schemaFormWithArgPicker?.resetSelected()}
|
|
{inputSelected}
|
|
/>
|
|
<div class="flex flex-row gap-2">
|
|
<Toggle
|
|
bind:checked={jsonView}
|
|
size="xs"
|
|
options={{
|
|
right: 'JSON',
|
|
rightTooltip: 'Fill args from JSON'
|
|
}}
|
|
lightMode
|
|
on:change={() => {
|
|
refresh()
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{#if jsonView}
|
|
<div class="py-2" style="height: {Math.max(schemaHeight, 100)}px" data-schema-picker>
|
|
<JsonInputs
|
|
bind:this={jsonEditor}
|
|
on:select={(e) => {
|
|
if (e.detail) {
|
|
previewArgs.val = e.detail
|
|
}
|
|
}}
|
|
initialCode={argsToJsonPayload(
|
|
flowStore.val.schema as Schema | undefined,
|
|
previewArgs.val
|
|
)}
|
|
updateOnBlur={false}
|
|
placeholder={`Write args as JSON.<br/><br/>Example:<br/><br/>{<br/> "foo": "12"<br/>}`}
|
|
/>
|
|
</div>
|
|
{:else}
|
|
{#key renderCount}
|
|
<div bind:clientHeight={schemaHeight} class="min-h-[40vh]">
|
|
<SchemaForm
|
|
noVariablePicker
|
|
compact
|
|
workspace={opWs}
|
|
schema={flowStore.val.schema}
|
|
bind:args={previewArgs.val}
|
|
on:change={() => {
|
|
savedArgs = $state.snapshot(previewArgs.val)
|
|
}}
|
|
bind:isValid
|
|
helperScript={flowStore.val.schema?.['x-windmill-dyn-select-code'] &&
|
|
flowStore.val.schema?.['x-windmill-dyn-select-lang']
|
|
? {
|
|
source: 'inline',
|
|
code: flowStore.val.schema['x-windmill-dyn-select-code'] as string,
|
|
lang: flowStore.val.schema['x-windmill-dyn-select-lang'] as ScriptLang
|
|
}
|
|
: undefined}
|
|
/>
|
|
</div>
|
|
{/key}
|
|
{/if}
|
|
</SchemaFormWithArgPicker>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
<div class="pt-4 flex flex-col border-t relative">
|
|
{#if flowHasChanged()}
|
|
<div class="pb-2">
|
|
<div
|
|
class="text-xs bg-orange-100 text-orange-700 border border-orange-700/30 p-2 flex items-center gap-2 rounded"
|
|
>
|
|
<AlertTriangle size={12} /> Flow changed since last preview
|
|
<div class="flex"></div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="w-full flex pb-2 items-start justify-between gap-4">
|
|
{#if job}
|
|
<JobDetailHeader {job} compact={true} />
|
|
{:else}
|
|
<div></div> <!-- empty div to keep the same layout -->
|
|
{/if}
|
|
<FlowHistoryJobPicker
|
|
selectInitial={jobId == undefined}
|
|
newFlow={$initialPathStore === ''}
|
|
on:select={(e) => {
|
|
if (!currentJobId) {
|
|
currentJobId = jobId
|
|
}
|
|
const detail = e.detail
|
|
jobId = detail.jobId
|
|
if (detail.initial && stepHistoryLoader?.flowJobInitial === undefined) {
|
|
stepHistoryLoader?.setFlowJobInitial(detail.initial)
|
|
}
|
|
}}
|
|
on:unselect={() => {
|
|
jobId = currentJobId
|
|
currentJobId = undefined
|
|
}}
|
|
path={$initialPathStore == '' ? $pathStore : $initialPathStore}
|
|
bind:loading={loadingHistory}
|
|
/>
|
|
</div>
|
|
|
|
{#if jobId}
|
|
<FlowProgressBar {job} bind:this={flowProgressBar} slim textPosition="bottom" showStepId />
|
|
{/if}
|
|
|
|
{#if job}
|
|
<div class="w-full my-6">
|
|
<FlowExecutionStatus
|
|
{job}
|
|
{isOwner}
|
|
innerModules={job?.flow_status?.modules}
|
|
{suspendStatus}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
{#if jobId}
|
|
{#if stepHistoryLoader?.flowJobInitial}
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
onclick={() => {
|
|
stepHistoryLoader?.setFlowJobInitial(false)
|
|
}}
|
|
class="cursor-pointer h-full hover:bg-gray-500/20 dark:hover:bg-gray-500/20 dark:bg-gray-500/80 rounded bg-gray-500/40 absolute top-0 left-0 w-full z-50"
|
|
>
|
|
<div class="text-center text-primary text-lg py-2 pt-20"
|
|
><span class="font-bold border p-2 bg-surface-secondary rounded-md"
|
|
>Previous run of this flow from history</span
|
|
></div
|
|
>
|
|
</div>
|
|
{/if}
|
|
<FlowStatusViewer
|
|
bind:job
|
|
bind:localModuleStates
|
|
bind:expandedSubflows={previewExpandedSubflows}
|
|
bind:localDurationStatuses
|
|
{suspendStatus}
|
|
hideDownloadInGraph={customUi?.downloadLogs === false}
|
|
wideResults
|
|
bind:flowState={flowStateStore.val}
|
|
workspaceId={opWs}
|
|
{jobId}
|
|
onDone={async ({ job: completedJob }) => {
|
|
isRunning = false
|
|
$executionCount = $executionCount + 1
|
|
// Reset 'initial' flags for modules that were part of this flow test,
|
|
// so OutputPicker no longer shows "Run loaded from history"
|
|
for (const mod of completedJob.flow_status?.modules ?? []) {
|
|
if (mod.id) {
|
|
stepHistoryLoader?.resetInitial(mod.id)
|
|
}
|
|
}
|
|
if (recordingMode && completedJob?.id) {
|
|
lastRecordingJobId = completedJob.id
|
|
}
|
|
onJobDone?.()
|
|
}}
|
|
bind:selectedJobStep
|
|
bind:rightColumnSelect
|
|
bind:isOwner
|
|
{render}
|
|
{customUi}
|
|
showLogsWithResult
|
|
notes={flowStore.val.value.notes}
|
|
groups={flowStore.val.value.groups}
|
|
/>
|
|
{:else if loadingHistory}
|
|
<div class="italic text-primary h-full grow mx-auto flex flex-row items-center gap-2">
|
|
<Loader2 class="animate-spin" /> <span> Loading history... </span>
|
|
</div>
|
|
{:else}
|
|
<div class="italic text-tertiary h-full grow mx-auto flex flex-row items-center">
|
|
Flow status will display here
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|