mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
feat: keep flow inputs and seed the agent when chat mode is enabled (#11177)
* feat: keep flow inputs and seed the agent when chat mode is enabled Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix: leave a linked agent's memory and streaming to the agent when enabling chat mode Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
23c24a9688
commit
68f2248018
+19
@@ -37,6 +37,25 @@ _Avoid_: argument field, param
|
||||
Any other place a property can be picked into: the loop iterator, skip and early-stop predicates, the retry condition, a branch predicate, timeout. Its prop picker opens in a popover from the connect button rather than taking a pane.
|
||||
_Avoid_: JS field, code input
|
||||
|
||||
### Flow chat
|
||||
|
||||
**Conversation**:
|
||||
One thread of messages against one chat-enabled flow, with its own agent memory. A flow has
|
||||
many; the chat shows one at a time.
|
||||
_Avoid_: thread, session (that names an AI session, a different thing), chat (that names the surface)
|
||||
|
||||
**Turn**:
|
||||
One question and the answer to it: the run the question started, the handle that stops it,
|
||||
and the rows it is writing. At most one per conversation, and the chat is held for its whole
|
||||
length — from the moment the question takes the chat, before it has a job, until it is ended.
|
||||
_Avoid_: request, exchange, message round
|
||||
|
||||
**Transcript**:
|
||||
The rows a conversation's chat holds. Not the conversation: it is the newest page plus
|
||||
whatever older pages the reader has scrolled back through, so a question it cannot answer
|
||||
from what it holds is one to ask the server rather than to guess at.
|
||||
_Avoid_: history, messages (too easily read as "all of them")
|
||||
|
||||
### Permissions
|
||||
|
||||
**Member**:
|
||||
|
||||
Generated
+36
@@ -5280,6 +5280,42 @@ tool, \`websearch\` for web search.
|
||||
"resource": "$res:<path>", "model": <model id> }\`. Required unless the module links to a saved
|
||||
agent through \`value.agent\`
|
||||
|
||||
### Chat-Mode Flows
|
||||
|
||||
A flow with \`value.chat_input_enabled: true\` is run from a chat instead of a form: the composer
|
||||
sends one message per turn and renders the conversation. It needs a required \`user_message\` string
|
||||
input, read by the agent. Any other flow input stays and is asked for under Configure inputs.
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"id": "chat_agent",
|
||||
"value": {
|
||||
"type": "aiagent",
|
||||
"input_transforms": {
|
||||
"provider": {
|
||||
"type": "static",
|
||||
"value": { "kind": "anthropic", "resource": "$res:f/ai/claude", "model": "claude-sonnet-5" }
|
||||
},
|
||||
"user_message": { "type": "javascript", "expr": "flow_input.user_message" },
|
||||
"user_attachments": { "type": "javascript", "expr": "flow_input.files" },
|
||||
"memory": { "type": "static", "value": { "kind": "auto", "context_length": 10 } },
|
||||
"streaming": { "type": "static", "value": true },
|
||||
"output_type": { "type": "static", "value": "text" }
|
||||
},
|
||||
"tools": []
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
- \`memory\` is what lets the agent see earlier turns; without it every message starts from nothing
|
||||
- \`streaming\` on makes the answer and its thinking appear token by token instead of all at once
|
||||
- \`user_attachments\` points at a flow input typed as an array of s3 objects
|
||||
(\`{ "type": "array", "items": { "type": "object", "resourceType": "s3object" } }\`), so files
|
||||
sent with a message reach the agent
|
||||
- Running one needs a \`memory_id\` **query parameter** — not a flow argument — naming the
|
||||
conversation the turn belongs to: a fresh UUID starts one, reusing a UUID continues it. The chat
|
||||
supplies it itself; a run driven any other way has to pass it or the server refuses the job
|
||||
|
||||
### Tool Naming Rules
|
||||
|
||||
These rules cover \`flowmodule\` tools, the ones the agent calls by name. A \`websearch\` tool's
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface SchemaProperty {
|
||||
required?: string[]
|
||||
showExpr?: string
|
||||
hideWhenChatEnabled?: boolean
|
||||
/** Why the oneOf variant is chat mode's to pick. Set = selector disabled, reason shown. */
|
||||
lockOneOfWhenChatEnabled?: string
|
||||
password?: boolean
|
||||
order?: string[]
|
||||
nullable?: boolean
|
||||
|
||||
@@ -123,6 +123,8 @@
|
||||
workspace?: string | undefined
|
||||
s3StorageConfigured?: boolean
|
||||
chatInputEnabled?: boolean
|
||||
/** Why the oneOf variant is fixed. Set = the selector is disabled and says so. */
|
||||
oneOfLockedReason?: string
|
||||
actions?: import('svelte').Snippet
|
||||
innerBottomSnippet?: import('svelte').Snippet
|
||||
fieldHeaderActions?: import('svelte').Snippet
|
||||
@@ -184,6 +186,7 @@
|
||||
workspace = undefined,
|
||||
s3StorageConfigured = true,
|
||||
chatInputEnabled = false,
|
||||
oneOfLockedReason = undefined,
|
||||
actions,
|
||||
innerBottomSnippet,
|
||||
fieldHeaderActions,
|
||||
@@ -1104,11 +1107,15 @@
|
||||
{:else if inputCat == 'object' || inputCat == 'resource-object' || isListJson}
|
||||
{#if oneOf && oneOf.length >= 2}
|
||||
<div class="flex flex-col gap-2 w-full border rounded-md p-4">
|
||||
{#if oneOfLockedReason !== undefined}
|
||||
<div class="text-2xs text-tertiary">{oneOfLockedReason}</div>
|
||||
{/if}
|
||||
{#if oneOf && oneOf.length >= 2}
|
||||
<ToggleButtonGroup
|
||||
selected={oneOfSelected}
|
||||
wrap
|
||||
class="mb-4"
|
||||
disabled={disabled || oneOfLockedReason !== undefined}
|
||||
on:selected={({ detail }) => {
|
||||
oneOfSelected = detail
|
||||
const selectedObjProperties =
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
interface Props {
|
||||
schema: Schema | any
|
||||
hiddenArgs?: string[]
|
||||
/** Fields another part of the app owns: shown, but not renameable, deletable or retypeable. */
|
||||
lockedArgs?: string[]
|
||||
args?: Record<string, any>
|
||||
shouldHideNoInputs?: boolean
|
||||
noVariablePicker?: boolean
|
||||
@@ -89,6 +91,7 @@
|
||||
let {
|
||||
schema = $bindable(),
|
||||
hiddenArgs = [],
|
||||
lockedArgs = [],
|
||||
args = $bindable(undefined),
|
||||
shouldHideNoInputs = false,
|
||||
noVariablePicker = false,
|
||||
@@ -587,6 +590,7 @@
|
||||
>
|
||||
{#if keys.length > 0}
|
||||
{#each keys as argName, i (argName)}
|
||||
{@const locked = lockedArgs.includes(argName)}
|
||||
<div>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
@@ -605,7 +609,7 @@
|
||||
>
|
||||
<div class="flex flex-row gap-2 text-sm">
|
||||
{argName}
|
||||
{#if !uiOnly}
|
||||
{#if !uiOnly && !locked}
|
||||
<div onclick={stopPropagation(preventDefault(bubble('click')))}>
|
||||
<Popover placement="bottom-end" closeButton>
|
||||
{#snippet trigger()}
|
||||
@@ -654,7 +658,7 @@
|
||||
<span class="text-red-500 text-xs"> Required </span>
|
||||
{/if}
|
||||
|
||||
{#if !uiOnly}
|
||||
{#if !uiOnly && !locked}
|
||||
<button
|
||||
class="delete-schema-field-button
|
||||
rounded-full p-1 text-gray-500 bg-white
|
||||
@@ -701,6 +705,7 @@
|
||||
<ToggleButtonGroup
|
||||
tabListClass="flex-wrap"
|
||||
class="h-auto"
|
||||
disabled={lockedArgs.includes(opened ?? '')}
|
||||
bind:selected={
|
||||
() => computeSelected(schema.properties[opened ?? '']),
|
||||
(v) => {
|
||||
|
||||
@@ -999,6 +999,11 @@
|
||||
{helperScript}
|
||||
{s3StorageConfigured}
|
||||
{chatInputEnabled}
|
||||
oneOfLockedReason={chatInputEnabled &&
|
||||
arg?.type === 'static' &&
|
||||
(arg.value as any)?.kind !== 'off'
|
||||
? schema.properties[argName]?.lockOneOfWhenChatEnabled
|
||||
: undefined}
|
||||
otherArgs={Object.fromEntries(
|
||||
Object.entries(otherArgs).map(([key, transform]) => [
|
||||
key,
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
Save,
|
||||
X,
|
||||
Check,
|
||||
Settings2
|
||||
Settings2,
|
||||
MessageSquare
|
||||
} from 'lucide-svelte'
|
||||
import CaptureIcon from '$lib/components/triggers/CaptureIcon.svelte'
|
||||
import FlowInputEditor from './FlowInputEditor.svelte'
|
||||
@@ -47,9 +48,12 @@
|
||||
import type { AiAgent, InputTransform, ScriptLang } from '$lib/gen'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { AI_AGENT_SCHEMA } from '../flowInfers'
|
||||
import { nextId } from '../flowModuleNextId'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { fetchAgentWithDraft, normalizeAgentRef } from '../linkedAgentDrafts'
|
||||
import type { AIAgentConfig } from '../agentResourceUtils'
|
||||
import FlowChat from '../conversations/FlowChat.svelte'
|
||||
import { SPECIAL_MODULE_IDS } from '$lib/components/copilot/chat/shared'
|
||||
|
||||
@@ -96,9 +100,10 @@
|
||||
)
|
||||
|
||||
let chatInputEnabled = $state(Boolean(flowStore.val.value?.chat_input_enabled))
|
||||
let showChatModeWarning = $state(false)
|
||||
let showAdditionalInputs = $state(false)
|
||||
// Chat mode shows one of the two at a time: the conversation, or the inputs it sends.
|
||||
let chatPanelTab = $state<'chat' | 'inputs'>('chat')
|
||||
let chatInputsEditTab = $state(false)
|
||||
let chatEditableSchemaForm: EditableSchemaForm | undefined = $state(undefined)
|
||||
let chatInputsAddPropertyV2: AddPropertyV2 | undefined = $state(undefined)
|
||||
|
||||
let addPropertyV2: AddPropertyV2 | undefined = $state(undefined)
|
||||
@@ -511,23 +516,9 @@
|
||||
return jobId
|
||||
}
|
||||
|
||||
function hasOtherInputs(): boolean {
|
||||
const properties = flowStore.val.schema?.properties
|
||||
return Boolean(
|
||||
properties &&
|
||||
Object.keys(properties).length > 0 &&
|
||||
!(Object.keys(properties).length === 1 && Object.keys(properties).includes('user_message'))
|
||||
)
|
||||
}
|
||||
|
||||
function handleToggleChatMode() {
|
||||
if (!flowStore.val.value?.chat_input_enabled) {
|
||||
// Check if there are existing inputs
|
||||
if (hasOtherInputs()) {
|
||||
showChatModeWarning = true
|
||||
} else {
|
||||
enableChatMode()
|
||||
}
|
||||
enableChatMode()
|
||||
} else {
|
||||
// Disable chat input - remove from flow.value
|
||||
if (flowStore.val.value) {
|
||||
@@ -536,21 +527,77 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the flow input the agent's `user_attachments` reads, and return its name. Chat
|
||||
* mode means files dropped in the composer, and that only works through a flow input —
|
||||
* so it is set up with the message and the memory rather than left to be discovered.
|
||||
*/
|
||||
function addAttachmentsInput(): string {
|
||||
const schema = (flowStore.val.schema ?? {}) as Record<string, any>
|
||||
const properties: Record<string, any> = (schema.properties ??= {})
|
||||
let name = 'files'
|
||||
for (let i = 2; name in properties; i++) name = `files_${i}`
|
||||
properties[name] = {
|
||||
type: 'array',
|
||||
items: { type: 'object', resourceType: 's3object' },
|
||||
description: 'Images or PDFs for the agent to read'
|
||||
}
|
||||
flowStore.val.schema = schema
|
||||
return name
|
||||
}
|
||||
|
||||
// The step panel seeds an array-typed field with [] and undefined persists as null
|
||||
// through JSON round-trips, so both count as nothing configured.
|
||||
function isEmptyAgentChatInputValue(value: unknown): boolean {
|
||||
return value == null || value === '' || (Array.isArray(value) && value.length === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Memory and streaming of a linked agent are the resource's, so enabling chat mode cannot
|
||||
* set them. Read the agent, its draft first, and say what the chat will lack.
|
||||
*/
|
||||
async function warnIfLinkedAgentCannotChat(path: string) {
|
||||
if (!opWs) return
|
||||
let args: AIAgentConfig
|
||||
try {
|
||||
const { response, draft } = await fetchAgentWithDraft(path, opWs)
|
||||
args = draft?.args ?? ((response.value ?? {}) as AIAgentConfig)
|
||||
} catch {
|
||||
// Unreadable here means unreadable at run time too; that run reports it.
|
||||
return
|
||||
}
|
||||
const missing: string[] = []
|
||||
if (!args.memory || (args.memory as { kind?: string }).kind === 'off') missing.push('memory')
|
||||
if (args.streaming !== true) missing.push('streaming')
|
||||
if (missing.length > 0) {
|
||||
sendUserToast(
|
||||
`The linked agent ${path} has ${missing.join(' and ')} off. The chat needs both; turn them on in the agent.`,
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function enableChatMode() {
|
||||
// Enable chat input - set in flow.value
|
||||
flowStore.val.value.chat_input_enabled = true
|
||||
|
||||
// Set up the schema for chat input
|
||||
// The chat fills the flow's form rather than standing in for it: all it needs is a
|
||||
// `user_message` string, which the server requires under that exact argument name.
|
||||
// Every other input stays — the composer drives the ones an agent field reads, and
|
||||
// the rest are asked for in the Configure-inputs modal.
|
||||
const schema = flowStore.val.schema ?? {}
|
||||
const properties = { ...(schema.properties ?? {}) }
|
||||
// Only a string can carry the message; anything else here cannot be what chat sends.
|
||||
if (properties['user_message']?.type !== 'string') {
|
||||
properties['user_message'] = { type: 'string', description: 'Message from user' }
|
||||
}
|
||||
const required: string[] = Array.isArray(schema.required) ? schema.required : []
|
||||
flowStore.val.schema = {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
...schema,
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_message: {
|
||||
type: 'string',
|
||||
description: 'Message from user'
|
||||
}
|
||||
},
|
||||
required: ['user_message']
|
||||
properties,
|
||||
required: required.includes('user_message') ? required : [...required, 'user_message']
|
||||
}
|
||||
|
||||
// Find all AI agent modules
|
||||
@@ -570,8 +617,12 @@
|
||||
(accu, key) => {
|
||||
if (key === 'user_message') {
|
||||
accu[key] = { type: 'javascript', expr: 'flow_input.user_message' }
|
||||
} else if (key === 'user_attachments') {
|
||||
accu[key] = { type: 'javascript', expr: `flow_input.${addAttachmentsInput()}` }
|
||||
} else if (key === 'memory') {
|
||||
accu[key] = { type: 'static', value: { kind: 'auto', context_length: 10 } }
|
||||
} else if (key === 'streaming') {
|
||||
accu[key] = { type: 'static', value: true }
|
||||
} else {
|
||||
accu[key] = {
|
||||
type: 'static',
|
||||
@@ -586,25 +637,23 @@
|
||||
}
|
||||
]
|
||||
sendUserToast(
|
||||
'Chat mode enabled. AI agent created with user message input and context memory set to 10.',
|
||||
'Chat mode enabled. AI agent created with user message and attachments inputs, context memory set to 10 and streaming turned on.',
|
||||
false
|
||||
)
|
||||
} else if (aiAgentModules.length === 1) {
|
||||
// Exactly one AI agent exists: fill in defaults only for inputs the
|
||||
// user hasn't configured, so re-enabling chat mode on an already
|
||||
// configured agent doesn't clobber a custom user_message expression
|
||||
// or a deliberate memory choice (e.g. off).
|
||||
// configured agent doesn't clobber a custom user_message expression.
|
||||
const aiAgent = aiAgentModules[0]
|
||||
const value = aiAgent.value as AiAgent
|
||||
|
||||
// Degenerate shapes the input form can produce without deliberate
|
||||
// configuration count as unconfigured: empty static value (undefined
|
||||
// persists as null through JSON round-trips), blank JS expression
|
||||
// (the JS toggle seeds a bare backtick pair), or an AI transform
|
||||
// (meaningless for the chat input).
|
||||
// configuration count as unconfigured: empty static value, blank JS
|
||||
// expression (the JS toggle seeds a bare backtick pair), or an AI
|
||||
// transform (meaningless for the chat input).
|
||||
const isUnconfigured = (transform: InputTransform | undefined) =>
|
||||
transform === undefined ||
|
||||
(transform.type === 'static' && (transform.value == null || transform.value === '')) ||
|
||||
(transform.type === 'static' && isEmptyAgentChatInputValue(transform.value)) ||
|
||||
(transform.type === 'javascript' && transform.expr.replaceAll('`', '').trim() === '') ||
|
||||
transform.type === 'ai'
|
||||
|
||||
@@ -617,12 +666,47 @@
|
||||
applied.push('user message input')
|
||||
}
|
||||
|
||||
if (isUnconfigured(value.input_transforms['memory'])) {
|
||||
value.input_transforms['memory'] = {
|
||||
type: 'static',
|
||||
value: { kind: 'auto', context_length: 10 }
|
||||
if (isUnconfigured(value.input_transforms['user_attachments'])) {
|
||||
value.input_transforms['user_attachments'] = {
|
||||
type: 'javascript',
|
||||
expr: `flow_input.${addAttachmentsInput()}`
|
||||
}
|
||||
applied.push('attachments input')
|
||||
}
|
||||
|
||||
// A linked step's brain lives in the agent resource: the worker overlays only the
|
||||
// flow-local keys from the step, so a memory or streaming transform written here
|
||||
// would be ignored. Those are checked on the agent itself below instead.
|
||||
const linkedAgent = value.agent ? normalizeAgentRef(value.agent) : undefined
|
||||
if (linkedAgent === undefined) {
|
||||
// `off` is the first oneOf variant of the memory field, so a step added by hand
|
||||
// carries it without anyone choosing it — and an agent that forgets every turn
|
||||
// makes the chat a series of unrelated questions. Overwritten rather than left
|
||||
// alone; the toast below says it happened.
|
||||
const memoryIsOff = (transform: InputTransform | undefined) =>
|
||||
transform?.type === 'static' && (transform.value as any)?.kind === 'off'
|
||||
|
||||
if (
|
||||
isUnconfigured(value.input_transforms['memory']) ||
|
||||
memoryIsOff(value.input_transforms['memory'])
|
||||
) {
|
||||
value.input_transforms['memory'] = {
|
||||
type: 'static',
|
||||
value: { kind: 'auto', context_length: 10 }
|
||||
}
|
||||
applied.push('context memory set to 10')
|
||||
}
|
||||
|
||||
// Without streaming the chat has no SSE to read, so a turn shows nothing —
|
||||
// no thinking, no answer — until the run ends and its rows are written.
|
||||
if (
|
||||
isUnconfigured(value.input_transforms['streaming']) ||
|
||||
(value.input_transforms['streaming']?.type === 'static' &&
|
||||
value.input_transforms['streaming'].value === false)
|
||||
) {
|
||||
value.input_transforms['streaming'] = { type: 'static', value: true }
|
||||
applied.push('streaming turned on')
|
||||
}
|
||||
applied.push('context memory set to 10')
|
||||
}
|
||||
|
||||
sendUserToast(
|
||||
@@ -631,42 +715,53 @@
|
||||
: 'Chat mode enabled. Existing AI agent configuration kept unchanged.',
|
||||
false
|
||||
)
|
||||
if (linkedAgent !== undefined) {
|
||||
warnIfLinkedAgentCannotChat(linkedAgent)
|
||||
}
|
||||
}
|
||||
// If there are multiple AI agents, don't auto-configure (ambiguous which one to configure)
|
||||
|
||||
showChatModeWarning = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add svelte:window to listen for keyboard events -->
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<ConfirmationModal
|
||||
open={showChatModeWarning}
|
||||
title="Enable Chat Mode?"
|
||||
confirmationText="Continue"
|
||||
onConfirmed={enableChatMode}
|
||||
onCanceled={() => {
|
||||
showChatModeWarning = false
|
||||
chatInputEnabled = false
|
||||
}}
|
||||
>
|
||||
<p class="text-sm text-secondary">
|
||||
Enabling Chat Mode will replace all existing flow inputs with a single
|
||||
<span class="font-mono text-xs bg-surface-secondary px-1 rounded">user_message</span>
|
||||
parameter.
|
||||
</p>
|
||||
<p class="text-sm text-secondary mt-2">
|
||||
Your current input configuration will be lost. Are you sure you want to continue?
|
||||
</p>
|
||||
</ConfirmationModal>
|
||||
<!-- The edit toggle and the add-input target, shared by both panels below: chat mode
|
||||
carries a smaller set of side tabs, but the controls themselves must not differ. -->
|
||||
{#snippet inputsEditButton(open: boolean, toggle: () => void)}
|
||||
<Button
|
||||
onClick={toggle}
|
||||
{...open
|
||||
? {
|
||||
title: 'Close input editor',
|
||||
startIcon: { icon: ChevronRight },
|
||||
btnClasses: 'rounded-none rounded-tl-md'
|
||||
}
|
||||
: {
|
||||
title: 'Open input editor',
|
||||
startIcon: { icon: Pen }
|
||||
}}
|
||||
variant="accent"
|
||||
iconOnly
|
||||
wrapperClasses="h-full"
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet inputsAddTrigger()}
|
||||
<div
|
||||
class="w-full py-2 flex justify-center items-center border border-dashed rounded-md hover:bg-surface-hover"
|
||||
id="add-flow-input-btn"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<FlowCard {noEditor} title="Flow Input">
|
||||
{#snippet action()}
|
||||
{#if !disabled}
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle
|
||||
size="sm"
|
||||
size="xs"
|
||||
bind:checked={chatInputEnabled}
|
||||
on:change={() => {
|
||||
handleToggleChatMode()
|
||||
@@ -674,20 +769,25 @@
|
||||
options={{
|
||||
right: 'Chat Mode',
|
||||
rightTooltip:
|
||||
'When enabled, the flow execution page will show a chat interface where each message sent runs the flow with the message as "user_message" input parameter. The flow schema will be automatically set to accept only a user_message string input.'
|
||||
'Turns this flow\'s page into a chat. Each message runs the flow with the message as its "user_message" input, and is kept as a chat — one conversation per chat, each with its own AI agent memory.',
|
||||
rightDocumentationLink:
|
||||
'https://www.windmill.dev/docs/core_concepts/ai_agents#chat-mode'
|
||||
}}
|
||||
/>
|
||||
{#if flowStore.val.value?.chat_input_enabled}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
color={showAdditionalInputs ? 'blue' : 'light'}
|
||||
startIcon={{ icon: Settings2 }}
|
||||
title="Manage inputs"
|
||||
on:click={() => (showAdditionalInputs = !showAdditionalInputs)}
|
||||
>
|
||||
Manage inputs
|
||||
</Button>
|
||||
<ToggleButtonGroup bind:selected={chatPanelTab} noWFull>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton size="sm" value="chat" label="Chat" icon={MessageSquare} {item} />
|
||||
<ToggleButton
|
||||
size="sm"
|
||||
value="inputs"
|
||||
label="Inputs"
|
||||
icon={Settings2}
|
||||
tooltip="Edit the flow inputs the chat sends alongside each message"
|
||||
{item}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -696,11 +796,15 @@
|
||||
<div class="flex flex-col h-full">
|
||||
{#if flowStore.val.value?.chat_input_enabled}
|
||||
<div class="flex flex-col h-full">
|
||||
{#if showAdditionalInputs}
|
||||
<div class="border-b p-2">
|
||||
{#if chatPanelTab === 'inputs'}
|
||||
<!-- EditableSchemaForm scrolls internally against `h-full`, so the wrapper has
|
||||
to be bounded (flex-1 min-h-0) or the form grows to content height and
|
||||
spills out of the panel. -->
|
||||
<div class="py-2 px-4 flex-1 min-h-0">
|
||||
<EditableSchemaForm
|
||||
bind:this={chatEditableSchemaForm}
|
||||
bind:schema={flowStore.val.schema}
|
||||
hiddenArgs={['user_message']}
|
||||
lockedArgs={['user_message']}
|
||||
isFlowInput
|
||||
showSensitiveToggle
|
||||
workspace={opWs}
|
||||
@@ -713,44 +817,42 @@
|
||||
}}
|
||||
>
|
||||
{#snippet openEditTab()}
|
||||
<Button
|
||||
size="xs"
|
||||
variant={chatInputsEditTab ? 'contained' : 'border'}
|
||||
color={chatInputsEditTab ? 'blue' : 'light'}
|
||||
startIcon={{ icon: chatInputsEditTab ? ChevronRight : Pen }}
|
||||
title={chatInputsEditTab ? 'Close editor' : 'Edit inputs'}
|
||||
onClick={() => {
|
||||
chatInputsEditTab = !chatInputsEditTab
|
||||
}}
|
||||
/>
|
||||
{@render inputsEditButton(
|
||||
chatInputsEditTab,
|
||||
() => (chatInputsEditTab = !chatInputsEditTab)
|
||||
)}
|
||||
{/snippet}
|
||||
{#snippet addProperty()}
|
||||
<AddPropertyV2
|
||||
bind:this={chatInputsAddPropertyV2}
|
||||
bind:schema={flowStore.val.schema}
|
||||
onAddNew={() => {}}
|
||||
onAddNew={(argName) => {
|
||||
chatInputsEditTab = true
|
||||
chatEditableSchemaForm?.openField(argName)
|
||||
refreshStateStore(flowStore)
|
||||
}}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Button
|
||||
size="xs"
|
||||
color="light"
|
||||
startIcon={{ icon: Plus }}
|
||||
title="Add additional input"
|
||||
>
|
||||
Add input
|
||||
</Button>
|
||||
{@render inputsAddTrigger()}
|
||||
{/snippet}
|
||||
</AddPropertyV2>
|
||||
{/snippet}
|
||||
</EditableSchemaForm>
|
||||
</div>
|
||||
{/if}
|
||||
<FlowChat
|
||||
onRunFlow={runFlowWithMessage}
|
||||
path={$pathStore}
|
||||
hideSidebar={true}
|
||||
inputSchema={flowStore.val.schema}
|
||||
/>
|
||||
<!-- Hidden rather than unmounted: tearing the chat down destroys its SDK chat,
|
||||
which ends the stream and poller of the turn in flight, so a turn started
|
||||
here would finish server-side with nothing following it and the reader would
|
||||
come back to their own message and no answer. One display class at a time,
|
||||
so the two cannot race in the cascade. -->
|
||||
<div class={chatPanelTab === 'inputs' ? 'hidden' : 'flex flex-col flex-1 min-h-0'}>
|
||||
<FlowChat
|
||||
onRunFlow={runFlowWithMessage}
|
||||
path={$pathStore}
|
||||
hideSidebar={true}
|
||||
inputSchema={flowStore.val.schema}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="py-2 px-4 flex-1 min-h-0">
|
||||
@@ -815,22 +917,9 @@
|
||||
<div class={twMerge('flex flex-row divide-x', ButtonType.ColorVariants.blue.divider)}>
|
||||
<SideBarTab {dropdownItems} fullMenu={!!$flowInputEditorState?.selectedTab}>
|
||||
{#snippet close_button()}
|
||||
<Button
|
||||
onClick={() => handleEditSchema()}
|
||||
{...!!$flowInputEditorState?.selectedTab
|
||||
? {
|
||||
title: 'Close input editor',
|
||||
startIcon: { icon: ChevronRight },
|
||||
btnClasses: 'rounded-none rounded-tl-md'
|
||||
}
|
||||
: {
|
||||
title: 'Open input editor',
|
||||
startIcon: { icon: Pen }
|
||||
}}
|
||||
variant="accent"
|
||||
iconOnly
|
||||
wrapperClasses="h-full"
|
||||
/>
|
||||
{@render inputsEditButton(!!$flowInputEditorState?.selectedTab, () =>
|
||||
handleEditSchema()
|
||||
)}
|
||||
{/snippet}
|
||||
</SideBarTab>
|
||||
</div>
|
||||
@@ -884,12 +973,7 @@
|
||||
}}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<div
|
||||
class="w-full py-2 flex justify-center items-center border border-dashed rounded-md hover:bg-surface-hover"
|
||||
id="add-flow-input-btn"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</div>
|
||||
{@render inputsAddTrigger()}
|
||||
{/snippet}
|
||||
</AddPropertyV2>
|
||||
{/if}
|
||||
|
||||
@@ -39,6 +39,12 @@ export const AI_AGENT_SCHEMA: Schema = {
|
||||
},
|
||||
memory: {
|
||||
type: 'object',
|
||||
// Chat mode keys memory on the conversation, so a chat whose agent has memory off
|
||||
// forgets every turn. Enabling chat mode sets `auto`; this keeps it there. A step
|
||||
// sitting at `off` stays switchable, or a flow that reached that state before —
|
||||
// an agent added to an already-chat-enabled flow — would have no way out of it.
|
||||
lockOneOfWhenChatEnabled:
|
||||
"Chat mode keys this agent's history on the conversation, so memory stays on while it is enabled.",
|
||||
description: 'History sent between the system message and the user message.',
|
||||
oneOf: [
|
||||
{
|
||||
|
||||
@@ -106,6 +106,42 @@ tool, `websearch` for web search.
|
||||
"resource": "$res:<path>", "model": <model id> }`. Required unless the module links to a saved
|
||||
agent through `value.agent`
|
||||
|
||||
### Chat-Mode Flows
|
||||
|
||||
A flow with `value.chat_input_enabled: true` is run from a chat instead of a form: the composer
|
||||
sends one message per turn and renders the conversation. It needs a required `user_message` string
|
||||
input, read by the agent. Any other flow input stays and is asked for under Configure inputs.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "chat_agent",
|
||||
"value": {
|
||||
"type": "aiagent",
|
||||
"input_transforms": {
|
||||
"provider": {
|
||||
"type": "static",
|
||||
"value": { "kind": "anthropic", "resource": "$res:f/ai/claude", "model": "claude-sonnet-5" }
|
||||
},
|
||||
"user_message": { "type": "javascript", "expr": "flow_input.user_message" },
|
||||
"user_attachments": { "type": "javascript", "expr": "flow_input.files" },
|
||||
"memory": { "type": "static", "value": { "kind": "auto", "context_length": 10 } },
|
||||
"streaming": { "type": "static", "value": true },
|
||||
"output_type": { "type": "static", "value": "text" }
|
||||
},
|
||||
"tools": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `memory` is what lets the agent see earlier turns; without it every message starts from nothing
|
||||
- `streaming` on makes the answer and its thinking appear token by token instead of all at once
|
||||
- `user_attachments` points at a flow input typed as an array of s3 objects
|
||||
(`{ "type": "array", "items": { "type": "object", "resourceType": "s3object" } }`), so files
|
||||
sent with a message reach the agent
|
||||
- Running one needs a `memory_id` **query parameter** — not a flow argument — naming the
|
||||
conversation the turn belongs to: a fresh UUID starts one, reusing a UUID continues it. The chat
|
||||
supplies it itself; a run driven any other way has to pass it or the server refuses the job
|
||||
|
||||
### Tool Naming Rules
|
||||
|
||||
These rules cover `flowmodule` tools, the ones the agent calls by name. A `websearch` tool's
|
||||
|
||||
@@ -137,6 +137,42 @@ tool, \`websearch\` for web search.
|
||||
"resource": "$res:<path>", "model": <model id> }\`. Required unless the module links to a saved
|
||||
agent through \`value.agent\`
|
||||
|
||||
### Chat-Mode Flows
|
||||
|
||||
A flow with \`value.chat_input_enabled: true\` is run from a chat instead of a form: the composer
|
||||
sends one message per turn and renders the conversation. It needs a required \`user_message\` string
|
||||
input, read by the agent. Any other flow input stays and is asked for under Configure inputs.
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"id": "chat_agent",
|
||||
"value": {
|
||||
"type": "aiagent",
|
||||
"input_transforms": {
|
||||
"provider": {
|
||||
"type": "static",
|
||||
"value": { "kind": "anthropic", "resource": "$res:f/ai/claude", "model": "claude-sonnet-5" }
|
||||
},
|
||||
"user_message": { "type": "javascript", "expr": "flow_input.user_message" },
|
||||
"user_attachments": { "type": "javascript", "expr": "flow_input.files" },
|
||||
"memory": { "type": "static", "value": { "kind": "auto", "context_length": 10 } },
|
||||
"streaming": { "type": "static", "value": true },
|
||||
"output_type": { "type": "static", "value": "text" }
|
||||
},
|
||||
"tools": []
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
- \`memory\` is what lets the agent see earlier turns; without it every message starts from nothing
|
||||
- \`streaming\` on makes the answer and its thinking appear token by token instead of all at once
|
||||
- \`user_attachments\` points at a flow input typed as an array of s3 objects
|
||||
(\`{ "type": "array", "items": { "type": "object", "resourceType": "s3object" } }\`), so files
|
||||
sent with a message reach the agent
|
||||
- Running one needs a \`memory_id\` **query parameter** — not a flow argument — naming the
|
||||
conversation the turn belongs to: a fresh UUID starts one, reusing a UUID continues it. The chat
|
||||
supplies it itself; a run driven any other way has to pass it or the server refuses the job
|
||||
|
||||
### Tool Naming Rules
|
||||
|
||||
These rules cover \`flowmodule\` tools, the ones the agent calls by name. A \`websearch\` tool's
|
||||
|
||||
@@ -194,6 +194,42 @@ tool, `websearch` for web search.
|
||||
"resource": "$res:<path>", "model": <model id> }`. Required unless the module links to a saved
|
||||
agent through `value.agent`
|
||||
|
||||
### Chat-Mode Flows
|
||||
|
||||
A flow with `value.chat_input_enabled: true` is run from a chat instead of a form: the composer
|
||||
sends one message per turn and renders the conversation. It needs a required `user_message` string
|
||||
input, read by the agent. Any other flow input stays and is asked for under Configure inputs.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "chat_agent",
|
||||
"value": {
|
||||
"type": "aiagent",
|
||||
"input_transforms": {
|
||||
"provider": {
|
||||
"type": "static",
|
||||
"value": { "kind": "anthropic", "resource": "$res:f/ai/claude", "model": "claude-sonnet-5" }
|
||||
},
|
||||
"user_message": { "type": "javascript", "expr": "flow_input.user_message" },
|
||||
"user_attachments": { "type": "javascript", "expr": "flow_input.files" },
|
||||
"memory": { "type": "static", "value": { "kind": "auto", "context_length": 10 } },
|
||||
"streaming": { "type": "static", "value": true },
|
||||
"output_type": { "type": "static", "value": "text" }
|
||||
},
|
||||
"tools": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `memory` is what lets the agent see earlier turns; without it every message starts from nothing
|
||||
- `streaming` on makes the answer and its thinking appear token by token instead of all at once
|
||||
- `user_attachments` points at a flow input typed as an array of s3 objects
|
||||
(`{ "type": "array", "items": { "type": "object", "resourceType": "s3object" } }`), so files
|
||||
sent with a message reach the agent
|
||||
- Running one needs a `memory_id` **query parameter** — not a flow argument — naming the
|
||||
conversation the turn belongs to: a fresh UUID starts one, reusing a UUID continues it. The chat
|
||||
supplies it itself; a run driven any other way has to pass it or the server refuses the job
|
||||
|
||||
### Tool Naming Rules
|
||||
|
||||
These rules cover `flowmodule` tools, the ones the agent calls by name. A `websearch` tool's
|
||||
|
||||
@@ -106,6 +106,42 @@ tool, `websearch` for web search.
|
||||
"resource": "$res:<path>", "model": <model id> }`. Required unless the module links to a saved
|
||||
agent through `value.agent`
|
||||
|
||||
### Chat-Mode Flows
|
||||
|
||||
A flow with `value.chat_input_enabled: true` is run from a chat instead of a form: the composer
|
||||
sends one message per turn and renders the conversation. It needs a required `user_message` string
|
||||
input, read by the agent. Any other flow input stays and is asked for under Configure inputs.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "chat_agent",
|
||||
"value": {
|
||||
"type": "aiagent",
|
||||
"input_transforms": {
|
||||
"provider": {
|
||||
"type": "static",
|
||||
"value": { "kind": "anthropic", "resource": "$res:f/ai/claude", "model": "claude-sonnet-5" }
|
||||
},
|
||||
"user_message": { "type": "javascript", "expr": "flow_input.user_message" },
|
||||
"user_attachments": { "type": "javascript", "expr": "flow_input.files" },
|
||||
"memory": { "type": "static", "value": { "kind": "auto", "context_length": 10 } },
|
||||
"streaming": { "type": "static", "value": true },
|
||||
"output_type": { "type": "static", "value": "text" }
|
||||
},
|
||||
"tools": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `memory` is what lets the agent see earlier turns; without it every message starts from nothing
|
||||
- `streaming` on makes the answer and its thinking appear token by token instead of all at once
|
||||
- `user_attachments` points at a flow input typed as an array of s3 objects
|
||||
(`{ "type": "array", "items": { "type": "object", "resourceType": "s3object" } }`), so files
|
||||
sent with a message reach the agent
|
||||
- Running one needs a `memory_id` **query parameter** — not a flow argument — naming the
|
||||
conversation the turn belongs to: a fresh UUID starts one, reusing a UUID continues it. The chat
|
||||
supplies it itself; a run driven any other way has to pass it or the server refuses the job
|
||||
|
||||
### Tool Naming Rules
|
||||
|
||||
These rules cover `flowmodule` tools, the ones the agent calls by name. A `websearch` tool's
|
||||
|
||||
Reference in New Issue
Block a user