mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-30 08:16:49 +00:00
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:
co-authored by
Claude Opus 5.5
parent
9447b65b07
commit
2e4c0789a2
@@ -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>
|
||||
|
||||
+121
-41
@@ -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
|
||||
|
||||
@@ -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: {}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user