Files
windmill/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte
centdix 047420e5ad feat(flow): add option to turn flow into chat (#6658)
* add toggle option + chat interface

* backend impl

* draft

* put info in schema

* Revert "backend impl"

This reverts commit c534eeb49986424e2c12e2c5642be4e17ba380d1.

* chat interface in flow input

* cleaning

* add logic for running flow + styling

* handle historic args

* fix frontend changes

* add tables

* add conv list

* add endpoints

* adapt frontend

* list message logic

* save message in db

* save response in db

* cleaning

* better migrations

* refresh on new conv

* better logic for messages

* nit

* genere conversation uuid from frontend

* store chat mode info in flow status

* better ui for chat

* collapse chat

* ui

* infinite scroll on convs

* infinite scroll on messages

* fix ui

* new chat entry on new

* cleaning

* change setting logic

* fix test logic from flow input

* move toggle to input

* add warning modal when enabling chat mode

* add summary and explanation on inline script

* add hint for chat mode on user_message desc

* show chat message instead of input in graph

* add warning for triggers

* one logo when not expanded

* use infinitelist for conversations

* add warning when deployment in progress

* full width button

* better icon for menu

* better input + nits

* put toggle in action

* use waitjob

* cleaning

* cleaning

* scroll on new + cleaning

* use enum

* fix logic

* full screen

* cleaning

* exit on updatesqlx error

* Update SQLx metadata

* fix

* cleaning

* add for wait result endpoint

* add missing drop

* delete cascade

* fix: use macro version of query_as in flow_conversations.rs

Use sqlx::query_as! macro instead of query_as function for compile-time
SQL validation and better type safety

Co-authored-by: centdix <centdix@users.noreply.github.com>

* fix: update comment to clarify conversation message update condition

The comment now accurately reflects that the update happens when
it's a flow and it's done (flow_is_done)

Co-authored-by: centdix <centdix@users.noreply.github.com>

* fix: only parse chat_input_enabled if conditions are met

Move the parse_chat_input_enabled() call inside the condition check
to avoid unnecessary parsing when the flow is not done or unsuccessful

Co-authored-by: centdix <centdix@users.noreply.github.com>

* fix: use the same transaction for conversation creation

Pass transaction to get_or_create_conversation_with_id instead of
creating a new one, ensuring all operations are atomic

Co-authored-by: centdix <centdix@users.noreply.github.com>

* fix: remove update trigger and handle updated_at in application code

Remove the database trigger that automatically updates conversation
timestamp and instead update it explicitly when creating messages.
This gives better control and consistency.

Co-authored-by: centdix <centdix@users.noreply.github.com>

* Update SQLx metadata

* cleaning

* feat(aiagent): handle memory (#6719)

* implement memory

* s3 logic for memory

* fix typo

* much cleaner

* cleaning

* cleaning

* only if chat

* display nit

* nit

* fix stack overflow

* cleaning

* use len arg from input

* cleaning

* change order

* delete memory when conv deleted

* cleaning

* nit

* show description in expr mode

* opti

* opti

* updatee ref

* store string as simple string

* use markdown

* do not wait for deletion

* add delete loading

* fix logic

* fix markdown

* Update ee-repo-ref.txt

* Update SQLx metadata

* fix in test interface

* nit

* nit

* fix layout

* use memory_id to store memory

* shorter description

* rls + grant

* fix text overflow

* extract output from res

* cleaning

* handle streaming

* cleaning

* fix tool error

* nit

* update ref

* fix

* Update SQLx metadata

* nit

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: centdix <centdix@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2025-10-03 14:04:21 +00:00

619 lines
18 KiB
Svelte

<script lang="ts">
import type { FlowEditorContext } from '../types'
import { createEventDispatcher, getContext, tick } from 'svelte'
import {
createInlineScriptModule,
createBranchAll,
createBranches,
createLoop,
createWhileLoop,
deleteFlowStateById,
emptyModule,
pickScript,
pickFlow,
insertNewPreprocessorModule,
createAiAgent
} from '$lib/components/flows/flowStateUtils.svelte'
import type { FlowModule, Job, ScriptLang } from '$lib/gen'
import { emptyFlowModuleState } from '../utils'
import { dfs } from '../dfs'
import { push } from '$lib/history.svelte'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import Portal from '$lib/components/Portal.svelte'
import { getDependentComponents } from '../flowExplorer'
import { copilotInfo, tutorialsToDo, workspaceStore } from '$lib/stores'
import FlowTutorials from '$lib/components/FlowTutorials.svelte'
import { ignoredTutorials } from '$lib/components/tutorials/ignoredTutorials'
import { tutorialInProgress } from '$lib/tutorialUtils'
import FlowGraphV2 from '$lib/components/graph/FlowGraphV2.svelte'
import { replaceId } from '../flowStore.svelte'
import { setScheduledPollSchedule, type TriggerContext } from '$lib/components/triggers'
import type { PropPickerContext } from '$lib/components/prop_picker'
import { JobService } from '$lib/gen'
import { dfsByModule } from '../previousResults'
import type { InlineScript, InsertKind } from '$lib/components/graph/graphBuilder.svelte'
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import type { GraphModuleState } from '$lib/components/graph'
import FlowStickyNode from './FlowStickyNode.svelte'
import { getStepHistoryLoaderContext } from '$lib/components/stepHistoryLoader.svelte'
import { ModulesTestStates } from '$lib/components/modulesTest.svelte'
import type { StateStore } from '$lib/utils'
interface Props {
sidebarSize?: number | undefined
disableStaticInputs?: boolean
disableTutorials?: boolean
disableAi?: boolean
disableSettings?: boolean
newFlow?: boolean
smallErrorHandler?: boolean
workspace?: string | undefined
onTestUpTo?: ((id: string) => void) | undefined
onEditInput?: (moduleId: string, key: string) => void
localModuleStates?: Record<string, GraphModuleState>
testModuleStates?: ModulesTestStates
aiChatOpen?: boolean
showFlowAiButton?: boolean
toggleAiChat?: () => void
isOwner?: boolean
onTestFlow?: () => void
isRunning?: boolean
onCancelTestFlow?: () => void
onOpenPreview?: () => void
onHideJobStatus?: () => void
individualStepTests?: boolean
flowJob?: Job | undefined
showJobStatus?: boolean
suspendStatus?: StateStore<Record<string, { job: Job; nb: number }>>
onDelete?: (id: string) => void
flowHasChanged?: boolean
}
let {
sidebarSize = $bindable(undefined),
disableStaticInputs = false,
disableTutorials = false,
disableAi = false,
disableSettings = false,
newFlow = false,
smallErrorHandler = false,
workspace = $workspaceStore,
onTestUpTo,
onEditInput,
localModuleStates = {},
testModuleStates = new ModulesTestStates(),
aiChatOpen,
showFlowAiButton,
toggleAiChat,
isOwner,
onTestFlow,
isRunning,
onCancelTestFlow,
onOpenPreview,
onHideJobStatus,
individualStepTests = false,
flowJob = undefined,
showJobStatus = false,
suspendStatus = $bindable({ val: {} }),
onDelete,
flowHasChanged
}: Props = $props()
let flowTutorials: FlowTutorials | undefined = $state(undefined)
const { customUi, selectedId, moving, history, flowStateStore, flowStore, pathStore } =
getContext<FlowEditorContext>('FlowEditorContext')
const { triggersCount, triggersState } = getContext<TriggerContext>('TriggerContext')
const { flowPropPickerConfig } = getContext<PropPickerContext>('PropPickerContext')
export async function insertNewModuleAtIndex(
modules: FlowModule[],
index: number,
kind: InsertKind,
wsScript?: { path: string; summary: string; hash: string | undefined },
wsFlow?: { path: string; summary: string },
inlineScript?: InlineScript
): Promise<FlowModule[]> {
push(history, flowStore.val)
let module = emptyModule(flowStateStore.val, flowStore.val, kind == 'flow')
let state = emptyFlowModuleState()
flowStateStore.val[module.id] = state
if (wsFlow) {
;[module, state] = await pickFlow(wsFlow.path, wsFlow.summary, module.id)
} else if (wsScript) {
;[module, state] = await pickScript(
wsScript.path,
wsScript.summary,
module.id,
wsScript.hash,
kind
)
} else if (kind == 'forloop') {
;[module, state] = await createLoop(module.id, !disableAi && $copilotInfo.enabled)
} else if (kind == 'whileloop') {
;[module, state] = await createWhileLoop(module.id)
} else if (kind == 'branchone') {
;[module, state] = await createBranches(module.id)
} else if (kind == 'branchall') {
;[module, state] = await createBranchAll(module.id)
} else if (kind == 'aiagent') {
;[module, state] = await createAiAgent(module.id)
} else if (inlineScript) {
const { language, kind, subkind, summary } = inlineScript
;[module, state] = await createInlineScriptModule(language, kind, subkind, module.id, summary)
flowStateStore.val[module.id] = state
if (kind == 'trigger') {
module.summary = 'Trigger'
} else if (kind == 'approval') {
module.summary = 'Approval'
}
}
flowStateStore.val[module.id] = state
if (kind == 'approval') {
module.suspend = { required_events: 1, timeout: 1800 }
} else if (kind == 'trigger') {
module.stop_after_if = {
expr: '!result || (Array.isArray(result) && result.length == 0)',
skip_if_stopped: true
}
} else if (kind == 'end') {
module.summary = 'Terminate flow'
module.stop_after_if = { skip_if_stopped: false, expr: 'true' }
}
if (!modules) return [module]
modules.splice(index, 0, module)
return modules
}
export function removeAtId(modules: FlowModule[], id: string): FlowModule[] {
const index = modules.findIndex((mod) => mod.id == id)
if (index != -1) {
const [removed] = modules.splice(index, 1)
const leaves = dfs([removed], (mod) => mod.id)
leaves.forEach((leafId: string) => deleteFlowStateById(leafId, flowStateStore))
return modules
}
return modules.map((mod) => {
if (mod.value.type == 'forloopflow' || mod.value.type == 'whileloopflow') {
mod.value.modules = removeAtId(mod.value.modules, id)
} else if (mod.value.type == 'branchall') {
mod.value.branches = mod.value.branches.map((branch) => {
branch.modules = removeAtId(branch.modules, id)
return branch
})
} else if (mod.value.type == 'branchone') {
mod.value.branches = mod.value.branches.map((branch) => {
branch.modules = removeAtId(branch.modules, id)
return branch
})
mod.value.default = removeAtId(mod.value.default, id)
} else if (mod.value.type == 'aiagent') {
mod.value.tools = removeAtId(mod.value.tools, id)
}
return mod
})
}
let sidebarMode: 'list' | 'graph' = 'graph'
let minHeight = $state(0)
export function selectNextId(id: any) {
if (flowStore.val.value.modules) {
let allIds = dfs(flowStore.val.value.modules, (mod) => mod.id)
if (allIds.length > 1) {
const idx = allIds.indexOf(id)
$selectedId = idx == 0 ? allIds[0] : allIds[idx - 1]
} else {
$selectedId = 'settings-metadata'
}
}
}
function findModuleById(id: string) {
return dfsByModule(id, flowStore.val.value.modules)[0]
}
export async function addBranch(id: string) {
push(history, flowStore.val)
let module = findModuleById(id)
if (!module) {
throw new Error(`Node ${id} not found`)
}
if (module.value.type === 'branchone' || module.value.type === 'branchall') {
module.value.branches.splice(module.value.branches.length, 0, {
summary: '',
expr: 'false',
modules: []
})
}
}
export function removeBranch(id: string, index: number) {
push(history, flowStore.val)
let module = findModuleById(id)
if (!module) {
throw new Error(`Node ${id} not found`)
}
if (module.value.type === 'branchone' || module.value.type === 'branchall') {
const offset = module.value.type === 'branchone' ? 1 : 0
if (module.value.branches[index - offset]?.modules) {
const leaves = dfs(module.value.branches[index - offset].modules, (mod) => mod.id)
leaves.forEach((leafId: string) => deleteFlowStateById(leafId, flowStateStore))
}
module.value.branches.splice(index - offset, 1)
}
}
let deleteCallback: (() => void) | undefined = $state(undefined)
let dependents: Record<string, string[]> = $state({})
let graph: FlowGraphV2 | undefined = $state(undefined)
export function isNodeVisible(nodeId: string): boolean {
return graph?.isNodeVisible(nodeId) ?? false
}
function shouldRunTutorial(tutorialName: string, name: string, index: number) {
return (
$tutorialsToDo.includes(index) &&
name == tutorialName &&
!$ignoredTutorials.includes(index) &&
!tutorialInProgress()
)
}
const dispatch = createEventDispatcher<{
generateStep: { moduleId: string; instructions: string; lang: ScriptLang }
change: void
}>()
export function setExpr(module: FlowModule, expr: string) {
if (module.value.type == 'forloopflow') {
module.value.iterator = { type: 'javascript', expr }
module.value.parallel = true
}
}
let stepHistoryLoader = getStepHistoryLoaderContext()
async function loadLastJob(path: string, moduleId: string) {
if (!path) {
return
}
if (stepHistoryLoader) {
stepHistoryLoader.stepStates[moduleId] = {
initial: true,
loadingJobs: true
}
}
const previousJobId = await JobService.listJobs({
workspace: $workspaceStore!,
scriptPathExact: path,
jobKinds: ['preview', 'script', 'flowpreview', 'flow'].join(','),
page: 1,
perPage: 1
})
if (previousJobId.length > 0) {
const getJobResult = await JobService.getCompletedJobResultMaybe({
workspace: $workspaceStore!,
id: previousJobId[0].id
})
if ('result' in getJobResult) {
flowStateStore.val[moduleId] = {
...(flowStateStore.val[moduleId] ?? {}),
previewResult: getJobResult.result,
previewJobId: previousJobId[0].id,
previewSuccess: getJobResult.success
}
if (stepHistoryLoader) {
stepHistoryLoader.stepStates[moduleId].loadingJobs = false
}
}
}
}
$effect(() => {
sidebarMode == 'graph' ? (sidebarSize = 40) : (sidebarSize = 20)
})
</script>
<Portal name="flow-module">
<ConfirmationModal
title="Confirm deleting step with dependents"
confirmationText="Delete step"
open={Boolean(deleteCallback)}
on:confirmed={() => {
if (deleteCallback) {
deleteCallback()
deleteCallback = undefined
}
}}
on:canceled={() => {
deleteCallback = undefined
}}
>
<div class="text-primary pb-2"
>Found the following steps that will require changes after this step is deleted:</div
>
{#each Object.entries(dependents) as [k, v]}
<div class="pb-3">
<h3 class="text-secondary font-semibold">{k}</h3>
<ul class="text-sm">
{#each v as dep}
<li>{dep}</li>
{/each}
</ul>
</div>
{/each}
</ConfirmationModal>
</Portal>
<div class="flex flex-col h-full relative -pt-1">
<div
class={`z-50 absolute inline-flex flex-col gap-2 top-3 left-1/2 -translate-x-1/2 flex-initial items-center transition-colors duration-[400ms] ease-linear bg-surface-100`}
>
<FlowStickyNode
{showFlowAiButton}
{disableSettings}
{disableStaticInputs}
{smallErrorHandler}
on:generateStep
{aiChatOpen}
{toggleAiChat}
/>
</div>
<div class="z-10 flex-auto grow bg-surface-secondary" bind:clientHeight={minHeight}>
<FlowGraphV2
bind:this={graph}
earlyStop={flowStore.val.value?.skip_expr !== undefined}
cache={flowStore.val.value?.cache_ttl !== undefined}
triggerNode={customUi?.triggers != false}
path={$pathStore}
{newFlow}
{disableAi}
insertable
scroll
{minHeight}
moving={$moving?.id}
maxHeight={minHeight}
modules={flowStore.val.value.modules}
preprocessorModule={flowStore.val.value?.preprocessor_module}
{selectedId}
{workspace}
editMode
{onTestUpTo}
{onEditInput}
flowModuleStates={localModuleStates}
{testModuleStates}
{isOwner}
{individualStepTests}
{flowJob}
{showJobStatus}
suspendStatus={suspendStatus.val}
{flowHasChanged}
chatInputEnabled={Boolean(flowStore.val.value?.chat_input_enabled)}
onDelete={(id) => {
dependents = getDependentComponents(id, flowStore.val)
const cb = () => {
push(history, flowStore.val)
if (id === 'preprocessor') {
$selectedId = 'Input'
flowStore.val.value.preprocessor_module = undefined
} else {
selectNextId(id)
removeAtId(flowStore.val.value.modules, id)
}
refreshStateStore(flowStore)
onDelete?.(id)
delete flowStateStore.val[id]
}
if (Object.keys(dependents).length > 0) {
deleteCallback = cb
} else {
cb()
}
}}
onInsert={async (detail) => {
if (shouldRunTutorial('forloop', detail.detail, 1)) {
flowTutorials?.runTutorialById('forloop', detail.index)
} else if (shouldRunTutorial('branchone', detail.detail, 2)) {
flowTutorials?.runTutorialById('branchone')
} else if (shouldRunTutorial('branchall', detail.detail, 3)) {
flowTutorials?.runTutorialById('branchall')
} else {
let originalModules
let targetModules
if (
detail.sourceId == 'Input' ||
detail.targetId == 'result' ||
detail.kind == 'trigger'
) {
targetModules = flowStore.val.value.modules
}
dfs(flowStore.val.value.modules, (mod, modules, branches) => {
// console.log('mod', mod.id, $moving?.id, detail, branches)
if (mod.id == $moving?.id) {
originalModules = modules
}
if (detail.branch) {
if (mod.id == detail.branch.rootId) {
targetModules = branches[detail.branch.branch]
}
} else if (mod.id == detail.sourceId || mod.id == detail.targetId) {
targetModules = modules
} else if (mod.id == detail.agentId && mod.value.type === 'aiagent') {
targetModules = mod.value.tools
}
})
if (flowStore.val.value.modules && Array.isArray(flowStore.val.value.modules)) {
await tick()
if ($moving) {
// console.log('modules', modules, movingModules, movingModule)
push(history, flowStore.val)
let indexToRemove = originalModules.findIndex((m) => $moving?.id == m.id)
let [removedModule] = originalModules.splice(indexToRemove, 1)
targetModules.splice(detail.index, 0, removedModule)
$selectedId = removedModule.id
$moving = undefined
} else {
if (detail.isPreprocessor) {
await insertNewPreprocessorModule(
flowStore,
flowStateStore,
detail.inlineScript,
detail.script
)
$selectedId = 'preprocessor'
if (detail.inlineScript?.instructions) {
dispatch('generateStep', {
moduleId: 'preprocessor',
lang: detail.inlineScript?.language,
instructions: detail.inlineScript?.instructions
})
}
} else {
const index = (detail.agentId ? targetModules?.length : detail.index) ?? 0
await insertNewModuleAtIndex(
targetModules,
index,
detail.kind,
detail.script,
detail.flow,
detail.inlineScript
)
const id = targetModules[index].id
$selectedId = id
if (detail.inlineScript?.instructions) {
dispatch('generateStep', {
moduleId: id,
lang: detail.inlineScript?.language,
instructions: detail.inlineScript?.instructions
})
}
if (detail.kind == 'trigger') {
await insertNewModuleAtIndex(
targetModules,
index + 1,
'forloop',
undefined,
undefined,
undefined
)
setExpr(targetModules[index + 1], `results.${id}`)
setScheduledPollSchedule(triggersState, triggersCount)
}
if (detail.flow?.path) {
loadLastJob(detail.flow.path, id)
} else if (detail.script?.path) {
loadLastJob(detail.script?.path, id)
}
}
}
if (['branchone', 'branchall'].includes(detail.kind)) {
await addBranch(targetModules[detail.index ?? 0].id)
}
refreshStateStore(flowStore)
dispatch('change')
}
}
}}
onNewBranch={async (id) => {
if (id) {
await addBranch(id)
refreshStateStore(flowStore)
}
}}
onSelect={(id) => {
flowPropPickerConfig.set(undefined)
}}
onChangeId={(detail) => {
let { id, newId, deps } = detail
dfs(flowStore.val.value.modules, (mod) => {
if (deps[mod.id]) {
deps[mod.id].forEach((dep) => {
if (
mod.value.type == 'rawscript' ||
mod.value.type == 'script' ||
mod.value.type == 'flow'
) {
mod.value.input_transforms = Object.fromEntries(
Object.entries(mod.value.input_transforms).map(([k, v]) => {
if (v.type == 'javascript') {
return [k, { ...v, expr: replaceId(v.expr, id, newId) }]
} else {
return [k, v]
}
})
)
} else if (mod?.value?.type === 'forloopflow') {
if (mod.value.iterator.type === 'javascript') {
mod.value.iterator.expr = replaceId(mod.value.iterator.expr, id, newId)
}
} else if (mod?.value?.type === 'branchone') {
mod.value.branches.forEach((branch) => {
branch.expr = replaceId(branch.expr, id, newId)
})
}
})
}
if (mod.id == id) {
mod.id = newId
}
})
flowStateStore.val[newId] = flowStateStore.val[id]
delete flowStateStore.val[id]
refreshStateStore(flowStore)
$selectedId = newId
}}
onDeleteBranch={async ({ id, index }) => {
if (id) {
await removeBranch(id, index)
refreshStateStore(flowStore)
$selectedId = id
}
}}
onMove={(id) => {
if (!$moving || $moving.id !== id) {
$moving = { id }
} else {
$moving = undefined
}
}}
onUpdateMock={(detail) => {
let module = findModuleById(detail.id)
module.mock = $state.snapshot(detail.mock)
refreshStateStore(flowStore)
}}
{onTestFlow}
{isRunning}
{onCancelTestFlow}
{onOpenPreview}
{onHideJobStatus}
/>
</div>
</div>
{#if !disableTutorials}
<FlowTutorials bind:this={flowTutorials} on:reload />
{/if}