mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 08:02:47 +00:00
* feat: add an AI decision flow step on TypeSafe's Jev An `aidecision` step asks TypeSafe's Jev typed questions (choice, score, yes/no) about a state and returns calibrated answers. It is a plain step: a flow routes on the answers with a Branch to one after it, which the editor builds and keeps in step with the questions. It can also be an AI agent tool, where the calling model supplies the state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: read single-quoted branch conditions and flag branches on a removed question Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: route decision tools by their tag and keep structured questions as JSON The questions editor writes only on an edit, so opening a step leaves its value untouched, and questions the cards cannot hold stay in a JSON editor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: re-pick the questions editor mode when the value is replaced from elsewhere Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep the questions JSON editor's text in step with the value Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: recreate the questions JSON editor when the value is replaced from elsewhere Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: run AI decisions on Cloudflare's Clef as well as TypeSafe's Jev A `cloudflare` provider kind reads the hub's `cloudflare` resource (token and account id), calls the model's Workers AI URL, and unwraps Workers AI's `result` envelope. Like TypeSafe, it serves decision steps only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * feat: branch from each AI decision question, and align its editor with the brand guidelines "Branch on this question" now sits in each choice question's card, which also links to the Branch to one that routes it and flags options it misses or branches it no longer matches. Labels, errors, cards and icons follow the form guidelines and the Branch to one's cards. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * feat: branch on an AI decision from its graph node A decision with a choice question nothing branches on yet shows a button beside its node that inserts the Branch to one, as its question card does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: resolve Cloudflare resources in the AI proxy, and use the design-system button on the graph Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: put the decision_inputs test's doc comment back on it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * chore: regenerate the flow schema in prompts and the chat after the AiDecision description edit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * feat: switch AI decision questions between the form and JSON JSON is always available; the form is offered whenever its cards can hold the questions, and a value they cannot hold still opens in JSON. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: show step ids as badges and use the JSON toggle of the run forms Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: quote option names in the AI decision branch alerts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * feat: remove stale branches from the AI decision question card An empty branch is removed directly; one with steps asks first, naming how many steps go with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * feat: branch on yes/no AI decision questions, and test a decision step from the AI chat A yes/no question branches like a choice: "Branch on this question" writes one "yes" branch on `noul >= 0.5`, with the default as "no", and the helper still reads it once the threshold is tuned. The AI chat's step test runs a decision alone as a one-step preview, taking the state the chat passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: record removing several stale branches as one undo step Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: name the typesafe resource type TypeSafe Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: store TypeSafe as the typesafe resource type's display name, not in the word map Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: leave the typesafe and cloudflare resource types to the hub instead of seeding them Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * docs(AGENTS): resource types ship through the hub, not migrations Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: show the questions JSON format beside the JSON toggle Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: give the questions JSON format in the field's description Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: style the decision's graph branch button like Add branch Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: show the questions JSON format only in JSON mode Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * feat: show each question's branching under the questions JSON editor Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: drop the redundant In JSON prefix from the questions format Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: shorten the decision state's description and show an example as its placeholder Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: escape a schema placeholder before the JSON editor renders it as HTML Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * fix: give the decision state's placeholder a text and an object example Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sEUVZVYBjkzkaVkjbFeAL * chore: update ee-repo-ref to 154cbccb6b8d47bf662b5507882917b5933d5a45 This commit updates the EE repository reference after PR #839 was merged in windmill-ee-private. Previous ee-repo-ref: eaf38c1388f3cc5bece163404fffa959ede8cae0 New ee-repo-ref: 154cbccb6b8d47bf662b5507882917b5933d5a45 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
321 lines
10 KiB
Svelte
321 lines
10 KiB
Svelte
<script lang="ts">
|
|
import {
|
|
ScriptService,
|
|
type AiAgent,
|
|
type AiDecision,
|
|
type FlowModule,
|
|
type InputTransform,
|
|
type Job
|
|
} from '$lib/gen'
|
|
import { getScriptByPath } from '$lib/scripts'
|
|
import { getContext, untrack } from 'svelte'
|
|
import type { FlowEditorContext } from './flows/types'
|
|
import JobLoader, { type Callbacks } from './JobLoader.svelte'
|
|
import { getStepHistoryLoaderContext } from './stepHistoryLoader.svelte'
|
|
import { loadSchemaFromModule } from './flows/flowInfers'
|
|
import {
|
|
inlineAgentDraft,
|
|
loadLinkedAgentDrafts,
|
|
normalizeAgentRef,
|
|
type LinkedAgentDraft
|
|
} from './flows/linkedAgentDrafts'
|
|
import { AGENT_FLOW_LOCAL_KEYS } from './flows/agentResourceUtils'
|
|
import { agentTestInputTransforms } from './flows/agentFormFields'
|
|
import { sendUserToast } from '$lib/toast'
|
|
import { quickjsReady } from '$lib/utils/quickjsEval.svelte'
|
|
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
|
|
|
const operatingWorkspace = useOperatingWorkspace()
|
|
|
|
interface Props {
|
|
mod: FlowModule
|
|
testJob?: Job | undefined
|
|
testIsLoading?: boolean
|
|
noEditor?: boolean
|
|
scriptProgress?: any
|
|
onJobDone?: () => void
|
|
}
|
|
|
|
let {
|
|
mod,
|
|
testJob = $bindable(undefined),
|
|
testIsLoading = $bindable(false),
|
|
noEditor = false,
|
|
scriptProgress = $bindable(undefined),
|
|
onJobDone
|
|
}: Props = $props()
|
|
|
|
const {
|
|
flowStore,
|
|
flowStateStore,
|
|
pathStore,
|
|
stepsInputArgs,
|
|
previewArgs,
|
|
modulesTestStates,
|
|
devTempScriptRefs,
|
|
opWorkspace
|
|
} = getContext<FlowEditorContext>('FlowEditorContext')
|
|
|
|
let previewBase = $derived($pathStore ?? '')
|
|
|
|
// Acting workspace when the flow editor runs in an AI session; else the nav workspace.
|
|
let opWs = $derived(opWorkspace?.() ?? $operatingWorkspace)
|
|
|
|
let jobLoader: JobLoader | undefined = $state(undefined)
|
|
let jobProgressReset: () => void = () => {}
|
|
let stepHistoryLoader = getStepHistoryLoaderContext()
|
|
|
|
// Every explicit run re-evaluates the args with errors surfaced. The reactive evaluations
|
|
// that follow each flow edit stay quiet, so without this a failing expression is silently
|
|
// `undefined` in what the run is built from. Manually edited args are preserved across the
|
|
// refresh by `initializeFromSchema`.
|
|
export async function runTestWithStepArgs() {
|
|
await quickjsReady()
|
|
stepsInputArgs?.updateStepArgs(
|
|
mod.id,
|
|
flowStateStore.val,
|
|
flowStore?.val,
|
|
previewArgs?.val,
|
|
true
|
|
)
|
|
runTest(stepsInputArgs.getStepArgs(mod.id))
|
|
}
|
|
|
|
// A step's timeout is an InputTransform. Only a static numeric value can be applied
|
|
// to a single-step preview; dynamic expressions are evaluated server-side and only
|
|
// take effect when running the full flow.
|
|
function staticTimeout(timeout: FlowModule['timeout']): number | undefined {
|
|
if (timeout?.type === 'static' && typeof timeout.value === 'number') {
|
|
return timeout.value
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
export async function runTest(args: any) {
|
|
// Not defined if JobProgressBar not loaded
|
|
if (jobProgressReset) jobProgressReset()
|
|
if (modulesTestStates.states[mod.id]) {
|
|
modulesTestStates.states[mod.id].cancel = async () => {
|
|
await jobLoader?.cancelJob()
|
|
modulesTestStates.states[mod.id].testJob = undefined
|
|
}
|
|
modulesTestStates.runTestCb?.(mod.id)
|
|
}
|
|
|
|
const val = mod.value
|
|
const timeout = staticTimeout(mod.timeout)
|
|
// let jobId: string | undefined = undefined
|
|
let callbacks: Callbacks = {
|
|
done: (x) => {
|
|
jobDone(x)
|
|
}
|
|
}
|
|
if (val.type == 'rawscript') {
|
|
await jobLoader?.runPreview(
|
|
// An empty base stays empty: `'' + '/' + id` is an absolute path, which
|
|
// `require_path_read_access_for_preview` rejects outright. A flow with no path yet
|
|
// previews unnamed instead.
|
|
val.path ?? (previewBase ? previewBase + '/' + mod.id : ''),
|
|
val.content,
|
|
val.language,
|
|
mod.id === 'preprocessor' ? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...args } : args,
|
|
flowStore?.val?.tag ?? val.tag,
|
|
undefined,
|
|
undefined,
|
|
callbacks,
|
|
previewBase,
|
|
undefined,
|
|
devTempScriptRefs?.(),
|
|
timeout
|
|
)
|
|
} else if (val.type == 'script') {
|
|
const script = val.hash
|
|
? await ScriptService.getScriptByHash({ workspace: opWs!, hash: val.hash })
|
|
: await getScriptByPath(val.path, opWs)
|
|
await jobLoader?.runPreview(
|
|
val.path,
|
|
script.content,
|
|
script.language,
|
|
mod.id === 'preprocessor' ? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...args } : args,
|
|
flowStore?.val?.tag ?? (val.tag_override ? val.tag_override : script.tag),
|
|
script.lock,
|
|
val.hash ?? script.hash,
|
|
callbacks,
|
|
previewBase,
|
|
undefined,
|
|
undefined,
|
|
timeout
|
|
)
|
|
} else if (val.type == 'flow') {
|
|
await jobLoader?.runFlowByPath(val.path, args, callbacks)
|
|
} else if (val.type == 'aiagent') {
|
|
const { schema } = await loadSchemaFromModule(mod, opWs)
|
|
|
|
// A linked step whose agent has an unsaved draft is tested as the draft, the same way the
|
|
// whole-flow preview and the agent editor's own test pane run it. `inlineAgentDraft`
|
|
// clears `agent` and moves the draft's brain and tools onto the step, so the branches
|
|
// below then treat it as a standalone agent.
|
|
let draft: LinkedAgentDraft | undefined
|
|
if (val.agent) {
|
|
const linked = normalizeAgentRef(val.agent)
|
|
try {
|
|
draft = (await loadLinkedAgentDrafts([linked], opWs)).get(linked)
|
|
} catch (err: any) {
|
|
// The load refuses when the agent's unsaved changes cannot be read, and this function's
|
|
// caller neither awaits nor catches: without this the rejection is unhandled and the
|
|
// button appears to do nothing, with the test already marked as started.
|
|
sendUserToast(`Could not run test: ${err?.body ?? err}`, true)
|
|
// Guarded like every other access to it here: the entry is only created for steps the
|
|
// panel is tracking, and this runs on a path where it may never have been.
|
|
if (modulesTestStates.states[mod.id]) {
|
|
modulesTestStates.states[mod.id].loading = false
|
|
}
|
|
return
|
|
}
|
|
}
|
|
const agentVal = draft ? inlineAgentDraft(val, draft.args) : val
|
|
|
|
// `args` spans the whole AI agent schema, so on a linked step it carries every brain key as
|
|
// undefined; overlaying those would shadow the draft's brain, so an inlined step takes only
|
|
// the inputs its form offers.
|
|
// The test form only covers the schema it was given, and for a standalone agent that may be
|
|
// the flow-local one (the agent editor shows the brain in its own form, not here). Take the
|
|
// brain from the module as authored and let the form's own keys win over it, so an edit made
|
|
// in the form after the test panel mounted is what runs. A linked agent needs none of this:
|
|
// the server reads its brain from the resource.
|
|
const inputTransforms = agentTestInputTransforms(
|
|
agentVal.agent ? {} : ((agentVal.input_transforms ?? {}) as Record<string, InputTransform>),
|
|
args,
|
|
draft ? AGENT_FLOW_LOCAL_KEYS : Object.keys(args)
|
|
)
|
|
|
|
await jobLoader?.runFlowPreview(
|
|
args,
|
|
{
|
|
value: {
|
|
modules: [
|
|
{
|
|
id: mod.id,
|
|
// A linked step has no tools of its own: the resource's tools are resolved
|
|
// server-side from `agent`. `tool_inputs` goes in either way — a step forked
|
|
// for editing has no `agent` yet still carries the flow's bindings, which the
|
|
// runtime overlays, so the preview must test against them too.
|
|
value: {
|
|
type: 'aiagent',
|
|
...(agentVal.agent ? { agent: agentVal.agent } : { tools: agentVal.tools ?? [] }),
|
|
tool_inputs: agentVal.tool_inputs,
|
|
input_transforms: inputTransforms as AiAgent['input_transforms']
|
|
} as Extract<FlowModule['value'], { type: 'aiagent' }>
|
|
}
|
|
]
|
|
},
|
|
summary: '',
|
|
schema
|
|
},
|
|
callbacks,
|
|
previewBase
|
|
)
|
|
} else if (val.type == 'aidecision') {
|
|
const { schema } = await loadSchemaFromModule(mod, opWs)
|
|
const inputTransforms = Object.fromEntries(
|
|
Object.keys(args).map((key) => [key, { expr: `flow_input.${key}`, type: 'javascript' }])
|
|
) as AiDecision['input_transforms']
|
|
await jobLoader?.runFlowPreview(
|
|
args,
|
|
{
|
|
value: {
|
|
modules: [
|
|
{
|
|
id: mod.id,
|
|
value: { type: 'aidecision', input_transforms: inputTransforms, tag: val.tag }
|
|
}
|
|
]
|
|
},
|
|
summary: '',
|
|
schema
|
|
},
|
|
callbacks,
|
|
previewBase
|
|
)
|
|
} else {
|
|
throw Error('Not supported module type')
|
|
}
|
|
}
|
|
|
|
function jobDone(testJob: Job & { result?: any }) {
|
|
if (testJob && !testJob.canceled && testJob.type == 'CompletedJob') {
|
|
if (flowStateStore.val[mod.id]) {
|
|
flowStateStore.val[mod.id] = {
|
|
...flowStateStore.val[mod.id],
|
|
previewResult: testJob.result,
|
|
previewSuccess: testJob.success,
|
|
previewJobId: testJob.id,
|
|
previewLogs: testJob['logs']
|
|
}
|
|
}
|
|
stepHistoryLoader?.resetInitial(mod.id)
|
|
}
|
|
if (modulesTestStates.states[mod.id]) {
|
|
modulesTestStates.states[mod.id].testJob = testJob
|
|
}
|
|
onJobDone?.()
|
|
}
|
|
|
|
export function cancelJob() {
|
|
modulesTestStates.states[mod.id]?.cancel?.()
|
|
}
|
|
|
|
$effect(() => {
|
|
// Update testIsLoading to read the state from parent components
|
|
testIsLoading = modulesTestStates.states?.[mod.id]?.loading ?? false
|
|
})
|
|
|
|
$effect(() => {
|
|
// Update testJob to read the state from parent components
|
|
testJob = modulesTestStates.states?.[mod.id]?.testJob
|
|
})
|
|
|
|
modulesTestStates.states[untrack(() => mod).id] = {
|
|
...(modulesTestStates.states?.[untrack(() => mod).id] ?? { loading: false }),
|
|
loading: testIsLoading,
|
|
testJob: testJob
|
|
}
|
|
</script>
|
|
|
|
<JobLoader
|
|
noCode={true}
|
|
toastError={noEditor}
|
|
workspaceOverride={opWs}
|
|
bind:scriptProgress
|
|
bind:this={jobLoader}
|
|
bind:isLoading={
|
|
() => modulesTestStates.states[mod.id]?.loading ?? false,
|
|
(v) => {
|
|
let newLoading = v ?? false
|
|
if (modulesTestStates.states && modulesTestStates.states?.[mod.id]?.loading !== newLoading) {
|
|
modulesTestStates.states[mod.id] = {
|
|
...(modulesTestStates.states?.[mod.id] ?? {}),
|
|
loading: newLoading,
|
|
hiddenInGraph: false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
bind:job={
|
|
() => modulesTestStates.states[mod.id]?.testJob,
|
|
(v) => modulesTestStates.states[mod.id] && (modulesTestStates.states[mod.id].testJob = v)
|
|
}
|
|
loadPlaceholderJobOnStart={{
|
|
type: 'QueuedJob',
|
|
id: '',
|
|
running: false,
|
|
canceled: false,
|
|
job_kind: 'preview',
|
|
permissioned_as: '',
|
|
is_flow_step: false,
|
|
email: '',
|
|
visible_to_owner: true,
|
|
tag: ''
|
|
}}
|
|
/>
|