Compare commits
3 Commits
worker-bat
...
change-d28
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c7c7b6f1e | ||
|
|
a837cb03cc | ||
|
|
15e6a60f7a |
105
frontend/src/lib/components/flows/agentToolTree.test.ts
Normal file
105
frontend/src/lib/components/flows/agentToolTree.test.ts
Normal file
@@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
162
frontend/src/lib/components/flows/agentToolTree.ts
Normal file
162
frontend/src/lib/components/flows/agentToolTree.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import type { FlowModule } from '$lib/gen'
|
||||
import { isFlowModuleTool, type AgentTool, type FlowModuleTool } from './agentToolUtils'
|
||||
|
||||
type FlowNodeLike = Pick<FlowModule, 'id' | 'value'>
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,68 +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'])
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
142
frontend/src/lib/components/flows/flowDeleteController.test.ts
Normal file
142
frontend/src/lib/components/flows/flowDeleteController.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../aiProviderStorage', () => ({
|
||||
loadStoredConfig: () => undefined
|
||||
}))
|
||||
|
||||
vi.mock('./flowInfers', () => ({
|
||||
AI_AGENT_SCHEMA: { properties: {} }
|
||||
}))
|
||||
|
||||
import { GroupDisplayState } from '$lib/components/graph/groupEditor.svelte'
|
||||
import {
|
||||
GroupedModulesProxy,
|
||||
type ExtendedOpenFlow
|
||||
} from '$lib/components/graph/groupedModulesProxy.svelte'
|
||||
import type { FlowModule, OpenFlow } from '$lib/gen'
|
||||
import type { StateStore } from '$lib/utils'
|
||||
import { executeDeletePlan, prepareDeleteRequest } from './flowDeleteController'
|
||||
|
||||
function makeRawModule(id: string, expr?: string): FlowModule {
|
||||
return {
|
||||
id,
|
||||
summary: id,
|
||||
value: {
|
||||
type: 'rawscript',
|
||||
content: '',
|
||||
language: 'python3',
|
||||
input_transforms: expr
|
||||
? {
|
||||
user_input: {
|
||||
type: 'javascript',
|
||||
expr
|
||||
}
|
||||
}
|
||||
: {}
|
||||
} 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('flowDeleteController', () => {
|
||||
it('prepares confirmations and executes grouped ai-agent deletes end to end', () => {
|
||||
const agent = makeAiAgent('agent_step', [
|
||||
makeFlowModuleTool(makeRawModule('lookup_user')),
|
||||
makeFlowModuleTool(
|
||||
makeAiAgent('support_agent', [makeFlowModuleTool(makeRawModule('create_ticket'))])
|
||||
)
|
||||
])
|
||||
const dependent = makeRawModule('dependent_step', 'results.create_ticket?.value')
|
||||
const flowStore = {
|
||||
val: {
|
||||
summary: 'Flow',
|
||||
value: {
|
||||
modules: [agent, dependent],
|
||||
groups: [
|
||||
{
|
||||
summary: 'Agent group',
|
||||
start_id: 'agent_step',
|
||||
end_id: 'agent_step'
|
||||
}
|
||||
]
|
||||
}
|
||||
} as OpenFlow
|
||||
} satisfies StateStore<OpenFlow>
|
||||
const proxy = new GroupedModulesProxy(flowStore as unknown as StateStore<ExtendedOpenFlow>)
|
||||
const displayState = new GroupDisplayState(() => flowStore.val.value.groups ?? [])
|
||||
|
||||
const request = prepareDeleteRequest({
|
||||
ids: ['agent_step'],
|
||||
flow: flowStore.val,
|
||||
tree: proxy.items,
|
||||
proxy,
|
||||
displayState
|
||||
})
|
||||
|
||||
expect(request?.needsDependencyConfirmation).toBe(true)
|
||||
expect(request?.plan.affectedGroups).toHaveLength(1)
|
||||
expect(request?.plan.affectedGroups[0]).toMatchObject({
|
||||
summary: 'Agent group',
|
||||
start_id: 'agent_step',
|
||||
end_id: 'agent_step'
|
||||
})
|
||||
|
||||
const selectionManager = {
|
||||
clearSelection: vi.fn(),
|
||||
selectId: vi.fn()
|
||||
}
|
||||
const flowStateStore = {
|
||||
val: {
|
||||
agent_step: {},
|
||||
lookup_user: {},
|
||||
support_agent: {},
|
||||
create_ticket: {},
|
||||
dependent_step: {}
|
||||
}
|
||||
} as StateStore<any>
|
||||
const onDelete = vi.fn()
|
||||
|
||||
const result = executeDeletePlan(request!.plan, {
|
||||
flowStore,
|
||||
flowStateStore,
|
||||
selectionManager,
|
||||
proxy,
|
||||
displayState,
|
||||
onDelete
|
||||
})
|
||||
|
||||
expect(result.removedStateIds).toEqual([
|
||||
'agent_step',
|
||||
'lookup_user',
|
||||
'support_agent',
|
||||
'create_ticket'
|
||||
])
|
||||
expect(flowStore.val.value.modules.map((module) => module.id)).toEqual(['dependent_step'])
|
||||
expect(flowStore.val.value.groups ?? []).toEqual([])
|
||||
expect(Object.keys(flowStateStore.val)).toEqual(['dependent_step'])
|
||||
expect(selectionManager.selectId).toHaveBeenCalledWith('dependent_step')
|
||||
expect(onDelete).toHaveBeenCalledWith('agent_step')
|
||||
})
|
||||
})
|
||||
135
frontend/src/lib/components/flows/flowDeleteController.ts
Normal file
135
frontend/src/lib/components/flows/flowDeleteController.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { GroupDisplayState } from '$lib/components/graph/groupEditor.svelte'
|
||||
import type { GroupedModulesProxy } from '$lib/components/graph/groupedModulesProxy.svelte'
|
||||
import type { SelectionManager } from '$lib/components/graph/selectionUtils.svelte'
|
||||
import type { FlowStructureNode } from '$lib/components/graph/flowStructure'
|
||||
import type { FlowModule, OpenFlow } from '$lib/gen'
|
||||
import { push, type History } from '$lib/history.svelte'
|
||||
import { refreshStateStore } from '$lib/svelte5Utils.svelte'
|
||||
import type { StateStore } from '$lib/utils'
|
||||
import { collectFlowNodeIds } from './agentToolTree'
|
||||
import {
|
||||
createDeletePlan,
|
||||
removeDeletePlanTools,
|
||||
type DeletePlan,
|
||||
type DeleteTarget
|
||||
} from './flowDeleteUtils'
|
||||
import type { FlowState } from './flowState'
|
||||
import { deleteFlowStateById } from './flowStateUtils.svelte'
|
||||
import { dfsByModule } from './previousResults'
|
||||
|
||||
export type PreparedDeleteRequest = {
|
||||
plan: DeletePlan
|
||||
needsDependencyConfirmation: boolean
|
||||
}
|
||||
|
||||
export type DeletePlanExecutionContext = {
|
||||
history?: History<OpenFlow>
|
||||
flowStore: StateStore<OpenFlow>
|
||||
flowStateStore: StateStore<FlowState>
|
||||
selectionManager: Pick<SelectionManager, 'clearSelection' | 'selectId'>
|
||||
proxy: GroupedModulesProxy
|
||||
displayState: GroupDisplayState
|
||||
onDelete?: (id: string) => void
|
||||
}
|
||||
|
||||
export type DeletePlanExecutionResult = {
|
||||
removedStateIds: string[]
|
||||
removedToolStateIds: string[]
|
||||
}
|
||||
|
||||
export function prepareDeleteRequest(args: {
|
||||
ids: string[]
|
||||
flow: OpenFlow
|
||||
tree: FlowStructureNode[]
|
||||
proxy: GroupedModulesProxy
|
||||
displayState: GroupDisplayState
|
||||
}): PreparedDeleteRequest | undefined {
|
||||
const plan = createDeletePlan(args)
|
||||
if (!plan) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
plan,
|
||||
needsDependencyConfirmation: Object.keys(plan.dependents).length > 0
|
||||
}
|
||||
}
|
||||
|
||||
export function executeDeletePlan(
|
||||
plan: DeletePlan,
|
||||
args: DeletePlanExecutionContext
|
||||
): DeletePlanExecutionResult {
|
||||
push(args.history, args.flowStore.val)
|
||||
const hasPreprocessor = Boolean(args.flowStore.val.value.preprocessor_module)
|
||||
const removedStructureStateIds = resolveLiveStructureStateIds(
|
||||
plan.targets,
|
||||
args.flowStore.val.value.modules,
|
||||
hasPreprocessor
|
||||
)
|
||||
|
||||
if (plan.selection.kind === 'clear') {
|
||||
args.selectionManager.clearSelection()
|
||||
} else {
|
||||
args.selectionManager.selectId(plan.selection.id)
|
||||
}
|
||||
|
||||
if (plan.targets.some((target) => target.kind === 'preprocessor')) {
|
||||
args.flowStore.val.value.preprocessor_module = undefined
|
||||
}
|
||||
|
||||
if (plan.structureIds.length > 0) {
|
||||
const structureDelete = args.proxy.prepareDelete(plan.structureIds, {
|
||||
displayState: args.displayState
|
||||
})
|
||||
structureDelete.commit({ removeDuplicates: plan.removeDuplicates })
|
||||
}
|
||||
|
||||
const removedToolStateIds = removeDeletePlanTools(plan.targets, args.flowStore.val.value.modules)
|
||||
const removedStateIds = uniqueIds([...removedStructureStateIds, ...removedToolStateIds])
|
||||
|
||||
for (const id of removedStateIds) {
|
||||
deleteFlowStateById(id, args.flowStateStore)
|
||||
}
|
||||
|
||||
refreshStateStore(args.flowStore)
|
||||
|
||||
if (plan.inputIds.length === 1) {
|
||||
args.onDelete?.(plan.targets[0]?.id ?? plan.inputIds[0])
|
||||
}
|
||||
|
||||
return {
|
||||
removedStateIds,
|
||||
removedToolStateIds
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLiveStructureStateIds(
|
||||
targets: DeleteTarget[],
|
||||
modules: FlowModule[],
|
||||
hasPreprocessor: boolean
|
||||
): string[] {
|
||||
const removedIds: string[] = []
|
||||
|
||||
for (const target of targets) {
|
||||
if (target.kind === 'preprocessor') {
|
||||
if (hasPreprocessor) {
|
||||
removedIds.push(target.id)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (target.kind !== 'structure_node') {
|
||||
continue
|
||||
}
|
||||
|
||||
const module = dfsByModule(target.id, modules)[0]
|
||||
if (module) {
|
||||
removedIds.push(...collectFlowNodeIds(module))
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueIds(removedIds)
|
||||
}
|
||||
|
||||
function uniqueIds(ids: string[]): string[] {
|
||||
return [...new Set(ids)]
|
||||
}
|
||||
166
frontend/src/lib/components/flows/flowDeleteUtils.test.ts
Normal file
166
frontend/src/lib/components/flows/flowDeleteUtils.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../aiProviderStorage', () => ({
|
||||
loadStoredConfig: () => undefined
|
||||
}))
|
||||
|
||||
vi.mock('./flowInfers', () => ({
|
||||
AI_AGENT_SCHEMA: { properties: {} }
|
||||
}))
|
||||
|
||||
import type { FlowModule, OpenFlow } from '$lib/gen'
|
||||
import type { FlowStructureNode } from '$lib/components/graph/flowStructure'
|
||||
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, expr?: string): FlowModule {
|
||||
return {
|
||||
id,
|
||||
summary: id,
|
||||
value: {
|
||||
type: 'rawscript',
|
||||
content: '',
|
||||
language: 'python3',
|
||||
input_transforms: expr
|
||||
? {
|
||||
user_input: {
|
||||
type: 'javascript',
|
||||
expr
|
||||
}
|
||||
}
|
||||
: {}
|
||||
} 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('resolveDeleteTargets', () => {
|
||||
it('resolves structure nodes, preprocessor, and ai tools while pruning nested descendants', () => {
|
||||
const rootAgent = makeAiAgent('root_agent', [
|
||||
makeFlowModuleTool(makeRawModule('lookup_user')),
|
||||
makeFlowModuleTool(makeAiAgent('support_agent', [makeFlowModuleTool(makeRawModule('create_ticket'))]))
|
||||
])
|
||||
const tree: FlowStructureNode[] = [{ id: 'root_agent', kind: 'leaf', branches: [] }]
|
||||
|
||||
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(() => ({
|
||||
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?.plannedStateIds).toEqual(['agent_step', 'lookup_user'])
|
||||
expect(plan?.structureIds).toEqual(['agent_step'])
|
||||
expect(plan?.affectedGroups).toEqual([])
|
||||
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, [rootAgent])).toEqual(['support_agent', 'create_ticket'])
|
||||
expect((rootAgent.value as any).tools).toEqual([])
|
||||
})
|
||||
|
||||
it('re-resolves live owners before removing planned tool targets', () => {
|
||||
const rootAgent = makeAiAgent('root_agent', [
|
||||
makeFlowModuleTool(makeRawModule('lookup_user')),
|
||||
makeFlowModuleTool(makeAiAgent('support_agent', [makeFlowModuleTool(makeRawModule('create_ticket'))]))
|
||||
])
|
||||
const tree: FlowStructureNode[] = []
|
||||
const { targets } = resolveDeleteTargets(tree, [rootAgent], ['support_agent'], false)
|
||||
|
||||
;(rootAgent.value as any).tools.unshift(makeFlowModuleTool(makeRawModule('new_lookup')))
|
||||
|
||||
expect(removeDeletePlanTools(targets, [rootAgent])).toEqual(['support_agent', 'create_ticket'])
|
||||
expect(((rootAgent.value as any).tools as any[]).map((tool) => tool.id)).toEqual([
|
||||
'new_lookup',
|
||||
'lookup_user'
|
||||
])
|
||||
})
|
||||
})
|
||||
274
frontend/src/lib/components/flows/flowDeleteUtils.ts
Normal file
274
frontend/src/lib/components/flows/flowDeleteUtils.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import type { FlowGroup, GroupDisplayState } from '$lib/components/graph/groupEditor.svelte'
|
||||
import type { GroupedModulesProxy } 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 { dfs } from './dfs'
|
||||
import { getDependentComponents } from './flowExplorer'
|
||||
import { dfsByModule } from './previousResults'
|
||||
|
||||
export type DeleteSelection =
|
||||
| { kind: 'clear' }
|
||||
| {
|
||||
kind: 'select'
|
||||
id: string
|
||||
}
|
||||
|
||||
type DeleteTargetBase = {
|
||||
id: string
|
||||
stateIds: string[]
|
||||
}
|
||||
|
||||
export type PreprocessorDeleteTarget = DeleteTargetBase & {
|
||||
kind: 'preprocessor'
|
||||
}
|
||||
|
||||
export type StructureDeleteTarget = DeleteTargetBase & {
|
||||
kind: 'structure_node'
|
||||
}
|
||||
|
||||
export type AgentToolDeleteTarget = DeleteTargetBase & {
|
||||
kind: 'agent_tool'
|
||||
expectedOwnerAgentId: string
|
||||
}
|
||||
|
||||
export type DeleteTarget =
|
||||
| PreprocessorDeleteTarget
|
||||
| StructureDeleteTarget
|
||||
| AgentToolDeleteTarget
|
||||
|
||||
export type DeletePlan = {
|
||||
inputIds: string[]
|
||||
targets: DeleteTarget[]
|
||||
plannedStateIds: string[]
|
||||
dependents: Record<string, string[]>
|
||||
selection: DeleteSelection
|
||||
structureIds: string[]
|
||||
affectedGroups: FlowGroup[]
|
||||
removeDuplicates: boolean
|
||||
}
|
||||
|
||||
export type ResolvedDeleteTargets = {
|
||||
targets: DeleteTarget[]
|
||||
missingIds: string[]
|
||||
}
|
||||
|
||||
export function resolveDeleteTargets(
|
||||
tree: FlowStructureNode[],
|
||||
modules: FlowModule[],
|
||||
ids: string[],
|
||||
hasPreprocessor: boolean
|
||||
): ResolvedDeleteTargets {
|
||||
const targets: DeleteTarget[] = []
|
||||
const missingIds: string[] = []
|
||||
const seenIds = new Set<string>()
|
||||
|
||||
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,
|
||||
expectedOwnerAgentId: owner.agentId,
|
||||
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 plannedStateIds = uniqueIds(targets.flatMap((target) => target.stateIds))
|
||||
|
||||
return {
|
||||
inputIds: args.ids,
|
||||
targets,
|
||||
plannedStateIds,
|
||||
dependents: collectDeleteDependents(plannedStateIds, args.flow),
|
||||
selection: getDeleteSelection(args.ids, plannedStateIds, args.flow.value.modules),
|
||||
structureIds,
|
||||
affectedGroups: structureDelete?.affectedGroups ?? [],
|
||||
removeDuplicates: Boolean(structureDelete?.duplicateGroups.length)
|
||||
}
|
||||
}
|
||||
|
||||
export function removeDeletePlanTools(
|
||||
targets: DeleteTarget[],
|
||||
modules: FlowModule[],
|
||||
onRemove?: (tool: AgentTool) => void
|
||||
): string[] {
|
||||
const removedIds = new Set<string>()
|
||||
const toolTargets = targets
|
||||
.filter((target): target is AgentToolDeleteTarget => target.kind === 'agent_tool')
|
||||
.map((target) => {
|
||||
const owner = findAgentToolOwner(modules, target.id)
|
||||
return owner ? { owner, target } : undefined
|
||||
})
|
||||
.filter((entry): entry is { owner: AgentToolOwner; target: AgentToolDeleteTarget } =>
|
||||
Boolean(entry)
|
||||
)
|
||||
.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 { owner } of toolTargets) {
|
||||
const removed = removeAgentToolOwner(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<string>()
|
||||
|
||||
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<string, string[]> {
|
||||
const deletingSet = new Set(ids)
|
||||
const dependents: Record<string, string[]> = {}
|
||||
|
||||
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)]
|
||||
}
|
||||
@@ -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'
|
||||
@@ -50,21 +50,24 @@
|
||||
createWebsearchTool,
|
||||
createAiAgentTool,
|
||||
SPECIAL_TOOL_KINDS,
|
||||
agentToolToFlowModule,
|
||||
removeAgentToolByIdDeep
|
||||
agentToolToFlowModule
|
||||
} from '../agentToolUtils'
|
||||
import { loadFlowModuleState } from '../flowStateUtils.svelte'
|
||||
import type { DeletePlan } from '../flowDeleteUtils'
|
||||
import { executeDeletePlan, prepareDeleteRequest } from '../flowDeleteController'
|
||||
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'
|
||||
|
||||
@@ -253,66 +256,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to remove an AgentTool by id from the tools array
|
||||
* Tools are always leaf nodes, so we just need to delete their state directly
|
||||
*/
|
||||
function removeAgentToolById(tools: AgentTool[], id: string): AgentTool[] {
|
||||
const index = tools.findIndex((tool) => tool.id === id)
|
||||
if (index != -1) {
|
||||
const [removed] = tools.splice(index, 1)
|
||||
deleteFlowStateById(removed.id, flowStateStore)
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
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 = removeAgentToolById(mod.value.tools, id)
|
||||
}
|
||||
return mod
|
||||
})
|
||||
}
|
||||
|
||||
let sidebarMode: 'list' | 'graph' = 'graph'
|
||||
|
||||
let minHeight = $state(0)
|
||||
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]
|
||||
}
|
||||
@@ -354,15 +303,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
let deleteCallback: (() => void) | undefined = $state(undefined)
|
||||
let dependents: Record<string, string[]> = $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)
|
||||
@@ -383,77 +336,53 @@
|
||||
noteMode = !noteMode
|
||||
}
|
||||
|
||||
export function deleteMultiple(ids: string[]) {
|
||||
const structureIds: string[] = []
|
||||
const toolIds: string[] = []
|
||||
for (const id of ids) {
|
||||
if (findInStructure(proxy.items, id)) {
|
||||
structureIds.push(id)
|
||||
} else {
|
||||
toolIds.push(id)
|
||||
}
|
||||
}
|
||||
const deletingSet = new Set(ids)
|
||||
const allDeps: Record<string, string[]> = {}
|
||||
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) {
|
||||
executeDeletePlan(plan, {
|
||||
history,
|
||||
flowStore,
|
||||
flowStateStore,
|
||||
selectionManager,
|
||||
proxy,
|
||||
displayState: groupDisplayState,
|
||||
onDelete
|
||||
})
|
||||
}
|
||||
|
||||
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: () => {}
|
||||
}
|
||||
|
||||
const affectedGroups = [...emptiedGroups, ...duplicateGroups]
|
||||
|
||||
const cb = () => {
|
||||
push(history, flowStore.val)
|
||||
commit({ removeDuplicates: duplicateGroups.length > 0 })
|
||||
for (const id of toolIds) {
|
||||
removeAgentToolByIdDeep(flowStore.val.value.modules, id, (removed) => {
|
||||
deleteFlowStateById(removed.id, flowStateStore)
|
||||
})
|
||||
}
|
||||
for (const id of ids) {
|
||||
delete flowStateStore.val[id]
|
||||
}
|
||||
selectionManager.clearSelection()
|
||||
refreshStateStore(flowStore)
|
||||
function requestDelete(ids: string[]) {
|
||||
const request = prepareDeleteRequest({
|
||||
ids,
|
||||
flow: flowStore.val,
|
||||
tree: proxy.items,
|
||||
proxy,
|
||||
displayState: groupDisplayState
|
||||
})
|
||||
if (!request) {
|
||||
return
|
||||
}
|
||||
|
||||
const proceed = () => {
|
||||
if (Object.keys(allDeps).length > 0) {
|
||||
dependents = allDeps
|
||||
deleteCallback = cb
|
||||
if (request.needsDependencyConfirmation) {
|
||||
pendingDeleteConfirmation = { plan: request.plan }
|
||||
} else {
|
||||
cb()
|
||||
applyDeletePlan(request.plan)
|
||||
}
|
||||
}
|
||||
|
||||
if (affectedGroups.length > 0) {
|
||||
affectedGroupsPending = affectedGroups
|
||||
affectedGroupsActionLabel = 'delete'
|
||||
affectedGroupsAction = proceed
|
||||
if (request.plan.affectedGroups.length > 0) {
|
||||
pendingGroupAction = {
|
||||
groups: request.plan.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[]) {
|
||||
@@ -562,21 +491,21 @@
|
||||
<ConfirmationModal
|
||||
title="Confirm deleting step with dependents"
|
||||
confirmationText="Delete step"
|
||||
open={Boolean(deleteCallback)}
|
||||
open={Boolean(pendingDeleteConfirmation)}
|
||||
on:confirmed={() => {
|
||||
if (deleteCallback) {
|
||||
deleteCallback()
|
||||
deleteCallback = undefined
|
||||
if (pendingDeleteConfirmation) {
|
||||
applyDeletePlan(pendingDeleteConfirmation.plan)
|
||||
pendingDeleteConfirmation = undefined
|
||||
}
|
||||
}}
|
||||
on:canceled={() => {
|
||||
deleteCallback = undefined
|
||||
pendingDeleteConfirmation = 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]}
|
||||
{#each Object.entries(pendingDeleteConfirmation?.plan.dependents ?? {}) as [k, v]}
|
||||
<div class="pb-3">
|
||||
<h3 class="text-secondary font-semibold">{k}</h3>
|
||||
<ul class="text-sm">
|
||||
@@ -589,36 +518,32 @@
|
||||
</ConfirmationModal>
|
||||
|
||||
<ConfirmationModal
|
||||
title={affectedGroupsPending.length === 1 ? 'Remove group?' : 'Remove groups?'}
|
||||
confirmationText={affectedGroupsActionLabel === 'delete' ? 'Delete step' : 'Move step'}
|
||||
open={affectedGroupsPending.length > 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]}
|
||||
<p
|
||||
>The group{group.summary ? ` "${group.summary}"` : ''} will be removed (empty or duplicate).
|
||||
Are you sure you want to {affectedGroupsActionLabel} the step?</p
|
||||
Are you sure you want to {pendingGroupAction.label} the step?</p
|
||||
>
|
||||
{:else}
|
||||
<p>The following groups will be removed (empty or duplicate):</p>
|
||||
<ul class="list-disc pl-4 mt-1">
|
||||
{#each affectedGroupsPending as group}
|
||||
{#each pendingGroupAction?.groups ?? [] as group}
|
||||
<li>{group.summary || `${group.start_id} → ${group.end_id}`}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="mt-2">Are you sure you want to {affectedGroupsActionLabel} the step?</p>
|
||||
<p class="mt-2">Are you sure you want to {pendingGroupAction?.label} the step?</p>
|
||||
{/if}
|
||||
</ConfirmationModal>
|
||||
</Portal>
|
||||
@@ -680,78 +605,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 removed = removeAgentToolByIdDeep(flowStore.val.value.modules, id, (tool) => {
|
||||
deleteFlowStateById(tool.id, flowStateStore)
|
||||
})
|
||||
if (!removed) 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()
|
||||
@@ -830,10 +684,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()
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
findDuplicateGroups,
|
||||
removeDuplicateGroups,
|
||||
flattenStructureIds,
|
||||
findInStructure,
|
||||
type FlowStructureNode
|
||||
} from './flowStructure'
|
||||
|
||||
@@ -25,6 +26,12 @@ export type ExtendedOpenFlow = {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export type PreparedStructureDelete = {
|
||||
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 +119,30 @@ export class GroupedModulesProxy {
|
||||
return { emptiedGroups, duplicateGroups, commit }
|
||||
}
|
||||
|
||||
prepareDelete(
|
||||
ids: string[],
|
||||
opts?: {
|
||||
displayState?: import('./groupEditor.svelte').GroupDisplayState
|
||||
}
|
||||
): PreparedStructureDelete {
|
||||
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)
|
||||
}
|
||||
}, opts)
|
||||
|
||||
return {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user