Files
windmill/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte
T
centdixandClaude 1afa36ceeb feat(aiagent): allow mcp as tools (#6790)
* draft mcp client

* testing

* fix

* cleaning

* mcp resource in inputtransforms

* cleaning

* big cleaning

* cleaning

* no arc

* add utils file

* refactor tools

* add mcp actions

* draft frontend

* send arguments from backend

* better frontend

* cleaning

* use token for auth

* add logo

* rm

* fix

* fix

* chore: refactor mcp for ai agents (#6829)

* Add Tool enum for AIAgent with backward compatibility

- Created Tool enum that can be either Windmill (FlowModule) or Mcp (resource reference)
- Created McpToolRef struct to hold MCP resource path
- Implemented custom Deserialize for Tool with backward compatibility:
  - New format: {type: 'windmill'|'mcp', ...}
  - Old format: FlowModule objects (automatically wrapped in Tool::Windmill)
- Updated AIAgent to use Vec<Tool> instead of Vec<FlowModule>
- Updated FlowValue::traverse_leafs to handle Tool enum
- Backward compatible: old flows with Vec<FlowModule> will deserialize correctly

* Refactor AI executor to process Tool enum instead of extracting MCP from input_transforms

- Separate Windmill tools and MCP resource paths from tools list
- Process Windmill FlowModules into Tool definitions
- Load MCP tools from resource paths in Tool::Mcp variants
- Remove old logic that extracted mcp_resources from input_transforms
- Import FlowModule, remove unused InputTransform
- Fix type issues: use .as_str() for path and handle Option<bool> properly

* handle in args

* mcp as flowmodule

* frontend

* config for mcp

* simplify logic

* fix ai executor logic

* cleaning

* clean frontend

* fix

* better resource picker

* fix and styling

* add endpoint to fetch tools

* apply tool filtering

* fix name validation

* better ui

* use cache

* fix

* fix merge

* refactor: Separate MCP tools from FlowModule in AIAgent

- Add new AgentTool, ToolValue, and McpToolValue types
- Update AIAgent to use Vec<AgentTool> instead of Vec<FlowModule>
- Implement From traits for clean conversion between AgentTool and FlowModule
- Add backward compatibility via custom deserializer for AgentTool
- Simplify resolve_module logic by reusing existing resolve_modules function
- Update traverse_leafs to handle AgentTool structure

This refactoring separates MCP tools from FlowModule tools, making the
type system clearer and eliminating the need to treat MCP servers as
a special case of FlowModule.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* refactor: Update ai_executor and worker_lockfiles for AgentTool

- Update ai_executor.rs to handle new AgentTool structure
  - Separate MCP tools from FlowModule tools using ToolValue enum
  - Convert AgentTool to FlowModule for backward compatibility
  - Add imports for AgentTool and ToolValue types

- Update worker_lockfiles.rs for lazy loading optimization
  - Convert AgentTool <-> FlowModule in insert_flow_modules
  - Preserve lazy loading for FlowModule tools via modules_node
  - Keep MCP tools inline (lightweight, no need for lazy loading)
  - Maintain backward compatibility with existing flows

This enables the lazy loading optimization for FlowModule tools while
keeping MCP tools inline, balancing performance and simplicity.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* cleaning

* adapt frontend

* cleaning

* cleaning

* type fix

* cleaning

* fix back comp

* move mcp button position

* nit

* cleaning

* fix nested removal

* cleaning

* opti

* fix chat markdown display

* fix chat messages layout

* fix back comp frontend

* fix deserializer

* nit

* simpler serializer

* use if else

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-21 15:28:52 +00:00

656 lines
18 KiB
Svelte

<script lang="ts">
import FlowModuleSchemaMap from '$lib/components/flows/map/FlowModuleSchemaMap.svelte'
import { getContext, untrack } from 'svelte'
import type { FlowCopilotContext } from '../../flow'
import type { ExtendedOpenFlow, FlowEditorContext } from '$lib/components/flows/types'
import { dfs } from '$lib/components/flows/previousResults'
import { dfs as dfsApply } from '$lib/components/flows/dfs'
import { getSubModules } from '$lib/components/flows/flowExplorer'
import type { FlowModule, OpenFlow } from '$lib/gen'
import { getIndexInNestedModules, getNestedModules } from './utils'
import type { AIModuleAction, FlowAIChatHelpers } from './core'
import {
insertNewFailureModule,
insertNewPreprocessorModule
} from '$lib/components/flows/flowStateUtils.svelte'
import { loadSchemaFromModule } from '$lib/components/flows/flowInfers'
import { aiChatManager } from '../AIChatManager.svelte'
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import type { AgentTool } from '$lib/components/flows/agentToolUtils'
let {
flowModuleSchemaMap
}: {
flowModuleSchemaMap: FlowModuleSchemaMap | undefined
} = $props()
const { flowStore, flowStateStore, selectedId, currentEditor } =
getContext<FlowEditorContext>('FlowEditorContext')
const { exprsToSet } = getContext<FlowCopilotContext | undefined>('FlowCopilotContext') ?? {}
let affectedModules: Record<
string,
{
action: AIModuleAction
}
> = $state({})
let lastSnapshot: ExtendedOpenFlow | undefined = $state(undefined)
let previewFlow = $derived.by(() => {
const flow = $state.snapshot(flowStore).val
if (Object.values(affectedModules).some((m) => m.action === 'removed')) {
dfsApply(flow.value.modules, (m, modules) => {
const action = affectedModules[m.id]?.action
if (action === 'removed') {
modules.splice(modules.indexOf(m), 1)
}
})
}
return flow
})
function setModuleStatus(id: string, action: AIModuleAction) {
const existingAction: AIModuleAction | undefined = affectedModules[id]?.action
if (existingAction === 'added' && action === 'modified') {
// means it was added but then edited => keep the action as added
action = 'added'
} else if (existingAction === 'added' && action === 'removed') {
delete affectedModules[id]
deleteStep(id)
return
} else if (existingAction === 'removed' && action === 'added') {
action = 'modified'
}
affectedModules[id] = {
action
}
}
function getModule(id: string, flow: OpenFlow = flowStore.val) {
if (id === 'preprocessor') {
return flow.value.preprocessor_module
} else if (id === 'failure') {
return flow.value.failure_module
} else {
return dfs(id, flow, false)[0]
}
}
const flowHelpers: FlowAIChatHelpers = {
// flow context
getFlowAndSelectedId: () => {
const flow = $state.snapshot(flowStore).val
return {
flow,
selectedId: $selectedId
}
},
// flow apply/reject
getPreviewFlow: () => {
return $state.snapshot(previewFlow)
},
hasDiff: () => {
return Object.keys(affectedModules).length > 0
},
acceptAllModuleActions() {
for (const id of Object.keys(affectedModules)) {
this.acceptModuleAction(id)
}
},
rejectAllModuleActions() {
// Do it in reverse to revert nested modules first then parents
const ids = Object.keys(affectedModules)
for (let i = ids.length - 1; i >= 0; i--) {
this.revertModuleAction(ids[i])
}
affectedModules = {}
},
setLastSnapshot: (snapshot) => {
lastSnapshot = snapshot
},
revertToSnapshot: (snapshot?: ExtendedOpenFlow) => {
affectedModules = {}
if (snapshot) {
flowStore.val = snapshot
refreshStateStore(flowStore)
if ($currentEditor) {
const module = getModule($currentEditor.stepId, snapshot)
if (module) {
if ($currentEditor.type === 'script' && module.value.type === 'rawscript') {
$currentEditor.editor.setCode(module.value.content)
} else if ($currentEditor.type === 'iterator' && module.value.type === 'forloopflow') {
$currentEditor.editor.setCode(
module.value.iterator.type === 'javascript' ? module.value.iterator.expr : ''
)
}
}
}
}
},
showModuleDiff(id: string) {
if (!lastSnapshot) {
return
}
const moduleLastSnapshot = id === 'Input' ? lastSnapshot.schema : getModule(id, lastSnapshot)
const currentModule = id === 'Input' ? flowStore.val.schema : getModule(id)
if (moduleLastSnapshot && currentModule) {
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'simple',
title: `Diff for ${id}`,
original: moduleLastSnapshot,
current: currentModule,
button: {
text: 'Accept',
onClick: () => {
diffDrawer?.closeDrawer()
this.acceptModuleAction(id)
}
}
})
}
},
getModuleAction: (id: string) => {
return affectedModules[id]?.action
},
revertModuleAction: (id: string) => {
{
const action = affectedModules[id]?.action
if (action && lastSnapshot) {
if (id === 'Input') {
flowStore.val.schema = lastSnapshot.schema
} else if (action === 'added') {
deleteStep(id)
} else if (action === 'modified') {
const oldModule = getModule(id, lastSnapshot)
if (!oldModule) {
throw new Error('Module not found')
}
const newModule = getModule(id)
if (!newModule) {
throw new Error('Module not found')
}
// Apply the old code to the editor and hide diff editor if the reverted module is a rawscript
if (
newModule.value.type === 'rawscript' &&
$currentEditor?.type === 'script' &&
$currentEditor.stepId === id
) {
const aiChatEditorHandler = $currentEditor.editor.getAiChatEditorHandler()
if (aiChatEditorHandler) {
aiChatEditorHandler.revertAll({ disableReviewCallback: true })
$currentEditor.hideDiffMode()
}
}
Object.keys(newModule).forEach((k) => delete newModule[k])
Object.assign(newModule, $state.snapshot(oldModule))
}
refreshStateStore(flowStore)
delete affectedModules[id]
}
}
},
acceptModuleAction: (id: string) => {
if (affectedModules[id]?.action === 'removed') {
deleteStep(id)
}
if (
affectedModules[id]?.action === 'modified' &&
$currentEditor &&
$currentEditor.type === 'script' &&
$currentEditor.stepId === id
) {
const aiChatEditorHandler = $currentEditor.editor.getAiChatEditorHandler()
if (aiChatEditorHandler) {
aiChatEditorHandler.keepAll({ disableReviewCallback: true })
}
}
delete affectedModules[id]
},
// ai chat tools
setCode: async (id, code) => {
const module = getModule(id)
if (!module) {
throw new Error('Module not found')
}
if (module.value.type === 'rawscript') {
module.value.content = code
const { input_transforms, schema } = await loadSchemaFromModule(module)
module.value.input_transforms = input_transforms
refreshStateStore(flowStore)
if (flowStateStore.val[id]) {
flowStateStore.val[id].schema = schema
} else {
flowStateStore.val[id] = {
schema
}
}
} else {
throw new Error('Module is not a rawscript or script')
}
if ($currentEditor && $currentEditor.type === 'script' && $currentEditor.stepId === id) {
$currentEditor.editor.setCode(code)
}
setModuleStatus(id, 'modified')
},
insertStep: async (location, step) => {
const { index, modules } =
location.type === 'start'
? {
index: -1,
modules: flowStore.val.value.modules
}
: location.type === 'start_inside_forloop'
? {
index: -1,
modules: getNestedModules(flowStore.val, location.inside)
}
: location.type === 'start_inside_branch'
? {
index: -1,
modules: getNestedModules(flowStore.val, location.inside, location.branchIndex)
}
: location.type === 'after'
? getIndexInNestedModules(flowStore.val, location.afterId)
: {
index: -1,
modules: flowStore.val.value.modules
}
const indexToInsertAt = index + 1
let newModules: FlowModule[] | AgentTool[] | undefined = undefined
switch (step.type) {
case 'rawscript': {
const inlineScript = {
language: step.language,
kind: 'script' as const,
subkind: 'flow' as const,
summary: step.summary
}
if (location.type === 'preprocessor') {
await insertNewPreprocessorModule(flowStore, flowStateStore, inlineScript)
} else if (location.type === 'failure') {
await insertNewFailureModule(flowStore, flowStateStore, inlineScript)
} else {
newModules = await flowModuleSchemaMap?.insertNewModuleAtIndex(
modules,
indexToInsertAt,
'script',
undefined,
undefined,
inlineScript
)
}
break
}
case 'script': {
const wsScript = {
path: step.path,
summary: '',
hash: undefined
}
if (location.type === 'preprocessor') {
await insertNewPreprocessorModule(flowStore, flowStateStore, undefined, wsScript)
} else if (location.type === 'failure') {
await insertNewFailureModule(flowStore, flowStateStore, undefined, wsScript)
} else {
newModules = await flowModuleSchemaMap?.insertNewModuleAtIndex(
modules,
indexToInsertAt,
'script',
wsScript
)
}
break
}
case 'forloop':
case 'branchall':
case 'branchone': {
if (location.type === 'preprocessor' || location.type === 'failure') {
throw new Error('Cannot insert a non-script module for preprocessing or error handling')
}
newModules = await flowModuleSchemaMap?.insertNewModuleAtIndex(
modules,
indexToInsertAt,
step.type
)
break
}
default: {
throw new Error('Unknown step type')
}
}
if (location.type === 'preprocessor' || location.type === 'failure') {
refreshStateStore(flowStore)
setModuleStatus(location.type, 'added')
return location.type
} else {
const newModule = newModules?.[indexToInsertAt]
if (!newModule) {
throw new Error('Failed to insert module')
}
if (['branchone', 'branchall'].includes(step.type)) {
await flowModuleSchemaMap?.addBranch(newModule.id)
}
refreshStateStore(flowStore)
setModuleStatus(newModule.id, 'added')
return newModule.id
}
},
removeStep: (id) => {
setModuleStatus(id, 'removed')
},
getStepInputs: async (id) => {
const module = getModule(id)
if (!module) {
throw new Error('Module not found')
}
const inputs =
module.value.type === 'script' || module.value.type === 'rawscript'
? module.value.input_transforms
: {}
return inputs
},
setStepInputs: async (id, inputs) => {
if (id === 'preprocessor') {
throw new Error('Cannot set inputs for preprocessor')
}
const regex = /\[\[(.+?)\]\]\s*\n([\s\S]*?)(?=\n\[\[|$)/g
const parsedInputs = Array.from(inputs.matchAll(regex)).map((match) => ({
input: match[1],
value: match[2].trim()
}))
if (id === $selectedId) {
exprsToSet?.set({})
const argsToUpdate = {}
for (const { input, value } of parsedInputs) {
argsToUpdate[input] = {
type: 'javascript',
expr: value
}
}
exprsToSet?.set(argsToUpdate)
} else {
const module = getModule(id)
if (!module) {
throw new Error('Module not found')
}
if (module.value.type !== 'script' && module.value.type !== 'rawscript') {
throw new Error('Module is not a script or rawscript')
}
for (const { input, value } of parsedInputs) {
module.value.input_transforms[input] = {
type: 'javascript',
expr: value
}
}
refreshStateStore(flowStore)
}
setModuleStatus(id, 'modified')
},
getFlowInputsSchema: async () => {
return flowStore.val.schema ?? {}
},
setFlowInputsSchema: async (newInputs) => {
flowStore.val.schema = newInputs
setModuleStatus('Input', 'modified')
},
selectStep: (id) => {
$selectedId = id
},
getStepCode: (id) => {
const module = getModule(id)
if (!module) {
throw new Error('Module not found')
}
if (module.value.type === 'rawscript') {
return module.value.content
} else {
throw new Error('Module is not a rawscript')
}
},
getModules: (id?: string) => {
if (id) {
const module = getModule(id)
if (!module) {
throw new Error('Module not found')
}
return getSubModules(module).flat()
}
return flowStore.val.value.modules
},
setBranchPredicate: async (id, branchIndex, expression) => {
const module = getModule(id)
if (!module) {
throw new Error('Module not found')
}
if (module.value.type !== 'branchone') {
throw new Error('Module is not a branchall or branchone')
}
const branch = module.value.branches[branchIndex]
if (!branch) {
throw new Error('Branch not found')
}
branch.expr = expression
refreshStateStore(flowStore)
setModuleStatus(id, 'modified')
},
addBranch: async (id) => {
flowModuleSchemaMap?.addBranch(id)
refreshStateStore(flowStore)
setModuleStatus(id, 'modified')
},
removeBranch: async (id, branchIndex) => {
const module = getModule(id)
if (!module) {
throw new Error('Module not found')
}
if (module.value.type !== 'branchall' && module.value.type !== 'branchone') {
throw new Error('Module is not a branchall or branchone')
}
// for branch one, we set index + 1 because the removeBranch function assumes the index is shifted by 1 because of the default branch
flowModuleSchemaMap?.removeBranch(
module.id,
module.value.type === 'branchone' ? branchIndex + 1 : branchIndex
)
refreshStateStore(flowStore)
setModuleStatus(id, 'modified')
},
setForLoopIteratorExpression: async (id, expression) => {
if ($currentEditor && $currentEditor.type === 'iterator' && $currentEditor.stepId === id) {
$currentEditor.editor.setCode(expression)
} else {
const module = getModule(id)
if (!module) {
throw new Error('Module not found')
}
if (module.value.type !== 'forloopflow') {
throw new Error('Module is not a forloopflow')
}
module.value.iterator = { type: 'javascript', expr: expression }
refreshStateStore(flowStore)
}
setModuleStatus(id, 'modified')
},
setForLoopOptions: async (id, opts) => {
const module = getModule(id)
if (!module) {
throw new Error('Module not found')
}
if (module.value.type !== 'forloopflow') {
throw new Error('Module is not a forloopflow')
}
// Apply skip_failures if provided
if (typeof opts.skip_failures === 'boolean') {
module.value.skip_failures = opts.skip_failures
}
// Apply parallel if provided
if (typeof opts.parallel === 'boolean') {
module.value.parallel = opts.parallel
}
// Handle parallelism
if (opts.parallel === false) {
// If parallel is disabled, clear parallelism
module.value.parallelism = undefined
} else if (opts.parallelism !== undefined) {
if (opts.parallelism === null) {
// Explicitly clear parallelism
module.value.parallelism = undefined
} else if (module.value.parallel || opts.parallel === true) {
// Only set parallelism if parallel is enabled
const n = Math.max(1, Math.floor(Math.abs(opts.parallelism)))
module.value.parallelism = {
type: 'static',
value: n
}
}
}
refreshStateStore(flowStore)
setModuleStatus(id, 'modified')
},
setModuleControlOptions: async (id, opts) => {
const module = getModule(id)
if (!module) {
throw new Error('Module not found')
}
// Handle stop_after_if
if (typeof opts.stop_after_if === 'boolean') {
if (opts.stop_after_if === false) {
module.stop_after_if = undefined
} else {
module.stop_after_if = {
expr: opts.stop_after_if_expr ?? '',
skip_if_stopped: opts.stop_after_if
}
}
}
// Handle skip_if
if (typeof opts.skip_if === 'boolean') {
if (opts.skip_if === false) {
module.skip_if = undefined
} else {
module.skip_if = {
expr: opts.skip_if_expr ?? ''
}
}
}
refreshStateStore(flowStore)
setModuleStatus(id, 'modified')
}
}
function deleteStep(id: string) {
flowModuleSchemaMap?.selectNextId(id)
if (id === 'preprocessor') {
flowStore.val.value.preprocessor_module = undefined
} else if (id === 'failure') {
flowStore.val.value.failure_module = undefined
} else {
const { modules } = getIndexInNestedModules(flowStore.val, id)
flowModuleSchemaMap?.removeAtId(modules, id)
}
refreshStateStore(flowStore)
}
const allModuleIds = $derived(dfsApply(flowStore.val.value.modules, (m) => m.id))
$effect(() => {
// remove any affected modules that are no longer in the flow
const untrackedAffectedModules = untrack(() => affectedModules)
for (const id of Object.keys(untrackedAffectedModules)) {
if (!allModuleIds.includes(id)) {
delete affectedModules[id]
}
}
})
$effect(() => {
const cleanup = aiChatManager.setFlowHelpers(flowHelpers)
return cleanup
})
$effect(() => {
const cleanup = aiChatManager.listenForSelectedIdChanges(
$selectedId,
flowStore.val,
flowStateStore.val,
$currentEditor
)
return cleanup
})
$effect(() => {
const cleanup = aiChatManager.listenForCurrentEditorChanges($currentEditor)
return cleanup
})
// Automatically show revert review when selecting a rawscript module with pending changes
$effect(() => {
if (
$currentEditor?.type === 'script' &&
$selectedId &&
affectedModules[$selectedId] &&
$currentEditor.editor.getAiChatEditorHandler()
) {
const moduleLastSnapshot = getModule($selectedId, lastSnapshot)
const content =
moduleLastSnapshot?.value.type === 'rawscript' ? moduleLastSnapshot.value.content : ''
if (content.length > 0) {
untrack(() =>
$currentEditor.editor.reviewAppliedCode(content, {
onFinishedReview: () => {
const id = $selectedId
flowHelpers.acceptModuleAction(id)
$currentEditor.hideDiffMode()
}
})
)
}
}
})
let diffDrawer: DiffDrawer | undefined = $state(undefined)
</script>
<DiffDrawer bind:this={diffDrawer} />