mirror of
https://github.com/OpenBMB/ChatDev.git
synced 2026-04-25 19:28:09 +00:00
2423 lines
68 KiB
Vue
Executable File
2423 lines
68 KiB
Vue
Executable File
<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">{{ workflowName }}</h1>
|
|
</div>
|
|
|
|
<div class="content">
|
|
<!-- YAML Editor Tab -->
|
|
<div v-if="activeTab === 'yaml'" class="yaml-editor">
|
|
<div v-if="yamlParseError" class="yaml-error">
|
|
YAML Parse Error: {{ yamlParseError }}
|
|
</div>
|
|
<textarea
|
|
v-model="yamlTextString"
|
|
class="yaml-textarea"
|
|
:class="{ 'yaml-error-border': yamlParseError }"
|
|
placeholder="Loading YAML content..."
|
|
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(); }"
|
|
>
|
|
Create Node
|
|
</div>
|
|
</RichTooltip>
|
|
<div
|
|
v-else
|
|
class="context-menu-item"
|
|
@click.stop="() => { hideContextMenu(); openCreateNodeModal(); }"
|
|
>
|
|
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(); }"
|
|
>
|
|
Copy Node
|
|
</div>
|
|
</RichTooltip>
|
|
<div
|
|
v-else
|
|
class="context-menu-item"
|
|
@click.stop="() => { hideContextMenu(); onCopyNodeFromContext(); }"
|
|
>
|
|
Copy Node
|
|
</div>
|
|
<RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.deleteNode" placement="right">
|
|
<div
|
|
class="context-menu-item"
|
|
@click.stop="() => { hideContextMenu(); onDeleteNodeFromContext(); }"
|
|
>
|
|
Delete Node
|
|
</div>
|
|
</RichTooltip>
|
|
<div
|
|
v-else
|
|
class="context-menu-item"
|
|
@click.stop="() => { hideContextMenu(); onDeleteNodeFromContext(); }"
|
|
>
|
|
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(); }"
|
|
>
|
|
Delete Edge
|
|
</div>
|
|
</RichTooltip>
|
|
<div
|
|
v-else
|
|
class="context-menu-item"
|
|
@click.stop="() => { hideContextMenu(); onDeleteEdgeFromContext(); }"
|
|
>
|
|
Delete Edge
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</transition>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tabs">
|
|
<div class="tab-buttons">
|
|
<button
|
|
:class="['tab', { active: activeTab === 'graph' }]"
|
|
@click="activeTab = 'graph'"
|
|
>
|
|
Workflow Graph
|
|
</button>
|
|
<button
|
|
:class="['tab', { active: activeTab === 'yaml' }]"
|
|
@click="activeTab = 'yaml'"
|
|
>
|
|
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>Create Node</span>
|
|
</button>
|
|
</RichTooltip>
|
|
<button v-else @click="openCreateNodeModal" class="glass-button">
|
|
<span>Create Node</span>
|
|
</button>
|
|
<RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.configureGraph" placement="bottom">
|
|
<button @click="openConfigureGraphModal" class="glass-button">
|
|
<span>Configure Graph</span>
|
|
</button>
|
|
</RichTooltip>
|
|
<button v-else @click="openConfigureGraphModal" class="glass-button">
|
|
<span>Configure Graph</span>
|
|
</button>
|
|
<RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.launch" placement="bottom">
|
|
<button @click="goToLaunch" class="launch-button-primary">
|
|
<span>Launch</span>
|
|
</button>
|
|
</RichTooltip>
|
|
<button v-else @click="goToLaunch" class="launch-button-primary">
|
|
<span>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">Rename Workflow</div>
|
|
</RichTooltip>
|
|
<div v-else @click="openRenameWorkflowModal" class="menu-item">Rename Workflow</div>
|
|
<RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.copyWorkflow" placement="left">
|
|
<div @click="openCopyWorkflowModal" class="menu-item">Copy Workflow</div>
|
|
</RichTooltip>
|
|
<div v-else @click="openCopyWorkflowModal" class="menu-item">Copy Workflow</div>
|
|
<RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.manageVariables" placement="left">
|
|
<div @click="openManageVarsModal" class="menu-item">Manage Variables</div>
|
|
</RichTooltip>
|
|
<div v-else @click="openManageVarsModal" class="menu-item">Manage Variables</div>
|
|
<RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.manageMemories" placement="left">
|
|
<div @click="openManageMemoriesModal" class="menu-item">Manage Memories</div>
|
|
</RichTooltip>
|
|
<div v-else @click="openManageMemoriesModal" class="menu-item">Manage Memories</div>
|
|
<RichTooltip v-if="shouldShowTooltip" :content="helpContent.contextMenu.createEdge" placement="left">
|
|
<div @click="openCreateEdgeModal" class="menu-item">Create Edge</div>
|
|
</RichTooltip>
|
|
<div v-else @click="openCreateEdgeModal" class="menu-item">Create Edge</div>
|
|
</div>
|
|
</transition>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<FormGenerator
|
|
v-if="showDynamicFormGenerator"
|
|
:breadcrumbs="formGeneratorBreadcrumbs"
|
|
:recursive="formGeneratorRecursive"
|
|
:workflow-name="workflowName"
|
|
: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">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">Workflow Name</label>
|
|
<input
|
|
id="rename-workflow-name"
|
|
v-model="renameWorkflowName"
|
|
type="text"
|
|
class="form-input"
|
|
placeholder="Enter new workflow name"
|
|
@keyup.enter="handleRenameSubmit"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button @click="closeRenameModal" class="cancel-button">Cancel</button>
|
|
<button @click="handleRenameSubmit" class="submit-button" :disabled="!renameWorkflowName.trim()">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()">Submit</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, watch, nextTick, onMounted, onBeforeUnmount, computed } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
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 { 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 workflowName = 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 (!workflowName.value) {
|
|
return false
|
|
}
|
|
const yamlString = yaml.dump(snapshot ?? {})
|
|
const result = await updateYaml(workflowName.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
|
|
}
|
|
workflowName.value = name
|
|
console.log('Workflow initialized: ', workflowName.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 = workflowName.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 = workflowName.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 (!workflowName.value) {
|
|
return
|
|
}
|
|
const result = await fetchYaml(workflowName.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 (!workflowName.value) {
|
|
return
|
|
}
|
|
const fileName = workflowName.value.endsWith('.yaml')
|
|
? workflowName.value
|
|
: `${workflowName.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 = workflowName.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(workflowName.value, newName)
|
|
|
|
if (result.success) {
|
|
// Handle VueGraph rename
|
|
const oldWorkflowKey = workflowName.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 = workflowName.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(workflowName.value, newName)
|
|
|
|
if (result.success) {
|
|
// Handle VueGraph copy
|
|
const sourceWorkflowKey = workflowName.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>
|