diff --git a/frontend/src/lib/components/flows/agentToolTree.test.ts b/frontend/src/lib/components/flows/agentToolTree.test.ts new file mode 100644 index 0000000000..f9b4625bda --- /dev/null +++ b/frontend/src/lib/components/flows/agentToolTree.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('../aiProviderStorage', () => ({ + loadStoredConfig: () => undefined +})) + +vi.mock('./flowInfers', () => ({ + AI_AGENT_SCHEMA: { properties: {} } +})) + +import type { FlowModule } from '$lib/gen' +import { + collectFlowNodeIds, + findAgentToolOwner, + removeAgentToolOwner +} from './agentToolTree' + +function makeRawModule(id: string): FlowModule { + return { + id, + summary: id, + value: { type: 'rawscript', content: '', language: 'python3', input_transforms: {} } as any + } as FlowModule +} + +function makeAiAgent(id: string, tools: any[]): FlowModule { + return { + id, + summary: id, + value: { + type: 'aiagent', + tools, + input_transforms: {} + } as any + } as FlowModule +} + +function makeFlowModuleTool(module: FlowModule) { + return { + id: module.id, + summary: module.summary, + value: { + tool_type: 'flowmodule', + ...module.value + } + } +} + +describe('findAgentToolOwner', () => { + it('finds a direct tool owner in an ai agent', () => { + const rootAgent = makeAiAgent('root_agent', [makeFlowModuleTool(makeRawModule('lookup_user'))]) + + expect(findAgentToolOwner([rootAgent], 'lookup_user')).toMatchObject({ + agentId: 'root_agent', + toolIndex: 0, + depth: 1 + }) + }) + + it('finds a nested tool owner inside a nested ai agent tool', () => { + const nestedAgent = makeAiAgent('support_agent', [makeFlowModuleTool(makeRawModule('create_ticket'))]) + const rootAgent = makeAiAgent('root_agent', [makeFlowModuleTool(nestedAgent)]) + + expect(findAgentToolOwner([rootAgent], 'create_ticket')).toMatchObject({ + agentId: 'support_agent', + toolIndex: 0, + depth: 2 + }) + }) +}) + +describe('removeAgentToolOwner', () => { + it('removes the matched tool and returns its subtree ids', () => { + const nestedAgent = makeAiAgent('support_agent', [makeFlowModuleTool(makeRawModule('create_ticket'))]) + const rootAgent = makeAiAgent('root_agent', [ + makeFlowModuleTool(makeRawModule('lookup_user')), + makeFlowModuleTool(nestedAgent) + ]) + const owner = findAgentToolOwner([rootAgent], 'support_agent') + + expect(owner).toBeDefined() + expect(removeAgentToolOwner(owner!)).toEqual({ + tool: expect.objectContaining({ id: 'support_agent' }), + removedIds: ['support_agent', 'create_ticket'] + }) + expect((rootAgent.value as any).tools).toHaveLength(1) + expect(((rootAgent.value as any).tools as any[]).map((tool) => tool.id)).toEqual(['lookup_user']) + }) +}) + +describe('collectFlowNodeIds', () => { + it('includes ai agent tool ids when deleting an ai agent flow module', () => { + const agent = makeAiAgent('root_agent', [ + makeFlowModuleTool(makeRawModule('lookup_user')), + makeFlowModuleTool(makeAiAgent('support_agent', [makeFlowModuleTool(makeRawModule('create_ticket'))])) + ]) + + expect(collectFlowNodeIds(agent)).toEqual([ + 'root_agent', + 'lookup_user', + 'support_agent', + 'create_ticket' + ]) + }) +}) diff --git a/frontend/src/lib/components/flows/agentToolTree.ts b/frontend/src/lib/components/flows/agentToolTree.ts new file mode 100644 index 0000000000..c00fac73d7 --- /dev/null +++ b/frontend/src/lib/components/flows/agentToolTree.ts @@ -0,0 +1,162 @@ +import type { FlowModule } from '$lib/gen' +import { isFlowModuleTool, type AgentTool, type FlowModuleTool } from './agentToolUtils' + +type FlowNodeLike = Pick + +export type AgentToolOwner = { + agentId: string + tools: AgentTool[] + toolIndex: number + tool: AgentTool + depth: number +} + +export type RemovedAgentTool = { + tool: AgentTool + removedIds: string[] +} + +export function findAgentToolOwner( + modules: FlowModule[], + toolId: string +): AgentToolOwner | undefined { + return findAgentToolOwnerInModules(modules, toolId, 0) +} + +export function removeAgentToolOwner(owner: AgentToolOwner): RemovedAgentTool | undefined { + const candidate = owner.tools[owner.toolIndex] + if (!candidate || candidate.id !== owner.tool.id) { + return undefined + } + + owner.tools.splice(owner.toolIndex, 1) + return { + tool: candidate, + removedIds: collectAgentToolIds(candidate) + } +} + +export function collectFlowNodeIds(module: FlowModule): string[] { + return collectFlowNodeIdsFromNode(module) +} + +export function collectAgentToolIds(tool: AgentTool): string[] { + return collectFlowNodeIdsFromNode(tool as FlowModuleTool) +} + +function findAgentToolOwnerInModules( + modules: FlowModule[], + toolId: string, + depth: number +): AgentToolOwner | undefined { + for (const module of modules) { + const owner = findAgentToolOwnerInNode(module, toolId, depth) + if (owner) { + return owner + } + } + + return undefined +} + +function findAgentToolOwnerInNode( + node: FlowNodeLike, + toolId: string, + depth: number +): AgentToolOwner | undefined { + if (node.value.type === 'forloopflow' || node.value.type === 'whileloopflow') { + return findAgentToolOwnerInModules(node.value.modules, toolId, depth) + } + + if (node.value.type === 'branchall') { + for (const branch of node.value.branches) { + const owner = findAgentToolOwnerInModules(branch.modules, toolId, depth) + if (owner) { + return owner + } + } + return undefined + } + + if (node.value.type === 'branchone') { + const defaultOwner = findAgentToolOwnerInModules(node.value.default, toolId, depth) + if (defaultOwner) { + return defaultOwner + } + for (const branch of node.value.branches) { + const owner = findAgentToolOwnerInModules(branch.modules, toolId, depth) + if (owner) { + return owner + } + } + return undefined + } + + if (node.value.type !== 'aiagent') { + return undefined + } + + const toolIndex = node.value.tools.findIndex((tool) => tool.id === toolId) + if (toolIndex !== -1) { + return { + agentId: node.id, + tools: node.value.tools, + toolIndex, + tool: node.value.tools[toolIndex], + depth: depth + 1 + } + } + + for (const tool of node.value.tools) { + if (!isFlowModuleTool(tool)) { + continue + } + + const owner = findAgentToolOwnerInNode(tool as FlowNodeLike, toolId, depth + 1) + if (owner) { + return owner + } + } + + return undefined +} + +function collectFlowNodeIdsFromNode(node: FlowNodeLike): string[] { + const ids = [node.id] + + if (node.value.type === 'forloopflow' || node.value.type === 'whileloopflow') { + for (const module of node.value.modules) { + ids.push(...collectFlowNodeIds(module)) + } + return ids + } + + if (node.value.type === 'branchall') { + for (const branch of node.value.branches) { + for (const module of branch.modules) { + ids.push(...collectFlowNodeIds(module)) + } + } + return ids + } + + if (node.value.type === 'branchone') { + for (const module of node.value.default) { + ids.push(...collectFlowNodeIds(module)) + } + for (const branch of node.value.branches) { + for (const module of branch.modules) { + ids.push(...collectFlowNodeIds(module)) + } + } + return ids + } + + if (node.value.type === 'aiagent') { + for (const tool of node.value.tools) { + ids.push(...collectAgentToolIds(tool)) + } + } + + return ids +} diff --git a/frontend/src/lib/components/flows/agentToolUtils.test.ts b/frontend/src/lib/components/flows/agentToolUtils.test.ts deleted file mode 100644 index 3b31fd3bc8..0000000000 --- a/frontend/src/lib/components/flows/agentToolUtils.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -vi.mock('../aiProviderStorage', () => ({ - loadStoredConfig: () => undefined -})) - -vi.mock('./flowInfers', () => ({ - AI_AGENT_SCHEMA: { properties: {} } -})) - -import type { FlowModule } from '$lib/gen' -import { removeAgentToolByIdDeep } from './agentToolUtils' - -function makeRawModule(id: string): FlowModule { - return { - id, - summary: id, - value: { type: 'rawscript', content: '', language: 'python3' } as any - } as FlowModule -} - -function makeAiAgent(id: string, tools: any[]): FlowModule { - return { - id, - summary: id, - value: { - type: 'aiagent', - tools, - input_transforms: {} - } as any - } as FlowModule -} - -function makeFlowModuleTool(module: FlowModule) { - return { - id: module.id, - summary: module.summary, - value: { - tool_type: 'flowmodule', - ...module.value - } - } -} - -describe('removeAgentToolByIdDeep', () => { - it('removes a direct tool from an ai agent', () => { - const tool = makeFlowModuleTool(makeRawModule('lookup_user')) - const agent = makeAiAgent('agent', [tool]) - const removed: string[] = [] - - expect(removeAgentToolByIdDeep([agent], 'lookup_user', (x) => removed.push(x.id))).toBe(true) - expect((agent.value as any).tools).toEqual([]) - expect(removed).toEqual(['lookup_user']) - }) - - it('removes a nested tool from a nested ai agent tool', () => { - const nestedTool = makeFlowModuleTool(makeRawModule('create_ticket')) - const nestedAgent = makeAiAgent('support_agent', [nestedTool]) - const rootAgent = makeAiAgent('root_agent', [makeFlowModuleTool(nestedAgent)]) - const removed: string[] = [] - - expect(removeAgentToolByIdDeep([rootAgent], 'create_ticket', (x) => removed.push(x.id))).toBe( - true - ) - expect((((rootAgent.value as any).tools as any[])[0].value as any).tools).toEqual([]) - expect(removed).toEqual(['create_ticket']) - }) - - it('returns false when the tool does not exist', () => { - const agent = makeAiAgent('agent', [makeFlowModuleTool(makeRawModule('lookup_user'))]) - const removed: string[] = [] - - expect(removeAgentToolByIdDeep([agent], 'missing_tool', (x) => removed.push(x.id))).toBe(false) - expect((agent.value as any).tools).toHaveLength(1) - expect(removed).toEqual([]) - }) -}) diff --git a/frontend/src/lib/components/flows/agentToolUtils.ts b/frontend/src/lib/components/flows/agentToolUtils.ts index 104c36bafc..84e102ce56 100644 --- a/frontend/src/lib/components/flows/agentToolUtils.ts +++ b/frontend/src/lib/components/flows/agentToolUtils.ts @@ -108,66 +108,6 @@ export function createWebsearchTool(id: string): WebsearchTool { } } -/** - * Remove an AI agent tool by id, recursively traversing nested modules and nested AI agents. - * Returns true when a matching tool was found and removed. - */ -export function removeAgentToolByIdDeep( - modules: FlowModule[], - id: string, - onRemove?: (tool: AgentTool) => void -): boolean { - for (const module of modules) { - if (module.value.type === 'forloopflow' || module.value.type === 'whileloopflow') { - if (removeAgentToolByIdDeep(module.value.modules, id, onRemove)) { - return true - } - continue - } - - if (module.value.type === 'branchall') { - for (const branch of module.value.branches) { - if (removeAgentToolByIdDeep(branch.modules, id, onRemove)) { - return true - } - } - continue - } - - if (module.value.type === 'branchone') { - if (removeAgentToolByIdDeep(module.value.default, id, onRemove)) { - return true - } - for (const branch of module.value.branches) { - if (removeAgentToolByIdDeep(branch.modules, id, onRemove)) { - return true - } - } - continue - } - - if (module.value.type !== 'aiagent') { - continue - } - - const toolIndex = module.value.tools.findIndex((tool) => tool.id === id) - if (toolIndex !== -1) { - const [removed] = module.value.tools.splice(toolIndex, 1) - onRemove?.(removed) - return true - } - - const nestedToolModules = module.value.tools - .filter(isFlowModuleTool) - .map((tool) => agentToolToFlowModule(tool)) - if (removeAgentToolByIdDeep(nestedToolModules, id, onRemove)) { - return true - } - } - - return false -} - /** * Convert a FlowModuleTool to a FlowModule for use with loadFlowModuleState etc. * Strips the extra `tool_type` field and maps AgentTool fields to FlowModule fields. diff --git a/frontend/src/lib/components/flows/flowDeleteUtils.test.ts b/frontend/src/lib/components/flows/flowDeleteUtils.test.ts index 9d77446d86..f878d3f47f 100644 --- a/frontend/src/lib/components/flows/flowDeleteUtils.test.ts +++ b/frontend/src/lib/components/flows/flowDeleteUtils.test.ts @@ -8,15 +8,33 @@ vi.mock('./flowInfers', () => ({ AI_AGENT_SCHEMA: { properties: {} } })) -import type { FlowModule } from '$lib/gen' +import type { FlowModule, OpenFlow } from '$lib/gen' import type { FlowStructureNode } from '$lib/components/graph/flowStructure' -import { partitionDeleteTargets, removeToolIds } from './flowDeleteUtils' +import { GroupDisplayState } from '$lib/components/graph/groupEditor.svelte' +import type { GroupedModulesProxy } from '$lib/components/graph/groupedModulesProxy.svelte' +import { + createDeletePlan, + removeDeletePlanTools, + resolveDeleteTargets +} from './flowDeleteUtils' -function makeRawModule(id: string): FlowModule { +function makeRawModule(id: string, expr?: string): FlowModule { return { id, summary: id, - value: { type: 'rawscript', content: '', language: 'python3' } as any + value: { + type: 'rawscript', + content: '', + language: 'python3', + input_transforms: expr + ? { + user_input: { + type: 'javascript', + expr + } + } + : {} + } as any } as FlowModule } @@ -43,39 +61,88 @@ function makeFlowModuleTool(module: FlowModule) { } } -describe('partitionDeleteTargets', () => { - it('splits structure nodes from AI tool ids in one pass', () => { - const tree: FlowStructureNode[] = [ - { id: 'step_a', kind: 'leaf', branches: [] }, - { - id: 'loop', - kind: 'forloopflow', - branches: [{ children: [{ id: 'nested_step', kind: 'leaf', branches: [] }] }] - } - ] - - expect(partitionDeleteTargets(tree, ['step_a', 'tool_x', 'nested_step'])).toEqual({ - structureIds: ['step_a', 'nested_step'], - toolIds: ['tool_x'] - }) - }) -}) - -describe('removeToolIds', () => { - it('returns only the ids that were actually removed', () => { +describe('resolveDeleteTargets', () => { + it('resolves structure nodes, preprocessor, and ai tools while pruning nested descendants', () => { const rootAgent = makeAiAgent('root_agent', [ makeFlowModuleTool(makeRawModule('lookup_user')), - makeFlowModuleTool(makeAiAgent('nested_agent', [makeFlowModuleTool(makeRawModule('create_ticket'))])) + makeFlowModuleTool(makeAiAgent('support_agent', [makeFlowModuleTool(makeRawModule('create_ticket'))])) ]) - const removed: string[] = [] + const tree: FlowStructureNode[] = [{ id: 'root_agent', kind: 'leaf', branches: [] }] - expect( - removeToolIds([rootAgent], ['lookup_user', 'missing_tool', 'create_ticket'], (tool) => { - removed.push(tool.id) - }) - ).toEqual(['lookup_user', 'create_ticket']) - expect((rootAgent.value as any).tools).toHaveLength(1) - expect((((rootAgent.value as any).tools as any[])[0].value as any).tools).toEqual([]) - expect(removed).toEqual(['lookup_user', 'create_ticket']) + const { targets, missingIds } = resolveDeleteTargets( + tree, + [rootAgent], + ['preprocessor', 'root_agent', 'create_ticket', 'missing_tool'], + true + ) + + expect(targets.map((target) => target.kind)).toEqual(['preprocessor', 'structure_node']) + expect(targets[1].stateIds).toEqual([ + 'root_agent', + 'lookup_user', + 'support_agent', + 'create_ticket' + ]) + expect(missingIds).toEqual(['missing_tool']) + }) +}) + +describe('createDeletePlan', () => { + it('collects subtree dependents and routes structure deletes through the proxy helper', () => { + const agent = makeAiAgent('agent_step', [makeFlowModuleTool(makeRawModule('lookup_user'))]) + const dependent = makeRawModule('dependent_step', 'results.lookup_user?.value') + const flow: OpenFlow = { + summary: 'Flow', + value: { + modules: [agent, dependent] + } + } + const tree: FlowStructureNode[] = [ + { id: 'agent_step', kind: 'leaf', branches: [] }, + { id: 'dependent_step', kind: 'leaf', branches: [] } + ] + const commit = vi.fn() + const prepareDelete = vi.fn(() => ({ + removedIds: ['agent_step'], + affectedGroups: [], + duplicateGroups: [], + commit + })) + const proxy = { + prepareDelete + } as unknown as GroupedModulesProxy + + const plan = createDeletePlan({ + ids: ['agent_step'], + flow, + tree, + proxy, + displayState: new GroupDisplayState(() => []) + }) + + expect(prepareDelete).toHaveBeenCalledWith(['agent_step'], expect.any(Object)) + expect(plan?.stateIds).toEqual(['agent_step', 'lookup_user']) + expect(plan?.dependents).toEqual({ + dependent_step: ['results.lookup_user?.value'] + }) + expect(plan?.selection).toEqual({ kind: 'select', id: 'dependent_step' }) + }) +}) + +describe('removeDeletePlanTools', () => { + it('removes nested tools before their parents and returns every removed id', () => { + const rootAgent = makeAiAgent('root_agent', [ + makeFlowModuleTool(makeAiAgent('support_agent', [makeFlowModuleTool(makeRawModule('create_ticket'))])) + ]) + const tree: FlowStructureNode[] = [] + const { targets } = resolveDeleteTargets( + tree, + [rootAgent], + ['support_agent', 'create_ticket'], + false + ) + + expect(removeDeletePlanTools(targets)).toEqual(['support_agent', 'create_ticket']) + expect((rootAgent.value as any).tools).toEqual([]) }) }) diff --git a/frontend/src/lib/components/flows/flowDeleteUtils.ts b/frontend/src/lib/components/flows/flowDeleteUtils.ts index c851cce2ff..b9534631f1 100644 --- a/frontend/src/lib/components/flows/flowDeleteUtils.ts +++ b/frontend/src/lib/components/flows/flowDeleteUtils.ts @@ -1,51 +1,267 @@ -import type { FlowModule } from '$lib/gen' +import type { GroupDisplayState } from '$lib/components/graph/groupEditor.svelte' +import type { + GroupedModulesProxy, + PreparedStructureDelete +} from '$lib/components/graph/groupedModulesProxy.svelte' import { findInStructure, type FlowStructureNode } from '$lib/components/graph/flowStructure' +import type { FlowModule, OpenFlow } from '$lib/gen' +import { + collectAgentToolIds, + collectFlowNodeIds, + findAgentToolOwner, + removeAgentToolOwner, + type AgentToolOwner +} from './agentToolTree' import type { AgentTool } from './agentToolUtils' -import { removeAgentToolByIdDeep } from './agentToolUtils' +import { dfs } from './dfs' +import { getDependentComponents } from './flowExplorer' +import { dfsByModule } from './previousResults' -export type DeleteTargetPartition = { - structureIds: string[] - toolIds: string[] +export type DeleteSelection = + | { kind: 'clear' } + | { + kind: 'select' + id: string + } + +type DeleteTargetBase = { + id: string + stateIds: string[] } -/** - * Split delete targets between structure-tree nodes and AI agent tool nodes. - * AI tools are rendered in the graph but are not represented in the grouped structure tree. - */ -export function partitionDeleteTargets( +export type PreprocessorDeleteTarget = DeleteTargetBase & { + kind: 'preprocessor' +} + +export type StructureDeleteTarget = DeleteTargetBase & { + kind: 'structure_node' +} + +export type AgentToolDeleteTarget = DeleteTargetBase & { + kind: 'agent_tool' + owner: AgentToolOwner +} + +export type DeleteTarget = + | PreprocessorDeleteTarget + | StructureDeleteTarget + | AgentToolDeleteTarget + +export type DeletePlan = { + inputIds: string[] + targets: DeleteTarget[] + stateIds: string[] + dependents: Record + selection: DeleteSelection + structureDelete?: PreparedStructureDelete + removeDuplicates: boolean +} + +export type ResolvedDeleteTargets = { + targets: DeleteTarget[] + missingIds: string[] +} + +export function resolveDeleteTargets( tree: FlowStructureNode[], - ids: string[] -): DeleteTargetPartition { - const structureIds: string[] = [] - const toolIds: string[] = [] - - for (const id of ids) { - if (findInStructure(tree, id)) { - structureIds.push(id) - } else { - toolIds.push(id) - } - } - - return { structureIds, toolIds } -} - -/** - * Remove AI agent tools by id and return the ids that were actually removed. - */ -export function removeToolIds( modules: FlowModule[], ids: string[], + hasPreprocessor: boolean +): ResolvedDeleteTargets { + const targets: DeleteTarget[] = [] + const missingIds: string[] = [] + const seenIds = new Set() + + for (const id of ids) { + if (seenIds.has(id)) { + continue + } + seenIds.add(id) + + if (id === 'preprocessor') { + if (hasPreprocessor) { + targets.push({ kind: 'preprocessor', id, stateIds: [id] }) + } else { + missingIds.push(id) + } + continue + } + + if (findInStructure(tree, id)) { + const module = findFlowModuleById(id, modules) + if (module) { + targets.push({ + kind: 'structure_node', + id, + stateIds: collectFlowNodeIds(module) + }) + continue + } + } + + const owner = findAgentToolOwner(modules, id) + if (owner) { + targets.push({ + kind: 'agent_tool', + id, + owner, + stateIds: collectAgentToolIds(owner.tool) + }) + continue + } + + missingIds.push(id) + } + + return { + targets: pruneNestedTargets(targets), + missingIds + } +} + +export function createDeletePlan(args: { + ids: string[] + flow: OpenFlow + tree: FlowStructureNode[] + proxy: GroupedModulesProxy + displayState: GroupDisplayState +}): DeletePlan | undefined { + const { targets } = resolveDeleteTargets( + args.tree, + args.flow.value.modules, + args.ids, + Boolean(args.flow.value.preprocessor_module) + ) + + if (targets.length === 0) { + return undefined + } + + const structureIds = targets + .filter((target): target is StructureDeleteTarget => target.kind === 'structure_node') + .map((target) => target.id) + + const structureDelete = + structureIds.length > 0 + ? args.proxy.prepareDelete(structureIds, { displayState: args.displayState }) + : undefined + + const stateIds = uniqueIds(targets.flatMap((target) => target.stateIds)) + + return { + inputIds: args.ids, + targets, + stateIds, + dependents: collectDeleteDependents(stateIds, args.flow), + selection: getDeleteSelection(args.ids, stateIds, args.flow.value.modules), + structureDelete, + removeDuplicates: Boolean(structureDelete && structureDelete.duplicateGroups.length > 0) + } +} + +export function removeDeletePlanTools( + targets: DeleteTarget[], onRemove?: (tool: AgentTool) => void ): string[] { const removedIds = new Set() - - for (const id of ids) { - removeAgentToolByIdDeep(modules, id, (tool) => { - removedIds.add(tool.id) - onRemove?.(tool) + const toolTargets = targets + .filter((target): target is AgentToolDeleteTarget => target.kind === 'agent_tool') + .sort((left, right) => { + if (left.owner.depth !== right.owner.depth) { + return right.owner.depth - left.owner.depth + } + if (left.owner.tools === right.owner.tools) { + return right.owner.toolIndex - left.owner.toolIndex + } + return 0 }) + + for (const target of toolTargets) { + const removed = removeAgentToolOwner(target.owner) + if (!removed) { + continue + } + + onRemove?.(removed.tool) + for (const id of removed.removedIds) { + removedIds.add(id) + } } return [...removedIds] } + +function findFlowModuleById(id: string, modules: FlowModule[]): FlowModule | undefined { + return dfsByModule(id, modules)[0] +} + +function pruneNestedTargets(targets: DeleteTarget[]): DeleteTarget[] { + const descendantIds = new Set() + + for (const target of targets) { + for (const stateId of target.stateIds) { + if (stateId !== target.id) { + descendantIds.add(stateId) + } + } + } + + return targets.filter((target) => !descendantIds.has(target.id)) +} + +function collectDeleteDependents(ids: string[], flow: OpenFlow): Record { + const deletingSet = new Set(ids) + const dependents: Record = {} + + for (const id of ids) { + const dependencies = getDependentComponents(id, flow) + for (const [dependentId, expressions] of Object.entries(dependencies)) { + if (deletingSet.has(dependentId)) { + continue + } + + dependents[dependentId] = [...(dependents[dependentId] ?? []), ...expressions] + } + } + + return dependents +} + +function getDeleteSelection( + ids: string[], + deletedIds: string[], + modules: FlowModule[] +): DeleteSelection { + if (ids.length !== 1) { + return { kind: 'clear' } + } + + const [id] = ids + if (id === 'preprocessor') { + return { kind: 'select', id: 'Input' } + } + + const orderedIds = dfs(modules, (module) => module.id) + const index = orderedIds.indexOf(id) + const deletedSet = new Set(deletedIds) + + for (let i = index - 1; i >= 0; i--) { + if (!deletedSet.has(orderedIds[i])) { + return { kind: 'select', id: orderedIds[i] } + } + } + for (let i = index + 1; i < orderedIds.length; i++) { + if (!deletedSet.has(orderedIds[i])) { + return { kind: 'select', id: orderedIds[i] } + } + } + if (index === -1) { + return { kind: 'select', id: 'settings-metadata' } + } + + return { kind: 'select', id: 'settings-metadata' } +} + +function uniqueIds(ids: string[]): string[] { + return [...new Set(ids)] +} diff --git a/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte b/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte index 82e5803823..95ff9ae83f 100644 --- a/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte +++ b/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte @@ -23,7 +23,7 @@ import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' import Portal from '$lib/components/Portal.svelte' - import { getAllModules, getDependentComponents } from '../flowExplorer' + import { getAllModules } from '../flowExplorer' import { locateModules, groupByParent } from '../multiSelectUtils' import { workspaceStore } from '$lib/stores' import { copilotInfo } from '$lib/aiStore' @@ -53,18 +53,24 @@ agentToolToFlowModule } from '../agentToolUtils' import { loadFlowModuleState } from '../flowStateUtils.svelte' - import { partitionDeleteTargets, removeToolIds } from '../flowDeleteUtils' + import { + createDeletePlan, + removeDeletePlanTools, + type DeletePlan + } from '../flowDeleteUtils' import { getNoteEditorContext } from '$lib/components/graph/noteEditor.svelte' import { GroupedModulesProxy, type ExtendedOpenFlow } from '$lib/components/graph/groupedModulesProxy.svelte' - import { GroupDisplayState } from '$lib/components/graph/groupEditor.svelte' + import { + GroupDisplayState, + type FlowGroup + } from '$lib/components/graph/groupEditor.svelte' import { type FlowStructureNode, matchStructureNode, dfsStructure, - findInStructure, moduleToStructureNode } from '$lib/components/graph/flowStructure' @@ -259,18 +265,6 @@ let flowPaneWidth = $state(0) let compactTopbar = $derived(flowPaneWidth < 700) - 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) - selectionManager.selectId(idx == 0 ? allIds[0] : allIds[idx - 1]) - } else { - selectionManager.selectId('settings-metadata') - } - } - } - function findModuleById(id: string) { return dfsByModule(id, flowStore.val.value.modules)[0] } @@ -312,15 +306,19 @@ } } - let deleteCallback: (() => void) | undefined = $state(undefined) - let dependents: Record = $state({}) + type PendingDeleteConfirmation = { + plan: DeletePlan + } - /** Confirmation gate for actions that would empty or duplicate groups */ - let affectedGroupsPending: import('$lib/components/graph/groupEditor.svelte').FlowGroup[] = - $state([]) - let affectedGroupsAction: (() => void) | undefined = $state(undefined) - let affectedGroupsCancel: (() => void) | undefined = $state(undefined) - let affectedGroupsActionLabel: 'delete' | 'move' = $state('delete') + type PendingGroupAction = { + groups: FlowGroup[] + label: 'delete' | 'move' + confirm: () => void + cancel?: () => void + } + + let pendingDeleteConfirmation: PendingDeleteConfirmation | undefined = $state(undefined) + let pendingGroupAction: PendingGroupAction | undefined = $state(undefined) let graph: FlowGraphV2 | undefined = $state(undefined) let noteMode = $state(false) @@ -341,69 +339,68 @@ noteMode = !noteMode } - export function deleteMultiple(ids: string[]) { - const { structureIds, toolIds } = partitionDeleteTargets(proxy.items, ids) - const deletingSet = new Set(ids) - const allDeps: Record = {} - for (const id of ids) { - const deps = getDependentComponents(id, flowStore.val) - for (const [depId, exprs] of Object.entries(deps)) { - if (!deletingSet.has(depId)) { - allDeps[depId] = [...(allDeps[depId] ?? []), ...exprs] - } - } + function applyDeletePlan(plan: DeletePlan) { + push(history, flowStore.val) + + if (plan.selection.kind === 'clear') { + selectionManager.clearSelection() + } else { + selectionManager.selectId(plan.selection.id) } - const opts = { displayState: groupDisplayState } - const { emptiedGroups, duplicateGroups, commit } = - structureIds.length > 0 - ? proxy.prepareMutation((tree) => { - for (const id of structureIds) { - const found = findInStructure(tree, id) - if (found) found.parentChildren.splice(found.index, 1) - } - }, opts) - : { - emptiedGroups: [], - duplicateGroups: [], - commit: () => {} - } + if (plan.targets.some((target) => target.kind === 'preprocessor')) { + flowStore.val.value.preprocessor_module = undefined + } - const affectedGroups = [...emptiedGroups, ...duplicateGroups] + plan.structureDelete?.commit({ removeDuplicates: plan.removeDuplicates }) + removeDeletePlanTools(plan.targets) - const cb = () => { - push(history, flowStore.val) - commit({ removeDuplicates: duplicateGroups.length > 0 }) - const removedToolIds = removeToolIds(flowStore.val.value.modules, toolIds, (tool) => { - deleteFlowStateById(tool.id, flowStateStore) - }) - for (const id of ids) { - if (structureIds.includes(id) || removedToolIds.includes(id)) { - delete flowStateStore.val[id] - } - } - selectionManager.clearSelection() - refreshStateStore(flowStore) + for (const id of plan.stateIds) { + deleteFlowStateById(id, flowStateStore) + } + + refreshStateStore(flowStore) + + if (plan.inputIds.length === 1) { + onDelete?.(plan.targets[0]?.id ?? plan.inputIds[0]) + } + } + + function requestDelete(ids: string[]) { + const plan = createDeletePlan({ + ids, + flow: flowStore.val, + tree: proxy.items, + proxy, + displayState: groupDisplayState + }) + if (!plan) { + return } const proceed = () => { - if (Object.keys(allDeps).length > 0) { - dependents = allDeps - deleteCallback = cb + if (Object.keys(plan.dependents).length > 0) { + pendingDeleteConfirmation = { plan } } else { - cb() + applyDeletePlan(plan) } } - if (affectedGroups.length > 0) { - affectedGroupsPending = affectedGroups - affectedGroupsActionLabel = 'delete' - affectedGroupsAction = proceed + if ((plan.structureDelete?.affectedGroups.length ?? 0) > 0) { + pendingGroupAction = { + groups: plan.structureDelete!.affectedGroups, + label: 'delete', + confirm: proceed + } } else { proceed() } } + export function deleteMultiple(ids: string[]) { + requestDelete(ids) + } + // Operates directly on the flat module array (not the structure tree). // Cloned modules are inserted after the originals, intentionally outside any group. export function duplicateMultiple(ids: string[]) { @@ -512,21 +509,21 @@ { - if (deleteCallback) { - deleteCallback() - deleteCallback = undefined + if (pendingDeleteConfirmation) { + applyDeletePlan(pendingDeleteConfirmation.plan) + pendingDeleteConfirmation = undefined } }} on:canceled={() => { - deleteCallback = undefined + pendingDeleteConfirmation = undefined }} >
Found the following steps that will require changes after this step is deleted:
- {#each Object.entries(dependents) as [k, v]} + {#each Object.entries(pendingDeleteConfirmation?.plan.dependents ?? {}) as [k, v]}

{k}

    @@ -539,36 +536,32 @@ 0} + title={pendingGroupAction?.groups.length === 1 ? 'Remove group?' : 'Remove groups?'} + confirmationText={pendingGroupAction?.label === 'delete' ? 'Delete step' : 'Move step'} + open={Boolean(pendingGroupAction)} on:confirmed={() => { - affectedGroupsAction?.() - affectedGroupsPending = [] - affectedGroupsAction = undefined - affectedGroupsCancel = undefined + pendingGroupAction?.confirm() + pendingGroupAction = undefined }} on:canceled={() => { - affectedGroupsCancel?.() - affectedGroupsPending = [] - affectedGroupsAction = undefined - affectedGroupsCancel = undefined + pendingGroupAction?.cancel?.() + pendingGroupAction = undefined }} > - {#if affectedGroupsPending.length === 1} - {@const group = affectedGroupsPending[0]} + {#if pendingGroupAction?.groups.length === 1} + {@const group = pendingGroupAction.groups[0]}

    The group{group.summary ? ` "${group.summary}"` : ''} will be removed (empty or duplicate). - Are you sure you want to {affectedGroupsActionLabel} the step?

    {:else}

    The following groups will be removed (empty or duplicate):

      - {#each affectedGroupsPending as group} + {#each pendingGroupAction?.groups ?? [] as group}
    • {group.summary || `${group.start_id} → ${group.end_id}`}
    • {/each}
    -

    Are you sure you want to {affectedGroupsActionLabel} the step?

    +

    Are you sure you want to {pendingGroupAction?.label} the step?

    {/if}
    @@ -630,78 +623,7 @@ suspendStatus={suspendStatus.val} {flowHasChanged} chatInputEnabled={Boolean(flowStore.val.value?.chat_input_enabled)} - onDelete={(id) => { - dependents = getDependentComponents(id, flowStore.val) - - if (id === 'preprocessor') { - const cb = () => { - push(history, flowStore.val) - selectionManager.selectId('Input') - flowStore.val.value.preprocessor_module = undefined - refreshStateStore(flowStore) - onDelete?.(id) - delete flowStateStore.val[id] - } - if (Object.keys(dependents).length > 0) { - deleteCallback = cb - } else { - cb() - } - return - } - - if (!findInStructure(proxy.items, id)) { - const cb = () => { - push(history, flowStore.val) - selectNextId(id) - const removedIds = removeToolIds(flowStore.val.value.modules, [id], (tool) => { - deleteFlowStateById(tool.id, flowStateStore) - }) - if (removedIds.length === 0) return - refreshStateStore(flowStore) - onDelete?.(id) - } - if (Object.keys(dependents).length > 0) { - deleteCallback = cb - } else { - cb() - } - return - } - - const dsOpts = { displayState: groupDisplayState } - const { emptiedGroups, duplicateGroups, commit } = proxy.prepareMutation((tree) => { - const found = findInStructure(tree, id) - if (found) found.parentChildren.splice(found.index, 1) - }, dsOpts) - - const affectedGroups = [...emptiedGroups, ...duplicateGroups] - - const cb = () => { - push(history, flowStore.val) - selectNextId(id) - commit({ removeDuplicates: duplicateGroups.length > 0 }) - refreshStateStore(flowStore) - onDelete?.(id) - delete flowStateStore.val[id] - } - - const proceed = () => { - if (Object.keys(dependents).length > 0) { - deleteCallback = cb - } else { - cb() - } - } - - if (affectedGroups.length > 0) { - affectedGroupsPending = affectedGroups - affectedGroupsActionLabel = 'delete' - affectedGroupsAction = proceed - } else { - proceed() - } - }} + onDelete={(id) => requestDelete([id])} onInsert={async (detail) => { if (!flowStore.val.value.modules || !Array.isArray(flowStore.val.value.modules)) return await tick() @@ -780,10 +702,12 @@ } if (affectedGroups.length > 0) { - affectedGroupsPending = affectedGroups - affectedGroupsActionLabel = 'move' - affectedGroupsAction = doMove - affectedGroupsCancel = () => moveManager.clearMoving() + pendingGroupAction = { + groups: affectedGroups, + label: 'move', + confirm: doMove, + cancel: () => moveManager.clearMoving() + } } else { doMove() } diff --git a/frontend/src/lib/components/graph/groupedModulesProxy.svelte.ts b/frontend/src/lib/components/graph/groupedModulesProxy.svelte.ts index 6965baf531..4fdcd2a2f9 100644 --- a/frontend/src/lib/components/graph/groupedModulesProxy.svelte.ts +++ b/frontend/src/lib/components/graph/groupedModulesProxy.svelte.ts @@ -13,6 +13,7 @@ import { findDuplicateGroups, removeDuplicateGroups, flattenStructureIds, + findInStructure, type FlowStructureNode } from './flowStructure' @@ -25,6 +26,13 @@ export type ExtendedOpenFlow = { [key: string]: any } +export type PreparedStructureDelete = { + removedIds: string[] + affectedGroups: FlowGroup[] + duplicateGroups: FlowGroup[] + commit: (commitOpts?: { removeDuplicates?: boolean }) => void +} + /** * Reactive read-only view of the flow structure tree. * The tree is always derived from flowStore (single source of truth). @@ -112,6 +120,33 @@ export class GroupedModulesProxy { return { emptiedGroups, duplicateGroups, commit } } + prepareDelete( + ids: string[], + opts?: { + displayState?: import('./groupEditor.svelte').GroupDisplayState + } + ): PreparedStructureDelete { + const removedIds: string[] = [] + const { emptiedGroups, duplicateGroups, commit } = this.prepareMutation((tree) => { + for (const id of ids) { + const found = findInStructure(tree, id) + if (!found) { + continue + } + + found.parentChildren.splice(found.index, 1) + removedIds.push(id) + } + }, opts) + + return { + removedIds, + affectedGroups: [...emptiedGroups, ...duplicateGroups], + duplicateGroups, + commit + } + } + /** * Convenience: prepare + auto-commit. Only use for mutations that cannot * empty groups (e.g. inserts). Throws if groups are unexpectedly emptied.