/
githubmirror
/
ChatDev
Обзор
Документация
Войти
/
githubmirror
/
ChatDev
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
frontend/src/pages/WorkflowView.vue
2 423 строки
69 KB
YunLong
feat: Add comprehensive i18n support and fix WorkflowEdge crash bug
01 апр 2026, 13:54
01 апр 2026, 13:54
37aaf31
Код
Авторство
О чём код?
<template> <div class="workflow-view"> <div class="workflow-bg"></div> <div class="header"> <!-- Back button disabled --> <!-- <button @click="goBack" class="back-button"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M19 12H5M5 12L12 19M5 12L12 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> </svg> </button> --> <h1 class="workflow-name">{{ currentWorkflowName }}</h1> </div> <div class="content"> <!-- YAML Editor Tab --> <div v-if="activeTab === 'yaml'" class="yaml-editor"> <div v-if="yamlParseError" class="yaml-error"> {{ $t('workflow_view.yaml_parse_error') }} {{ yamlParseError }} </div> <textarea v-model="yamlTextString" class="yaml-textarea" :class="{ 'yaml-error-border': yamlParseError }" :placeholder="$t('workflow_view.loading_yaml')" readonly ></textarea> </div> <!-- VueFlow Graph Tab --> <div v-if="activeTab === 'graph'" class="vueflow-container" ref="vueflowContainerRef" > <VueFlow v-model:nodes="nodes" v-model:edges="edges" :delete-key-code="false" :fit-view-on-init="true" class="vueflow-graph" @node-click="onNodeClick" @edge-click="onEdgeClick" @connect="onConnect" @node-drag-stop="onNodeDragStop" @pane-context-menu="onPaneContextMenu" @node-context-menu="onNodeContextMenu" @edge-context-menu="onEdgeContextMenu"> <template #node-workflow-node="props"> <WorkflowNode :id="props.id" :data="props.data" @hover="onNodeHover" @leave="onNodeLeave" /> </template> <template #node-start-node="props"> <StartNode :id="props.id" :data="props.data" /> </template> <template #edge-workflow-edge="props"> <WorkflowEdge :id="props.id" :source="props.source" :target="props.target" :source-x="props.sourceX" :source-y="props.sourceY" :target-x="props.targetX" :target-y="props.targetY" :source-position="props.sourcePosition" :target-position="props.targetPosition" :data="props.data" :marker-end="props.markerEnd" :animated="props.animated" :hovered-node-id="hoveredNodeId" /> </template> <Background pattern-color="#aaa"/> <Controls position="bottom-right"/> </VueFlow> <!-- Right-click context menu inside VueFlow container --> <transition name="fade"> <div v-if="contextMenuVisible" class="context-menu" :style="{ left: contextMenuX + 'px', top: contextMenuY + 'px' }" @click.stop > <!-- Pane context menu --> <template v-if="contextMenuType === 'pane'"> <RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.createNode" placement="right"> <div class="context-menu-item" @click.stop="() => { hideContextMenu(); openCreateNodeModal(); }" > {{ $t('workflow_view.create_node') }} </div> </RichTooltip> <div v-else class="context-menu-item" @click.stop="() => { hideContextMenu(); openCreateNodeModal(); }" > {{ $t('workflow_view.create_node') }} </div> </template> <!-- Node context menu --> <template v-else-if="contextMenuType === 'node'"> <RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.copyNode" placement="right"> <div class="context-menu-item" @click.stop="() => { hideContextMenu(); onCopyNodeFromContext(); }" > {{ $t('workflow_view.copy_node') }} </div> </RichTooltip> <div v-else class="context-menu-item" @click.stop="() => { hideContextMenu(); onCopyNodeFromContext(); }" > {{ $t('workflow_view.copy_node') }} </div> <RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.deleteNode" placement="right"> <div class="context-menu-item" @click.stop="() => { hideContextMenu(); onDeleteNodeFromContext(); }" > {{ $t('workflow_view.delete_node') }} </div> </RichTooltip> <div v-else class="context-menu-item" @click.stop="() => { hideContextMenu(); onDeleteNodeFromContext(); }" > {{ $t('workflow_view.delete_node') }} </div> </template> <!-- Edge context menu --> <template v-else-if="contextMenuType === 'edge'"> <RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.deleteEdge" placement="right"> <div class="context-menu-item" @click.stop="() => { hideContextMenu(); onDeleteEdgeFromContext(); }" > {{ $t('workflow_view.delete_edge') }} </div> </RichTooltip> <div v-else class="context-menu-item" @click.stop="() => { hideContextMenu(); onDeleteEdgeFromContext(); }" > {{ $t('workflow_view.delete_edge') }} </div> </template> </div> </transition> </div> </div> <div class="tabs"> <div class="tab-buttons"> <button :class="['tab', { active: activeTab === 'graph' }]" @click="activeTab = 'graph'" > {{ $t('workflow_view.workflow_graph') }} </button> <button :class="['tab', { active: activeTab === 'yaml' }]" @click="activeTab = 'yaml'" > {{ $t('workflow_view.yaml_configuration') }} </button> </div> <div v-if="activeTab === 'graph'" class="editor-actions"> <RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.createNodeButton" placement="bottom"> <button @click="openCreateNodeModal" class="glass-button"> <span>{{ $t('workflow_view.create_node') }}</span> </button> </RichTooltip> <button v-else @click="openCreateNodeModal" class="glass-button"> <span>{{ $t('workflow_view.create_node') }}</span> </button> <RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.configureGraph" placement="bottom"> <button @click="openConfigureGraphModal" class="glass-button"> <span>{{ $t('workflow_view.configure_graph') }}</span> </button> </RichTooltip> <button v-else @click="openConfigureGraphModal" class="glass-button"> <span>{{ $t('workflow_view.configure_graph') }}</span> </button> <RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.launch" placement="bottom"> <button @click="goToLaunch" class="launch-button-primary"> <span>{{ $t('workflow_view.launch') }}</span> </button> </RichTooltip> <button v-else @click="goToLaunch" class="launch-button-primary"> <span>{{ $t('workflow_view.launch') }}</span> </button> <div class="menu-container" @mouseenter="showMenu = true" @mouseleave="showMenu = false" > <div class="menu-trigger" :class="{ 'menu-trigger-active': showMenu }" > <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M3 12H21M3 6H21M3 18H21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> </svg> </div> <transition name="fade"> <div v-if="showMenu" class="menu-dropdown"> <RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.renameWorkflow" placement="left"> <div @click="openRenameWorkflowModal" class="menu-item">{{ $t('workflow_view.rename_workflow') }}</div> </RichTooltip> <div v-else @click="openRenameWorkflowModal" class="menu-item">{{ $t('workflow_view.rename_workflow') }}</div> <RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.copyWorkflow" placement="left"> <div @click="openCopyWorkflowModal" class="menu-item">{{ $t('workflow_view.copy_workflow') }}</div> </RichTooltip> <div v-else @click="openCopyWorkflowModal" class="menu-item">{{ $t('workflow_view.copy_workflow') }}</div> <RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.manageVariables" placement="left"> <div @click="openManageVarsModal" class="menu-item">{{ $t('workflow_view.manage_variables') }}</div> </RichTooltip> <div v-else @click="openManageVarsModal" class="menu-item">{{ $t('workflow_view.manage_variables') }}</div> <RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.manageMemories" placement="left"> <div @click="openManageMemoriesModal" class="menu-item">{{ $t('workflow_view.manage_memories') }}</div> </RichTooltip> <div v-else @click="openManageMemoriesModal" class="menu-item">{{ $t('workflow_view.manage_memories') }}</div> <RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.createEdge" placement="left"> <div @click="openCreateEdgeModal" class="menu-item">{{ $t('workflow_view.create_edge') }}</div> </RichTooltip> <div v-else @click="openCreateEdgeModal" class="menu-item">{{ $t('workflow_view.create_edge') }}</div> </div> </transition> </div> </div> </div> </div> <FormGenerator v-if="showDynamicFormGenerator" :breadcrumbs="formGeneratorBreadcrumbs" :recursive="formGeneratorRecursive" :workflow-name="currentWorkflowName" :initial-yaml="formGeneratorInitialYaml ?? yamlContent" :initial-form-data="formGeneratorInitialFormData" :mode="formGeneratorMode" :field-filter="formGeneratorFieldFilter" :read-only-fields="formGeneratorReadOnlyFields" @close="closeDynamicFormGenerator" @submit="handleFormGeneratorSubmit" @copy="handleFormGeneratorCopy" /> <!-- Rename Workflow Modal --> <div v-if="showRenameModal" class="modal-overlay" @click.self="closeRenameModal"> <div class="modal-content"> <div class="modal-header"> <h3 class="modal-title">{{ $t('workflow_view.rename_workflow') }}</h3> <button @click="closeRenameModal" class="close-button"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> </svg> </button> </div> <div class="modal-body"> <div class="form-group"> <label for="rename-workflow-name" class="form-label">{{ $t('workflow_view.workflow_name') }}</label> <input id="rename-workflow-name" v-model="renameWorkflowName" type="text" class="form-input" :placeholder="$t('workflow_view.enter_new_name')" @keyup.enter="handleRenameSubmit" /> </div> </div> <div class="modal-footer"> <button @click="closeRenameModal" class="cancel-button">{{ $t('common.cancel') }}</button> <button @click="handleRenameSubmit" class="submit-button" :disabled="!renameWorkflowName.trim()">{{ $t('common.submit') }}</button> </div> </div> </div> <!-- Copy Workflow Modal --> <div v-if="showCopyModal" class="modal-overlay" @click.self="closeCopyModal"> <div class="modal-content"> <div class="modal-header"> <h3 class="modal-title">Copy Workflow</h3> <button @click="closeCopyModal" class="close-button"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> </svg> </button> </div> <div class="modal-body"> <div class="form-group"> <label for="copy-workflow-name" class="form-label">Workflow Name</label> <input id="copy-workflow-name" v-model="copyWorkflowName" type="text" class="form-input" placeholder="Enter new workflow name" @keyup.enter="handleCopySubmit" /> </div> </div> <div class="modal-footer"> <button @click="closeCopyModal" class="cancel-button">Cancel</button> <button @click="handleCopySubmit" class="submit-button" :disabled="!copyWorkflowName.trim()">{{ $t('common.submit') }}</button> </div> </div> </div> </template> <script setup> import { ref, watch, nextTick, onMounted, onBeforeUnmount, computed } from 'vue' import { useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import { VueFlow, useVueFlow, MarkerType } from '@vue-flow/core' import { Background } from '@vue-flow/background' import { Controls } from '@vue-flow/controls' import '@vue-flow/core/dist/style.css' import '@vue-flow/controls/dist/style.css' import '../utils/vueflow.css' import WorkflowNode from '../components/WorkflowNode.vue' import WorkflowEdge from '../components/WorkflowEdge.vue' import StartNode from '../components/StartNode.vue' import FormGenerator from '../components/FormGenerator.vue' import RichTooltip from '../components/RichTooltip.vue' import yaml from 'js-yaml' import { fetchYaml, fetchVueGraph, postVuegraphs, updateYaml, postYamlNameChange, postYamlCopy } from '../utils/apiFunctions' import { helpContent } from '../utils/helpContent.js' import { configStore } from '../utils/configStore.js' const shouldShowTooltip = computed(() => configStore.ENABLE_HELP_TOOLTIPS) const props = defineProps({ workflowName: { type: String, required: true } }) const emit = defineEmits(['refresh-workflows']) const router = useRouter() const { t } = useI18n() const { toObject, fromObject, fitView, getViewport } = useVueFlow() const vueflowContainerRef = ref(null) // Hovered node id for highlighting related edges const hoveredNodeId = ref(null) const onNodeHover = (nodeId) => { hoveredNodeId.value = nodeId || null } const onNodeLeave = (_nodeId) => { hoveredNodeId.value = null } const currentWorkflowName = ref('') const activeTab = ref('graph') const yamlContent = ref({}) // YAML object const yamlTextString = ref('') // YAML string const yamlParseError = ref(null) const showDynamicFormGenerator = ref(false) const showMenu = ref(false) const formGeneratorBreadcrumbs = ref([]) const formGeneratorRecursive = ref(false) const formGeneratorInitialYaml = ref(null) const formGeneratorInitialFormData = ref(null) const formGeneratorMode = ref('create') const formGeneratorFieldFilter = ref([]) const formGeneratorReadOnlyFields = ref([]) // Modal states for rename and copy const showRenameModal = ref(false) const showCopyModal = ref(false) const renameWorkflowName = ref('') const copyWorkflowName = ref('') const FORM_GENERATOR_CONFIG = Object.freeze({ graph: [ { node: 'DesignConfig', field: 'graph' } ], node: [ { node: 'DesignConfig', field: 'graph' }, { node: 'GraphDefinition', field: 'nodes' } ], edge: [ { node: 'DesignConfig', field: 'graph' }, { node: 'GraphDefinition', field: 'edges' } ], memory: [ { node: 'DesignConfig', field: 'graph' } ], vars: [ // Empty breadcrumbs for managing global vars ] }) const cloneDeep = (value) => { if (value === null || value === undefined) { return null } if (typeof value === 'object') { return JSON.parse(JSON.stringify(value)) } return value } // VueFlow nodes and edges const nodes = ref([]) const edges = ref([]) const isCreatingConnection = ref(false) // Start node ID const START_NODE_ID = '__start' // Context menu state const contextMenuVisible = ref(false) const contextMenuX = ref(0) const contextMenuY = ref(0) const pendingNodePosition = ref(null) const contextMenuType = ref('pane') // 'pane' | 'node' | 'edge' const contextNodeId = ref(null) const contextEdgeInfo = ref(null) // { from, to } const hideContextMenu = () => { contextMenuVisible.value = false } const getFlowPositionFromEvent = (event) => { try { const viewport = getViewport() const container = vueflowContainerRef.value if (!viewport || !container || !event) { return getCentralPosition() } const rect = container.getBoundingClientRect() const screenX = event.clientX - rect.left const screenY = event.clientY - rect.top const x = (screenX - viewport.x) / viewport.zoom const y = (screenY - viewport.y) / viewport.zoom return { x, y } } catch (error) { console.warn('Failed to convert click position to flow coordinates:', error) return getCentralPosition() } } const onContainerContextMenu = (event) => { // Only respond to right-clicks inside the flow container if (!vueflowContainerRef.value) { return } // Store menu position relative to container (for UI) const rect = vueflowContainerRef.value.getBoundingClientRect() contextMenuX.value = event.clientX - rect.left contextMenuY.value = event.clientY - rect.top contextMenuType.value = 'pane' contextNodeId.value = null contextEdgeInfo.value = null contextMenuVisible.value = true // Pre-compute desired node position in flow coordinates pendingNodePosition.value = getFlowPositionFromEvent(event) } // Disable default pane context behaviour in VueFlow and use custom behaviour const onPaneContextMenu = (params) => { const mouseEvent = params?.event || params mouseEvent?.preventDefault?.() if (!mouseEvent) return onContainerContextMenu(mouseEvent) } const onNodeContextMenu = (params) => { const mouseEvent = params?.event || params const node = params?.node || params?.event?.node || params if (!mouseEvent || !node?.id || !vueflowContainerRef.value) { return } mouseEvent.preventDefault?.() // Ignore context menu for start node, show dim animation if (node.id === START_NODE_ID) { dimStartNode() return } const rect = vueflowContainerRef.value.getBoundingClientRect() contextMenuX.value = mouseEvent.clientX - rect.left contextMenuY.value = mouseEvent.clientY - rect.top contextMenuType.value = 'node' contextNodeId.value = node.id contextEdgeInfo.value = null pendingNodePosition.value = null contextMenuVisible.value = true } const onEdgeContextMenu = (params) => { const mouseEvent = params?.event || params const edge = params?.edge || params?.event?.edge || params if (!mouseEvent || !edge || !vueflowContainerRef.value) { return } mouseEvent.preventDefault?.() const fromId = edge.data?.from || edge.source const toId = edge.data?.to || edge.target if (!fromId || !toId) { return } const rect = vueflowContainerRef.value.getBoundingClientRect() contextMenuX.value = mouseEvent.clientX - rect.left contextMenuY.value = mouseEvent.clientY - rect.top contextMenuType.value = 'edge' contextNodeId.value = null contextEdgeInfo.value = { from: fromId, to: toId } pendingNodePosition.value = null contextMenuVisible.value = true } const onGlobalClick = () => { // Clicks outside the menu will bubble to window and handled here // Clicks on the menu itself are stopped with @click.stop if (contextMenuVisible.value) { contextMenuVisible.value = false } } const dimStartNode = () => { const startNode = nodes.value.find(node => node.id === START_NODE_ID) if (!startNode) return const dimSpeed = 120 // milliseconds between opacity changes - slightly slower for smoothness const dimSteps = [1, 0.6, 1, 0.6, 1] // Full opacity -> dim -> full -> dim -> full dimSteps.forEach((opacity, index) => { setTimeout(() => { // Add opacity to node data for animation startNode.data = { ...startNode.data, opacity } // Force VueFlow to update by triggering reactivity nodes.value = [...nodes.value] }, index * dimSpeed) }) } // Persist an updated YAML snapshot back to the server and refresh local state const persistYamlSnapshot = async (snapshot) => { try { if (!currentWorkflowName.value) { return false } const yamlString = yaml.dump(snapshot ?? {}) const result = await updateYaml(currentWorkflowName.value, yamlString) if (!result?.success) { console.error('Failed to update workflow YAML:', result?.message || result?.detail) return false } console.log("YAML snapshot successfully persisted through API") return true } catch (error) { console.error('Error persisting YAML snapshot:', error) return false } } const onCopyNodeFromContext = () => { const nodeId = contextNodeId.value if (!nodeId) { return } const yamlNodeContent = (yamlContent.value?.graph?.nodes || []).find(node => node.id === nodeId) if (!yamlNodeContent) { console.warn(`[WorkflowView] Node with id "${nodeId}" not found for copying`) return } handleFormGeneratorCopy({ initialFormData: yamlNodeContent }) } const deleteNodeById = async (nodeId) => { if (!nodeId) { return } const source = yamlContent.value if (!source?.graph) { return } const sourceGraph = source.graph const nodesArr = Array.isArray(sourceGraph.nodes) ? sourceGraph.nodes : [] const edgesArr = Array.isArray(sourceGraph.edges) ? sourceGraph.edges : [] // Remove the node and its related edges const nextNodes = nodesArr.filter(node => node?.id !== nodeId) const nextEdges = edgesArr.filter(edge => edge?.from !== nodeId && edge?.to !== nodeId) // Remove node ID from graph.start/end const nextStart = Array.isArray(sourceGraph.start) ? sourceGraph.start.filter(id => id !== nodeId) : sourceGraph.start const nextEnd = Array.isArray(sourceGraph.end) ? sourceGraph.end.filter(id => id !== nodeId) : sourceGraph.end const nextSnapshot = { ...source, graph: { ...sourceGraph, nodes: nextNodes, edges: nextEdges, start: nextStart, end: nextEnd } } const ok = await persistYamlSnapshot(nextSnapshot) if (!ok) { return } await loadYamlFile() syncVueNodesAndEdgesData() await nextTick() await saveVueFlowGraph() } const deleteEdgeByEndpoints = async (fromId, toId) => { if (!fromId || !toId) { return } const source = yamlContent.value if (!source?.graph || !Array.isArray(source.graph.edges)) { return } const sourceGraph = source.graph let removed = false const nextEdges = sourceGraph.edges.filter(edge => { if (!removed && edge?.from === fromId && edge?.to === toId) { removed = true return false } return true }) // Delete from .start if edge is from Start Node let nextStart = sourceGraph.start if (fromId === START_NODE_ID) { nextStart = Array.isArray(sourceGraph.start) ? sourceGraph.start.filter(id => id !== toId) : sourceGraph.start // Empty start node array is not allowed const startArray = Array.isArray(nextStart) ? nextStart : [] if (startArray.length === 0) { alert("At least one connection required from start node!") return } } const nextSnapshot = { ...source, graph: { ...sourceGraph, edges: nextEdges, start: nextStart } } const ok = await persistYamlSnapshot(nextSnapshot) if (!ok) { return } await loadYamlFile() syncVueNodesAndEdgesData() await nextTick() await saveVueFlowGraph() } const onDeleteNodeFromContext = async () => { const nodeId = contextNodeId.value contextNodeId.value = null const confirmed = window.confirm(`Are you sure you want to delete this node?`) if (!confirmed) { return } if (!nodeId) { return } await deleteNodeById(nodeId) } const onDeleteEdgeFromContext = async () => { const info = contextEdgeInfo.value contextEdgeInfo.value = null const confirmed = window.confirm(`Are you sure you want to delete this edge?`) if (!confirmed) { return } if (!info?.from || !info?.to) { return } await deleteEdgeByEndpoints(info.from, info.to) } const initializeWorkflow = async (name) => { if (!name) { return } currentWorkflowName.value = name console.log('Workflow initialized: ', currentWorkflowName.value) await loadYamlFile() loadAndSyncVueFlowGraph() await nextTick() fitView?.({ padding: 0.1 }) } watch( () => props.workflowName, async (newName) => { await initializeWorkflow(newName) }, { immediate: false } ) onMounted(async () => { window.addEventListener('click', onGlobalClick) await initializeWorkflow(props.workflowName) }) onBeforeUnmount(() => { window.removeEventListener('click', onGlobalClick) }) watch(activeTab, async (newTab) => { if (newTab === 'graph') { await nextTick() fitView?.({ padding: 0.1 }) } }) const saveVueFlowGraph = async () => { try { const flowObj = toObject() const key = currentWorkflowName.value const result = await postVuegraphs({ filename: key, content: JSON.stringify(flowObj) }) if (!result?.success) { console.error('Failed to save VueFlow graph:', result?.message || result?.detail) return false } return true } catch (error) { console.error('Failed to save VueFlow graph:', error) return false } } const loadAndSyncVueFlowGraph = async () => { try { const key = currentWorkflowName.value const result = await fetchVueGraph(key) if(result?.success === true) { console.log("Graph fetched successfully") } if (result?.status === 404) { // Not found in server storage, fallback console.log("No graph found, fallback to generation") await generateNodesAndEdges() return false } if (!result?.success) { console.error('Failed to load VueFlow graph:', result?.message || result?.detail) // Fallback if server error generateNodesAndEdges() return false } const content = result?.content if (content) { const flow = JSON.parse(content) if (flow) { fromObject(flow) await nextTick() syncVueNodesAndEdgesData() return true } } } catch (error) { console.error('Failed to load saved VueFlow graph:', error) } // If no VueFlow graph restored, fall back to manual generation await generateNodesAndEdges() return false } const loadYamlFile = async () => { try { if (!currentWorkflowName.value) { return } const result = await fetchYaml(currentWorkflowName.value) if (!result?.success) { console.error('Failed to load YAML file', result?.message || result?.detail) return } const yamlString = result.content ?? '' console.log('YAML content loaded successfully') // Parse YAML string to YAML object try { const parsed = yaml.load(yamlString) yamlContent.value = parsed || {} yamlTextString.value = yamlString yamlParseError.value = null } catch (parseError) { console.error('Error parsing YAML:', parseError) yamlParseError.value = parseError.message yamlTextString.value = yamlString } } catch (error) { console.error('Error loading YAML file: ', error) } } const getCentralPosition = () => { try { const viewport = getViewport() if (viewport && vueflowContainerRef.value) { // Get container dimensions const container = vueflowContainerRef.value const containerWidth = container.clientWidth || container.offsetWidth const containerHeight = container.clientHeight || container.offsetHeight const screenCenterX = containerWidth / 2 const screenCenterY = containerHeight / 2 // Convert screen coordinates to flow coordinates // Formula: flowCoord = (screenCoord - viewportOffset) / zoom const centerX = (screenCenterX - viewport.x) / viewport.zoom const centerY = (screenCenterY - viewport.y) / viewport.zoom return { x: centerX, y: centerY } } } catch (error) { console.warn('Failed to get viewport center, using default position:', error) } // Fallback to default center position return { x: 400, y: 300 } } const updateNodesAndEdgesFromYaml = (preserveExistingLayout = false) => { try { const yamlNodes = Array.isArray(yamlContent.value?.graph?.nodes) ? yamlContent.value.graph.nodes : [] const yamlEdges = Array.isArray(yamlContent.value?.graph?.edges) ? yamlContent.value.graph.edges : [] const currentNodes = nodes.value || [] const currentEdges = edges.value || [] const defaultCenterPosition = getCentralPosition() const getDefaultCenterPosition = () => ({ x: defaultCenterPosition.x, y: defaultCenterPosition.y }) const existingNodeById = preserveExistingLayout ? new Map(currentNodes.map(node => [node.id, node])) : null const existingEdgeByKey = preserveExistingLayout ? new Map(currentEdges.map(edge => [`${edge.source}-${edge.target}`, edge])) : null // Compute node positions using a simple topological layering // This arranges nodes by levels (distance from sources) // Ignore backward/cycle edges for cyclic graphs try { const nodeIds = (yamlNodes || []).map(n => n?.id).filter(Boolean) // Build adjacency and indegree const adj = new Map() const indegree = new Map() nodeIds.forEach(id => { adj.set(id, new Set()) indegree.set(id, 0) }) ;(yamlEdges || []).forEach(e => { if (!e || !e.from || !e.to) return if (!adj.has(e.from) || !adj.has(e.to)) return adj.get(e.from).add(e.to) indegree.set(e.to, (indegree.get(e.to) || 0) + 1) }) // Kahn's algorithm to compute levels const levelById = new Map() const q = [] nodeIds.forEach(id => { if ((indegree.get(id) || 0) === 0) { q.push(id) levelById.set(id, 0) } }) // Heuristic: if the graph has no indegree-0 nodes (pure cycle), // pick the first node declared in YAML `graph.start` as a pseudo-source // so Kahn's algorithm can proceed and assign at least one level. if (q.length === 0) { try { const yamlStartList = Array.isArray(yamlContent.value?.graph?.start) ? yamlContent.value.graph.start : [] const firstStart = yamlStartList.find(s => nodeIds.includes(s)) if (firstStart) { // Force indegree to zero and seed the queue so at least one node gets level 0 indegree.set(firstStart, 0) q.push(firstStart) levelById.set(firstStart, 0) } } catch (e) { // ignore and fall back later } } let queueIndex = 0 while (queueIndex < q.length) { const id = q[queueIndex++] const baseLevel = levelById.get(id) || 0 const neighbors = adj.get(id) || new Set() for (const nb of neighbors) { const prev = levelById.get(nb) ?? 0 const newLevel = Math.max(prev, baseLevel + 1) levelById.set(nb, newLevel) indegree.set(nb, indegree.get(nb) - 1) if (indegree.get(nb) === 0) q.push(nb) } } const predecessors = new Map() nodeIds.forEach(id => predecessors.set(id, new Set())) ;(yamlEdges || []).forEach(e => { if (!e || !e.from || !e.to) return if (!predecessors.has(e.to)) return predecessors.get(e.to).add(e.from) }) let changed = true let iterations = 0 const maxIterations = nodeIds.length + 5 while (changed && iterations < maxIterations) { changed = false iterations++ nodeIds.forEach(id => { if (levelById.has(id)) return const preds = predecessors.get(id) || new Set() const predLevels = Array.from(preds).map(p => levelById.get(p)).filter(l => typeof l === 'number') if (predLevels.length) { const lvl = Math.max(...predLevels) + 1 levelById.set(id, lvl) changed = true } }) } // Any remaining unassigned nodes -> fallback to level 0 nodeIds.forEach(id => { if (!levelById.has(id)) levelById.set(id, 0) }) // Group nodes by level and compute positions (simple grid per level) const buckets = new Map() for (const [id, lvl] of levelById.entries()) { if (!buckets.has(lvl)) buckets.set(lvl, []) buckets.get(lvl).push(id) } const positions = new Map() const levelKeys = Array.from(buckets.keys()).sort((a, b) => a - b) const spacingX = 280 const spacingY = 120 const startX = 50 const startY = 50 levelKeys.forEach(lvl => { const ids = buckets.get(lvl) || [] const x = startX + lvl * spacingX let currentY = startY // Apply a slight Y offset (+/-10) to the first node in each layer to avoid exact horizontal alignments const layerOffset = (lvl % 2 === 0) ? -30 : 30 ids.forEach((id, idx) => { const yPos = currentY + (idx === 0 ? layerOffset : 0) positions.set(id, { x, y: yPos }) currentY += spacingY }) }) // Build nextNodes respecting preserveExistingLayout where possible const nextNodes = yamlNodes.map((yamlNode, index) => { const id = yamlNode?.id if (!id) return null if (preserveExistingLayout && existingNodeById?.has(id)) { const existingNode = existingNodeById.get(id) return { ...existingNode, id, label: id, data: yamlNode } } const pos = positions.get(id) || getDefaultCenterPosition() return { id, type: 'workflow-node', label: id, position: pos, data: yamlNode } }).filter(Boolean) nodes.value = nextNodes } catch (err) { console.error('Failed to compute topological layout, falling back to center positions:', err) // Fallback to previous behavior const nextNodes = yamlNodes.map((yamlNode, index) => ({ id: yamlNode.id, type: 'workflow-node', label: yamlNode.id, position: getDefaultCenterPosition(), data: yamlNode })) nodes.value = nextNodes } // Build edges from YAML (preserve layout where possible) const nextYamlEdges = yamlEdges.map(yamlEdge => { const key = `${yamlEdge.from}-${yamlEdge.to}` const baseEdge = preserveExistingLayout && existingEdgeByKey?.has(key) ? existingEdgeByKey.get(key) : { id: key, source: yamlEdge.from, target: yamlEdge.to, type: 'workflow-edge' } return { ...baseEdge, id: key, source: yamlEdge.from, target: yamlEdge.to, data: yamlEdge, markerEnd: { type: MarkerType.Arrow, width: 16, height: 16, // Set color to match with edge color: (yamlEdge && yamlEdge.trigger === false) ? '#868686' : '#f2f2f2', strokeWidth: 1.5, }, } }) // Nodes in .start const declaredStartSet = new Set(Array.isArray(yamlContent.value?.graph?.start) ? yamlContent.value.graph.start : []) // Create visual-only start node (reuse existing if present) let startNode = null if (preserveExistingLayout && existingNodeById?.has(START_NODE_ID)) { startNode = { ...existingNodeById.get(START_NODE_ID), id: START_NODE_ID, type: 'start-node', data: { id: START_NODE_ID, label: 'Start' } } } else { try { // Place start node to the left of the leftmost column const yamlNodesInGraph = (nodes.value || []).filter(n => n && n.id !== START_NODE_ID) if (yamlNodesInGraph.length) { const xs = yamlNodesInGraph.map(n => (n?.position && typeof n.position.x === 'number') ? n.position.x : defaultCenterPosition.x) const minX = Math.min(...xs) // Find nodes in that left column const tol = 1 const leftColumn = yamlNodesInGraph.filter(n => Math.abs((n?.position?.x || 0) - minX) <= tol) const ys = leftColumn.map(n => (n?.position && typeof n.position.y === 'number') ? n.position.y : defaultCenterPosition.y) const avgY = ys.length ? ys.reduce((a, b) => a + b, 0) / ys.length : defaultCenterPosition.y const startXOffset = -100 const startYOffset = 80 startNode = { id: START_NODE_ID, type: 'start-node', label: 'Start', position: { x: minX + startXOffset, y: avgY + startYOffset }, data: { id: START_NODE_ID, label: 'Start' } } } else { startNode = { id: START_NODE_ID, type: 'start-node', label: 'Start', position: getDefaultCenterPosition(), data: { id: START_NODE_ID, label: 'Start' } } } } catch (err) { console.warn('Failed to compute start node position, falling back to center:', err) startNode = { id: START_NODE_ID, type: 'start-node', label: 'Start', position: getDefaultCenterPosition(), data: { id: START_NODE_ID, label: 'Start' } } } } // Build start edges to YAML nodes that are declared in graph.start const startEdges = (yamlNodes || []).map(yamlNode => { if (!yamlNode?.id) return null if (!declaredStartSet.has(yamlNode.id)) return null const key = `${START_NODE_ID}-${yamlNode.id}` const baseEdge = preserveExistingLayout && existingEdgeByKey?.has(key) ? existingEdgeByKey.get(key) : { id: key, source: START_NODE_ID, target: yamlNode.id, type: 'workflow-edge' } return { ...baseEdge, id: key, source: START_NODE_ID, target: yamlNode.id, data: { from: START_NODE_ID, to: yamlNode.id }, markerEnd: { type: MarkerType.Arrow, width: 16, height: 16, color: '#f2f2f2', strokeWidth: 1.5, }, animated: false } }).filter(Boolean) // Combine YAML edges with visual start edges (preserve any existing non-yaml edges) const nextYamlEdgeIdSet = new Set(nextYamlEdges.map(edge => edge.id)) edges.value = [ // keep any existing edges that are not YAML edges (e.g., visual-only) when preserving layout // but always exclude previous Start edges so they are replaced by the newly computed ones ...(preserveExistingLayout ? currentEdges.filter(e => { const k = `${e.source}-${e.target}` // drop if it's a YAML-defined edge or a previous Start edge const isYamlEdge = nextYamlEdgeIdSet.has(k) const isStartEdge = e.source === START_NODE_ID // Also drop if it looks like a YAML edge (has data.from/to) but isn't in nextYamlEdges (stale) const isStaleYamlEdge = e.data?.from && e.data?.to return !isYamlEdge && !isStartEdge && !isStaleYamlEdge }) : []), ...nextYamlEdges, ...startEdges ] // Ensure start node is present in nodes list (preserving layout if asked) if (!nodes.value.some(n => n.id === START_NODE_ID)) { nodes.value = [startNode, ...nodes.value] } else { // if present, ensure the start node data/type is correct nodes.value = nodes.value.map(n => n.id === START_NODE_ID ? startNode : n) } } catch (error) { console.error('Error syncing nodes and edges from YAML:', error) } } const generateNodesAndEdges = async (options = {}) => { updateNodesAndEdgesFromYaml(false) // Save generated graph at nextTick try { await nextTick() if (options.fit) { fitView?.({ padding: 0.1 }) } await saveVueFlowGraph() } catch (err) { console.warn('Failed to persist generated VueFlow graph:', err) } } const syncVueNodesAndEdgesData = () => { updateNodesAndEdgesFromYaml(true) } const updateVueFlowNodeId = (oldId, newId) => { if (!oldId || !newId || oldId === newId) { return } nodes.value = (nodes.value || []).map(node => { if (node.id !== oldId) { return node } return { ...node, id: newId, label: newId, data: node.data ? { ...node.data, id: newId } : { id: newId } } }) edges.value = (edges.value || []).map(edge => { let source = edge.source let target = edge.target let edgeChanged = false if (source === oldId) { source = newId edgeChanged = true } if (target === oldId) { target = newId edgeChanged = true } let nextData = edge.data if (edge.data) { const nextFrom = edge.data.from === oldId ? newId : edge.data.from const nextTo = edge.data.to === oldId ? newId : edge.data.to if (nextFrom !== edge.data.from || nextTo !== edge.data.to) { nextData = { ...edge.data, from: nextFrom, to: nextTo } } } const nextEdge = { ...edge, source, target, data: nextData } if (edgeChanged) { nextEdge.id = `${source}-${target}` } return nextEdge }) // Update node ID in graph.start if (yamlContent.value?.graph?.start && Array.isArray(yamlContent.value.graph.start)) { yamlContent.value.graph.start = yamlContent.value.graph.start.map(startNodeId => startNodeId === oldId ? newId : startNodeId ) } // Same for graph.end if (yamlContent.value?.graph?.end && Array.isArray(yamlContent.value.graph.end)) { yamlContent.value.graph.end = yamlContent.value.graph.end.map(endNodeId => endNodeId === oldId ? newId : endNodeId ) } } // FormGenerator integration // Build YAML without specific node (shallow clone path to avoid full deep-clone on editor open) const buildYamlWithoutNode = (nodeId) => { const source = yamlContent.value if (!source?.graph?.nodes || !Array.isArray(source.graph.nodes)) { return source } return { ...source, graph: { ...source.graph, nodes: source.graph.nodes.filter(node => node?.id !== nodeId) } } } const buildYamlWithoutEdge = (fromId, toId) => { const source = yamlContent.value if (!source?.graph?.edges || !Array.isArray(source.graph.edges)) { return source } let removed = false const filteredEdges = source.graph.edges.filter(edge => { if (!removed && edge?.from === fromId && edge?.to === toId) { removed = true return false } return true }) return { ...source, graph: { ...source.graph, edges: filteredEdges } } } const buildYamlWithoutVars = () => { const source = yamlContent.value if (!source || typeof source !== 'object') { return source } if (!Object.prototype.hasOwnProperty.call(source, 'vars')) { return source } const sanitized = { ...source } delete sanitized.vars return sanitized } const buildYamlWithoutMemory = () => { const source = yamlContent.value if (!source?.graph) { return source } if (Object.prototype.hasOwnProperty.call(source.graph, 'memory')) { const newGraph = { ...source.graph } delete newGraph.memory return { ...source, graph: newGraph } } return source } const buildYamlWithoutGraph = () => { const source = yamlContent.value if (!source || typeof source !== 'object') { return source } if (!Object.prototype.hasOwnProperty.call(source, 'graph')) { return source } const sanitized = { ...source } delete sanitized.graph return sanitized } const autoAddStartEdge = async (nextNodeId) => { const workflowNodes = (yamlContent.value?.graph?.nodes || []).filter(node => node?.id !== START_NODE_ID) if (workflowNodes.length === 1 && workflowNodes[0]?.id === nextNodeId) { const source = yamlContent.value const sourceGraph = source?.graph && typeof source.graph === 'object' ? source.graph : {} const currentStart = Array.isArray(sourceGraph.start) ? sourceGraph.start : [] if (!currentStart.includes(nextNodeId)) { const nextSnapshot = { ...source, graph: { ...sourceGraph, start: [...currentStart, nextNodeId] } } const ok = await persistYamlSnapshot(nextSnapshot) if (ok) { await loadYamlFile() syncVueNodesAndEdgesData() await nextTick() } } } } const openDynamicFormGenerator = (type, options = {}) => { const config = FORM_GENERATOR_CONFIG[type] if (!config) { console.error(`[FormGenerator] Unknown type: ${type}`) return } formGeneratorBreadcrumbs.value = config.map(crumb => ({ ...crumb })) formGeneratorRecursive.value = options.recursive ?? true const resolvedMode = typeof options.mode === 'string' && ['create', 'edit'].includes(options.mode) ? options.mode : (options.initialFormData ? 'edit' : 'create') formGeneratorMode.value = resolvedMode const hasCustomYaml = Object.prototype.hasOwnProperty.call(options, 'initialYaml') const yamlSource = hasCustomYaml ? options.initialYaml : yamlContent.value formGeneratorInitialYaml.value = yamlSource || null if (Object.prototype.hasOwnProperty.call(options, 'initialFormData')) { formGeneratorInitialFormData.value = options.initialFormData || null } else { formGeneratorInitialFormData.value = null } formGeneratorFieldFilter.value = options.fieldFilter ?? [] formGeneratorReadOnlyFields.value = options.readOnlyFields ?? [] showDynamicFormGenerator.value = true } const closeDynamicFormGenerator = () => { showDynamicFormGenerator.value = false formGeneratorBreadcrumbs.value = [] formGeneratorInitialYaml.value = null formGeneratorInitialFormData.value = null formGeneratorMode.value = 'create' formGeneratorFieldFilter.value = null formGeneratorReadOnlyFields.value = [] } const handleFormGeneratorSubmit = async (payload) => { try { const previousNodeId = formGeneratorInitialFormData.value?.id const nextNodeId = payload?.rawFormData?.id //Update VueFlow node ID based on updated YAML if change present if (previousNodeId && nextNodeId && previousNodeId !== nextNodeId) { updateVueFlowNodeId(previousNodeId, nextNodeId) } await loadYamlFile() syncVueNodesAndEdgesData() // Ensure VueFlow internal state is updated from v-model bindings // before taking a snapshot to be saved into vuegraphs.db await nextTick() // If we opened the FormGenerator from a right-click context menu while creating // a new node, place that node at the stored position. if (formGeneratorMode.value === 'create' && pendingNodePosition.value && nextNodeId) { const newNode = (nodes.value || []).find(node => node.id === nextNodeId) if (newNode) { newNode.position = { x: pendingNodePosition.value.x, y: pendingNodePosition.value.y } } pendingNodePosition.value = null } // Auto-connect start node to new node if (formGeneratorMode.value === 'create' && nextNodeId) { await autoAddStartEdge(nextNodeId) } await saveVueFlowGraph() } catch (error) { console.error('Error refreshing workflow after dynamic form submission:', error) } finally { closeDynamicFormGenerator() } } const handleFormGeneratorCopy = (payload) => { try { const copied = payload?.initialFormData ? cloneDeep(payload.initialFormData) : null if (copied && typeof copied === 'object') { copied.id = '' } // @close of original modal calls closeDynamicFormGenerator() // Defer new "create node" modal to the next tick to avoid being closed setTimeout(() => { openDynamicFormGenerator('node', { mode: 'create', initialFormData: copied }) }, 0) } catch (error) { console.error('Error copying node:', error) } } const openNodeEditor = (nodeId) => { if (!nodeId) { return } const yamlNodeContent = (yamlContent.value?.graph?.nodes || []).find(node => node.id === nodeId) if (!yamlNodeContent) { console.warn(`[WorkflowView] Node with id "${nodeId}" not found for editing`) return } // Pass YAML without specific node to the FormGenerator to "recreate" the node const sanitizedYaml = buildYamlWithoutNode(nodeId) openDynamicFormGenerator('node', { initialYaml: sanitizedYaml, initialFormData: yamlNodeContent, mode: 'edit' }) } const openEdgeEditor = (fromId, toId, fallbackData = null) => { if (!fromId || !toId) { return } const yamlEdge = (yamlContent.value?.graph?.edges || []).find(edge => edge.from === fromId && edge.to === toId) const edgeData = yamlEdge || (fallbackData ? cloneDeep(fallbackData) : null) if (!edgeData) { console.warn(`[WorkflowView] Edge "${fromId}-${toId}" not found for editing`) return } const sanitizedYaml = buildYamlWithoutEdge(fromId, toId) openDynamicFormGenerator('edge', { initialYaml: sanitizedYaml, initialFormData: edgeData, mode: 'edit' }) } // Create Node functions const openCreateNodeModal = () => { // Set position to center of graph when creating from 'Create Node' pendingNodePosition.value = getCentralPosition() openDynamicFormGenerator('node', { mode: 'create' }) } const openManageVarsModal = () => { const currentVars = yamlContent.value?.vars || null const sanitizedYaml = buildYamlWithoutVars() openDynamicFormGenerator('vars', { recursive: false, initialYaml: sanitizedYaml, initialFormData: currentVars ? { vars: currentVars } : null, mode: currentVars ? 'edit' : 'create', fieldFilter: ['vars'] }) } const openManageMemoriesModal = () => { const currentMemories = yamlContent.value?.graph?.memory || null const sanitizedYaml = buildYamlWithoutMemory() openDynamicFormGenerator('memory', { initialYaml: sanitizedYaml, initialFormData: currentMemories ? { memory: currentMemories } : null, mode: currentMemories ? 'edit' : 'create', fieldFilter: ['memory'] }) } const openConfigureGraphModal = () => { const currentGraph = yamlContent.value?.graph || null const sanitizedYaml = buildYamlWithoutGraph() openDynamicFormGenerator('graph', { recursive: false, initialYaml: sanitizedYaml, initialFormData: currentGraph, mode: currentGraph ? 'edit' : 'create', fieldFilter: null, readOnlyFields: ['id'] }) } const onNodeClick = (event) => { if (isCreatingConnection.value) { return } const clickedNode = event?.node || event if (!clickedNode?.id) { return } // Ignore left click for start node but show dim animation if (clickedNode.id === START_NODE_ID) { dimStartNode() return } openNodeEditor(clickedNode.id) } const onEdgeClick = (event) => { const clickedEdge = event?.edge || event if (!clickedEdge?.id) { return } const fromId = clickedEdge.data?.from || clickedEdge.source || '' const toId = clickedEdge.data?.to || clickedEdge.target || '' // Ignore start node edge if (fromId === START_NODE_ID || toId === START_NODE_ID) { return } if (!fromId || !toId) { return } const fallbackData = { from: fromId, to: toId } if (clickedEdge.data?.condition !== undefined) { fallbackData.condition = clickedEdge.data.condition } if (clickedEdge.data?.trigger !== undefined) { fallbackData.trigger = clickedEdge.data.trigger } openEdgeEditor(fromId, toId, fallbackData) } // Autosave when moving nodes const onNodeDragStop = () => { saveVueFlowGraph() } const onConnect = async (connection) => { if (!connection?.source || !connection?.target) { return } // Set flag to avoid opening node edit modal isCreatingConnection.value = true // Special handling for StartNode connections if (connection.source === START_NODE_ID) { // Add target node to graph.start array instead of opening FormGenerator const source = yamlContent.value if (!source?.graph) { setTimeout(() => { isCreatingConnection.value = false }, 10) return } const sourceGraph = source.graph // Ensure graph.start exists as an array const currentStart = Array.isArray(sourceGraph.start) ? sourceGraph.start : [] // Add target node to start array if not already present if (!currentStart.includes(connection.target)) { const nextSnapshot = { ...source, graph: { ...sourceGraph, start: [...currentStart, connection.target] } } // Persist the updated YAML const ok = await persistYamlSnapshot(nextSnapshot) if (ok) { await loadYamlFile() syncVueNodesAndEdgesData() await nextTick() await saveVueFlowGraph() } } setTimeout(() => { isCreatingConnection.value = false }, 10) return } // Do not open modal if edge already exists const yamlEdges = yamlContent.value?.graph?.edges || [] const edgeAlreadyExistsInYaml = yamlEdges.some( e => e.from === connection.source && e.to === connection.target ) const edgeAlreadyExistsInGraph = edges.value.some( e => e.source === connection.source && e.target === connection.target ) if (edgeAlreadyExistsInYaml || edgeAlreadyExistsInGraph) { setTimeout(() => { isCreatingConnection.value = false }, 10) return } // Remove the automatically created edge (VueFlow may optimistically add one) const autoCreatedEdgeIndex = edges.value.findIndex( edge => edge.source === connection.source && edge.target === connection.target ) if (autoCreatedEdgeIndex !== -1) { edges.value.splice(autoCreatedEdgeIndex, 1) } openDynamicFormGenerator('edge', { initialFormData: { from: connection.source, to: connection.target, condition: { type: 'function', config: { name: 'true' } }, trigger: true }, mode: 'create' }) // Reset flag after a short delay so click handlers stay disabled setTimeout(() => { isCreatingConnection.value = false }, 100) } // Create Edge functions const openCreateEdgeModal = () => { openDynamicFormGenerator('edge', { mode: 'create' }) } const goToLaunch = () => { if (!currentWorkflowName.value) { return } const fileName = currentWorkflowName.value.endsWith('.yaml') ? currentWorkflowName.value : `${currentWorkflowName.value}.yaml` const resolved = router.resolve({ path: '/launch', query: { workflow: fileName } }) window.open(resolved.href, '_blank', 'noopener') } // Modal functions for rename and copy workflow const openRenameWorkflowModal = () => { showMenu.value = false renameWorkflowName.value = currentWorkflowName.value.replace('.yaml', '') showRenameModal.value = true } const closeRenameModal = () => { showRenameModal.value = false renameWorkflowName.value = '' } const handleRenameSubmit = async () => { if (!renameWorkflowName.value.trim()) { return } const newName = renameWorkflowName.value.trim() const result = await postYamlNameChange(currentWorkflowName.value, newName) if (result.success) { // Handle VueGraph rename const oldWorkflowKey = currentWorkflowName.value.replace('.yaml', '') const newWorkflowKey = newName // Save VueGraph into new workflow try { const oldVueGraphResult = await fetchVueGraph(oldWorkflowKey) if (oldVueGraphResult.success && oldVueGraphResult.content) { const saveResult = await postVuegraphs({ filename: newWorkflowKey, content: oldVueGraphResult.content }) if (!saveResult.success) { console.warn('Failed to rename VueGraph:', saveResult.message) } } } catch (error) { console.warn('Error handling VueGraph rename:', error) } alert(result.message) closeRenameModal() // Refresh workflow list first emit('refresh-workflows') // Small delay to allow workflow list to refresh before navigating await new Promise(resolve => setTimeout(resolve, 500)) // Navigate to the renamed workflow const newWorkflowName = result.filename || `${newName}.yaml` const workflowNameWithoutExtension = newWorkflowName.replace('.yaml', '') router.push({ path: `/workflows/${workflowNameWithoutExtension}` }) } else { alert(result.error?.message || 'Failed to rename workflow') } } const openCopyWorkflowModal = () => { showMenu.value = false copyWorkflowName.value = currentWorkflowName.value.replace('.yaml', '') + '_copy' showCopyModal.value = true } const closeCopyModal = () => { showCopyModal.value = false copyWorkflowName.value = '' } const handleCopySubmit = async () => { if (!copyWorkflowName.value.trim()) { return } const newName = copyWorkflowName.value.trim() const result = await postYamlCopy(currentWorkflowName.value, newName) if (result.success) { // Handle VueGraph copy const sourceWorkflowKey = currentWorkflowName.value.replace('.yaml', '') const targetWorkflowKey = newName try { // Load the VueGraph for the source workflow const sourceVueGraphResult = await fetchVueGraph(sourceWorkflowKey) if (sourceVueGraphResult.success && sourceVueGraphResult.content) { // Save the VueGraph with the new workflow name const saveResult = await postVuegraphs({ filename: targetWorkflowKey, content: sourceVueGraphResult.content }) if (!saveResult.success) { console.warn('Failed to copy VueGraph:', saveResult.message) } } } catch (error) { console.warn('Error handling VueGraph copy:', error) } alert(result.message) closeCopyModal() // Refresh workflow list first emit('refresh-workflows') // Small delay to allow workflow list to refresh before navigating await new Promise(resolve => setTimeout(resolve, 500)) // Navigate to the copied workflow const newWorkflowName = result.filename || `${newName}.yaml` const workflowNameWithoutExtension = newWorkflowName.replace('.yaml', '') router.push({ path: `/workflows/${workflowNameWithoutExtension}` }) } else { alert(result.message || result.error?.message || 'Failed to copy workflow') } } </script> <style scoped> .workflow-view { width: 100%; height: calc(100vh - 55px); display: flex; flex-direction: column; background-color: #1a1a1a; color: #f2f2f2; font-family: 'Inter', sans-serif; position: relative; overflow: hidden; } .workflow-bg { position: fixed; top: -150px; left: 0; right: 0; height: 500px; background: linear-gradient( 90deg, #aaffcd, #99eaf9, #a0c4ff ); filter: blur(120px); opacity: 0.15; z-index: 0; pointer-events: none; } .content { position: relative; z-index: 1; } .header, .tabs { position: relative; z-index: 2; } .header { display: flex; align-items: center; padding: 0 20px; height: 40px; background-color: rgba(255, 255, 255, 0.05); border-bottom: 1px solid rgba(255, 255, 255, 0.1); backdrop-filter: blur(5px); flex-shrink: 0; } .back-button { padding: 8px; margin-right: 16px; background: transparent; color: rgba(255, 255, 255, 0.6); cursor: pointer; transition: color 0.2s ease; display: flex; align-items: center; justify-content: center; border: 1px solid transparent; outline: none; } .back-button:hover { background: transparent; color: #f2f2f2; border-color: transparent; } .back-button:focus, .back-button:focus-visible { outline: none; border-color: transparent; } .workflow-name { color: #f2f2f2; font-size: 18px; font-weight: 600; margin: 0; } .tabs { display: flex; justify-content: space-between; align-items: center; padding: 0 20px; height: 50px; background-color: rgba(255, 255, 255, 0.02); border-top: 1px solid rgba(255, 255, 255, 0.1); backdrop-filter: blur(5px); flex-shrink: 0; position: sticky; bottom: 0; z-index: 2; } .tab-buttons { display: flex; gap: 4px; height: 100%; } .tab { padding: 0 20px; height: 100%; border: none; background: transparent; cursor: pointer; font-size: 14px; font-family: 'Inter', sans-serif; color: #8e8e8e; font-weight: 500; transition: color 0.2s ease; position: relative; } .tab:hover { color: #f2f2f2; } .tab.active { background: linear-gradient( 135deg, #aaffcd, #99eaf9, #a0c4ff ); background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent; font-weight: 500; } .editor-actions { display: flex; gap: 12px; align-items: center; height: 100%; } /* Glass Button - Matching WorkflowList entries */ .glass-button { display: flex; align-items: center; gap: 8px; padding: 8px 16px; background-color: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 12px; cursor: pointer; transition: all 0.3s ease; color: #f2f2f2; font-size: 14px; position: relative; z-index: 1; backdrop-filter: blur(5px); } .launch-button-primary { display: flex; align-items: center; gap: 8px; padding: 8px 16px; border: none; border-radius: 12px; cursor: pointer; transition: all 0.3s ease; color: #1a1a1a; font-size: 14px; font-weight: 600; background: linear-gradient( 135deg, #aaffcd, #99eaf9, #a0c4ff ); background-size: 200% 100%; animation: gradientShift 6s ease-in-out infinite; backdrop-filter: blur(5px); position: relative; z-index: 1; } .launch-button-primary:hover { transform: translateY(-2px); box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); opacity: 0.9; } .glass-button:hover { background-color: rgba(255, 255, 255, 0.05); border-color: rgba(255, 255, 255, 0.05); } @keyframes gradientShift { 0%, 100% { background-position: 0% 0%; } 50% { background-position: 100% 0%; } } .glass-button::before { content: ''; position: absolute; inset: -2px; z-index: -1; border-radius: 14px; padding: 2px; background: linear-gradient( 135deg, #aaffcd, #99eaf9, #a0c4ff ); -webkit-mask: linear-gradient(#f2f2f2 0 0) content-box, linear-gradient(#f2f2f2 0 0); mask: linear-gradient(#f2f2f2 0 0) content-box, linear-gradient(#f2f2f2 0 0); -webkit-mask-composite: xor; mask-composite: exclude; opacity: 0; transition: opacity 0.3s ease; background-size: 200% 100%; animation: gradientShift 6s ease-in-out infinite; filter: blur(4px); } .glass-button:hover::before { opacity: 1; } .btn-icon { font-size: 16px; } /* Menu Dropdown */ .menu-container { position: relative; z-index: 3; height: 100%; } .menu-trigger { width: 40px; height: 100%; display: flex; align-items: center; justify-content: center; border-radius: 12px; cursor: pointer; transition: all 0.2s ease; color: rgba(255, 255, 255, 0.7); } .menu-dropdown { position: absolute; bottom: 100%; right: 0; background: rgba(60, 60, 60, 0.99); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 12px; padding: 8px; min-width: 180px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); backdrop-filter: blur(10px); z-index: 3; } .menu-item { padding: 10px 16px; color: rgba(255, 255, 255, 0.8); font-size: 14px; cursor: pointer; border-radius: 8px; transition: all 0.2s ease; } .menu-item:hover { background-color: rgba(255, 255, 255, 0.1); color: #f2f2f2; } /* Transitions */ .fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; } .fade-enter-from, .fade-leave-to { opacity: 0; transform: translateY(-10px); } .content { flex: 1; overflow: hidden; position: relative; min-height: 0; } .yaml-editor { height: 100%; display: flex; flex-direction: column; } .yaml-error { padding: 12px 20px; background-color: rgba(255, 68, 68, 0.1); border-bottom: 1px solid rgba(255, 68, 68, 0.3); color: #ff8888; font-size: 14px; margin: 0; } .yaml-textarea { flex: 1; padding: 20px; border: none; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 14px; line-height: 1.5; resize: none; outline: none; background: transparent; color: #d4d4d4; } .yaml-textarea::-webkit-scrollbar { display: none; } .yaml-error-border { border: 2px solid #ff4444 !important; } .vueflow-container { height: 100%; width: 100%; background-color: rgba(255, 255, 255, 0.03); backdrop-filter: blur(5px); z-index: 1; position: relative; } .vueflow-graph { width: 100%; height: 100%; } .context-menu { position: absolute; min-width: 160px; background: rgba(40, 40, 40, 0.98); border-radius: 10px; padding: 6px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6); border: 1px solid rgba(255, 255, 255, 0.1); backdrop-filter: blur(10px); z-index: 5; } .context-menu-item { padding: 8px 12px; color: rgba(255, 255, 255, 0.85); font-size: 13px; border-radius: 8px; cursor: pointer; transition: background-color 0.15s ease, color 0.15s ease; } .context-menu-item:hover { background-color: rgba(255, 255, 255, 0.1); color: #f2f2f2; } /* Modal Styles - Matching FormGenerator modal-content */ .modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; backdrop-filter: blur(4px); } .modal-content { display: flex; flex-direction: column; width: 90%; max-width: 500px; max-height: 90vh; background-color: rgba(33, 33, 33, 0.92); border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.08); box-shadow: 0 20px 40px rgba(0, 0, 0, 0.6); overflow: hidden; backdrop-filter: blur(10px); } .modal-header { flex-shrink: 0; height: 28px; display: flex; align-items: center; padding: 0 20px; background-color: transparent; border-bottom: 1px solid rgba(255, 255, 255, 0.04); } .modal-title { color: #f2f2f2; font-size: 15px; font-weight: 600; margin: 0; flex: 1; } .close-button { margin-left: auto; background: transparent; border: none; width: 18px; height: 18px; display: flex; align-items: center; justify-content: center; cursor: pointer; color: rgba(255, 255, 255, 0.6); transition: color 0.2s ease; } .close-button:hover { color: #f2f2f2; } .modal-body { flex: 1; padding: 20px; max-height: none; overflow-y: auto; border-top: 1px solid rgba(255, 255, 255, 0.04); border-bottom: 1px solid rgba(255, 255, 255, 0.04); scrollbar-width: none; } .modal-body::-webkit-scrollbar { display: none; } .form-group { margin-bottom: 16px; } .form-label { display: block; color: #f2f2f2; font-size: 13px; font-weight: 500; margin-bottom: 8px; } .form-input { width: 90%; padding: 12px 16px; background-color: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 8px; color: #f2f2f2; font-size: 14px; font-family: 'Inter', sans-serif; outline: none; transition: border-color 0.2s ease, background-color 0.2s ease; } .form-input:focus { border-color: rgba(170, 255, 205, 0.5); background-color: rgba(255, 255, 255, 0.08); } .form-input::placeholder { color: rgba(255, 255, 255, 0.4); } .modal-footer { flex-shrink: 0; display: flex; justify-content: flex-end; gap: 12px; padding: 16px 20px; background-color: transparent; border-top: 1px solid rgba(255, 255, 255, 0.04); } .cancel-button, .submit-button { padding: 8px 16px; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s ease; font-family: 'Inter', sans-serif; } .cancel-button { background-color: transparent; border: 1px solid rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.7); } .cancel-button:hover { background-color: rgba(255, 255, 255, 0.05); border-color: rgba(255, 255, 255, 0.2); color: #f2f2f2; } .submit-button { background: linear-gradient(135deg, #aaffcd, #99eaf9, #a0c4ff); border: none; color: #1a1a1a; font-weight: 600; } .submit-button:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); } .submit-button:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } </style>