mirror of https://github.com/langgenius/dify.git
863 lines
26 KiB
TypeScript
863 lines
26 KiB
TypeScript
'use client'
|
|
|
|
import type { ToolDefaultValue } from '../block-selector/types'
|
|
import type { Edge, Node, ToolWithProvider } from '../types'
|
|
import type { Tool } from '@/app/components/tools/types'
|
|
import type { Model } from '@/types/app'
|
|
import { useSessionStorageState } from 'ahooks'
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { useStoreApi } from 'reactflow'
|
|
import { v4 as uuid4 } from 'uuid'
|
|
import Toast from '@/app/components/base/toast'
|
|
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
|
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
|
import { useGetLanguage } from '@/context/i18n'
|
|
import { generateFlowchart } from '@/service/debug'
|
|
import {
|
|
useAllBuiltInTools,
|
|
useAllCustomTools,
|
|
useAllMCPTools,
|
|
useAllWorkflowTools,
|
|
} from '@/service/use-tools'
|
|
import { ModelModeType } from '@/types/app'
|
|
import { basePath } from '@/utils/var'
|
|
import {
|
|
CUSTOM_EDGE,
|
|
NODE_WIDTH,
|
|
NODE_WIDTH_X_OFFSET,
|
|
VIBE_APPLY_EVENT,
|
|
VIBE_COMMAND_EVENT,
|
|
} from '../constants'
|
|
import { useHooksStore } from '../hooks-store'
|
|
import { useWorkflowStore } from '../store'
|
|
import { BlockEnum } from '../types'
|
|
import {
|
|
generateNewNode,
|
|
getLayoutByDagre,
|
|
getNodeCustomTypeByNodeDataType,
|
|
getNodesConnectedSourceOrTargetHandleIdsMap,
|
|
} from '../utils'
|
|
import { useNodesMetaData } from './use-nodes-meta-data'
|
|
import { useNodesSyncDraft } from './use-nodes-sync-draft'
|
|
import { useNodesReadOnly } from './use-workflow'
|
|
import { useWorkflowHistory, WorkflowHistoryEvent } from './use-workflow-history'
|
|
|
|
type VibeCommandDetail = {
|
|
dsl?: string
|
|
}
|
|
|
|
type ParsedNodeDraft = {
|
|
id: string
|
|
type?: BlockEnum
|
|
title?: string
|
|
toolKey?: string
|
|
}
|
|
|
|
type ParsedNode = {
|
|
id: string
|
|
type: BlockEnum
|
|
title?: string
|
|
toolKey?: string
|
|
}
|
|
|
|
type ParsedEdge = {
|
|
sourceId: string
|
|
targetId: string
|
|
label?: string
|
|
}
|
|
|
|
type ParseError = {
|
|
error: 'invalidMermaid' | 'missingNodeType' | 'unknownNodeType' | 'unknownTool' | 'missingNodeDefinition' | 'unknownNodeId' | 'unsupportedEdgeLabel'
|
|
detail?: string
|
|
}
|
|
|
|
type ParseResult = {
|
|
nodes: ParsedNode[]
|
|
edges: ParsedEdge[]
|
|
}
|
|
|
|
type FlowGraph = {
|
|
nodes: Node[]
|
|
edges: Edge[]
|
|
}
|
|
|
|
const NODE_DECLARATION = /^([A-Z][\w-]*)\s*\[(?:"([^"]+)"|([^\]]+))\]\s*$/i
|
|
const EDGE_DECLARATION = /^(.+?)\s*-->\s*(?:\|([^|]+)\|\s*)?(.+)$/
|
|
|
|
const extractMermaidCode = (raw: string) => {
|
|
const fencedMatch = raw.match(/```(?:mermaid)?\s*([\s\S]*?)```/i)
|
|
return (fencedMatch ? fencedMatch[1] : raw).trim()
|
|
}
|
|
|
|
const isMermaidFlowchart = (value: string) => {
|
|
const trimmed = value.trim().toLowerCase()
|
|
return trimmed.startsWith('flowchart') || trimmed.startsWith('graph')
|
|
}
|
|
|
|
const normalizeKey = (value: string) => value.trim().toLowerCase().replace(/[^\p{L}\p{N}]/gu, '')
|
|
|
|
const normalizeProviderIcon = (icon?: ToolWithProvider['icon']) => {
|
|
if (!icon)
|
|
return icon
|
|
if (typeof icon === 'string' && basePath && icon.startsWith('/') && !icon.startsWith(`${basePath}/`))
|
|
return `${basePath}${icon}`
|
|
return icon
|
|
}
|
|
|
|
const parseNodeLabel = (label: string) => {
|
|
const tokens = label.split('|').map(token => token.trim()).filter(Boolean)
|
|
const info: Record<string, string> = {}
|
|
|
|
tokens.forEach((token) => {
|
|
const [rawKey, ...rest] = token.split('=')
|
|
if (!rawKey || rest.length === 0)
|
|
return
|
|
info[rawKey.trim().toLowerCase()] = rest.join('=').trim()
|
|
})
|
|
|
|
if (!info.type && tokens.length === 1 && !tokens[0].includes('=')) {
|
|
info.type = tokens[0]
|
|
}
|
|
|
|
if (!info.tool && info.tool_key)
|
|
info.tool = info.tool_key
|
|
|
|
return info
|
|
}
|
|
|
|
const parseNodeToken = (token: string) => {
|
|
const trimmed = token.trim()
|
|
const match = trimmed.match(NODE_DECLARATION)
|
|
if (match)
|
|
return { id: match[1], label: match[2] || match[3] }
|
|
const idMatch = trimmed.match(/^([A-Z][\w-]*)$/i)
|
|
if (idMatch)
|
|
return { id: idMatch[1] }
|
|
return null
|
|
}
|
|
|
|
const normalizeBranchLabel = (label?: string) => {
|
|
if (!label)
|
|
return ''
|
|
const normalized = label.trim().toLowerCase()
|
|
if (['true', 'yes', 'y', '1'].includes(normalized))
|
|
return 'true'
|
|
if (['false', 'no', 'n', '0'].includes(normalized))
|
|
return 'false'
|
|
return ''
|
|
}
|
|
|
|
const parseMermaidFlowchart = (
|
|
raw: string,
|
|
nodeTypeLookup: Map<string, BlockEnum>,
|
|
toolLookup: Map<string, ToolDefaultValue>,
|
|
): ParseResult | ParseError => {
|
|
const code = extractMermaidCode(raw)
|
|
const lines = code.split(/\r?\n/).map((line) => {
|
|
const commentIndex = line.indexOf('%%')
|
|
return (commentIndex >= 0 ? line.slice(0, commentIndex) : line).trim()
|
|
}).filter(Boolean)
|
|
|
|
const nodesMap = new Map<string, ParsedNodeDraft>()
|
|
const declaredNodeIds = new Set<string>()
|
|
const edges: ParsedEdge[] = []
|
|
|
|
const registerNode = (id: string, label?: string): ParseError | null => {
|
|
const existing = nodesMap.get(id)
|
|
if (!label) {
|
|
if (!existing)
|
|
nodesMap.set(id, { id })
|
|
return null
|
|
}
|
|
|
|
const info = parseNodeLabel(label)
|
|
if (!info.type)
|
|
return { error: 'missingNodeType', detail: label }
|
|
|
|
const typeKey = normalizeKey(info.type)
|
|
const nodeType = nodeTypeLookup.get(typeKey)
|
|
if (!nodeType)
|
|
return { error: 'unknownNodeType', detail: info.type }
|
|
|
|
const nodeData: ParsedNodeDraft = {
|
|
id,
|
|
type: nodeType,
|
|
title: info.title,
|
|
}
|
|
|
|
if (nodeType === BlockEnum.Tool) {
|
|
if (!info.tool)
|
|
return { error: 'unknownTool', detail: 'tool' }
|
|
const toolKey = normalizeKey(info.tool)
|
|
if (!toolLookup.has(toolKey))
|
|
return { error: 'unknownTool', detail: info.tool }
|
|
nodeData.toolKey = toolKey
|
|
}
|
|
|
|
nodesMap.set(id, { ...(existing || {}), ...nodeData })
|
|
declaredNodeIds.add(id)
|
|
return null
|
|
}
|
|
|
|
for (const line of lines) {
|
|
if (line.toLowerCase().startsWith('flowchart') || line.toLowerCase().startsWith('graph'))
|
|
continue
|
|
|
|
if (line.includes('-->')) {
|
|
const edgeMatch = line.match(EDGE_DECLARATION)
|
|
if (!edgeMatch)
|
|
return { error: 'invalidMermaid', detail: line }
|
|
|
|
const sourceToken = parseNodeToken(edgeMatch[1])
|
|
const targetToken = parseNodeToken(edgeMatch[3])
|
|
if (!sourceToken || !targetToken)
|
|
return { error: 'invalidMermaid', detail: line }
|
|
|
|
if (!sourceToken.label && !declaredNodeIds.has(sourceToken.id))
|
|
return { error: 'unknownNodeId', detail: sourceToken.id }
|
|
if (!targetToken.label && !declaredNodeIds.has(targetToken.id))
|
|
return { error: 'unknownNodeId', detail: targetToken.id }
|
|
|
|
const sourceError = registerNode(sourceToken.id, sourceToken.label)
|
|
if (sourceError)
|
|
return sourceError
|
|
const targetError = registerNode(targetToken.id, targetToken.label)
|
|
if (targetError)
|
|
return targetError
|
|
|
|
edges.push({
|
|
sourceId: sourceToken.id,
|
|
targetId: targetToken.id,
|
|
label: edgeMatch[2]?.trim() || undefined,
|
|
})
|
|
continue
|
|
}
|
|
|
|
const nodeMatch = line.match(NODE_DECLARATION)
|
|
if (nodeMatch) {
|
|
const error = registerNode(nodeMatch[1], nodeMatch[2] || nodeMatch[3])
|
|
if (error)
|
|
return error
|
|
}
|
|
}
|
|
|
|
const parsedNodes: ParsedNode[] = []
|
|
const nodeTypeById = new Map<string, BlockEnum>()
|
|
for (const node of nodesMap.values()) {
|
|
if (!node.type)
|
|
return { error: 'missingNodeDefinition', detail: node.id }
|
|
parsedNodes.push(node as ParsedNode)
|
|
nodeTypeById.set(node.id, node.type)
|
|
}
|
|
|
|
if (!parsedNodes.length)
|
|
return { error: 'invalidMermaid', detail: '' }
|
|
|
|
for (const edge of edges) {
|
|
if (!edge.label)
|
|
continue
|
|
const sourceType = nodeTypeById.get(edge.sourceId)
|
|
const branchLabel = normalizeBranchLabel(edge.label)
|
|
if (sourceType !== BlockEnum.IfElse || !branchLabel)
|
|
return { error: 'unsupportedEdgeLabel', detail: edge.label }
|
|
}
|
|
|
|
return { nodes: parsedNodes, edges }
|
|
}
|
|
|
|
const dedupeHandles = (handles?: string[]) => {
|
|
if (!handles)
|
|
return handles
|
|
return Array.from(new Set(handles))
|
|
}
|
|
|
|
const buildToolParams = (parameters?: Tool['parameters']) => {
|
|
const params: Record<string, string> = {}
|
|
if (!parameters)
|
|
return params
|
|
parameters.forEach((item) => {
|
|
params[item.name] = ''
|
|
})
|
|
return params
|
|
}
|
|
|
|
type UseVibeFlowDataParams = {
|
|
storageKey: string
|
|
}
|
|
|
|
const keyPrefix = 'vibe-flow-'
|
|
|
|
export const useVibeFlowData = ({ storageKey }: UseVibeFlowDataParams) => {
|
|
const [versions, setVersions] = useSessionStorageState<FlowGraph[]>(`${keyPrefix}${storageKey}-versions`, {
|
|
defaultValue: [],
|
|
})
|
|
|
|
const [currentVersionIndex, setCurrentVersionIndex] = useSessionStorageState<number>(`${keyPrefix}${storageKey}-version-index`, {
|
|
defaultValue: 0,
|
|
})
|
|
|
|
const current = useMemo(() => {
|
|
if (!versions || versions.length === 0)
|
|
return undefined
|
|
const index = currentVersionIndex ?? 0
|
|
return versions[index] || versions[versions.length - 1]
|
|
}, [versions, currentVersionIndex])
|
|
|
|
const addVersion = useCallback((version: FlowGraph) => {
|
|
// Prevent adding empty graphs
|
|
if (!version || !version.nodes || version.nodes.length === 0)
|
|
return
|
|
|
|
setVersions((prev) => {
|
|
const newVersions = [...(prev || []), version]
|
|
// Set index in setVersions callback to ensure using the latest length
|
|
setCurrentVersionIndex(newVersions.length - 1)
|
|
return newVersions
|
|
})
|
|
}, [setVersions, setCurrentVersionIndex])
|
|
|
|
return {
|
|
versions,
|
|
addVersion,
|
|
currentVersionIndex,
|
|
setCurrentVersionIndex,
|
|
current,
|
|
}
|
|
}
|
|
|
|
export const useWorkflowVibe = () => {
|
|
const { t } = useTranslation()
|
|
const store = useStoreApi()
|
|
const workflowStore = useWorkflowStore()
|
|
const configsMap = useHooksStore(s => s.configsMap)
|
|
|
|
const language = useGetLanguage()
|
|
const { nodesMap: nodesMetaDataMap } = useNodesMetaData()
|
|
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
|
const { getNodesReadOnly } = useNodesReadOnly()
|
|
const { saveStateToHistory } = useWorkflowHistory()
|
|
const { defaultModel } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration)
|
|
|
|
const { data: buildInTools } = useAllBuiltInTools()
|
|
const { data: customTools } = useAllCustomTools()
|
|
const { data: workflowTools } = useAllWorkflowTools()
|
|
const { data: mcpTools } = useAllMCPTools()
|
|
|
|
const [modelConfig, setModelConfig] = useState<Model | null>(null)
|
|
const isGeneratingRef = useRef(false)
|
|
const lastInstructionRef = useRef<string>('')
|
|
|
|
const { addVersion, current: currentFlowGraph } = useVibeFlowData({
|
|
storageKey: configsMap?.flowId || '',
|
|
})
|
|
|
|
useEffect(() => {
|
|
const storedModel = (() => {
|
|
if (typeof window === 'undefined')
|
|
return null
|
|
const stored = localStorage.getItem('auto-gen-model')
|
|
if (!stored)
|
|
return null
|
|
try {
|
|
return JSON.parse(stored) as Model
|
|
}
|
|
catch {
|
|
return null
|
|
}
|
|
})()
|
|
|
|
if (storedModel) {
|
|
setModelConfig(storedModel)
|
|
return
|
|
}
|
|
|
|
if (defaultModel) {
|
|
setModelConfig({
|
|
name: defaultModel.model,
|
|
provider: defaultModel.provider.provider,
|
|
mode: ModelModeType.chat,
|
|
completion_params: {} as Model['completion_params'],
|
|
})
|
|
}
|
|
}, [defaultModel])
|
|
|
|
const getLatestModelConfig = useCallback(() => {
|
|
if (typeof window === 'undefined')
|
|
return modelConfig
|
|
const stored = localStorage.getItem('auto-gen-model')
|
|
if (!stored)
|
|
return modelConfig
|
|
try {
|
|
return JSON.parse(stored) as Model
|
|
}
|
|
catch {
|
|
return modelConfig
|
|
}
|
|
}, [modelConfig])
|
|
|
|
const availableNodesList = useMemo(() => {
|
|
if (!nodesMetaDataMap)
|
|
return []
|
|
return Object.values(nodesMetaDataMap).map(node => ({
|
|
type: node.metaData.type,
|
|
title: node.metaData.title,
|
|
description: node.metaData.description,
|
|
}))
|
|
}, [nodesMetaDataMap])
|
|
|
|
const toolOptions = useMemo(() => {
|
|
const collections = [
|
|
buildInTools,
|
|
customTools,
|
|
workflowTools,
|
|
mcpTools,
|
|
].filter(Boolean) as ToolWithProvider[][]
|
|
|
|
const tools: ToolDefaultValue[] = []
|
|
const seen = new Set<string>()
|
|
|
|
collections.forEach((collection) => {
|
|
collection.forEach((provider) => {
|
|
provider.tools.forEach((tool) => {
|
|
const key = `${provider.id}:${tool.name}`
|
|
if (seen.has(key))
|
|
return
|
|
seen.add(key)
|
|
|
|
const params = buildToolParams(tool.parameters)
|
|
const toolDescription = typeof tool.description === 'object'
|
|
? tool.description?.[language]
|
|
: tool.description
|
|
tools.push({
|
|
provider_id: provider.id,
|
|
provider_type: provider.type,
|
|
provider_name: provider.name,
|
|
plugin_id: provider.plugin_id,
|
|
plugin_unique_identifier: provider.plugin_unique_identifier,
|
|
provider_icon: normalizeProviderIcon(provider.icon),
|
|
provider_icon_dark: normalizeProviderIcon(provider.icon_dark),
|
|
tool_name: tool.name,
|
|
tool_label: tool.label[language] || tool.name,
|
|
tool_description: toolDescription || '',
|
|
title: tool.label[language] || tool.name,
|
|
is_team_authorization: provider.is_team_authorization,
|
|
paramSchemas: tool.parameters,
|
|
params,
|
|
output_schema: tool.output_schema,
|
|
meta: provider.meta,
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
return tools
|
|
}, [buildInTools, customTools, workflowTools, mcpTools, language])
|
|
|
|
const toolLookup = useMemo(() => {
|
|
const map = new Map<string, ToolDefaultValue>()
|
|
toolOptions.forEach((tool) => {
|
|
const primaryKey = normalizeKey(`${tool.provider_id}/${tool.tool_name}`)
|
|
map.set(primaryKey, tool)
|
|
|
|
const providerNameKey = normalizeKey(`${tool.provider_name}/${tool.tool_name}`)
|
|
map.set(providerNameKey, tool)
|
|
|
|
const labelKey = normalizeKey(tool.tool_label)
|
|
map.set(labelKey, tool)
|
|
})
|
|
return map
|
|
}, [toolOptions])
|
|
|
|
const nodeTypeLookup = useMemo(() => {
|
|
const map = new Map<string, BlockEnum>()
|
|
if (!nodesMetaDataMap)
|
|
return map
|
|
Object.values(nodesMetaDataMap).forEach((node) => {
|
|
map.set(normalizeKey(node.metaData.type), node.metaData.type)
|
|
if (node.metaData.title)
|
|
map.set(normalizeKey(node.metaData.title), node.metaData.type)
|
|
})
|
|
map.set('ifelse', BlockEnum.IfElse)
|
|
map.set('ifelsecase', BlockEnum.IfElse)
|
|
return map
|
|
}, [nodesMetaDataMap])
|
|
|
|
const flowchartToWorkflowGraph = useCallback(async (mermaidCode: string): Promise<FlowGraph> => {
|
|
const { getNodes } = store.getState()
|
|
const nodes = getNodes()
|
|
|
|
const parseResultToUse = parseMermaidFlowchart(mermaidCode, nodeTypeLookup, toolLookup)
|
|
const emptyGraph = {
|
|
nodes: [],
|
|
edges: [],
|
|
}
|
|
if ('error' in parseResultToUse) {
|
|
switch (parseResultToUse.error) {
|
|
case 'missingNodeType':
|
|
case 'missingNodeDefinition':
|
|
Toast.notify({ type: 'error', message: t('workflow.vibe.invalidFlowchart') })
|
|
return emptyGraph
|
|
case 'unknownNodeId':
|
|
Toast.notify({ type: 'error', message: t('workflow.vibe.unknownNodeId', { id: parseResultToUse.detail }) })
|
|
return emptyGraph
|
|
case 'unknownNodeType':
|
|
Toast.notify({ type: 'error', message: t('workflow.vibe.nodeTypeUnavailable', { type: parseResultToUse.detail }) })
|
|
return emptyGraph
|
|
case 'unknownTool':
|
|
Toast.notify({ type: 'error', message: t('workflow.vibe.toolUnavailable', { tool: parseResultToUse.detail }) })
|
|
return emptyGraph
|
|
case 'unsupportedEdgeLabel':
|
|
Toast.notify({ type: 'error', message: t('workflow.vibe.unsupportedEdgeLabel', { label: parseResultToUse.detail }) })
|
|
return emptyGraph
|
|
default:
|
|
Toast.notify({ type: 'error', message: t('workflow.vibe.invalidFlowchart') })
|
|
return emptyGraph
|
|
}
|
|
}
|
|
|
|
if (!nodesMetaDataMap) {
|
|
Toast.notify({ type: 'error', message: t('workflow.vibe.nodesUnavailable') })
|
|
return emptyGraph
|
|
}
|
|
|
|
const existingStartNode = nodes.find(node => node.data.type === BlockEnum.Start)
|
|
const newNodes: Node[] = []
|
|
const nodeIdMap = new Map<string, Node>()
|
|
|
|
parseResultToUse.nodes.forEach((nodeSpec) => {
|
|
if (nodeSpec.type === BlockEnum.Start && existingStartNode) {
|
|
nodeIdMap.set(nodeSpec.id, existingStartNode)
|
|
return
|
|
}
|
|
|
|
const nodeDefault = nodesMetaDataMap![nodeSpec.type]
|
|
if (!nodeDefault)
|
|
return
|
|
|
|
const defaultValue = nodeDefault.defaultValue || {}
|
|
const title = nodeSpec.title?.trim() || nodeDefault.metaData.title || defaultValue.title || nodeSpec.type
|
|
|
|
const toolDefaultValue = nodeSpec.toolKey ? toolLookup.get(nodeSpec.toolKey) : undefined
|
|
const desc = (toolDefaultValue?.tool_description || (defaultValue as { desc?: string }).desc || '') as string
|
|
|
|
const data = {
|
|
...(defaultValue as Record<string, unknown>),
|
|
title,
|
|
desc,
|
|
type: nodeSpec.type,
|
|
selected: false,
|
|
...(toolDefaultValue || {}),
|
|
}
|
|
|
|
const newNode = generateNewNode({
|
|
id: uuid4(),
|
|
type: getNodeCustomTypeByNodeDataType(nodeSpec.type),
|
|
data,
|
|
position: { x: 0, y: 0 },
|
|
}).newNode
|
|
|
|
newNodes.push(newNode)
|
|
nodeIdMap.set(nodeSpec.id, newNode)
|
|
})
|
|
|
|
if (!newNodes.length) {
|
|
Toast.notify({ type: 'error', message: t('workflow.vibe.invalidFlowchart') })
|
|
return emptyGraph
|
|
}
|
|
|
|
const buildEdge = (
|
|
source: Node,
|
|
target: Node,
|
|
sourceHandle = 'source',
|
|
targetHandle = 'target',
|
|
): Edge => ({
|
|
id: `${source.id}-${sourceHandle}-${target.id}-${targetHandle}`,
|
|
type: CUSTOM_EDGE,
|
|
source: source.id,
|
|
sourceHandle,
|
|
target: target.id,
|
|
targetHandle,
|
|
data: {
|
|
sourceType: source.data.type,
|
|
targetType: target.data.type,
|
|
isInIteration: false,
|
|
isInLoop: false,
|
|
_connectedNodeIsSelected: false,
|
|
},
|
|
zIndex: 0,
|
|
})
|
|
|
|
const newEdges: Edge[] = []
|
|
for (const edgeSpec of parseResultToUse.edges) {
|
|
const sourceNode = nodeIdMap.get(edgeSpec.sourceId)
|
|
const targetNode = nodeIdMap.get(edgeSpec.targetId)
|
|
if (!sourceNode || !targetNode)
|
|
continue
|
|
|
|
let sourceHandle = 'source'
|
|
if (sourceNode.data.type === BlockEnum.IfElse) {
|
|
const branchLabel = normalizeBranchLabel(edgeSpec.label)
|
|
if (branchLabel === 'true') {
|
|
sourceHandle = (sourceNode.data as { cases?: { case_id: string }[] })?.cases?.[0]?.case_id || 'true'
|
|
}
|
|
if (branchLabel === 'false') {
|
|
sourceHandle = 'false'
|
|
}
|
|
}
|
|
|
|
newEdges.push(buildEdge(sourceNode, targetNode, sourceHandle))
|
|
}
|
|
|
|
const bounds = nodes.reduce(
|
|
(acc, node) => {
|
|
const width = node.width ?? NODE_WIDTH
|
|
acc.maxX = Math.max(acc.maxX, node.position.x + width)
|
|
acc.minY = Math.min(acc.minY, node.position.y)
|
|
return acc
|
|
},
|
|
{ maxX: 0, minY: 0 },
|
|
)
|
|
|
|
const baseX = nodes.length ? bounds.maxX + NODE_WIDTH_X_OFFSET : 0
|
|
const baseY = Number.isFinite(bounds.minY) ? bounds.minY : 0
|
|
const branchOffset = Math.max(120, NODE_WIDTH_X_OFFSET / 2)
|
|
|
|
const layoutNodeIds = new Set(newNodes.map(node => node.id))
|
|
const layoutEdges = newEdges.filter(edge =>
|
|
layoutNodeIds.has(edge.source) && layoutNodeIds.has(edge.target),
|
|
)
|
|
|
|
try {
|
|
const layout = await getLayoutByDagre(newNodes, layoutEdges)
|
|
const layoutedNodes = newNodes.map((node) => {
|
|
const info = layout.nodes.get(node.id)
|
|
if (!info)
|
|
return node
|
|
return {
|
|
...node,
|
|
position: {
|
|
x: baseX + info.x,
|
|
y: baseY + info.y,
|
|
},
|
|
}
|
|
})
|
|
newNodes.splice(0, newNodes.length, ...layoutedNodes)
|
|
}
|
|
catch {
|
|
newNodes.forEach((node, index) => {
|
|
const row = Math.floor(index / 4)
|
|
const col = index % 4
|
|
node.position = {
|
|
x: baseX + col * NODE_WIDTH_X_OFFSET,
|
|
y: baseY + row * branchOffset,
|
|
}
|
|
})
|
|
}
|
|
|
|
const allNodes = [...nodes, ...newNodes]
|
|
const nodesConnectedMap = getNodesConnectedSourceOrTargetHandleIdsMap(
|
|
newEdges.map(edge => ({ type: 'add', edge })),
|
|
allNodes,
|
|
)
|
|
|
|
const updatedNodes = allNodes.map((node) => {
|
|
const connected = nodesConnectedMap[node.id]
|
|
if (!connected)
|
|
return node
|
|
|
|
return {
|
|
...node,
|
|
data: {
|
|
...node.data,
|
|
...connected,
|
|
_connectedSourceHandleIds: dedupeHandles(connected._connectedSourceHandleIds),
|
|
_connectedTargetHandleIds: dedupeHandles(connected._connectedTargetHandleIds),
|
|
},
|
|
}
|
|
})
|
|
return {
|
|
nodes: updatedNodes,
|
|
edges: newEdges,
|
|
}
|
|
}, [nodeTypeLookup, toolLookup])
|
|
|
|
const applyFlowchartToWorkflow = useCallback(() => {
|
|
if (!currentFlowGraph || !currentFlowGraph.nodes || currentFlowGraph.nodes.length === 0) {
|
|
Toast.notify({ type: 'error', message: t('workflow.vibe.invalidFlowchart') })
|
|
return
|
|
}
|
|
|
|
const { setNodes, setEdges } = store.getState()
|
|
const vibePanelPreviewNodes = currentFlowGraph.nodes || []
|
|
const vibePanelPreviewEdges = currentFlowGraph.edges || []
|
|
|
|
setNodes(vibePanelPreviewNodes)
|
|
setEdges(vibePanelPreviewEdges)
|
|
saveStateToHistory(WorkflowHistoryEvent.NodeAdd, { nodeId: vibePanelPreviewNodes[0].id })
|
|
handleSyncWorkflowDraft()
|
|
|
|
workflowStore.setState(state => ({
|
|
...state,
|
|
showVibePanel: false,
|
|
vibePanelMermaidCode: '',
|
|
}))
|
|
}, [
|
|
currentFlowGraph,
|
|
handleSyncWorkflowDraft,
|
|
nodeTypeLookup,
|
|
nodesMetaDataMap,
|
|
saveStateToHistory,
|
|
store,
|
|
t,
|
|
toolLookup,
|
|
])
|
|
|
|
const handleVibeCommand = useCallback(async (dsl?: string, skipPanelPreview = false) => {
|
|
if (getNodesReadOnly()) {
|
|
Toast.notify({ type: 'error', message: t('workflow.vibe.readOnly') })
|
|
return
|
|
}
|
|
|
|
const trimmed = dsl?.trim() || ''
|
|
if (!trimmed) {
|
|
Toast.notify({ type: 'error', message: t('workflow.vibe.missingInstruction') })
|
|
return
|
|
}
|
|
|
|
if (!nodesMetaDataMap || Object.keys(nodesMetaDataMap).length === 0) {
|
|
Toast.notify({ type: 'error', message: t('workflow.vibe.nodesUnavailable') })
|
|
return
|
|
}
|
|
|
|
const latestModelConfig = getLatestModelConfig()
|
|
if (!latestModelConfig && !isMermaidFlowchart(trimmed)) {
|
|
Toast.notify({ type: 'error', message: t('workflow.vibe.modelUnavailable') })
|
|
return
|
|
}
|
|
|
|
if (isGeneratingRef.current)
|
|
return
|
|
isGeneratingRef.current = true
|
|
|
|
if (!isMermaidFlowchart(trimmed))
|
|
lastInstructionRef.current = trimmed
|
|
|
|
workflowStore.setState(state => ({
|
|
...state,
|
|
showVibePanel: true,
|
|
isVibeGenerating: true,
|
|
vibePanelMermaidCode: '',
|
|
vibePanelInstruction: trimmed,
|
|
}))
|
|
|
|
try {
|
|
const { getNodes } = store.getState()
|
|
const nodes = getNodes()
|
|
const {
|
|
setIsVibeGenerating,
|
|
} = workflowStore.getState()
|
|
|
|
const existingNodesPayload = nodes.map(node => ({
|
|
id: node.id,
|
|
type: node.data.type,
|
|
title: node.data.title || '',
|
|
}))
|
|
|
|
const toolsPayload = toolOptions.map(tool => ({
|
|
provider_id: tool.provider_id,
|
|
provider_name: tool.provider_name,
|
|
provider_type: tool.provider_type,
|
|
tool_name: tool.tool_name,
|
|
tool_label: tool.tool_label,
|
|
tool_key: `${tool.provider_id}/${tool.tool_name}`,
|
|
}))
|
|
|
|
const availableNodesPayload = availableNodesList.map(node => ({
|
|
type: node.type,
|
|
title: node.title,
|
|
description: node.description,
|
|
}))
|
|
|
|
let mermaidCode = trimmed
|
|
if (!isMermaidFlowchart(trimmed)) {
|
|
const { error, flowchart } = await generateFlowchart({
|
|
instruction: trimmed,
|
|
model_config: latestModelConfig,
|
|
available_nodes: availableNodesPayload,
|
|
existing_nodes: existingNodesPayload,
|
|
available_tools: toolsPayload,
|
|
})
|
|
|
|
if (error) {
|
|
Toast.notify({ type: 'error', message: error })
|
|
setIsVibeGenerating(false)
|
|
return
|
|
}
|
|
|
|
if (!flowchart) {
|
|
Toast.notify({ type: 'error', message: t('workflow.vibe.missingFlowchart') })
|
|
setIsVibeGenerating(false)
|
|
return
|
|
}
|
|
|
|
mermaidCode = flowchart
|
|
}
|
|
|
|
workflowStore.setState(state => ({
|
|
...state,
|
|
vibePanelMermaidCode: mermaidCode,
|
|
isVibeGenerating: false,
|
|
}))
|
|
|
|
const workflowGraph = await flowchartToWorkflowGraph(mermaidCode)
|
|
// Only add to versions if workflowGraph contains nodes
|
|
if (workflowGraph && workflowGraph.nodes && workflowGraph.nodes.length > 0) {
|
|
addVersion(workflowGraph)
|
|
}
|
|
|
|
if (skipPanelPreview)
|
|
applyFlowchartToWorkflow()
|
|
}
|
|
finally {
|
|
isGeneratingRef.current = false
|
|
}
|
|
}, [
|
|
availableNodesList,
|
|
getNodesReadOnly,
|
|
handleSyncWorkflowDraft,
|
|
nodeTypeLookup,
|
|
nodesMetaDataMap,
|
|
saveStateToHistory,
|
|
store,
|
|
t,
|
|
toolLookup,
|
|
toolOptions,
|
|
getLatestModelConfig,
|
|
])
|
|
|
|
const handleAccept = useCallback(() => {
|
|
applyFlowchartToWorkflow()
|
|
}, [applyFlowchartToWorkflow])
|
|
|
|
useEffect(() => {
|
|
const handler = (event: CustomEvent<VibeCommandDetail>) => {
|
|
handleVibeCommand(event.detail?.dsl, false)
|
|
}
|
|
|
|
const acceptHandler = () => {
|
|
handleAccept()
|
|
}
|
|
|
|
document.addEventListener(VIBE_COMMAND_EVENT, handler as EventListener)
|
|
document.addEventListener(VIBE_APPLY_EVENT, acceptHandler as EventListener)
|
|
|
|
return () => {
|
|
document.removeEventListener(VIBE_COMMAND_EVENT, handler as EventListener)
|
|
document.removeEventListener(VIBE_APPLY_EVENT, acceptHandler as EventListener)
|
|
}
|
|
}, [handleVibeCommand, handleAccept])
|
|
|
|
return null
|
|
}
|