feat: agent to flow, tabbed agent page, one webhook panel with an AI prompt (#11903)

* feat: open a flow linking an agent from its menu

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: toast when the flow handoff cannot be stored

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: turn an agent into a flow from its editor, and into a chat app from its page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: let sandboxed apps run agents, and keep the flow handoff race-free

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: save the new flow as a draft before letting the agent draft go

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: offer the chat app only to those who may build apps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: give the agent page's chat app section a tab on small screens

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: show the flow handoff as an info alert, framed around context and output

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep the flow handoff from operators and say why the chat app is locked

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: collapse the flow handoff hint to its title and button

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: show the flow handoff as a corner hint, as the flow editor shows its own

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: name the agent on the app button and say why it is disabled

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: ask whether the agent needs steps before or after it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: name what a flow adds around the agent in its hint

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: say a never-deployed agent turns into a flow

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* revert: leave agent runs and the raw-app guidance to the chat SDK's runnable transport

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: show the agent page's sections as tabs at every width, as a chat-mode flow's page does

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: one webhook panel for scripts, flows and agents, with an AI prompt and chat-mode examples

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: describe the preprocessor's event argument instead of the v1 wm_trigger

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: give the flow chat's preprocessor example the v2 event signature

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: group a webhook's request choices and examples under an HTTP tab, beside the AI prompt and chat SDK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: drop a comment about guidance this branch no longer adds

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: say where the AI prompt expects the webhook token

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: tell the chat-flow prompt that memory_id is required

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep the webhook tabs next to their content

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep the webhook's REST tab name

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: build an agent's chat app from a template, not an AI session

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: don't send the agent chat app's message on an IME confirmation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: agent API tab with one row of call examples and a copy-prompt button

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: open build app in an AI session when AI sessions are on

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: app conversations through the memory_id query parameter, chat-mode flows included

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: regenerate the mcp session instructions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: type backendAsync's memoryId option in the eval diagnostics

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: pin git-sync hub scripts to windmill-cli 1.849.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep build app in the editor when windmill ai is off

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: webhook panel with http and chat sdk tabs over toggle settings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: memoryId option on the sync backend call too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: bump the ui builder artifact for the memoryId option

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep the webhook panel's http and chat sdk tabs close to their content

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: shared-utils 1.0.14

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: cli takes shared-utils 1.0.14 for the memoryId option

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: pin the app conversation scoping and the memoryId signatures

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: assert the ownership refusal, not any refusal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: match the memoryId option in the cli wmill.ts signature tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: show the webhook request formats in a card with their toggle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: put the chat sdk example in the same card as the http request

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: add a windmill app tab to chat-mode flow pages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: title the webhook example cards and label the ai prompt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: offer the chat app from the conversations sidebar instead of a tab

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: raise the chat app's chat-sdk floor to 1.851.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: render the agent's chat app card only once the workspace is known

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: gate the flow's build chat app menu item like the sidebar card

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: build the agent's chat app through the page's navigation, for app-only authors too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep an imported app in the editor inside a session preview

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: build a form app from an agent without managed memory

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: cover the agent form app's generated source

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-10-28 21:32:20 +01:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 9447b65b07
commit 2e4c0789a2
22 changed files with 1369 additions and 511 deletions
@@ -0,0 +1,80 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import { goto } from '$lib/navigation'
import { LayoutDashboard } from 'lucide-svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import { useEditRights } from '$lib/operatorWriteRights'
import { createChatRawApp } from './createRawAppFromScript'
/** At the foot of the conversations sidebar of an agent's or a chat-mode flow's page: builds
* a chat app over it. Shown only to those who may author raw apps. */
let {
kind,
path,
workspace,
expanded,
summary = undefined,
schema = undefined,
onNavigate = (url) => goto(url)
}: {
kind: 'agent' | 'flow'
path: string
workspace: string
/** Whether the sidebar is open: a card when it is, an icon button on its collapsed strip. */
expanded: boolean
summary?: string
schema?: Record<string, any>
onNavigate?: (url: string) => void | Promise<void>
} = $props()
const editRights = useEditRights(() => workspace)
let canBuild = $derived(editRights.roleCanAuthor('raw_app'))
async function buildApp() {
const app = createChatRawApp({ path, runType: kind, summary, schema })
// The raw app editor reads the payload from sessionStorage on mount.
sessionStorage.setItem('rawAppImport', JSON.stringify(app))
await onNavigate('/apps_raw/add')
}
</script>
{#snippet card(classes: string)}
<div class="flex flex-col gap-2 p-3 {classes}">
<span class="text-sm font-semibold text-emphasis">Share as an app</span>
<span class="text-xs text-secondary">
Turn this {kind} into a chat app you can share. Adapt its design to fit your needs.
</span>
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: LayoutDashboard, classes: 'text-orange-500' }}
onClick={buildApp}
>
Build chat app
</Button>
</div>
{/snippet}
{#if canBuild}
{#if expanded}
{@render card('rounded-md border border-light bg-surface-tertiary')}
{:else}
<!-- Collapsed, the icon alone does not say what it builds: it opens the card. -->
<Popover placement="right-end" closeButton={false}>
{#snippet trigger()}
<Button
nonCaptureEvent
unifiedSize="md"
variant="subtle"
iconOnly
startIcon={{ icon: LayoutDashboard, classes: 'ml-[2px] text-orange-500' }}
title="Share as an app"
btnClasses="w-fit px-2"
/>
{/snippet}
{#snippet content()}
{@render card('w-60')}
{/snippet}
</Popover>
{/if}
{/if}
@@ -55,7 +55,11 @@
import DetailPageHeader from '$lib/components/details/DetailPageHeader.svelte'
import FlowGraphViewer from '$lib/components/FlowGraphViewer.svelte'
import { createRawAppFromFlow } from '$lib/components/details/createRawAppFromScript'
import {
createChatRawApp,
createRawAppFromFlow
} from '$lib/components/details/createRawAppFromScript'
import BuildChatAppCard from '$lib/components/details/BuildChatAppCard.svelte'
import TimeAgo from '$lib/components/TimeAgo.svelte'
import FlowGraphViewerStep from '$lib/components/FlowGraphViewerStep.svelte'
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
@@ -466,19 +470,28 @@
}
})
if (!flow || !canAuthorFlow || !can_write) {
return buttons
}
// Build app starts from the full template, not the builder one a compose-only role deploys.
if (caps.apps === 'full') {
// A chat-mode flow gets the chat template rather than a form over its inputs. It holds no
// inline code, so authoring raw apps is enough, as on the sidebar card. The form app starts
// from the full template, not the builder one a compose-only role deploys.
const chat = !!flow?.value?.chat_input_enabled
const canBuildApp = chat
? editRights.roleCanAuthor('raw_app')
: canAuthorFlow && can_write && caps.apps === 'full'
if (flow && canBuildApp) {
buttons.push({
label: 'Build app',
label: chat ? 'Build chat app' : 'Build app',
placement: 'menu',
alwaysPlaced: true,
buttonProps: {
onClick: async () => {
const app = createRawAppFromFlow(flow.path, flow.summary, flow.schema)
const app = chat
? createChatRawApp({
path: flow.path,
runType: 'flow',
summary: flow.summary,
schema: flow.schema
})
: createRawAppFromFlow(flow.path, flow.summary, flow.schema)
// The raw app editor reads the payload from sessionStorage on mount.
sessionStorage.setItem('rawAppImport', JSON.stringify(app))
await onNavigate('/apps_raw/add')
@@ -491,6 +504,10 @@
})
}
if (!flow || !canAuthorFlow || !can_write) {
return buttons
}
buttons.push({
label: 'Edit',
buttonProps: {
@@ -857,7 +874,21 @@
flowModules={flow?.value?.modules}
wideLayout
frame="none"
/>
>
{#snippet sidebarFooter({ expanded })}
{#if workspace && flow}
<BuildChatAppCard
kind="flow"
path={flow.path}
{workspace}
{expanded}
summary={flow.summary}
schema={flow.schema}
{onNavigate}
/>
{/if}
{/snippet}
</FlowChat>
{:else}
{@const hasSchema =
flow.schema && Object.keys(flow.schema.properties ?? {}).length > 0}
@@ -1016,6 +1047,7 @@
isDeployed={true}
noCapture={true}
isEditor={false}
chatMode={chatInputEnabled}
/>
{/if}
{/snippet}
@@ -2,11 +2,28 @@ import { describe, expect, it } from 'vitest'
import {
RESERVED_LOCALS,
createRawAppFromAgent,
createRawAppFromFlow,
createRawAppFromScript
} from './createRawAppFromScript'
describe('createRawAppFromScript', () => {
it('builds an agent form over its message that shows only the answer', () => {
const app = createRawAppFromAgent('u/dev/helper')
const runnable = app.value.runnables.helper as {
runType: string
schema: { properties: object }
}
expect(runnable.runType).toBe('agent')
expect(Object.keys(runnable.schema.properties)).toEqual(['user_message'])
const appTsx = app.value.files['/App.tsx']
expect(appTsx).toContain('setResult((await backend.helper({')
expect(appTsx).toContain('))?.output)')
expect(appTsx).toContain(
"typeof result === 'string' ? result : JSON.stringify(result, null, 2)"
)
})
it('builds a path runnable and a form calling it', () => {
const app = createRawAppFromScript('u/dev/greet_user', 'Greet a user', {
type: 'object',
@@ -1,10 +1,13 @@
import { forbiddenIds } from '$lib/components/flows/idUtils'
import { react19Template } from '$lib/components/raw_apps/templates'
import { react19Template, themeCss } from '$lib/components/raw_apps/templates'
import { AGENT_CHAT_SCHEMA } from '$lib/components/flows/conversations/agentEditorChat'
/** Shape the `/apps_raw/add` import handoff consumes (`$importStore` /
* `sessionStorage.rawAppImport`). */
export type RawAppImport = {
summary: string
/** Open it in an AI session where AI is used through sessions, rather than the bare editor. */
openInAiSession?: boolean
value: {
files: Record<string, string>
runnables: Record<string, any>
@@ -415,8 +418,10 @@ function generateAppTsx(opts: {
fields: Field[]
title: string
subtitle: string
/** An agent's run also returns its usage, every message and the raw stream: keep the answer. */
outputOnly?: boolean
}): string {
const { runnableId, fields, title, subtitle } = opts
const { runnableId, fields, title, subtitle, outputOnly } = opts
const states = fields
.map((f) => ` const [${f.local}, ${f.setter}] = useState(${f.init})`)
.join('\n')
@@ -457,7 +462,7 @@ ${states}${states === '' ? '' : '\n'} const [result, setResult] = useState(undef
setRunning(true)
setError(undefined)
try {
setResult(await backend.${runnableId}(${callArgs}))
setResult(${outputOnly ? `(await backend.${runnableId}(${callArgs}))?.output` : `await backend.${runnableId}(${callArgs})`})
} catch (e) {
setResult(undefined)
setError(e instanceof Error ? e.message : String(e))
@@ -481,7 +486,7 @@ ${form} <button className="run-button" type="submit" disabled={running}>
</button>
</form>
{error !== undefined && <pre className="app-error">{error}</pre>}
{result !== undefined && <pre className="app-result">{JSON.stringify(result, null, 2)}</pre>}
{result !== undefined && <pre className="app-result">{${outputOnly ? `typeof result === 'string' ? result : ` : ''}JSON.stringify(result, null, 2)}</pre>}
</main>
)
}
@@ -651,7 +656,7 @@ function createRawApp(opts: {
path: string
summary: string | undefined
schema: Record<string, any> | undefined
runType: 'script' | 'flow'
runType: 'script' | 'flow' | 'agent'
}): RawAppImport {
const { path, summary, schema, runType } = opts
const runnableId = runnableIdFromPath(path)
@@ -677,6 +682,7 @@ function createRawApp(opts: {
return {
summary: title,
openInAiSession: true,
value: {
files: {
...react19Template,
@@ -684,7 +690,8 @@ function createRawApp(opts: {
runnableId,
fields,
title,
subtitle: `${runType} · ${path}`
subtitle: `${runType} · ${path}`,
outputOnly: runType === 'agent'
}),
'/index.css': indexCss
},
@@ -718,3 +725,448 @@ export function createRawAppFromFlow(
): RawAppImport {
return createRawApp({ path, summary, schema, runType: 'flow' })
}
/** For an agent without managed memory, which answers each message on its own: a form over the
* message. Attachments are left out, a raw app having no upload for them. */
export function createRawAppFromAgent(path: string): RawAppImport {
const { user_message } = AGENT_CHAT_SCHEMA.properties
return createRawApp({
path,
summary: undefined,
schema: { ...AGENT_CHAT_SCHEMA, properties: { user_message } },
runType: 'agent'
})
}
const chatAppTsx = (chatName: string) => `import React, { useEffect, useRef, useState } from 'react'
import { useWindmillChat } from '@windmill-labs/chat-sdk/react'
import * as wmill from './wmill'
import './index.css'
const CHAT_NAME = ${JSON.stringify(chatName)}
document.title = CHAT_NAME
export default function App() {
// Each turn runs through the \`chat\` runnable, as this app. Conversations are
// kept in the viewer's browser.
const chat = useWindmillChat({ wmill, runnable: 'chat' })
const [input, setInput] = useState('')
const [showList, setShowList] = useState(false)
const end = useRef<HTMLDivElement>(null)
const busy = chat.status === 'submitted' || chat.status === 'streaming'
const last = chat.messages[chat.messages.length - 1]
useEffect(() => {
end.current?.scrollIntoView({ block: 'end' })
}, [chat.messages])
function send() {
const text = input.trim()
if (!text || busy) return
setInput('')
// A message refused before it starts leaves the transcript as it was: give it back.
chat.sendMessage(text).catch(() => setInput(text))
}
function open(id?: string) {
if (id) chat.selectConversation(id)
else chat.newConversation()
setShowList(false)
}
return (
<div className="chat">
<aside className={showList ? 'sidebar open' : 'sidebar'}>
<button className="primary" onClick={() => open()}>New chat</button>
<nav>
{chat.conversations.map((c) => (
<div key={c.id} className={c.id === chat.conversationId ? 'conv active' : 'conv'}>
<button className="conv-title" onClick={() => open(c.id)}>
{c.title || 'New chat'}
</button>
<button
className="conv-delete"
aria-label="Delete conversation"
onClick={() => chat.deleteConversation(c.id)}
>
×
</button>
</div>
))}
</nav>
</aside>
{showList && <div className="backdrop" onClick={() => setShowList(false)} />}
<section className="main">
<header>
<button className="menu" aria-label="Conversations" onClick={() => setShowList(true)}>
☰
</button>
<h1>{CHAT_NAME}</h1>
</header>
<div className="messages">
<div className="thread">
{chat.messages.length === 0 && (
<p className="empty">Ask {CHAT_NAME} anything.</p>
)}
{chat.messages.map((m) =>
m.tool ? (
<div key={m.id} className="tool">
{m.tool.name}
{m.tool.status === 'running' ? '…' : m.tool.status === 'error' ? ' failed' : ''}
</div>
) : m.role === 'user' || m.role === 'assistant' ? (
<div key={m.id} className={'msg ' + m.role + (m.success ? '' : ' failed')}>
{m.content || (m.pending && <span className="thinking">Thinking…</span>)}
</div>
) : null
)}
{busy && last?.role === 'user' && (
<div className="msg assistant">
<span className="thinking">Thinking…</span>
</div>
)}
<div ref={end} />
</div>
</div>
<footer>
{chat.status === 'error' && chat.error && (
<div className="error-banner">{chat.error.message}</div>
)}
<form
onSubmit={(e) => {
e.preventDefault()
send()
}}
>
<textarea
value={input}
rows={1}
placeholder={'Message ' + CHAT_NAME}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault()
send()
}
}}
/>
{busy ? (
<button type="button" onClick={() => chat.stop()}>Stop</button>
) : (
<button type="submit" className="primary" disabled={!input.trim()}>
Send
</button>
)}
</form>
</footer>
</section>
</div>
)
}
`
const chatAppCss = `${themeCss}
button {
font: inherit;
cursor: pointer;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--card);
color: var(--fg-strong);
padding: 8px 14px;
}
button.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
button.primary:hover:not(:disabled) {
background: var(--accent-hover);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
.chat {
display: grid;
grid-template-columns: 240px 1fr;
height: 100vh;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
background: var(--sunken);
border-right: 1px solid var(--border);
overflow-y: auto;
}
.sidebar nav {
display: flex;
flex-direction: column;
gap: 2px;
}
.conv {
display: flex;
align-items: center;
border-radius: 6px;
}
.conv:hover,
.conv.active {
background: var(--card);
}
.conv-title {
flex: 1;
min-width: 0;
border: 0;
background: none;
padding: 6px 8px;
text-align: left;
color: var(--fg);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conv-delete {
border: 0;
background: none;
padding: 4px 8px;
color: var(--muted);
visibility: hidden;
}
.conv:hover .conv-delete {
visibility: visible;
}
.main {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
background: var(--card);
border-bottom: 1px solid var(--border);
}
header h1 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--fg-strong);
}
.menu {
display: none;
padding: 4px 10px;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 24px 20px;
}
.thread {
max-width: 760px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.empty {
margin: 64px 0 0;
text-align: center;
color: var(--muted);
}
.msg {
max-width: 85%;
padding: 8px 12px;
border-radius: 12px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.msg.user {
align-self: flex-end;
background: var(--accent);
color: #fff;
}
.msg.assistant {
align-self: flex-start;
background: var(--card);
border: 1px solid var(--border);
color: var(--fg-strong);
}
.msg.failed {
border-color: var(--error);
}
.tool {
align-self: flex-start;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
color: var(--muted);
}
.thinking {
color: var(--muted);
animation: pulse 1.4s ease-in-out infinite;
}
@keyframes pulse {
50% {
opacity: 0.4;
}
}
footer {
padding: 12px 20px;
background: var(--card);
border-top: 1px solid var(--border);
}
footer form,
.error-banner {
max-width: 760px;
margin: 0 auto;
}
footer form {
display: flex;
gap: 8px;
align-items: flex-end;
}
.error-banner {
margin-bottom: 8px;
padding: 8px 12px;
border-radius: 8px;
background: var(--error-soft);
color: var(--error);
}
textarea {
flex: 1;
resize: none;
field-sizing: content;
min-height: 40px;
max-height: 200px;
padding: 9px 12px;
font: inherit;
color: var(--fg-strong);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
}
textarea:focus {
outline: none;
border-color: var(--accent);
}
.backdrop {
display: none;
}
@media (max-width: 720px) {
.chat {
grid-template-columns: 1fr;
}
.sidebar {
position: fixed;
inset: 0 auto 0 0;
width: 260px;
z-index: 2;
transform: translateX(-100%);
transition: transform 0.2s;
}
.sidebar.open {
transform: none;
}
.backdrop {
display: block;
position: fixed;
inset: 0;
z-index: 1;
background: rgb(0 0 0 / 0.3);
}
.menu {
display: inline-flex;
}
.conv-delete {
visibility: visible;
}
}
`
/** React chat over a saved AI agent, or a flow in chat mode, at `path`, through its `chat`
* runnable. */
export function createChatRawApp({
path,
runType,
summary,
schema
}: {
path: string
runType: 'agent' | 'flow'
summary?: string
schema?: Record<string, any>
}): RawAppImport {
const name = summary?.trim() || (path.split('/').pop() ?? path)
return {
summary: `Chat with ${name}`,
openInAiSession: true,
value: {
files: {
...react19Template,
'/App.tsx': chatAppTsx(name),
'/index.css': chatAppCss,
// The runnable transport (`{ wmill, runnable }`) sends `memory_id` as a query parameter from 1.851.0.
'/package.json': JSON.stringify(
{
dependencies: {
react: '19.0.0',
'react-dom': '19.0.0',
'@windmill-labs/chat-sdk': '^1.851.0'
},
devDependencies: { '@types/react-dom': '^19.0.0', '@types/react': '^19.0.0' }
},
null,
4
)
},
runnables: {
chat: { name, type: 'path', runType, path, schema: schema ?? {}, fields: {} }
}
}
}
}
@@ -1,5 +1,11 @@
import { Copy, Eye, FileUp, History, List, Shield, Trash } from 'lucide-svelte'
import { ResourceService } from '$lib/gen'
import { Copy, Eye, FileUp, History, LayoutDashboard, List, Shield, Trash } from 'lucide-svelte'
import { DraftService, ResourceService, type AiAgent, type OpenFlow } from '$lib/gen'
import { mintDraftPath } from '$lib/mintDraftPath'
import { keepsManagedMemory } from './agentFormFields'
import type { AIAgentConfig } from './agentResourceUtils'
import { AGENT_CHAT_SCHEMA } from './conversations/agentEditorChat'
import { inlineAgentDraft } from './linkedAgentDrafts'
import { nextId } from './flowModuleNextId'
import { sendUserToast } from '$lib/toast'
import { copyToClipboard, type Item } from '$lib/utils'
import { isDeployable } from '$lib/utils_deployable'
@@ -18,6 +24,61 @@ export async function deleteAgent(workspace: string, path: string): Promise<bool
}
}
/**
* Opens a new flow whose one step is the agent, reading the message and files from the flow's
* inputs, and chatting when the agent keeps the conversation. The step links the deployed agent
* at `path`, or with `inline` carries `config` itself, for an agent that was never deployed.
* `beforeOpen` runs once the flow is handed over, and a throw from it keeps the page where it is.
*/
export async function openAgentInFlow(opts: {
workspace: string
path: string
config: AIAgentConfig
inline: boolean
beforeOpen?: () => Promise<unknown>
}): Promise<void> {
const linked = {
type: 'aiagent',
agent: opts.path,
tools: [],
input_transforms: {
user_message: { type: 'javascript', expr: 'flow_input.user_message' },
user_attachments: { type: 'javascript', expr: 'flow_input.user_attachments' }
}
} as AiAgent & { agent: string }
const value = opts.inline ? inlineAgentDraft(linked, opts.config) : linked
// An inlined agent brings its tools' ids, which the step's own must not repeat.
const id = nextId({}, { summary: '', value: { modules: [{ id: '', value }] } })
const flow: OpenFlow & { path: string; description: string; extra_perms: {} } = {
path: opts.path,
summary: '',
description: '',
extra_perms: {},
value: {
...(keepsManagedMemory(opts.config.memory) ? { chat_input_enabled: true } : {}),
modules: [{ id, value }]
},
schema: AGENT_CHAT_SCHEMA
}
// Saved as a draft before anything is let go, so the flow outlives a refresh of its editor: a
// new flow's editor writes nothing until its first edit. The editor opens it as it opens any
// draft-only flow, in the workspace named here rather than the one navigation last stored.
const draftPath = mintDraftPath()
try {
await DraftService.updateDraft({
workspace: opts.workspace,
kind: 'flow',
path: draftPath,
requestBody: { value: flow }
})
await opts.beforeOpen?.()
} catch (err) {
sendUserToast(`Could not open the flow editor: ${err}`, true)
return
}
await goto(`/flows/edit/${draftPath}?workspace=${encodeURIComponent(opts.workspace)}`)
}
/** An agent's menu, as its home row and its page both show it. */
export function agentMenuItems(agent: {
path: string
@@ -33,6 +94,9 @@ export function agentMenuItems(agent: {
onDeploy: () => void
onHistory: () => void
onDelete: (event?: MouseEvent) => void
/** Set where the user may build an app over the agent: a chat app when it keeps a chat, a form
* over its message otherwise. */
buildApp?: { chat: boolean; action: () => void }
}): Item[] {
const deployable =
!agent.noCode &&
@@ -62,6 +126,15 @@ export function agentMenuItems(agent: {
]),
{ displayName: 'Copy path', icon: Copy, action: () => copyToClipboard(agent.path) }
]),
...(agent.buildApp
? [
{
displayName: agent.buildApp.chat ? 'Build chat app' : 'Build app',
icon: LayoutDashboard,
action: agent.buildApp.action
}
]
: []),
{
displayName: 'Permissions',
icon: Shield,
@@ -1,7 +1,6 @@
<script lang="ts">
import { untrack } from 'svelte'
import { FlaskConical, FormInput, MessageSquare, Pen } from 'lucide-svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { FlaskConical, FormInput, LayoutDashboard, MessageSquare, Pen } from 'lucide-svelte'
import { resource } from 'runed'
import { goto } from '$lib/navigation'
import { copilotInfo } from '$lib/aiStore'
@@ -16,6 +15,11 @@
import ShareModal from '$lib/components/ShareModal.svelte'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import AgentEditorHost from '$lib/components/flows/content/AgentEditorHost.svelte'
import BuildChatAppCard from '$lib/components/details/BuildChatAppCard.svelte'
import {
createChatRawApp,
createRawAppFromAgent
} from '$lib/components/details/createRawAppFromScript'
import AgentConfigModal from '$lib/components/flows/content/AgentConfigModal.svelte'
import RunForm from '$lib/components/RunForm.svelte'
import InputSelectedBadge from '$lib/components/schema/InputSelectedBadge.svelte'
@@ -86,6 +90,15 @@
const editRights = useEditRights(() => ws)
let canAuthor = $derived(editRights.roleCanAuthor('agent'))
let canCode = $derived(editRights.capabilities.code)
async function buildApp() {
const app = chatAvailable
? createChatRawApp({ path, runType: 'agent' })
: createRawAppFromAgent(path)
// The raw app editor reads the payload from sessionStorage on mount.
sessionStorage.setItem('rawAppImport', JSON.stringify(app))
await onNavigate('/apps_raw/add')
}
let canEdit = $derived(config != undefined && (agent?.canWrite ?? false) && canAuthor)
// Recorded once the agent is in: a path with nothing deployed never loads one. Not from a
@@ -133,11 +146,11 @@
let history: AgentHistory | undefined = $state(undefined)
let deleteOpen = $state(false)
let evalsModal: AgentEvalsModal | undefined = $state(undefined)
let clientWidth = $state(window.innerWidth)
let pickedMobileTab = $state('agent')
let pickedTab = $state<'agent' | 'api' | 'slack'>('agent')
// The page is reused across agents, and an agent without managed memory has no Slack tab.
let mobileTab = $derived(pickedMobileTab === 'slack' && !chatAvailable ? 'api' : pickedMobileTab)
let wide = $derived(clientWidth >= 1024)
let tab = $derived(
pickedTab !== 'agent' && pickedTab !== 'api' && !chatAvailable ? 'api' : pickedTab
)
const slack = resource(
() => ({ workspace: ws, path }),
@@ -152,7 +165,7 @@
function onKeyDown(event: KeyboardEvent) {
if (!active) return
if (event.defaultPrevented || event.key !== 'Enter' || !(event.ctrlKey || event.metaKey)) return
if (!runForm || testPane?.mode === 'chat' || (!wide && mobileTab !== 'agent')) return
if (!runForm || testPane?.mode === 'chat' || tab !== 'agent') return
if (isValid) {
event.preventDefault()
runForm.run()
@@ -186,27 +199,45 @@
() => ws,
() => getDeployUiSettings()
)
// A role that cannot build agents gets no menu, as on a flow's page.
// The chat template runs the agent by path and holds no code, so raw app rights are enough, as
// on the sidebar card. The form app starts from the full template, as on a flow's page.
let buildAppItem = $derived(
(chatAvailable ? editRights.roleCanAuthor('raw_app') : editRights.capabilities.apps === 'full')
? { chat: chatAvailable, action: buildApp }
: undefined
)
// A role that cannot build agents gets no menu, as on a flow's page, but for the app.
let menuItems = $derived(
config != undefined && canAuthor
? agentMenuItems({
path,
canWrite: canEdit,
noCode: !canCode,
wsSpecific: agent?.state?.wsSpecific,
deployUiSettings: deployUiSettings.current,
onPermissions: () => shareModal?.openDrawer?.(path, 'resource'),
onDeploy: () => deploymentDrawer?.openDrawer(path, 'resource'),
onHistory: () => history?.open(),
onDelete: () => (deleteOpen = true)
}).map((item) => ({
label: item.displayName,
Icon: item.icon,
onclick: (e: MouseEvent) => item.action?.(e),
disabled: item.disabled,
color: item.type === 'delete' ? ('red' as const) : undefined
}))
: []
config == undefined
? []
: !canAuthor
? buildAppItem
? [
{
label: buildAppItem.chat ? 'Build chat app' : 'Build app',
Icon: LayoutDashboard,
onclick: buildApp
}
]
: []
: agentMenuItems({
path,
canWrite: canEdit,
noCode: !canCode,
wsSpecific: agent?.state?.wsSpecific,
deployUiSettings: deployUiSettings.current,
onPermissions: () => shareModal?.openDrawer?.(path, 'resource'),
onDeploy: () => deploymentDrawer?.openDrawer(path, 'resource'),
onHistory: () => history?.open(),
onDelete: () => (deleteOpen = true),
buildApp: buildAppItem
}).map((item) => ({
label: item.displayName,
Icon: item.icon,
onclick: (e: MouseEvent) => item.action?.(e),
disabled: item.disabled,
color: item.type === 'delete' ? ('red' as const) : undefined
}))
)
</script>
@@ -319,15 +350,10 @@
{/if}
{/snippet}
</DetailPageHeader>
<!-- Below `lg` the split would squeeze the chat, so the panel's sections become tabs of the
page, as on a script's or a flow's page. They cover the agent rather than replacing it:
unmounting the host destroys the chat and any turn in flight, so the agent pane keeps one
parent at every width. -->
{#if !wide && ws && config}
<Tabs
bind:selected={() => mobileTab, (t) => (pickedMobileTab = t)}
wrapperClass="flex-none no-scrollbar"
>
<!-- Tabs at every width, as a chat-mode flow's page has them. They cover the agent rather than
replacing it: unmounting the host destroys the chat and any turn in flight. -->
{#if ws && config}
<Tabs bind:selected={() => tab, (t) => (pickedTab = t)} wrapperClass="flex-none no-scrollbar">
<Tab value="agent" label={testPane?.mode === 'chat' ? 'Chat' : 'Run form'} />
<Tab value="api" label="API" />
{#if chatAvailable}
@@ -335,22 +361,13 @@
{/if}
</Tabs>
{/if}
<div class="relative flex-1 min-h-0" bind:clientWidth>
<Splitpanes>
<Pane size={wide ? 65 : 100} minSize={50}>
<div class="h-full" inert={!wide && mobileTab !== 'agent'}>
{@render agentPane()}
</div>
</Pane>
{#if wide && ws && config}
<Pane size={35} minSize={15}>
{@render usePane(ws)}
</Pane>
{/if}
</Splitpanes>
{#if !wide && ws && config}
<div class={['absolute inset-0 z-10 bg-surface', mobileTab === 'agent' && 'hidden']}>
{@render usePane(ws, mobileTab === 'slack' ? 'slack' : 'api')}
<div class="relative flex-1 min-h-0">
<div class="h-full" inert={tab !== 'agent'}>
{@render agentPane()}
</div>
{#if ws && config}
<div class={['absolute inset-0 z-10 bg-surface', tab === 'agent' && 'hidden']}>
{@render usePane(tab === 'agent' ? 'api' : tab)}
</div>
{/if}
</div>
@@ -372,6 +389,11 @@
onOpenConfig={() => configModal?.open()}
onRunStarted={embedded || pendingRun ? runStarted : undefined}
>
{#snippet chatSidebarFooter({ expanded })}
{#if ws}
<BuildChatAppCard kind="agent" {path} workspace={ws} {expanded} {onNavigate} />
{/if}
{/snippet}
{#snippet viewForm({ schema, run, loading, actions })}
{#if config && !chatAvailable}
<Alert type="info" size="xs" title="Managed memory is off" class="mb-4">
@@ -408,10 +430,9 @@
{/key}
{/snippet}
{#snippet usePane(ws: string, section?: 'api' | 'slack')}
{#snippet usePane(section: 'api' | 'slack')}
<AgentUsePanel
agentPath={path}
workspace={ws}
isAdmin={!!(operatingUser.current?.is_admin || operatingUser.current?.is_super_admin)}
chat={chatAvailable}
slackLoading={slack.loading && !slack.current}
@@ -86,6 +86,10 @@
}
]
>
/** A hint pinned under the configuration, outside its scroll. */
configFooter?: import('svelte').Snippet
/** Pinned at the foot of the chat's conversations sidebar. */
chatSidebarFooter?: import('svelte').Snippet<[{ expanded: boolean }]>
}
let {
@@ -99,7 +103,9 @@
view = false,
onOpenConfig = undefined,
onRunStarted = undefined,
viewForm = undefined
viewForm = undefined,
configFooter = undefined,
chatSidebarFooter = undefined
}: Props = $props()
/** The one module the editor edits. Standalone (no `agent` key) so `initFlowState` loads a
@@ -611,31 +617,40 @@
</div>
{#snippet configPane()}
<div class="h-full min-h-0 overflow-auto">
<PropPickerWrapper pickableProperties={stepPropPicker?.pickableProperties} noPadding sidePane>
<AiAgentStepInputs
class="px-4 pt-4 pb-8"
{schema}
filter={brainFilter}
previousModuleId={undefined}
<div class="h-full min-h-0 flex flex-col">
<div class="flex-1 min-h-0 overflow-auto">
<PropPickerWrapper
pickableProperties={stepPropPicker?.pickableProperties}
extraLib={stepPropPicker?.extraLib ?? 'missing extraLib'}
{enableAi}
{workspace}
staticOnly
visibilityKey={`agent:${path}`}
{tools}
{readOnly}
onSelectTool={(id) => onSelectTool?.(id)}
onAddTool={readOnly ? undefined : addTool}
onDeleteTool={readOnly ? undefined : deleteTool}
toolPickerPortal="#agent-editor"
bind:args={
() => (agentValue?.input_transforms ?? {}) as Record<string, InputTransform>,
(v) => agentValue && (agentValue.input_transforms = v)
}
/>
</PropPickerWrapper>
noPadding
sidePane
>
<AiAgentStepInputs
class="px-4 pt-4 pb-8"
{schema}
filter={brainFilter}
previousModuleId={undefined}
pickableProperties={stepPropPicker?.pickableProperties}
extraLib={stepPropPicker?.extraLib ?? 'missing extraLib'}
{enableAi}
{workspace}
staticOnly
visibilityKey={`agent:${path}`}
{tools}
{readOnly}
onSelectTool={(id) => onSelectTool?.(id)}
onAddTool={readOnly ? undefined : addTool}
onDeleteTool={readOnly ? undefined : deleteTool}
toolPickerPortal="#agent-editor"
bind:args={
() => (agentValue?.input_transforms ?? {}) as Record<string, InputTransform>,
(v) => agentValue && (agentValue.input_transforms = v)
}
/>
</PropPickerWrapper>
</div>
{#if configFooter}
<div class="shrink-0 px-3 py-1.5">{@render configFooter()}</div>
{/if}
</div>
{/snippet}
@@ -755,6 +770,7 @@
inputSchema={AGENT_CHAT_SCHEMA}
flowModules={chatModules}
composerSettings={onOpenConfig ? configButton : undefined}
sidebarFooter={chatSidebarFooter}
/>
</div>
</div>
@@ -28,6 +28,10 @@
showAgentEditorView
} from '../agentEditorStore.svelte'
import { agentConfigToInputTransforms } from '../agentResourceUtils'
import { openAgentInFlow } from '../agentActions'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import { useEditRights } from '$lib/operatorWriteRights'
import { userStore } from '$lib/stores'
import { publishLinkedAgentTools } from '../flowState'
import { linkedModulesForAgent, linkedToolsScope } from '../linkedAgentToolsStore.svelte'
import AgentEditorHost from './AgentEditorHost.svelte'
@@ -38,7 +42,6 @@
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
import { emptyString } from '$lib/utils'
import Tooltip from '$lib/components/Tooltip.svelte'
import { useEditRights } from '$lib/operatorWriteRights'
import { itemDisplayName } from '$lib/components/sessions/previewRouter'
import { UserDraft } from '$lib/userDraft.svelte'
import OpenInSessionButton, {
@@ -328,20 +331,79 @@
let deployingFor = $state<WriteTarget | undefined>(undefined)
async function onDeploy() {
async function onDeploy(): Promise<boolean> {
saving = true
deployingFor = currentWriteTarget()
try {
await host?.deploy()
return (await host?.deploy()) ?? false
} finally {
saving = false
deployingFor = undefined
}
}
// Only where the agent stands alone: opened from a flow's step, it is already in one. Linking
// a deployed agent as it is only reads it. Not for operators: the server refuses their flows
// a linked agent, and an inlined one's tools may hold code.
let offerFlow = $derived(
target?.host === undefined &&
draft?.state != undefined &&
!draft.loading &&
!refused &&
!$userStore?.operator &&
editRights.roleCanAuthor('flow') &&
(!readOnly || (!draft.noDeployed && !draft.sync.hasDraft))
)
let openingFlow = $state(false)
/** Set while a deploy is followed by the flow's page: the page loads next, so the deploy's own
* navigation would race it. Holds the path the deploy wrote. */
let handOff = $state<{ savedPath?: string } | undefined>(undefined)
async function useInFlow() {
const state = draft?.state
const at = target
const workspace = ws
if (!draft || !state || !at || !workspace) return
openingFlow = true
try {
if (draft.noDeployed) {
// Never a workspace item: it becomes the flow's step, and its draft goes. Through the
// sync, so an autosave still pending cannot write it back.
const sync = draft.sync
await openAgentInFlow({
workspace,
path: state.path,
config: state.args,
inline: true,
beforeOpen: async () => sync.discard(at.path, undefined)
})
return
}
// The flow links the deployed agent, so it has to run what is shown here.
const config = state.args
let path = at.path
if (draft.sync.hasDraft) {
handOff = {}
try {
if (!(await onDeploy()) || !handOff.savedPath) return
path = handOff.savedPath
} finally {
handOff = undefined
}
}
await openAgentInFlow({ workspace, path, config, inline: false })
} finally {
openingFlow = false
}
}
/** What a successful deploy has to reconcile. `savedPath` is the path it wrote, which a rename
* moves off the one the editor opened. */
async function onSaved(savedPath: string) {
if (handOff) {
handOff.savedPath = savedPath
return
}
const at = deployingFor ?? currentWriteTarget()
// Before the rename is announced: it finds the steps to refresh under the old path.
const reconciled = reconcile(at, savedPath)
@@ -665,9 +727,34 @@
onSelectTool={(id) => showTool(id)}
{onSaved}
isNew={target?.isNew}
configFooter={offerFlow ? flowFooter : undefined}
/>
{/snippet}
{#snippet flowFooter()}
<div class="flex flex-wrap items-center gap-x-1.5 text-xs text-hint">
Need to fetch context first, process the output, or add other steps?
<Button
unifiedSize="xs"
variant="subtle"
startIcon={{ icon: BarsStaggered, props: { style: 'fill: currentColor;' } }}
loading={openingFlow}
onClick={useInFlow}
title={draft?.noDeployed
? 'It becomes the step of a new flow, and this draft is discarded.'
: draft?.sync.hasDraft
? 'Your changes are deployed, then a new flow links this agent.'
: 'A new flow links this agent, which stays configured here.'}
>
{draft?.noDeployed
? 'Turn into a flow'
: draft?.sync.hasDraft
? 'Deploy and use in a flow'
: 'Use in a flow'}
</Button>
</div>
{/snippet}
{#snippet settingsFields()}
{#if draft}
<AgentSettings
@@ -1,28 +1,8 @@
<script lang="ts">
import { Copy } from 'lucide-svelte'
import CollapseLink from '$lib/components/CollapseLink.svelte'
import { bash, markdown, typescript } from 'svelte-highlight/languages'
import { Alert, Badge, Button, CopyButton, Tab, TabContent, Tabs } from '$lib/components/common'
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
import CopyableCodeBlock from '$lib/components/details/CopyableCodeBlock.svelte'
import Label from '$lib/components/Label.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { Alert, Badge, Button, CopyButton, TabContent, Tabs } from '$lib/components/common'
import Tooltip from '$lib/components/Tooltip.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import UserSettings from '$lib/components/UserSettings.svelte'
import type { SlackAgentChannel } from '$lib/gen'
import { userStore } from '$lib/stores'
import { copyToClipboard, generateRandomString } from '$lib/utils'
import { apiBaseUrl } from '$lib/apiBaseUrl.svelte'
import {
curlExample,
fetchExample,
integrationPrompt,
runUrl,
sdkExample,
sdkReactExample
} from './agentApiExamples'
import WebhooksPanel from '$lib/components/triggers/webhook/WebhooksPanel.svelte'
/**
* The ways to use an agent outside its page: in the Slack channels it is shared in, set in its
@@ -30,7 +10,6 @@
*/
let {
agentPath,
workspace,
isAdmin,
chat,
slackLoading,
@@ -45,7 +24,6 @@
section
}: {
agentPath: string
workspace: string
isAdmin: boolean
/** Whether the agent keeps the conversation. Slack is offered only then: a thread is one. */
chat: boolean
@@ -66,53 +44,15 @@
/** Whether the user can change its Slack settings: anyone who can edit it. */
canShare: boolean
channels: SlackAgentChannel[]
/** Shows only this section, without the panel's own tabs: the page offers them as its tabs. */
section?: 'api' | 'slack'
/** The section shown: the page offers them as its tabs. */
section: 'api' | 'slack'
} = $props()
let pickedTab = $state('api')
let tab = $derived(section ?? (chat ? pickedTab : 'api'))
let handle = $derived(`+${agentPath.split('/').pop()}`)
let userSettings: UserSettings | undefined = $state(undefined)
let token = $state('')
let target = $derived({
api: `${apiBaseUrl('webhooks')}/api/w/${workspace}`,
agentPath,
chat,
token
})
let body = JSON.stringify({ user_message: 'Hello' }, null, 2)
let headers = $derived(
JSON.stringify(
{ 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
null,
2
)
)
let apiTab = $state('prompt')
let tsFlavor = $state('react')
</script>
<UserSettings
bind:this={userSettings}
on:tokenCreated={(e) => (token = e.detail)}
newTokenWorkspace={workspace}
newTokenLabel={`agent-${$userStore?.username ?? 'superadmin'}-${generateRandomString(4)}`}
scopes={[`jobs:run:agents:${agentPath}`]}
/>
<div class="flex flex-col h-full">
<Tabs
bind:selected={() => tab, (t) => (pickedTab = t)}
hideTabs={section !== undefined}
wrapperClass="flex-none w-full"
>
<Tab value="api" label="API" />
{#if chat}
<Tab value="slack" label="Slack" />
{/if}
<Tabs selected={section} hideTabs wrapperClass="flex-none w-full">
{#snippet content()}
<div class="min-h-0 grow overflow-y-auto">
<TabContent value="slack" class="flex flex-col gap-6 p-4">
@@ -223,93 +163,15 @@
{/if}
</TabContent>
<TabContent value="api" class="flex flex-col gap-6 p-4">
<Label label="Token">
<div class="flex flex-col gap-2">
<TextInput
bind:value={token}
inputProps={{ placeholder: 'Paste your token here once created' }}
class="!text-xs !font-normal"
/>
<div>
<Button
variant="default"
unifiedSize="sm"
onClick={() => userSettings?.openDrawer()}
>
Create an agent-specific token
<Tooltip light>
The token can only run this agent. It is safe to share as it cannot be used to
impersonate you.
</Tooltip>
</Button>
</div>
</div>
</Label>
<div class="flex flex-col">
<Tabs bind:selected={apiTab}>
<Tab value="prompt" label="AI prompt" />
<Tab value="ts" label="TypeScript" />
<Tab value="http" label="HTTP" />
{#snippet content()}
<TabContent value="ts" class="flex flex-col gap-2 mt-2">
{#if chat}
<ToggleButtonGroup bind:selected={tsFlavor} noWFull>
{#snippet children({ item })}
<ToggleButton value="react" label="React" {item} small />
<ToggleButton value="any" label="Any framework" {item} small />
<ToggleButton value="fetch" label="No SDK" {item} small />
{/snippet}
</ToggleButtonGroup>
{/if}
<CopyableCodeBlock
code={!chat || tsFlavor === 'fetch'
? fetchExample(target)
: tsFlavor === 'react'
? sdkReactExample(target)
: sdkExample(target)}
language={typescript}
/>
</TabContent>
<TabContent value="http" class="flex flex-col gap-6 mt-2">
<Label label="Url">
<ClipboardPanel
content={runUrl(target) + (chat ? '?memory_id=<conversation id>' : '')}
/>
</Label>
<Label label="Body"><ClipboardPanel content={body} /></Label>
<Label label="Headers"><ClipboardPanel content={headers} /></Label>
<Label label="Curl">
<span class="text-xs text-secondary">
Runs the agent and waits for its answer. The TypeScript tab streams it
instead.
</span>
<CopyableCodeBlock code={curlExample(target)} language={bash} />
</Label>
</TabContent>
<TabContent value="prompt" class="flex flex-col gap-2 mt-2">
<span class="text-xs text-secondary">
Share this with your coding assistant. It assumes your token is in <code
>WINDMILL_TOKEN</code
>.
</span>
<div>
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: Copy }}
onClick={() => copyToClipboard(integrationPrompt(target))}
>
Copy prompt
</Button>
</div>
<CollapseLink text="Show prompt">
<CopyableCodeBlock code={integrationPrompt(target)} language={markdown} wrap />
</CollapseLink>
</TabContent>
{/snippet}
</Tabs>
</div>
<TabContent value="api" class="p-4">
<WebhooksPanel
agent
{chat}
path={agentPath}
token=""
args={{ user_message: 'Hello' }}
scopes={[`jobs:run:agents:${agentPath}`]}
/>
</TabContent>
</div>
{/snippet}
@@ -176,6 +176,7 @@
isDeployed={savedFlow && !savedFlow?.draft_only}
schema={flowStore.val.schema}
{onDeployTrigger}
chatMode={!!flowStore.val.value.chat_input_enabled}
/>
{:else if selectedId?.startsWith('subflow:')}
<ExpandedSubflowStep
@@ -147,8 +147,9 @@
type="info"
title="Preprocessor is called when the flow is triggered by API or email"
>
It prepares arguments for the flow. Besides request arguments, the preprocessor receives a
`wm_trigger` argument with trigger details.
It prepares arguments for the flow. It receives an `event` argument: the trigger's `kind`
(such as `webhook` or `email`) and what the trigger received, a webhook's `body` and
`headers` for instance.
</Alert>
</div>
{/if}
@@ -63,6 +63,8 @@
subject?: 'flow' | 'agent'
/** The host's own controls, after the model's in the composer's footer. */
composerSettings?: import('svelte').Snippet
/** Pinned at the foot of the conversations sidebar. */
sidebarFooter?: import('svelte').Snippet<[{ expanded: boolean }]>
}
let {
@@ -78,7 +80,8 @@
frame = 'top',
conversationKind = 'deployed',
subject = 'flow',
composerSettings = undefined
composerSettings = undefined,
sidebarFooter = undefined
}: Props = $props()
const flowEditorContext = getContext<FlowEditorContext>('FlowEditorContext')
@@ -202,6 +205,7 @@
{poolState}
defaultKind={conversationKind}
canFilterKind={conversationKind !== 'deployed' && subject === 'flow'}
footer={sidebarFooter}
/>
{/if}
<!-- pb-3 on the chat alone, not on the row: the transcript and composer stop short of
@@ -45,6 +45,8 @@
* put editor scratch in front of the flow's users.
*/
canFilterKind?: boolean
/** Pinned under the list, open or collapsed. */
footer?: import('svelte').Snippet<[{ expanded: boolean }]>
}
let {
@@ -52,7 +54,8 @@
pool,
poolState,
defaultKind = 'deployed',
canFilterKind = false
canFilterKind = false,
footer = undefined
}: Props = $props()
let expanded = $state(false)
@@ -450,4 +453,8 @@
{/snippet}
</InfiniteList>
</div>
{#if footer}
<!-- mt-auto: collapsed, the list above is hidden and no longer pushes it down. -->
<div class="flex-shrink-0 mt-auto p-1">{@render footer({ expanded })}</div>
{/if}
</div>
@@ -516,7 +516,7 @@ const app = mount(App, { target: document.getElementById("root")! });
export default app;
`
const indexCss = `/* The app renders inside an iframe whose host page has its own theme the
export const themeCss = `/* The app renders inside an iframe whose host page has its own theme the
iframe cannot read, so paint an opaque surface here: without one the host's
background shows through and dark text lands on it unreadable. */
:root {
@@ -575,7 +575,9 @@ body {
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
`
const indexCss = `${themeCss}
h1,
h2 {
margin: 0;
@@ -58,6 +58,8 @@
noCapture?: boolean
isEditor?: boolean
onDeployTrigger?: (trigger: Trigger) => void
/** A flow in chat mode: its webhook examples then name a conversation. */
chatMode?: boolean
}
let {
@@ -75,7 +77,8 @@
schema = undefined,
noCapture = false,
isEditor = true,
onDeployTrigger
onDeployTrigger,
chatMode = false
}: Props = $props()
let config: Record<string, any> = $state({})
@@ -450,6 +453,7 @@
{newItem}
{schema}
{isEditor}
{chatMode}
onDelete={() => {
deleteTrigger(triggersState.selectedTriggerIndex)
}}
@@ -39,6 +39,8 @@
onDelete?: () => void
onReset?: () => void
onEmailDomain: (domain: string) => void
/** A flow in chat mode: its webhook examples then name a conversation. */
chatMode?: boolean
}
let {
@@ -53,6 +55,7 @@
newItem,
schema,
onEmailDomain,
chatMode = false,
...props
}: Props = $props()
@@ -79,6 +82,7 @@
{args}
scopes={isFlow ? [`jobs:run:flows:${currentPath}`] : [`jobs:run:scripts:${currentPath}`]}
{newItem}
chat={isFlow && chatMode}
/>
{:else if selectedTrigger.type === 'default_email'}
<DefaultEmailPanel
@@ -11,7 +11,6 @@
import bash from 'svelte-highlight/languages/bash'
import { Tabs, Tab, TabContent, Button } from '$lib/components/common'
import { ArrowDownRight, ArrowUpRight, Copy } from 'lucide-svelte'
import { Highlight } from 'svelte-highlight'
import { typescript } from 'svelte-highlight/languages'
import ClipboardPanel from '../../details/ClipboardPanel.svelte'
import { copyToClipboard, isObject, readFieldsRecursively } from '$lib/utils'
@@ -23,6 +22,17 @@
import { generateRandomString } from '$lib/utils'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import CollapseLink from '$lib/components/CollapseLink.svelte'
import CopyableCodeBlock from '$lib/components/details/CopyableCodeBlock.svelte'
import { markdown } from 'svelte-highlight/languages'
import {
curlExample,
fetchExample,
integrationPrompt,
sdkExample,
sdkReactExample,
type WebhookTarget
} from './webhookExamples'
interface Props {
isFlow?: boolean
@@ -32,6 +42,11 @@
runnableArgs: any
triggerTokens?: TriggerTokens | undefined
scopes?: string[]
/** An AI agent rather than a script or a flow: it is run at its own endpoint, asynchronously. */
agent?: boolean
/** It keeps the conversation (a chat-mode flow, an agent with managed memory): the examples
* then name one with `memory_id`, and the chat SDK is offered. */
chat?: boolean
}
let {
@@ -41,8 +56,11 @@
token = $bindable(''),
runnableArgs,
triggerTokens = $bindable(undefined),
scopes = []
scopes = [],
agent = false,
chat = false
}: Props = $props()
const kind: WebhookTarget['kind'] = $derived(agent ? 'agent' : isFlow ? 'flow' : 'script')
const operatingWorkspace = useOperatingWorkspace()
const wsId = $derived($operatingWorkspace)
@@ -51,7 +69,9 @@
let baseWebhookUrl = $derived.by(() => {
let webhookUrlPath: string
if (isFlow) {
if (agent) {
return `${WEBHOOK_BASE_URL}/run/agent/${path}`
} else if (isFlow) {
webhookUrlPath = runnableId == 'path' ? `f/${path}` : `fv/${runnableVersion}`
} else {
webhookUrlPath = runnableId == 'path' ? `p/${path}` : `h/${runnableVersion}`
@@ -66,13 +86,19 @@
}
})
let selectedTab: string = $state('rest')
let surface = $state('http') as 'http' | 'sdk'
let example = $state('rest') as 'rest' | 'curl' | 'fetch'
let promptOpen = $state(false)
let userSettings: UserSettings | undefined = $state()
let requestType = $state(DEFAULT_WEBHOOK_TYPE) as 'async' | 'sync' | 'sync_sse'
let callMethod = $state('post') as 'get' | 'post'
let runnableId = $state('path') as 'runnableVersion' | 'path'
let tokenType = $state('headers') as 'query' | 'headers'
const sdkOffered = $derived(chat && kind !== 'script')
$effect(() => {
if (surface === 'sdk' && !sdkOffered) surface = 'http'
})
$effect(() => {
if (requestType === 'async' && callMethod === 'get') {
callMethod = 'post'
@@ -81,24 +107,33 @@
let cleanedRunnableArgs = $derived.by(() => {
readFieldsRecursively(runnableArgs)
return isObject(runnableArgs) && 'wm_trigger' in runnableArgs
? Object.fromEntries(Object.entries(runnableArgs).filter(([key]) => key !== 'wm_trigger'))
: runnableArgs
const args =
isObject(runnableArgs) && 'wm_trigger' in runnableArgs
? Object.fromEntries(Object.entries(runnableArgs).filter(([key]) => key !== 'wm_trigger'))
: runnableArgs
// A chat page fills no form, and each turn is a message.
return chat ? { ...args, user_message: args?.user_message || 'Hello' } : args
})
let url: string = $derived(
baseWebhookUrl +
(tokenType === 'query'
? `?token=${token}${
callMethod === 'get' || requestType === 'sync_sse'
? `&payload=${encodeURIComponent(btoa(JSON.stringify(cleanedRunnableArgs ?? {})))}`
: ''
}`
: `${
callMethod === 'get'
? `?payload=${encodeURIComponent(btoa(JSON.stringify(cleanedRunnableArgs ?? {})))}`
: ''
}`)
)
/** The url, naming the conversation as `memory` writes it in each example. */
function urlWith(memory: string): string {
const params: string[] = []
if (tokenType === 'query') params.push(`token=${token}`)
if (chat) params.push(`memory_id=${memory}`)
if (callMethod === 'get' || (tokenType === 'query' && requestType === 'sync_sse')) {
params.push(`payload=${encodeURIComponent(btoa(JSON.stringify(cleanedRunnableArgs ?? {})))}`)
}
return baseWebhookUrl + (params.length ? `?${params.join('&')}` : '')
}
let url: string = $derived(urlWith('<conversation id>'))
let target: WebhookTarget = $derived({
api: `${apiBaseUrl('webhooks')}/api/w/${wsId}`,
kind,
path,
chat,
token,
inputs: cleanedRunnableArgs
})
let sdkFlavor = $state('react')
function headers() {
const headers = {}
@@ -113,11 +148,20 @@
}
function fetchCode() {
if (agent) return fetchExample(target)
const fetchUrl = urlWith('${conversation}')
const conversation = chat
? '// Any string names a conversation: reuse it for follow-ups, pick a new one to start over.\nconst conversation = crypto.randomUUID();\n\n'
: ''
return conversation + fetchBody(fetchUrl)
}
function fetchBody(fetchUrl: string) {
if (requestType === 'sync_sse') {
return `import { EventSource } from "eventsource";
export async function main() {
const response = await fetch(\`${url}\`, {
const response = await fetch(\`${fetchUrl}\`, {
method: '${callMethod === 'get' ? 'GET' : 'POST'}',
headers: ${JSON.stringify(headers(), null, 2).replaceAll('\n', '\n ')},
body: ${callMethod === 'get' ? 'undefined' : `JSON.stringify(${JSON.stringify(cleanedRunnableArgs ?? {}, null, 2).replaceAll('\n', '\n ')})`}
@@ -184,7 +228,7 @@ async function triggerJob() {
2
).replaceAll('\n', '\n\t')});`
}
const endpoint = \`${url}\`;
const endpoint = \`${fetchUrl}\`;
return await fetch(endpoint, {
method: '${callMethod === 'get' ? 'GET' : 'POST'}',
@@ -209,7 +253,7 @@ async function triggerJob() {
'\n',
'\n\t'
)});
const endpoint = \`${url}\`;
const endpoint = \`${fetchUrl}\`;
return await fetch(endpoint, {
method: '${callMethod === 'get' ? 'GET' : 'POST'}',
@@ -252,9 +296,13 @@ function waitForJobCompletion(UUID) {
}
function curlCode() {
return `TOKEN='${token}'
if (agent) return curlExample(target)
const conversation = chat
? '# Any string names a conversation: reuse it for follow-ups, pick a new one to start over.\nCONVERSATION=$(uuidgen)\n'
: ''
return `${conversation}TOKEN='${token}'
${callMethod !== 'get' ? `BODY='${JSON.stringify(cleanedRunnableArgs ?? {})}'` : ''}
URL='${url}'
${chat ? `URL="${urlWith('$CONVERSATION')}"` : `URL='${url}'`}
${requestType === 'sync' ? 'RESULT=$(' : requestType === 'async' ? 'UUID=$(' : ''}curl -s ${
callMethod != 'get' ? "-H 'Content-Type: application/json'" : ''
} ${tokenType === 'headers' ? `-H "Authorization: Bearer $TOKEN"` : ''} -X ${
@@ -305,7 +353,7 @@ done`
<Button size="xs" variant="default" on:click={() => userSettings?.openDrawer()}>
Create a Webhook-specific Token
<Tooltip light>
The token will have a scope such that it can only be used to trigger this script. It is
The token will have a scope such that it can only be used to trigger this {kind}. It is
safe to share as it cannot be used to impersonate you.
</Tooltip>
</Button>
@@ -313,154 +361,185 @@ done`
</Label>
{/if}
<div class="flex flex-col gap-6">
<Label label="Request type">
<ToggleButtonGroup bind:selected={requestType}>
{#snippet children({ item })}
<ToggleButton
label="Async"
value="async"
tooltip="The returning value is the uuid of the job assigned to execute the job."
{item}
/>
<ToggleButton
label="Sync"
value="sync"
tooltip="Triggers the execution, wait for the job to complete and return it as a response."
{item}
/>
<ToggleButton
label="Sync SSE"
value="sync_sse"
tooltip={'Triggers the execution and returns an SSE stream. ' +
(isFlow
? 'Only useful if the last step of the flow returns a stream.'
: 'Only useful if the script returns a stream.')}
{item}
/>
{/snippet}
</ToggleButtonGroup>
</Label>
<Label label="Call method">
<ToggleButtonGroup bind:selected={callMethod}>
{#snippet children({ item })}
<ToggleButton
label="POST"
icon={ArrowDownRight}
selectedColor="#14b8a6"
value="post"
{item}
/>
<ToggleButton
label="GET"
icon={ArrowUpRight}
selectedColor="#fb923c"
value="get"
{item}
disabled={requestType !== 'sync' && requestType !== 'sync_sse'}
/>
{/snippet}
</ToggleButtonGroup>
</Label>
<Label label="Reference type">
<ToggleButtonGroup bind:selected={runnableId}>
{#snippet children({ item })}
<ToggleButton label="Path" value="path" {item} />
<ToggleButton
label={isFlow ? 'Flow version' : 'Hash'}
value="runnableVersion"
disabled={!runnableVersion}
{item}
/>
{/snippet}
</ToggleButtonGroup>
</Label>
<Label label="Token configuration">
<ToggleButtonGroup bind:selected={tokenType}>
{#snippet children({ item })}
<ToggleButton label="Token in Headers" value="headers" {item} />
<ToggleButton label="Token in Query" value="query" {item} />
{/snippet}
</ToggleButtonGroup>
</Label>
</div>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div>
<Tabs bind:selected={selectedTab}>
<Tab value="rest" label="REST" />
{#if SCRIPT_VIEW_SHOW_EXAMPLE_CURL}
<Tab value="curl" label="Curl" />
<Label label="AI prompt">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-4">
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: Copy }}
onClick={() => copyToClipboard(integrationPrompt(target))}
>
Copy prompt for AI
</Button>
<CollapseLink text="Show prompt" class="mb-0" bind:open={promptOpen} />
</div>
<span class="text-2xs text-hint">
Share it with your coding assistant, after setting the token above as <code
>WINDMILL_TOKEN</code
> in your project's environment.
</span>
{#if promptOpen}
<CopyableCodeBlock code={integrationPrompt(target)} language={markdown} wrap />
{/if}
<Tab value="fetch" label="Fetch" />
{#snippet content()}
{#key token}
<TabContent value="rest" class="flex flex-col flex-1 h-full mt-2">
<div class="flex flex-col gap-6">
<Label label="Url">
<ClipboardPanel content={url} />
</Label>
{#if callMethod !== 'get'}
<Label label="Body">
<ClipboardPanel content={JSON.stringify(cleanedRunnableArgs ?? {}, null, 2)} />
</Label>
</div>
</Label>
{#snippet httpExamples()}
<div class="flex flex-col gap-6">
<!-- An agent has one endpoint: it starts a run, by path, and answers with its job id. -->
{#if !agent}
<Label label="Request type">
<ToggleButtonGroup bind:selected={requestType}>
{#snippet children({ item })}
<ToggleButton
label="Async"
value="async"
tooltip="The returning value is the uuid of the job assigned to execute the job."
{item}
/>
<ToggleButton
label="Sync"
value="sync"
tooltip="Triggers the execution, wait for the job to complete and return it as a response."
{item}
/>
<ToggleButton
label="Sync SSE"
value="sync_sse"
tooltip={'Triggers the execution and returns an SSE stream. ' +
(isFlow
? 'Only useful if the last step of the flow returns a stream.'
: 'Only useful if the script returns a stream.')}
{item}
/>
{/snippet}
</ToggleButtonGroup>
</Label>
<Label label="Call method">
<ToggleButtonGroup bind:selected={callMethod}>
{#snippet children({ item })}
<ToggleButton
label="POST"
icon={ArrowDownRight}
selectedColor="#14b8a6"
value="post"
{item}
/>
<ToggleButton
label="GET"
icon={ArrowUpRight}
selectedColor="#fb923c"
value="get"
{item}
disabled={requestType !== 'sync' && requestType !== 'sync_sse'}
/>
{/snippet}
</ToggleButtonGroup>
</Label>
<Label label="Reference type">
<ToggleButtonGroup bind:selected={runnableId}>
{#snippet children({ item })}
<ToggleButton label="Path" value="path" {item} />
<ToggleButton
label={isFlow ? 'Flow version' : 'Hash'}
value="runnableVersion"
disabled={!runnableVersion}
{item}
/>
{/snippet}
</ToggleButtonGroup>
</Label>
<Label label="Token configuration">
<ToggleButtonGroup bind:selected={tokenType}>
{#snippet children({ item })}
<ToggleButton label="Token in Headers" value="headers" {item} />
<ToggleButton label="Token in Query" value="query" {item} />
{/snippet}
</ToggleButtonGroup>
</Label>
{/if}
<div class="rounded-md border border-light bg-surface-tertiary">
<div class="flex items-center justify-between gap-2 px-3 pt-3">
<span class="text-sm font-semibold text-emphasis">Request</span>
<ToggleButtonGroup bind:selected={example} noWFull>
{#snippet children({ item })}
<ToggleButton label="REST" value="rest" {item} small />
{#if SCRIPT_VIEW_SHOW_EXAMPLE_CURL}
<ToggleButton label="Curl" value="curl" {item} small />
{/if}
{#key callMethod}
{#key tokenType}
<Label label="Headers">
<ClipboardPanel content={JSON.stringify(headers(), null, 2)} />
<ToggleButton label="Fetch" value="fetch" {item} small />
{/snippet}
</ToggleButtonGroup>
</div>
<div class="p-3">
{#key token}
{#if example === 'rest'}
<div class="flex flex-col gap-6">
<Label label="Url">
<ClipboardPanel content={url} />
</Label>
{#if callMethod !== 'get'}
<Label label="Body">
<ClipboardPanel content={JSON.stringify(cleanedRunnableArgs ?? {}, null, 2)} />
</Label>
{/key}
{/key}
</div>
</TabContent>
<TabContent value="curl" class="flex flex-col flex-1 h-full mt-2">
<div class="relative">
{#key runnableArgs}
{/if}
{#key callMethod}
{#key requestType}
{#key tokenType}
<div
class="flex flex-row flex-1 h-full border p-2 rounded-md overflow-auto relative"
onclick={(e) => {
e.preventDefault()
copyToClipboard(curlCode())
}}
>
<Highlight language={bash} code={curlCode()} />
<Copy size={14} class="w-8 top-2 right-2 absolute cursor-pointer" />
</div>
{/key}
{#key tokenType}
<Label label="Headers">
<ClipboardPanel content={JSON.stringify(headers(), null, 2)} />
</Label>
{/key}
{/key}
{/key}
</div>
{:else if example === 'curl'}
<CopyableCodeBlock code={curlCode()} language={bash} />
{:else}
<CopyableCodeBlock code={fetchCode()} language={typescript} />
{/if}
{/key}
</div>
</div>
</div>
{/snippet}
<!-- The chat SDK names every endpoint itself, so the HTTP settings live under their own tab. -->
{#if sdkOffered}
<div>
<Tabs bind:selected={surface}>
<Tab value="http" label="HTTP" />
<Tab value="sdk" label="Chat SDK" />
{#snippet content()}
<TabContent value="http" class="mt-4">
{@render httpExamples()}
</TabContent>
<TabContent value="sdk" class="flex flex-col gap-2 mt-4">
<span class="text-xs text-secondary">
Streams each answer and keeps the conversations, with any framework.
</span>
<div class="flex flex-col gap-3 rounded-md border border-light bg-surface-tertiary p-3">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-semibold text-emphasis">Usage</span>
<ToggleButtonGroup bind:selected={sdkFlavor} noWFull>
{#snippet children({ item })}
<ToggleButton value="react" label="React" {item} small />
<ToggleButton value="any" label="Any framework" {item} small />
{/snippet}
</ToggleButtonGroup>
</div>
<CopyableCodeBlock
code={sdkFlavor === 'react' ? sdkReactExample(target) : sdkExample(target)}
language={typescript}
/>
</div>
</TabContent>
<TabContent value="fetch" class="mt-2">
{#key runnableArgs}
{#key callMethod}
{#key requestType}
{#key tokenType}
{#key token}
<div
class="flex flex-row flex-1 h-full border p-2 rounded-md overflow-auto relative"
onclick={(e) => {
e.preventDefault()
copyToClipboard(fetchCode())
}}
>
<Highlight language={typescript} code={fetchCode()} />
<Copy size={14} class="w-8 top-2 right-2 absolute cursor-pointer" />
</div>
{/key}{/key}{/key}{/key}
{/key}
</TabContent>
{/key}
{/snippet}
</Tabs>
</div>
<TriggerTokens bind:this={triggerTokens} {isFlow} {path} labelPrefix="webhook" />
{/snippet}
</Tabs>
</div>
{:else}
{@render httpExamples()}
{/if}
{#if !agent}
<TriggerTokens bind:this={triggerTokens} {isFlow} {path} labelPrefix="webhook" />
{/if}
</div>
@@ -13,6 +13,9 @@
runnableVersion?: string | undefined
path: string
newItem?: boolean
/** See WebhooksConfigSection. */
agent?: boolean
chat?: boolean
}
let {
@@ -22,16 +25,25 @@
isFlow = false,
runnableVersion = undefined,
path,
newItem = false
newItem = false,
agent = false,
chat = false
}: Props = $props()
</script>
<HighlightTheme />
<Section label="Webhooks" class="flex flex-col gap-4">
<Section label={agent ? 'API' : 'Webhooks'} class="flex flex-col gap-4">
<Description link="https://www.windmill.dev/docs/core_concepts/webhooks">
Webhooks trigger scripts or flows via HTTP requests. Each webhook can be configured to run
synchronously or asynchronously. You can secure webhooks using tokens with specific permissions.
{#if agent}
Call the agent from your own code or app. Each request starts a run{chat
? ', and a memory_id keeps the conversation going'
: ''}. Secure it with a token that can only run this agent.
{:else}
Webhooks trigger scripts or flows via HTTP requests. Each webhook can be configured to run
synchronously or asynchronously. You can secure webhooks using tokens with specific
permissions.
{/if}
</Description>
{#if newItem}
<Alert type="warning" title="Attached to a deployed path">
@@ -39,5 +51,14 @@
{isFlow ? 'flow' : 'script'}.
</Alert>
{/if}
<WebhooksConfigSection {isFlow} {path} {runnableVersion} {token} runnableArgs={args} {scopes} />
<WebhooksConfigSection
{isFlow}
{path}
{runnableVersion}
{token}
runnableArgs={args}
{scopes}
{agent}
{chat}
/>
</Section>
@@ -1,21 +1,45 @@
/**
* The code an agent's API tab offers to copy. Every example is meant to run as copied once the
* token is filled in, so each one is exercised against a live agent before it changes here.
* The code a webhook panel offers to copy beyond its REST, Curl and Fetch tabs: the chat SDK for
* what keeps a conversation, a brief for a coding assistant, and an agent's own Curl and Fetch.
* Every example is meant to run as copied once the token is filled in, so each one is exercised
* against a live script, flow or agent before it changes here.
*/
export type AgentApiTarget = {
export type WebhookTarget = {
/** `https://host/api/w/<workspace>`, base path included. */
api: string
agentPath: string
/** Whether the agent keeps the conversation: only then is `memory_id` worth sending. */
kind: 'script' | 'flow' | 'agent'
path: string
/** Whether it keeps the conversation: an agent with managed memory, a flow in chat mode. Only
* then is `memory_id` worth sending. */
chat: boolean
token: string
/** An example of its inputs, for a script or a flow outside chat mode. */
inputs?: Record<string, unknown>
}
export function runUrl({ api, agentPath }: AgentApiTarget): string {
return `${api}/jobs/run/agent/${agentPath}`
const noun = (t: WebhookTarget) => t.kind
/** The segment of `/jobs/run/...` that names the target by path. */
function runSegment({ kind, path }: WebhookTarget): string {
return kind === 'agent' ? `agent/${path}` : kind === 'flow' ? `f/${path}` : `p/${path}`
}
export function curlExample(t: AgentApiTarget): string {
export function runUrl(t: WebhookTarget): string {
return `${t.api}/jobs/run/${runSegment(t)}`
}
/** The token scope that can run this target and nothing else. */
export function runScope({ kind, path }: WebhookTarget): string {
return `jobs:run:${kind === 'agent' ? 'agents' : kind === 'flow' ? 'flows' : 'scripts'}:${path}`
}
/** What the conversations are filed under: an agent's under `<path>.chat`, a flow's under its path. */
function conversationsPath({ kind, path }: WebhookTarget): string {
return kind === 'agent' ? `${path}.chat` : path
}
/** Agents only: Curl that runs the agent and polls for its answer. */
export function curlExample(t: WebhookTarget): string {
const conversation = t.chat
? `# Any string names a conversation: reuse it for follow-ups, pick a new one to start over.
CONVERSATION=$(uuidgen)
@@ -36,7 +60,8 @@ while true; do
done`
}
export function fetchExample(t: AgentApiTarget): string {
/** Agents only: Fetch that runs the agent and streams its answer. */
export function fetchExample(t: WebhookTarget): string {
const usage = t.chat
? `export async function main() {
// Any string names a conversation: reuse it for follow-ups, pick a new one to start over.
@@ -54,7 +79,7 @@ ${usage}
/** Runs the agent and streams its answer to \`onText\` as it is written; resolves to the full answer. */
async function ask(message${t.chat ? ', conversation' : ''}, onText) {
const run = await fetch(\`\${API}/jobs/run/agent/${t.agentPath}${t.chat ? '?memory_id=${conversation}' : ''}\`, {
const run = await fetch(\`\${API}/jobs/run/agent/${t.path}${t.chat ? '?memory_id=${conversation}' : ''}\`, {
method: 'POST',
headers: { ...HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({ user_message: message })
@@ -102,17 +127,26 @@ async function ask(message${t.chat ? ', conversation' : ''}, onText) {
}`
}
function sdkOptions(t: AgentApiTarget, indent: string): string {
return `{
baseUrl: '${t.api.replace(/\/api\/w\/[^/]+$/, '')}',
function sdkOptions(t: WebhookTarget, indent: string): string {
const connection = ` baseUrl: '${t.api.replace(/\/api\/w\/[^/]+$/, '')}',
workspace: '${t.api.split('/').pop()}',
token: TOKEN,
token: TOKEN,`
if (t.kind !== 'agent') {
return `{
${connection}
// With a token, history stays in this browser; history: 'server' also needs the token's
// flow_conversations:write scope.
flowPath: '${t.path}'
}`.replaceAll('\n', '\n' + indent)
}
return `{
${connection}
// What the agent's conversations are filed under. With a token, history stays in this
// browser; history: 'server' also needs the token's flow_conversations:write scope.
flowPath: '${t.agentPath}.chat',
flowPath: '${t.path}.chat',
// Runs the agent for each turn; the conversation id is its memory.
run: async (args, { conversationId, signal }) => {
const res = await fetch(\`\${API}/jobs/run/agent/${t.agentPath}?memory_id=\${conversationId}\`, {
const res = await fetch(\`\${API}/jobs/run/agent/${t.path}?memory_id=\${conversationId}\`, {
method: 'POST',
headers: { Authorization: \`Bearer \${TOKEN}\`, 'Content-Type': 'application/json' },
body: JSON.stringify(args),
@@ -124,16 +158,21 @@ function sdkOptions(t: AgentApiTarget, indent: string): string {
}`.replaceAll('\n', '\n' + indent)
}
export function sdkReactExample(t: AgentApiTarget): string {
/** The constants the SDK examples open with: an agent's `run` also needs the API's url. */
function sdkConstants(t: WebhookTarget): string {
return `${t.kind === 'agent' ? `const API = '${t.api}'\n` : ''}const TOKEN = '${t.token}'`
}
/** A chat over an agent or a chat-mode flow, in React. */
export function sdkReactExample(t: WebhookTarget): string {
return `import { useState } from 'react'
// On the Vercel AI SDK or assistant-ui? Use @windmill-labs/chat-sdk/ai-sdk or /assistant-ui instead:
// https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme
import { useWindmillChat } from '@windmill-labs/chat-sdk/react'
const API = '${t.api}'
const TOKEN = '${t.token}'
${sdkConstants(t)}
export function AgentChat() {
export function ${t.kind === 'agent' ? 'AgentChat' : 'FlowChat'}() {
const chat = useWindmillChat(${sdkOptions(t, ' ')})
const [draft, setDraft] = useState('')
// Also chat.stop(), and the conversations: chat.conversations, chat.selectConversation(id), chat.newConversation()
@@ -148,11 +187,11 @@ export function AgentChat() {
}`
}
export function sdkExample(t: AgentApiTarget): string {
/** A chat over an agent or a chat-mode flow, with any framework or none. */
export function sdkExample(t: WebhookTarget): string {
return `import { createChat } from '@windmill-labs/chat-sdk'
const API = '${t.api}'
const TOKEN = '${t.token}'
${sdkConstants(t)}
const chat = createChat(${sdkOptions(t, '')})
@@ -163,20 +202,25 @@ await chat.sendMessage('Hello')
// Also chat.stop(), and the conversations: chat.newConversation(), chat.selectConversation(id)`
}
/** A brief for a coding assistant asked to integrate the agent. It reads the token from `WINDMILL_TOKEN`. */
export function integrationPrompt(t: AgentApiTarget): string {
const conversation = t.chat
? `
function conversationSection(t: WebhookTarget): string {
const sdkPointer =
t.kind === 'agent'
? `Point it at the
agent with \`flowPath: '${t.path}.chat'\` and a \`run(args, { conversationId, signal })\` option
that POSTs \`args\` to the run endpoint with \`memory_id=conversationId\` and returns the job id.`
: `Point it at the
flow with \`flowPath: '${t.path}'\`: it runs the flow itself.`
return `
## Conversations
The agent keeps the conversation. Pass \`memory_id\` as a query parameter on every run: any
The ${noun(t)} keeps the conversation. Pass \`memory_id\` as a query parameter on every run: any
string names a conversation, a UUID is best. Reuse it for follow-ups; a new one starts over.
Without it each message is answered on its own.
${t.kind === 'agent' ? 'Without it each message is answered on its own.' : 'It is required: a run without it is refused, so answer a message on its own with a fresh one.'}
Windmill also keeps each conversation, as the token's owner's. One token shared by all your
users makes every conversation theirs, so keep your users' conversation ids in your own app.
Reading them back needs a token with the \`flow_conversations:read\` scope (\`:write\` to delete):
- \`GET ${t.api}/flow_conversations/list?flow_path=${t.agentPath}.chat\` (\`page\`, \`per_page\`).
- \`GET ${t.api}/flow_conversations/list?flow_path=${conversationsPath(t)}\` (\`page\`, \`per_page\`).
A conversation's \`id\` is the \`memory_id\` when that was a UUID.
- \`GET ${t.api}/flow_conversations/{id}/messages\`: its latest messages, oldest first.
- \`DELETE ${t.api}/flow_conversations/delete/{id}\`.
@@ -186,27 +230,31 @@ https://github.com/windmill-labs/windmill/tree/main/chat-sdk): it does all of th
\`createChat\` from \`@windmill-labs/chat-sdk\` works with any framework (or none); use the adapter that
matches the project when there is one: \`useWindmillChat\` from \`@windmill-labs/chat-sdk/react\`, the Vercel
AI SDK transport (\`@windmill-labs/chat-sdk/ai-sdk\`) or the assistant-ui runtime
(\`@windmill-labs/chat-sdk/assistant-ui\`). Point it at the
agent with \`flowPath: '${t.agentPath}.chat'\` and a \`run(args, { conversationId, signal })\` option
that POSTs \`args\` to the run endpoint with \`memory_id=conversationId\` and returns the job id.
(\`@windmill-labs/chat-sdk/assistant-ui\`). ${sdkPointer}
`
: ''
return `Integrate the Windmill AI agent \`${t.agentPath}\` into this project.
}
## Authentication
function authSection(t: WebhookTarget): string {
const specific = `${t.kind === 'agent' ? 'an\nagent' : `a\n${t.kind}`}-specific token (scope \`${runScope(t)}\`): that one can only run this ${noun(t)}`
return `## Authentication
Every request sends \`Authorization: Bearer <token>\`. Read the token from the \`WINDMILL_TOKEN\`
environment variable (or the project's secret store under that name), never hardcode it: the agent
runs as the token's owner, with their permissions. Keep it on the server, unless it is an
agent-specific token (scope \`jobs:run:agents:${t.agentPath}\`): that one can only run this agent, so a
environment variable (or the project's secret store under that name), never hardcode it: the ${noun(t)}
runs as the token's owner, with their permissions. Keep it on the server, unless it is ${specific}, so a
browser app may read it from the bundler's public variable (\`VITE_WINDMILL_TOKEN\`,
\`NEXT_PUBLIC_WINDMILL_TOKEN\`, ...).
\`NEXT_PUBLIC_WINDMILL_TOKEN\`, ...).`
}
function agentPrompt(t: WebhookTarget): string {
return `Integrate the Windmill AI agent \`${t.path}\` into this project.
${authSection(t)}
## Sending a message
\`POST ${runUrl(t)}\` with the JSON body \`{"user_message": "..."}\` starts a run and returns its
job id as plain text. An error is a non-2xx status with the reason as text.
${conversation}
${t.chat ? conversationSection(t) : ''}
## Streaming the answer
\`GET ${t.api}/jobs_u/getupdate_sse/{job_id}?fast=true&only_result=true\` is a server-sent events
@@ -226,3 +274,35 @@ Without streaming, poll \`GET ${t.api}/jobs_u/completed/get_result_maybe/{job_id
To stop a run: \`POST ${t.api}/jobs_u/queue/cancel/{job_id}\` with \`{"reason": "..."}\`.
`
}
function runnablePrompt(t: WebhookTarget): string {
const segment = runSegment(t)
const body = t.chat
? '`{"user_message": "..."}`'
: `its inputs, for example \`${JSON.stringify(t.inputs ?? {})}\``
return `Integrate the Windmill ${t.chat ? 'chat flow' : noun(t)} \`${t.path}\` into this project.
${authSection(t)}
## Running it
The JSON body of each request is ${body}. An error is a non-2xx status with the reason as text.
- \`POST ${t.api}/jobs/run/${segment}\` starts a run and returns its job id as plain text.
- \`POST ${t.api}/jobs/run_wait_result/${segment}\` runs it and answers with its result as JSON, for
runs short enough to wait on.
- \`POST ${t.api}/jobs/run_and_stream/${segment}\` answers with a server-sent events stream of its
updates, for a ${noun(t)} that streams its result.
${t.chat ? conversationSection(t) : ''}
## Reading the result of a started run
Poll \`GET ${t.api}/jobs_u/completed/get_result_maybe/{job_id}\` until \`completed\` is true. \`result\`
is what the ${noun(t)} returned${t.kind === 'flow' ? ' (a flow ending on an AI agent step answers in `result.output`)' : ''}, or carries \`result.error.message\` when the run failed.
To stop a run: \`POST ${t.api}/jobs_u/queue/cancel/{job_id}\` with \`{"reason": "..."}\`.
`
}
/** A brief for a coding assistant asked to call the target. It reads the token from `WINDMILL_TOKEN`. */
export function integrationPrompt(t: WebhookTarget): string {
return t.kind === 'agent' ? agentPrompt(t) : runnablePrompt(t)
}
@@ -12,6 +12,9 @@
import type { HiddenRunnable } from '$lib/components/apps/types'
import RawAppEditor from '$lib/components/raw_apps/RawAppEditor.svelte'
import { prefersSessionHandoff } from '$lib/components/copilot/chat/global/gate'
import { copilotInfo, copilotWorkspace } from '$lib/aiStore'
import { loadCopilot } from '$lib/components/copilot/loadCopilot'
import { get } from 'svelte/store'
import { openEditorInSession } from '$lib/components/sessions/sessionSwitch.svelte'
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
import { page } from '$app/state'
@@ -278,6 +281,10 @@
newPath = pathParam ?? ''
// Imported content is the starting state — skip the picker.
templatePicker = false
if (importRaw.openInAiSession && files) {
const app = { files, runnables, data, summary, policy }
void tick().then(() => handOffToSession(app))
}
return
}
// Seed the React 19 template so the editor has a usable state even if the
@@ -549,6 +556,45 @@
return composeOnlyApps ? {} : structuredClone(STARTER_RUNNABLES)
}
/** Opens the app in an AI session when sessions are how this user gets AI; false when the
* editor should keep it instead. */
async function handOffToSession(app: Omit<RawAppDraft, 'draft_path'>): Promise<boolean> {
const workspace = $workspaceStore
// Inside a session's preview the sessions page refuses to render, so the editor keeps it.
if (window.self !== window.top) return false
if (!prefersSessionHandoff(!$roleCaps.code) || !workspace) return false
if (get(copilotWorkspace) !== workspace) await loadCopilot(workspace)
if (get(copilotWorkspace) !== workspace || !get(copilotInfo).enabled) return false
// The session loads the app by path, so it is saved before it opens. `new_draft` tells
// it the app is still the starter template.
try {
draftSync.draft = {
...app,
...(pendingDraftPath ? { draft_path: pendingDraftPath } : {})
} as RawAppDraft
// The autosave this write queues would otherwise land after the session has
// read the draft, leaving it a baseline older than the row.
await tick()
await UserDraftDbSyncer.flush({ workspace, itemKind: 'raw_app', path })
await UserDraft.forcePersist('raw_app', path, { workspace })
// A failed save is recorded, not thrown, and the session would open on nothing.
const saved = UserDraftDbSyncer.getState({ workspace, itemKind: 'raw_app', path })
if (saved.state === 'failed') {
throw new Error(saved.failureMessage ?? 'the app could not be saved')
}
await openEditorInSession(
{ kind: 'raw_app', path },
workspace,
{ new_draft: 'true' },
{ untilStarted: true }
)
return true
} catch (e) {
sendUserToast(`Could not open the AI session: ${e?.body ?? e?.message ?? e}`, true)
return false
}
}
async function onTemplatePickerStart(result: RawAppTemplatePickerResult, mode: RawAppBuildMode) {
const picked = {
files: { ...result.files },
@@ -557,36 +603,7 @@
summary: '',
policy: result.policy
}
const workspace = $workspaceStore
if (mode === 'ai' && prefersSessionHandoff(!$roleCaps.code) && workspace) {
// The session loads the app by path, so the picked setup is saved before it
// opens. `new_draft` tells it the app is still the starter template.
try {
draftSync.draft = {
...picked,
...(pendingDraftPath ? { draft_path: pendingDraftPath } : {})
} as RawAppDraft
// The autosave this write queues would otherwise land after the session has
// read the draft, leaving it a baseline older than the row.
await tick()
await UserDraftDbSyncer.flush({ workspace, itemKind: 'raw_app', path })
await UserDraft.forcePersist('raw_app', path, { workspace })
// A failed save is recorded, not thrown, and the session would open on nothing.
const saved = UserDraftDbSyncer.getState({ workspace, itemKind: 'raw_app', path })
if (saved.state === 'failed') {
throw new Error(saved.failureMessage ?? 'the app could not be saved')
}
await openEditorInSession(
{ kind: 'raw_app', path },
workspace,
{ new_draft: 'true' },
{ untilStarted: true }
)
return
} catch (e) {
sendUserToast(`Could not open the AI session: ${e?.body ?? e?.message ?? e}`, true)
}
}
if (mode === 'ai' && (await handOffToSession(picked))) return
files = picked.files
runnables = picked.runnables
data = picked.data
+3 -4
View File
@@ -1818,6 +1818,7 @@ A caught failure reads the same whether it came from a task or from a \`step()\`
export const FLOW_CHAT_SPECIAL_MODULES = `## Special Modules
- Use \`set_preprocessor_module\` to add, replace, or remove the top-level \`value.preprocessor_module\`
- A preprocessor receives the trigger's \`event\`, not the flow's inputs: leave its \`input_transforms\` empty
- Use \`set_failure_module\` to add, replace, or remove the top-level \`value.failure_module\`
- Use \`set_flow_json\` only when you are replacing the whole flow, including normal modules and optional special modules
@@ -1829,10 +1830,8 @@ set_preprocessor_module({
value: {
type: "rawscript",
language: "bun",
content: "export async function preprocessor(payload: string) { const trimmed = payload.trim(); if (!trimmed) { throw new Error('payload must not be empty'); } return { payload: trimmed }; }",
input_transforms: {
payload: { type: "javascript", expr: "flow_input.payload" }
}
content: "export async function preprocessor(event: { kind: string; body?: { payload?: string } }) { const payload = String(event.body?.payload ?? '').trim(); if (!payload) { throw new Error('payload must not be empty'); } return { payload }; }",
input_transforms: {}
}
})
})
@@ -1,6 +1,7 @@
## Special Modules
- Use `set_preprocessor_module` to add, replace, or remove the top-level `value.preprocessor_module`
- A preprocessor receives the trigger's `event`, not the flow's inputs: leave its `input_transforms` empty
- Use `set_failure_module` to add, replace, or remove the top-level `value.failure_module`
- Use `set_flow_json` only when you are replacing the whole flow, including normal modules and optional special modules
@@ -12,10 +13,8 @@ set_preprocessor_module({
value: {
type: "rawscript",
language: "bun",
content: "export async function preprocessor(payload: string) { const trimmed = payload.trim(); if (!trimmed) { throw new Error('payload must not be empty'); } return { payload: trimmed }; }",
input_transforms: {
payload: { type: "javascript", expr: "flow_input.payload" }
}
content: "export async function preprocessor(event: { kind: string; body?: { payload?: string } }) { const payload = String(event.body?.payload ?? '').trim(); if (!payload) { throw new Error('payload must not be empty'); } return { payload }; }",
input_transforms: {}
}
})
})