Spaces:
Sleeping
Sleeping
| import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | |
| import { | |
| applyEdgeChanges, | |
| applyNodeChanges, | |
| Background, | |
| Controls, | |
| MiniMap, | |
| ReactFlow, | |
| ReactFlowProvider, | |
| useReactFlow, | |
| } from '@xyflow/react'; | |
| import { WorkflowProvider } from './context/WorkflowContext.jsx'; | |
| import Toolbar from './components/Toolbar.jsx'; | |
| import NodePalette from './components/NodePalette.jsx'; | |
| import SettingsDialog from './components/SettingsDialog.jsx'; | |
| import InteractivePanel from './components/InteractivePanel.jsx'; | |
| import { | |
| appendTranscriptLog, | |
| getLlmSessionLog, | |
| getDeepinfraKeyStatus, | |
| loadConfig, | |
| loadDefaultWorkflow, | |
| loadPresets, | |
| evaluateAssistantAnswer, | |
| saveCustomPreset, | |
| saveDefaultWorkflow, | |
| saveDeepinfraKey, | |
| simulateUserAnswer, | |
| startTestRun, | |
| startTranscriptLog, | |
| DEFAULT_BACKEND_URL, | |
| DEFAULT_LIVE_DEBUG_URL, | |
| } from './lib/api.js'; | |
| import { | |
| buildComponentNodeDataFromTemplate, | |
| loadComponentLibrary, | |
| mergeComponentTemplates, | |
| persistComponentLibrary, | |
| upsertComponentTemplateFromNode, | |
| } from './lib/componentLibrary.js'; | |
| import { | |
| loadAppSettings, | |
| normalizeBackendUrl, | |
| persistAppSettings, | |
| resolveBackendUrl, | |
| resolveLiveDebugUrl, | |
| } from './lib/settings.js'; | |
| import { | |
| COMPONENT_NODE_TYPE, | |
| createComponentSubgraph, | |
| createNodeInstance, | |
| getNodeHandles, | |
| hydrateNode, | |
| isComponentNodeType, | |
| listNodeDefinitions, | |
| nodeTypes, | |
| } from './lib/nodeRegistry.js'; | |
| import { | |
| AUTOSAVE_KEY, | |
| LEGACY_AUTOSAVE_KEY, | |
| connectEdge, | |
| createComponentFromSelection, | |
| createBlankWorkflow, | |
| createDefaultWorkflow, | |
| continueInteractiveWorkflowRun, | |
| createInteractiveWorkflowRun, | |
| DEFAULT_VIEWPORT, | |
| getGraphAtPath, | |
| hydrateGraph, | |
| isValidConnection, | |
| parseWorkflowData, | |
| serializeWorkflow, | |
| updateGraphAtPath, | |
| } from './lib/workflow.js'; | |
| const THEME_KEY = 'nodes_ui_flow_theme'; | |
| const LLM_PROVIDERS = new Set(['ollama', 'deepinfra']); | |
| const FALLBACK_LLM_PROVIDER = 'ollama'; | |
| const DEFAULT_LLM_ROLE_PROMPT = | |
| 'Ты посетитель выставки RosUpack. Отвечай коротко, естественно и по делу. Ты представляешь средний или крупный бизнес.'; | |
| function detectInitialTheme() { | |
| const savedTheme = window.localStorage.getItem(THEME_KEY); | |
| if (savedTheme === 'dark' || savedTheme === 'light') { | |
| return savedTheme; | |
| } | |
| return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; | |
| } | |
| function isEditableTarget(target) { | |
| if (!(target instanceof HTMLElement)) { | |
| return false; | |
| } | |
| return Boolean(target.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]')); | |
| } | |
| function mergeRuntimeIntoEditorGraph(editorGraph, runtimeGraph) { | |
| const runtimeNodesById = new Map((runtimeGraph?.nodes || []).map((node) => [node.id, node])); | |
| return hydrateGraph({ | |
| ...editorGraph, | |
| nodes: (editorGraph?.nodes || []).map((node) => { | |
| const runtimeNode = runtimeNodesById.get(node.id); | |
| if (!runtimeNode) { | |
| return node; | |
| } | |
| const nextData = { | |
| ...node.data, | |
| runtime: runtimeNode.data?.runtime || node.data?.runtime, | |
| }; | |
| if (isComponentNodeType(node.type) && node.data?.subgraph && runtimeNode.data?.subgraph) { | |
| nextData.subgraph = mergeRuntimeIntoEditorGraph(node.data.subgraph, runtimeNode.data.subgraph); | |
| } | |
| return hydrateNode({ | |
| ...node, | |
| data: nextData, | |
| }); | |
| }), | |
| edges: editorGraph?.edges || [], | |
| viewport: editorGraph?.viewport || null, | |
| }); | |
| } | |
| function createLlmSessionId() { | |
| return `llm_session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; | |
| } | |
| function safeDownloadName(value) { | |
| return String(value || 'workflow') | |
| .replace(/\.json$/i, '') | |
| .replace(/[^a-zA-Z0-9а-яА-ЯёЁ._-]+/g, '_') | |
| .replace(/^_+|_+$/g, '') || 'workflow'; | |
| } | |
| function normalizeLlmProvider(value) { | |
| return LLM_PROVIDERS.has(value) ? value : FALLBACK_LLM_PROVIDER; | |
| } | |
| function formatMessageRole(role) { | |
| return role === 'assistant' ? 'АССИСТЕНТ' : 'ПОЛЬЗОВАТЕЛЬ'; | |
| } | |
| function formatSessionLog({ workflowName, sessionId, provider, transcript, runtimeLogs, llmCalls }) { | |
| const lines = [ | |
| `Workflow: ${workflowName}`, | |
| `Сессия: ${sessionId || 'не запущена'}`, | |
| `Provider: ${provider}`, | |
| `Сохранено: ${new Date().toLocaleString()}`, | |
| '', | |
| '=== Диалог ===', | |
| '', | |
| ]; | |
| if (transcript.length === 0) { | |
| lines.push('В текущей сессии нет сообщений диалога.', ''); | |
| } else { | |
| transcript.forEach((message) => { | |
| lines.push(`${formatMessageRole(message.role)}: ${message.text}`, ''); | |
| }); | |
| } | |
| lines.push('=== Runtime события ===', ''); | |
| if (!runtimeLogs?.length) { | |
| lines.push('В текущей сессии нет runtime-событий.', ''); | |
| } else { | |
| runtimeLogs.forEach((entry) => { | |
| lines.push(`[${entry.level || 'info'}] ${entry.message || ''}`); | |
| }); | |
| lines.push(''); | |
| } | |
| lines.push('=== LLM-вызовы ===', ''); | |
| if (llmCalls.length === 0) { | |
| lines.push('В текущей сессии не было LLM-вызовов.', ''); | |
| } else { | |
| llmCalls.forEach((call, index) => { | |
| lines.push( | |
| `#${index + 1} ${call.timestamp || ''}`, | |
| `Endpoint: ${call.endpoint || ''}`, | |
| `Node: ${call.node_id || ''}`, | |
| `Provider: ${call.provider || ''}`, | |
| `Model: ${call.model || ''}`, | |
| '', | |
| 'Сообщения:', | |
| ); | |
| (call.messages || []).forEach((message) => { | |
| lines.push(`${String(message.role || 'unknown').toUpperCase()}:`); | |
| lines.push(message.content || ''); | |
| lines.push(''); | |
| }); | |
| lines.push('Сырой ответ:'); | |
| lines.push(JSON.stringify(call.response || {}, null, 2)); | |
| lines.push(''); | |
| }); | |
| } | |
| return lines.join('\n'); | |
| } | |
| function resolveLiveDebugWebSocketUrl(liveDebugUrl) { | |
| const normalized = normalizeBackendUrl(liveDebugUrl) || DEFAULT_LIVE_DEBUG_URL; | |
| return `${normalized.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:')}/workflow-runtime/active/live`; | |
| } | |
| function getLiveDebugEventKey(event) { | |
| return [ | |
| event.timestamp || '', | |
| event.type || '', | |
| event.nodeId || event.node_id || '', | |
| event.title || '', | |
| event.question || event.answer || event.message || event.error || JSON.stringify(event.memoryPatch || {}), | |
| ].join('|'); | |
| } | |
| function WorkflowEditor() { | |
| const reactFlow = useReactFlow(); | |
| const fileInputRef = useRef(null); | |
| const fileHandleRef = useRef(null); | |
| const spawnCounterRef = useRef(0); | |
| const interactiveRunRef = useRef(null); | |
| const interactiveRunPathRef = useRef([]); | |
| const logCounterRef = useRef(0); | |
| const transcriptCounterRef = useRef(0); | |
| const autoAnsweredQuestionRef = useRef(null); | |
| const interactiveTranscriptRef = useRef([]); | |
| const lastUserAnswerRef = useRef(''); | |
| const llmTestModeRef = useRef(false); | |
| const llmTestStopRequestedRef = useRef(false); | |
| const testRunRef = useRef(null); | |
| const testAutoAnswerCountRef = useRef(0); | |
| const transcriptLogRef = useRef(null); | |
| const writeTranscriptLogsRef = useRef(false); | |
| const llmSessionIdRef = useRef(''); | |
| const suppressNextUserAnswerLogRef = useRef(false); | |
| const liveDebugSocketRef = useRef(null); | |
| const liveDebugEventKeysRef = useRef(new Set()); | |
| const [workflowDocument, setWorkflowDocument] = useState(null); | |
| const [graphPath, setGraphPath] = useState([]); | |
| const [backendUrl, setBackendUrl] = useState(DEFAULT_BACKEND_URL); | |
| const [liveDebugUrl, setLiveDebugUrl] = useState(DEFAULT_LIVE_DEBUG_URL); | |
| const [defaultBackendUrl, setDefaultBackendUrl] = useState(DEFAULT_BACKEND_URL); | |
| const [defaultLlmProvider, setDefaultLlmProvider] = useState(FALLBACK_LLM_PROVIDER); | |
| const [defaultDeepinfraApiKey, setDefaultDeepinfraApiKey] = useState(''); | |
| const [presets, setPresets] = useState({ default: [], custom: [] }); | |
| const [componentTemplates, setComponentTemplates] = useState(() => loadComponentLibrary()); | |
| const [isRunning, setIsRunning] = useState(false); | |
| const [notice, setNotice] = useState('Редактор workflow готов'); | |
| const [isReady, setIsReady] = useState(false); | |
| const [isSettingsOpen, setIsSettingsOpen] = useState(false); | |
| const [settingsBackendUrl, setSettingsBackendUrl] = useState(DEFAULT_BACKEND_URL); | |
| const [settingsLiveDebugUrl, setSettingsLiveDebugUrl] = useState(''); | |
| const [settingsLlmProvider, setSettingsLlmProvider] = useState(FALLBACK_LLM_PROVIDER); | |
| const [settingsDeepinfraApiKey, setSettingsDeepinfraApiKey] = useState(''); | |
| const [settingsDeepinfraHasKey, setSettingsDeepinfraHasKey] = useState(false); | |
| const [settingsDeepinfraKeyStatus, setSettingsDeepinfraKeyStatus] = useState(''); | |
| const [isDemoMode, setIsDemoMode] = useState(false); | |
| const [theme, setTheme] = useState(detectInitialTheme); | |
| const [isPaletteVisible, setIsPaletteVisible] = useState(true); | |
| const [pendingQuestion, setPendingQuestion] = useState(null); | |
| const [interactiveTranscript, setInteractiveTranscript] = useState([]); | |
| const [interactiveLogs, setInteractiveLogs] = useState([]); | |
| const [answerDraft, setAnswerDraft] = useState(''); | |
| const [llmProvider, setLlmProvider] = useState(FALLBACK_LLM_PROVIDER); | |
| const [llmAutoAnswer, setLlmAutoAnswer] = useState(false); | |
| const [llmTestMode, setLlmTestMode] = useState(false); | |
| const [writeTranscriptLogs, setWriteTranscriptLogs] = useState(false); | |
| const [llmRolePrompt, setLlmRolePrompt] = useState(DEFAULT_LLM_ROLE_PROMPT); | |
| const [liveDebugStatus, setLiveDebugStatus] = useState('off'); | |
| const currentGraph = workflowDocument ? getGraphAtPath(workflowDocument, graphPath) : { nodes: [], edges: [], viewport: null }; | |
| const nodes = currentGraph.nodes || []; | |
| const edges = currentGraph.edges || []; | |
| const selectedNodeCount = nodes.filter((node) => node.selected).length; | |
| useEffect(() => { | |
| document.documentElement.dataset.theme = theme; | |
| document.documentElement.style.colorScheme = theme; | |
| window.localStorage.setItem(THEME_KEY, theme); | |
| }, [theme]); | |
| const applyComponentTemplates = useCallback((templates, options = {}) => { | |
| const { persist = true } = options; | |
| setComponentTemplates(templates); | |
| setWorkflowDocument((previousDocument) => | |
| previousDocument | |
| ? { | |
| ...previousDocument, | |
| componentTemplates: templates, | |
| } | |
| : previousDocument, | |
| ); | |
| if (persist) { | |
| persistComponentLibrary(templates); | |
| } | |
| }, []); | |
| const syncViewportForGraph = useCallback( | |
| (graph, options = {}) => { | |
| requestAnimationFrame(() => { | |
| if (graph?.viewport && !options.fitView) { | |
| reactFlow.setViewport(graph.viewport, { duration: 0 }); | |
| } else { | |
| reactFlow.fitView({ padding: 0.2, duration: 240 }); | |
| } | |
| }); | |
| }, | |
| [reactFlow], | |
| ); | |
| useEffect(() => { | |
| let isActive = true; | |
| const initialize = async () => { | |
| const storedComponentTemplates = loadComponentLibrary(); | |
| const storedSettings = loadAppSettings(); | |
| const [config, loadedPresets, serverDefaultWorkflow] = await Promise.all([ | |
| loadConfig(), | |
| loadPresets(), | |
| loadDefaultWorkflow().catch((error) => { | |
| console.error('Failed to load server default workflow:', error); | |
| return null; | |
| }), | |
| ]); | |
| if (!isActive) { | |
| return; | |
| } | |
| const resolvedDefaultBackendUrl = normalizeBackendUrl(config.backendUrl) || DEFAULT_BACKEND_URL; | |
| const resolvedBackendUrl = resolveBackendUrl(resolvedDefaultBackendUrl, storedSettings); | |
| const resolvedDefaultLiveDebugUrl = normalizeBackendUrl(config.liveDebugUrl) || DEFAULT_LIVE_DEBUG_URL; | |
| const resolvedLiveDebugUrl = resolveLiveDebugUrl(resolvedDefaultLiveDebugUrl, storedSettings); | |
| const resolvedDefaultLlmProvider = normalizeLlmProvider(config.llmProvider); | |
| const resolvedDefaultDeepinfraApiKey = String(config.deepinfraApiKey || '').trim(); | |
| setDefaultBackendUrl(resolvedDefaultBackendUrl); | |
| setDefaultLlmProvider(resolvedDefaultLlmProvider); | |
| setDefaultDeepinfraApiKey(resolvedDefaultDeepinfraApiKey); | |
| setIsDemoMode(Boolean(config.demo)); | |
| setBackendUrl(resolvedBackendUrl); | |
| setLiveDebugUrl(resolvedLiveDebugUrl); | |
| setSettingsBackendUrl(resolvedBackendUrl); | |
| setSettingsLiveDebugUrl(storedSettings.liveDebugUrlOverride || ''); | |
| setPresets(loadedPresets); | |
| let documentToLoad = null; | |
| const autosave = localStorage.getItem(AUTOSAVE_KEY); | |
| if (autosave) { | |
| try { | |
| documentToLoad = parseWorkflowData(JSON.parse(autosave)); | |
| setNotice('Восстановлен autosave'); | |
| } catch (error) { | |
| console.error('Failed to restore React Flow autosave:', error); | |
| } | |
| } | |
| if (!documentToLoad) { | |
| const legacyAutosave = localStorage.getItem(LEGACY_AUTOSAVE_KEY); | |
| if (legacyAutosave) { | |
| try { | |
| documentToLoad = parseWorkflowData(JSON.parse(legacyAutosave)); | |
| setNotice('Импортирован старый autosave LiteGraph'); | |
| } catch (error) { | |
| console.error('Failed to import LiteGraph autosave:', error); | |
| } | |
| } | |
| } | |
| if (!documentToLoad && serverDefaultWorkflow) { | |
| try { | |
| documentToLoad = parseWorkflowData(serverDefaultWorkflow); | |
| setNotice('Загружен workflow с сервера'); | |
| } catch (error) { | |
| console.error('Failed to parse server default workflow:', error); | |
| } | |
| } | |
| if (!documentToLoad) { | |
| documentToLoad = createDefaultWorkflow(); | |
| } | |
| const mergedTemplates = mergeComponentTemplates(documentToLoad.componentTemplates || [], storedComponentTemplates); | |
| const initialLlmProvider = normalizeLlmProvider(config.llmProvider || documentToLoad.settings?.llmProvider); | |
| setLlmRolePrompt(documentToLoad.settings?.llmRolePrompt || DEFAULT_LLM_ROLE_PROMPT); | |
| setLlmProvider(initialLlmProvider); | |
| setWorkflowDocument({ | |
| ...documentToLoad, | |
| componentTemplates: mergedTemplates, | |
| settings: { | |
| ...(documentToLoad.settings || {}), | |
| llmRolePrompt: documentToLoad.settings?.llmRolePrompt || DEFAULT_LLM_ROLE_PROMPT, | |
| llmProvider: initialLlmProvider, | |
| }, | |
| }); | |
| setComponentTemplates(mergedTemplates); | |
| persistComponentLibrary(mergedTemplates); | |
| if (initialLlmProvider === 'deepinfra' && resolvedDefaultDeepinfraApiKey) { | |
| saveDeepinfraKey(resolvedBackendUrl, resolvedDefaultDeepinfraApiKey).catch((error) => { | |
| console.error('Failed to apply DeepInfra key from config:', error); | |
| }); | |
| } | |
| setGraphPath([]); | |
| syncViewportForGraph(documentToLoad.rootGraph, { | |
| fitView: !documentToLoad.rootGraph.viewport, | |
| }); | |
| setIsReady(true); | |
| }; | |
| initialize(); | |
| return () => { | |
| isActive = false; | |
| }; | |
| }, [syncViewportForGraph]); | |
| useEffect(() => { | |
| if (!isReady || !workflowDocument) { | |
| return; | |
| } | |
| const timeoutId = window.setTimeout(() => { | |
| localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(serializeWorkflow(workflowDocument))); | |
| }, 180); | |
| return () => { | |
| window.clearTimeout(timeoutId); | |
| }; | |
| }, [isReady, workflowDocument]); | |
| const updateCurrentGraph = useCallback( | |
| (updater) => { | |
| setWorkflowDocument((previousDocument) => updateGraphAtPath(previousDocument, graphPath, updater)); | |
| }, | |
| [graphPath], | |
| ); | |
| const updateWorkflowSettings = useCallback((patch) => { | |
| setWorkflowDocument((previousDocument) => | |
| previousDocument | |
| ? { | |
| ...previousDocument, | |
| settings: { | |
| ...(previousDocument.settings || {}), | |
| ...patch, | |
| }, | |
| } | |
| : previousDocument, | |
| ); | |
| }, []); | |
| const handleLlmRolePromptChange = useCallback( | |
| (value) => { | |
| setLlmRolePrompt(value); | |
| updateWorkflowSettings({ llmRolePrompt: value }); | |
| }, | |
| [updateWorkflowSettings], | |
| ); | |
| const refreshPresets = useCallback(async () => { | |
| const loadedPresets = await loadPresets(); | |
| setPresets(loadedPresets); | |
| return loadedPresets; | |
| }, []); | |
| const patchNodeData = useCallback( | |
| (nodeId, patch) => { | |
| updateCurrentGraph((graph) => ({ | |
| ...graph, | |
| nodes: graph.nodes.map((node) => { | |
| if (node.id !== nodeId) { | |
| return node; | |
| } | |
| const nextPatch = typeof patch === 'function' ? patch(node.data, node) : patch; | |
| return hydrateNode({ | |
| ...node, | |
| data: { | |
| ...node.data, | |
| ...nextPatch, | |
| }, | |
| }); | |
| }), | |
| })); | |
| }, | |
| [updateCurrentGraph], | |
| ); | |
| const replaceNodeData = useCallback( | |
| (nodeId, updater) => { | |
| updateCurrentGraph((graph) => ({ | |
| ...graph, | |
| nodes: graph.nodes.map((node) => { | |
| if (node.id !== nodeId) { | |
| return node; | |
| } | |
| const nextData = typeof updater === 'function' ? updater(node.data, node) : updater; | |
| return hydrateNode({ | |
| ...node, | |
| data: nextData, | |
| }); | |
| }), | |
| })); | |
| }, | |
| [updateCurrentGraph], | |
| ); | |
| const removeHandleConnections = useCallback( | |
| (nodeId, handleId, direction) => { | |
| updateCurrentGraph((graph) => ({ | |
| ...graph, | |
| edges: graph.edges.filter((edge) => { | |
| if (direction === 'source') { | |
| return !(edge.source === nodeId && edge.sourceHandle === handleId); | |
| } | |
| return !(edge.target === nodeId && edge.targetHandle === handleId); | |
| }), | |
| })); | |
| }, | |
| [updateCurrentGraph], | |
| ); | |
| const savePreset = useCallback( | |
| async (name, text) => { | |
| try { | |
| const success = await saveCustomPreset(backendUrl, name, text); | |
| if (success) { | |
| await refreshPresets(); | |
| setNotice(`Preset saved: ${name}`); | |
| } | |
| return success; | |
| } catch (error) { | |
| console.error('Failed to save preset:', error); | |
| setNotice('Не удалось сохранить пресет'); | |
| return false; | |
| } | |
| }, | |
| [backendUrl, refreshPresets], | |
| ); | |
| const saveComponentNode = useCallback( | |
| (nodeId) => { | |
| const componentNode = nodes.find((node) => node.id === nodeId && isComponentNodeType(node.type)); | |
| if (!componentNode) { | |
| setNotice('Компонентная нода не найдена'); | |
| return false; | |
| } | |
| const { template, templates } = upsertComponentTemplateFromNode(componentTemplates, componentNode); | |
| applyComponentTemplates(templates); | |
| patchNodeData(nodeId, { libraryId: template.id }); | |
| setNotice(`Saved component node: ${template.title}`); | |
| return true; | |
| }, | |
| [applyComponentTemplates, componentTemplates, nodes, patchNodeData], | |
| ); | |
| const addNode = useCallback( | |
| (type, overrides = {}) => { | |
| const offset = spawnCounterRef.current % 5; | |
| spawnCounterRef.current += 1; | |
| const position = reactFlow.screenToFlowPosition({ | |
| x: Math.round(window.innerWidth * 0.62) + offset * 26, | |
| y: Math.round(window.innerHeight * 0.38) + offset * 26, | |
| }); | |
| updateCurrentGraph((graph) => ({ | |
| ...graph, | |
| nodes: [...graph.nodes, createNodeInstance(type, position, overrides)], | |
| })); | |
| setNotice(`Added ${type}`); | |
| }, | |
| [reactFlow, updateCurrentGraph], | |
| ); | |
| const addComponentNodeFromLibrary = useCallback( | |
| (templateId) => { | |
| const template = componentTemplates.find((candidate) => candidate.id === templateId); | |
| if (!template) { | |
| setNotice('Сохраненная компонентная нода не найдена'); | |
| return; | |
| } | |
| addNode(COMPONENT_NODE_TYPE, { | |
| data: buildComponentNodeDataFromTemplate(template), | |
| }); | |
| setNotice(`Added component node: ${template.title}`); | |
| }, | |
| [addNode, componentTemplates], | |
| ); | |
| const navigateToPath = useCallback( | |
| (nextPath, options = {}) => { | |
| if (!workflowDocument) { | |
| return; | |
| } | |
| const nextGraph = getGraphAtPath(workflowDocument, nextPath); | |
| setGraphPath(nextPath); | |
| syncViewportForGraph(nextGraph, options); | |
| }, | |
| [syncViewportForGraph, workflowDocument], | |
| ); | |
| const openComponent = useCallback( | |
| (nodeId) => { | |
| const componentNode = nodes.find((node) => node.id === nodeId && isComponentNodeType(node.type)); | |
| if (!componentNode) { | |
| return; | |
| } | |
| const nextPath = [...graphPath, nodeId]; | |
| navigateToPath(nextPath, { | |
| fitView: !componentNode.data.subgraph?.viewport, | |
| }); | |
| setNotice(`Opened ${componentNode.data.title}`); | |
| }, | |
| [graphPath, navigateToPath, nodes], | |
| ); | |
| const goBackOneLevel = useCallback(() => { | |
| if (graphPath.length === 0) { | |
| return; | |
| } | |
| navigateToPath(graphPath.slice(0, -1), { fitView: false }); | |
| setNotice('Вернулись в родительский граф'); | |
| }, [graphPath, navigateToPath]); | |
| useEffect(() => { | |
| if (!isSettingsOpen) { | |
| return undefined; | |
| } | |
| const handleKeyDown = (event) => { | |
| if (event.key !== 'Escape' || event.defaultPrevented || event.repeat) { | |
| return; | |
| } | |
| event.preventDefault(); | |
| setIsSettingsOpen(false); | |
| }; | |
| window.addEventListener('keydown', handleKeyDown); | |
| return () => { | |
| window.removeEventListener('keydown', handleKeyDown); | |
| }; | |
| }, [isSettingsOpen]); | |
| useEffect(() => { | |
| if (isSettingsOpen || graphPath.length === 0) { | |
| return undefined; | |
| } | |
| if (graphPath.length === 0) { | |
| return undefined; | |
| } | |
| const handleKeyDown = (event) => { | |
| if (event.key !== 'Escape' || event.defaultPrevented || event.repeat) { | |
| return; | |
| } | |
| if (isEditableTarget(event.target)) { | |
| return; | |
| } | |
| event.preventDefault(); | |
| goBackOneLevel(); | |
| }; | |
| window.addEventListener('keydown', handleKeyDown); | |
| return () => { | |
| window.removeEventListener('keydown', handleKeyDown); | |
| }; | |
| }, [goBackOneLevel, graphPath.length, isSettingsOpen]); | |
| const breadcrumbs = useMemo(() => { | |
| if (!workflowDocument) { | |
| return []; | |
| } | |
| const items = [ | |
| { | |
| id: 'root', | |
| label: 'Корень', | |
| }, | |
| ]; | |
| let graph = workflowDocument.rootGraph; | |
| graphPath.forEach((nodeId, index) => { | |
| const node = graph.nodes.find((candidate) => candidate.id === nodeId && isComponentNodeType(candidate.type)); | |
| if (!node) { | |
| return; | |
| } | |
| const pathSlice = graphPath.slice(0, index + 1); | |
| items.push({ | |
| id: nodeId, | |
| label: node.data.title || 'Компонент', | |
| onClick: () => navigateToPath(pathSlice, { fitView: !node.data.subgraph?.viewport }), | |
| }); | |
| graph = node.data.subgraph || createComponentSubgraph(); | |
| }); | |
| return items; | |
| }, [graphPath, navigateToPath, workflowDocument]); | |
| const onNodesChange = useCallback( | |
| (changes) => { | |
| updateCurrentGraph((graph) => ({ | |
| ...graph, | |
| nodes: applyNodeChanges(changes, graph.nodes).map((node) => hydrateNode(node)), | |
| })); | |
| }, | |
| [updateCurrentGraph], | |
| ); | |
| const onEdgesChange = useCallback( | |
| (changes) => { | |
| updateCurrentGraph((graph) => ({ | |
| ...graph, | |
| edges: applyEdgeChanges(changes, graph.edges), | |
| })); | |
| }, | |
| [updateCurrentGraph], | |
| ); | |
| const onConnect = useCallback( | |
| (connection) => { | |
| updateCurrentGraph((graph) => ({ | |
| ...graph, | |
| edges: connectEdge(connection, graph.edges), | |
| })); | |
| }, | |
| [updateCurrentGraph], | |
| ); | |
| const handleCreateComponent = useCallback(() => { | |
| const result = createComponentFromSelection(currentGraph); | |
| if (result.error) { | |
| setNotice(result.error); | |
| return; | |
| } | |
| setWorkflowDocument((previousDocument) => updateGraphAtPath(previousDocument, graphPath, () => result.graph)); | |
| setNotice('Компонент создан. Дважды нажмите на него или кнопку Открыть, чтобы войти.'); | |
| }, [currentGraph, graphPath]); | |
| const handleToggleTheme = useCallback(() => { | |
| setTheme((currentTheme) => (currentTheme === 'dark' ? 'light' : 'dark')); | |
| }, []); | |
| const handleOpenSettings = useCallback(() => { | |
| setSettingsBackendUrl(backendUrl); | |
| setSettingsLiveDebugUrl(liveDebugUrl === DEFAULT_LIVE_DEBUG_URL ? '' : liveDebugUrl); | |
| setSettingsLlmProvider(normalizeLlmProvider(llmProvider)); | |
| setSettingsDeepinfraApiKey(defaultDeepinfraApiKey); | |
| setSettingsDeepinfraKeyStatus('Проверяю DeepInfra key...'); | |
| setIsSettingsOpen(true); | |
| getDeepinfraKeyStatus(backendUrl) | |
| .then((status) => { | |
| setSettingsDeepinfraHasKey(Boolean(status.has_key)); | |
| setSettingsDeepinfraKeyStatus( | |
| status.has_key ? 'DeepInfra key уже сохранен локально на backend.' : 'DeepInfra key еще не сохранен.', | |
| ); | |
| }) | |
| .catch((error) => { | |
| console.error('Failed to check DeepInfra key:', error); | |
| setSettingsDeepinfraHasKey(false); | |
| setSettingsDeepinfraKeyStatus('Не удалось проверить DeepInfra key.'); | |
| }); | |
| }, [backendUrl, defaultDeepinfraApiKey, liveDebugUrl, llmProvider]); | |
| const handleSettingsResetToDefault = useCallback(() => { | |
| setSettingsBackendUrl(defaultBackendUrl); | |
| setSettingsLiveDebugUrl(''); | |
| setSettingsLlmProvider(defaultLlmProvider); | |
| setSettingsDeepinfraApiKey(defaultDeepinfraApiKey); | |
| }, [defaultBackendUrl, defaultDeepinfraApiKey, defaultLlmProvider]); | |
| const handleSettingsSave = useCallback(async () => { | |
| const normalizedDraft = normalizeBackendUrl(settingsBackendUrl); | |
| const normalizedLiveDebugDraft = normalizeBackendUrl(settingsLiveDebugUrl); | |
| const nextLlmProvider = normalizeLlmProvider(settingsLlmProvider); | |
| if (!normalizedDraft) { | |
| setNotice('Введите корректный Backend URL'); | |
| return; | |
| } | |
| const normalizedDefault = normalizeBackendUrl(defaultBackendUrl) || DEFAULT_BACKEND_URL; | |
| const backendUrlOverride = normalizedDraft === normalizedDefault ? '' : normalizedDraft; | |
| const nextBackendUrl = backendUrlOverride || normalizedDefault; | |
| const liveDebugUrlOverride = normalizedLiveDebugDraft && normalizedLiveDebugDraft !== DEFAULT_LIVE_DEBUG_URL | |
| ? normalizedLiveDebugDraft | |
| : ''; | |
| const nextLiveDebugUrl = liveDebugUrlOverride || DEFAULT_LIVE_DEBUG_URL; | |
| if (nextLlmProvider === 'deepinfra') { | |
| const keyDraft = settingsDeepinfraApiKey.trim(); | |
| let hasDeepinfraKey = settingsDeepinfraHasKey; | |
| try { | |
| if (keyDraft) { | |
| const saveResult = await saveDeepinfraKey(nextBackendUrl, keyDraft); | |
| if (!saveResult.success) { | |
| setSettingsDeepinfraKeyStatus(saveResult.message || 'Не удалось сохранить DeepInfra key.'); | |
| setNotice(saveResult.message || 'Не удалось сохранить DeepInfra key'); | |
| return; | |
| } | |
| hasDeepinfraKey = true; | |
| setSettingsDeepinfraApiKey(''); | |
| } else { | |
| const status = await getDeepinfraKeyStatus(nextBackendUrl); | |
| hasDeepinfraKey = Boolean(status.has_key); | |
| } | |
| } catch (error) { | |
| console.error('Failed to save/check DeepInfra key:', error); | |
| setSettingsDeepinfraKeyStatus(error instanceof Error ? error.message : 'Не удалось сохранить или проверить DeepInfra key.'); | |
| setNotice(error instanceof Error ? error.message : 'Не удалось сохранить или проверить DeepInfra key'); | |
| return; | |
| } | |
| setSettingsDeepinfraHasKey(hasDeepinfraKey); | |
| if (!hasDeepinfraKey) { | |
| setSettingsDeepinfraKeyStatus('Введите DeepInfra API key перед сохранением provider DeepInfra.'); | |
| setNotice('Введите DeepInfra API key'); | |
| return; | |
| } | |
| } | |
| persistAppSettings({ backendUrlOverride, liveDebugUrlOverride }); | |
| setBackendUrl(nextBackendUrl); | |
| setLiveDebugUrl(nextLiveDebugUrl); | |
| setSettingsBackendUrl(nextBackendUrl); | |
| setSettingsLiveDebugUrl(liveDebugUrlOverride); | |
| setLlmProvider(nextLlmProvider); | |
| updateWorkflowSettings({ llmProvider: nextLlmProvider }); | |
| setIsSettingsOpen(false); | |
| setNotice(`Настройки сохранены: ${nextLlmProvider}, backend ${nextBackendUrl}, live ${nextLiveDebugUrl}`); | |
| }, [ | |
| defaultBackendUrl, | |
| settingsBackendUrl, | |
| settingsDeepinfraApiKey, | |
| settingsDeepinfraHasKey, | |
| settingsLiveDebugUrl, | |
| settingsLlmProvider, | |
| updateWorkflowSettings, | |
| ]); | |
| const appendInteractiveLog = useCallback((level, message) => { | |
| const id = `log-${logCounterRef.current}`; | |
| logCounterRef.current += 1; | |
| setInteractiveLogs((currentLogs) => [ | |
| ...currentLogs, | |
| { | |
| id, | |
| level, | |
| message, | |
| time: new Date().toLocaleTimeString(), | |
| }, | |
| ]); | |
| }, []); | |
| const getWorkflowLogName = useCallback(() => fileHandleRef.current?.name || 'workflow', []); | |
| const ensureTranscriptLog = useCallback(async () => { | |
| if (!writeTranscriptLogsRef.current) { | |
| return null; | |
| } | |
| if (transcriptLogRef.current) { | |
| return transcriptLogRef.current; | |
| } | |
| const log = await startTranscriptLog(backendUrl, getWorkflowLogName()); | |
| transcriptLogRef.current = { | |
| logId: log.log_id, | |
| logPath: log.log_path, | |
| }; | |
| appendInteractiveLog('info', `Txt-лог: ${log.log_path}`); | |
| return transcriptLogRef.current; | |
| }, [appendInteractiveLog, backendUrl, getWorkflowLogName]); | |
| const writeTranscriptMessage = useCallback( | |
| (role, text) => { | |
| if (!writeTranscriptLogsRef.current) { | |
| return; | |
| } | |
| ensureTranscriptLog() | |
| .then((log) => { | |
| if (!log?.logPath) { | |
| return; | |
| } | |
| return appendTranscriptLog(backendUrl, log.logPath, role, text); | |
| }) | |
| .catch((error) => { | |
| appendInteractiveLog( | |
| 'error', | |
| error instanceof Error ? error.message : 'Не удалось записать txt-лог', | |
| ); | |
| }); | |
| }, | |
| [appendInteractiveLog, backendUrl, ensureTranscriptLog], | |
| ); | |
| const appendTranscriptMessage = useCallback((role, text) => { | |
| const id = `message-${transcriptCounterRef.current}`; | |
| transcriptCounterRef.current += 1; | |
| const message = { | |
| id, | |
| role, | |
| text, | |
| }; | |
| interactiveTranscriptRef.current = [...interactiveTranscriptRef.current, message]; | |
| setInteractiveTranscript(interactiveTranscriptRef.current); | |
| writeTranscriptMessage(role, text); | |
| }, [writeTranscriptMessage]); | |
| const handleInteractiveEvent = useCallback( | |
| (event) => { | |
| if (event.type === 'assistant-question') { | |
| appendTranscriptMessage('assistant', event.question); | |
| if (!llmAutoAnswer && !llmTestModeRef.current) { | |
| appendInteractiveLog('info', `Вопрос: ${event.question}`); | |
| } | |
| if (llmTestModeRef.current && testRunRef.current) { | |
| evaluateAssistantAnswer( | |
| backendUrl, | |
| '', | |
| event.question, | |
| interactiveTranscriptRef.current, | |
| testRunRef.current, | |
| 'question', | |
| llmProvider, | |
| llmSessionIdRef.current, | |
| ) | |
| .then((evaluation) => { | |
| if (evaluation.ok) { | |
| appendInteractiveLog('debug', `LLM-тест вопроса OK: ${evaluation.reason || 'вопрос принят'}`); | |
| } else { | |
| appendInteractiveLog( | |
| 'error', | |
| `LLM-тест вопроса провален: ${evaluation.reason || 'плохой вопрос ассистента'}. Лог: ${evaluation.log_path || 'test log'}`, | |
| ); | |
| } | |
| }) | |
| .catch((error) => { | |
| appendInteractiveLog( | |
| 'error', | |
| error instanceof Error ? error.message : 'Не удалось оценить вопрос ассистента', | |
| ); | |
| }); | |
| } | |
| return; | |
| } | |
| if (event.type === 'user-answer') { | |
| lastUserAnswerRef.current = event.answer; | |
| appendTranscriptMessage('user', event.answer); | |
| if (suppressNextUserAnswerLogRef.current) { | |
| suppressNextUserAnswerLogRef.current = false; | |
| } else if (!llmAutoAnswer && !llmTestModeRef.current) { | |
| appendInteractiveLog('info', `Ответ: ${event.answer}`); | |
| } | |
| return; | |
| } | |
| if (event.type === 'assistant-message') { | |
| appendTranscriptMessage('assistant', event.message); | |
| if (!llmAutoAnswer && !llmTestModeRef.current) { | |
| appendInteractiveLog('info', `Ассистент: ${event.message}`); | |
| } | |
| if (llmTestModeRef.current && testRunRef.current) { | |
| evaluateAssistantAnswer( | |
| backendUrl, | |
| lastUserAnswerRef.current, | |
| event.message, | |
| interactiveTranscriptRef.current, | |
| testRunRef.current, | |
| 'answer', | |
| llmProvider, | |
| llmSessionIdRef.current, | |
| ) | |
| .then((evaluation) => { | |
| if (evaluation.ok) { | |
| appendInteractiveLog('debug', `LLM-тест OK: ${evaluation.reason || 'ответ принят'}`); | |
| } else { | |
| appendInteractiveLog( | |
| 'error', | |
| `LLM-тест провален: ${evaluation.reason || 'плохой ответ ассистента'}. Лог: ${evaluation.log_path || 'test log'}`, | |
| ); | |
| } | |
| }) | |
| .catch((error) => { | |
| appendInteractiveLog( | |
| 'error', | |
| error instanceof Error ? error.message : 'Не удалось оценить ответ ассистента', | |
| ); | |
| }); | |
| } | |
| return; | |
| } | |
| if (event.type === 'memory-update') { | |
| const summary = Object.entries(event.memoryPatch || {}) | |
| .map(([key, value]) => `{${key}}=${value}`) | |
| .join(', '); | |
| appendInteractiveLog('info', `Память сохранена: ${summary}`); | |
| return; | |
| } | |
| if (event.type === 'node-start') { | |
| appendInteractiveLog('debug', `Старт ноды: ${event.title}`); | |
| return; | |
| } | |
| if (event.type === 'node-success') { | |
| appendInteractiveLog('debug', `Нода завершена: ${event.title}`); | |
| return; | |
| } | |
| if (event.type === 'skip') { | |
| appendInteractiveLog('debug', `Нода пропущена: ${event.title}`); | |
| return; | |
| } | |
| if (event.type === 'node-error') { | |
| appendInteractiveLog('error', `Ошибка в ${event.title}: ${event.error}`); | |
| return; | |
| } | |
| if (event.type === 'restart') { | |
| appendInteractiveLog('info', 'Сценарий перезапускается с начала'); | |
| } | |
| }, | |
| [appendInteractiveLog, appendTranscriptMessage, backendUrl, llmAutoAnswer, llmProvider], | |
| ); | |
| const applyLiveDebugPayload = useCallback( | |
| (payload) => { | |
| if (!payload || typeof payload !== 'object') { | |
| return; | |
| } | |
| if (payload.type === 'live-disabled') { | |
| setLiveDebugStatus('disabled'); | |
| setNotice('Live debug выключен в runtime. Включите галочку в окне main.py.'); | |
| appendInteractiveLog('error', 'Runtime не отдает live debug: галочка в main.py выключена.'); | |
| return; | |
| } | |
| const state = payload.state || {}; | |
| const latestResponse = state.last_response || state.lastResponse || null; | |
| const runtimeResponse = state.runtime || latestResponse || payload.runtime || null; | |
| const nextGraph = runtimeResponse?.graph || state.graph || null; | |
| if (nextGraph) { | |
| const hydratedGraph = hydrateGraph(nextGraph); | |
| setWorkflowDocument((previousDocument) => | |
| previousDocument | |
| ? updateGraphAtPath(previousDocument, [], (editorGraph) => | |
| mergeRuntimeIntoEditorGraph(editorGraph, hydratedGraph), | |
| ) | |
| : previousDocument, | |
| ); | |
| if (graphPath.length > 0) { | |
| setGraphPath([]); | |
| } | |
| } | |
| const nextPendingQuestion = state.pending_question || runtimeResponse?.pending_question || null; | |
| setPendingQuestion(nextPendingQuestion); | |
| const events = Array.isArray(payload.events) && payload.events.length > 0 | |
| ? payload.events | |
| : Array.isArray(runtimeResponse?.events) && runtimeResponse.events.length > 0 | |
| ? runtimeResponse.events | |
| : Array.isArray(latestResponse?.events) | |
| ? latestResponse.events | |
| : []; | |
| events.forEach((event) => { | |
| const eventKey = getLiveDebugEventKey(event); | |
| if (liveDebugEventKeysRef.current.has(eventKey)) { | |
| return; | |
| } | |
| liveDebugEventKeysRef.current.add(eventKey); | |
| handleInteractiveEvent(event); | |
| }); | |
| const status = state.status || runtimeResponse?.status || 'running'; | |
| if (status === 'paused') { | |
| setNotice('Live debug: workflow ждет ответ пользователя'); | |
| } else if (status === 'complete') { | |
| setNotice('Live debug: workflow завершен'); | |
| } else if (status === 'idle') { | |
| setNotice('Live debug: runtime подключен, активного workflow пока нет'); | |
| } else { | |
| setNotice('Live debug: workflow выполняется'); | |
| } | |
| }, | |
| [appendInteractiveLog, graphPath.length, handleInteractiveEvent], | |
| ); | |
| const closeLiveDebugConnection = useCallback((status = 'off') => { | |
| const socket = liveDebugSocketRef.current; | |
| liveDebugSocketRef.current = null; | |
| if (socket && socket.readyState !== WebSocket.CLOSED) { | |
| socket.close(); | |
| } | |
| setLiveDebugStatus(status); | |
| }, []); | |
| const handleToggleLiveDebug = useCallback(() => { | |
| if (liveDebugSocketRef.current) { | |
| closeLiveDebugConnection('off'); | |
| setNotice('Live debug отключен'); | |
| appendInteractiveLog('info', 'Live debug отключен'); | |
| return; | |
| } | |
| if (!workflowDocument) { | |
| setNotice('Откройте workflow JSON перед live debug'); | |
| return; | |
| } | |
| const wsUrl = resolveLiveDebugWebSocketUrl(liveDebugUrl); | |
| let socket; | |
| try { | |
| socket = new WebSocket(wsUrl); | |
| } catch (error) { | |
| console.error('Failed to open live debug socket:', error); | |
| setLiveDebugStatus('error'); | |
| setNotice('Не удалось открыть WebSocket live debug'); | |
| return; | |
| } | |
| liveDebugSocketRef.current = socket; | |
| liveDebugEventKeysRef.current = new Set(); | |
| transcriptCounterRef.current = 0; | |
| interactiveTranscriptRef.current = []; | |
| transcriptLogRef.current = null; | |
| lastUserAnswerRef.current = ''; | |
| autoAnsweredQuestionRef.current = null; | |
| llmTestModeRef.current = false; | |
| llmTestStopRequestedRef.current = false; | |
| setLlmTestMode(false); | |
| setPendingQuestion(null); | |
| setInteractiveTranscript([]); | |
| setInteractiveLogs([]); | |
| setAnswerDraft(''); | |
| setLiveDebugStatus('connecting'); | |
| setNotice('Подключаю live debug...'); | |
| socket.addEventListener('open', () => { | |
| setLiveDebugStatus('connected'); | |
| setNotice('Live debug подключен'); | |
| appendInteractiveLog('info', `Live debug подключен: ${wsUrl}`); | |
| }); | |
| socket.addEventListener('message', (event) => { | |
| try { | |
| applyLiveDebugPayload(JSON.parse(event.data)); | |
| } catch (error) { | |
| console.error('Failed to parse live debug message:', error); | |
| appendInteractiveLog('error', 'Live debug прислал невалидный JSON'); | |
| } | |
| }); | |
| socket.addEventListener('error', () => { | |
| setLiveDebugStatus('error'); | |
| setNotice('Ошибка WebSocket live debug'); | |
| }); | |
| socket.addEventListener('close', (event) => { | |
| liveDebugSocketRef.current = null; | |
| if (event.code === 1008) { | |
| setLiveDebugStatus('disabled'); | |
| return; | |
| } | |
| setLiveDebugStatus((currentStatus) => (currentStatus === 'disabled' ? currentStatus : 'off')); | |
| }); | |
| }, [ | |
| appendInteractiveLog, | |
| applyLiveDebugPayload, | |
| backendUrl, | |
| closeLiveDebugConnection, | |
| liveDebugUrl, | |
| workflowDocument, | |
| ]); | |
| useEffect(() => () => { | |
| closeLiveDebugConnection('off'); | |
| }, [closeLiveDebugConnection]); | |
| const continueInteractiveRun = useCallback( | |
| async (userAnswer = null) => { | |
| if (!interactiveRunRef.current) { | |
| return; | |
| } | |
| if (llmTestStopRequestedRef.current) { | |
| setIsRunning(false); | |
| return; | |
| } | |
| setIsRunning(true); | |
| setNotice(userAnswer ? 'Продолжаю интерактивный прогон...' : 'Запускаю интерактивный прогон...'); | |
| try { | |
| const executionPath = [...interactiveRunPathRef.current]; | |
| const result = await continueInteractiveWorkflowRun(interactiveRunRef.current, { | |
| backendUrl, | |
| llmProvider, | |
| llmSessionId: llmSessionIdRef.current, | |
| userAnswer, | |
| knowledgeContextId: '', | |
| testRun: testRunRef.current, | |
| onGraphChange: (nextGraph) => { | |
| const hydratedRuntimeGraph = hydrateGraph(nextGraph); | |
| setWorkflowDocument((previousDocument) => | |
| updateGraphAtPath(previousDocument, executionPath, (editorGraph) => | |
| mergeRuntimeIntoEditorGraph(editorGraph, hydratedRuntimeGraph), | |
| ), | |
| ); | |
| }, | |
| onEvent: handleInteractiveEvent, | |
| }); | |
| interactiveRunRef.current = result.state; | |
| setPendingQuestion(result.pendingQuestion); | |
| if (llmTestStopRequestedRef.current) { | |
| appendInteractiveLog('info', `LLM-тест остановлен. Лог: ${testRunRef.current?.logPath || 'test log'}`); | |
| llmTestModeRef.current = false; | |
| llmTestStopRequestedRef.current = false; | |
| setLlmTestMode(false); | |
| setNotice('LLM-тест остановлен'); | |
| return; | |
| } | |
| if (result.status === 'restart') { | |
| const restartState = createInteractiveWorkflowRun(result.state.graph); | |
| interactiveRunRef.current = restartState; | |
| llmSessionIdRef.current = createLlmSessionId(); | |
| transcriptCounterRef.current = 0; | |
| interactiveTranscriptRef.current = []; | |
| transcriptLogRef.current = null; | |
| lastUserAnswerRef.current = ''; | |
| autoAnsweredQuestionRef.current = null; | |
| setInteractiveTranscript([]); | |
| appendInteractiveLog('info', 'Интерактивный прогон перезапущен'); | |
| const restartResult = await continueInteractiveWorkflowRun(restartState, { | |
| backendUrl, | |
| llmProvider, | |
| llmSessionId: llmSessionIdRef.current, | |
| knowledgeContextId: '', | |
| testRun: testRunRef.current, | |
| onGraphChange: (nextGraph) => { | |
| const hydratedRuntimeGraph = hydrateGraph(nextGraph); | |
| setWorkflowDocument((previousDocument) => | |
| updateGraphAtPath(previousDocument, executionPath, (editorGraph) => | |
| mergeRuntimeIntoEditorGraph(editorGraph, hydratedRuntimeGraph), | |
| ), | |
| ); | |
| }, | |
| onEvent: handleInteractiveEvent, | |
| }); | |
| interactiveRunRef.current = restartResult.state; | |
| setPendingQuestion(restartResult.pendingQuestion); | |
| setNotice(restartResult.status === 'complete' ? 'Интерактивный прогон завершен' : 'Ждем ответ пользователя'); | |
| } else if (result.status === 'complete') { | |
| appendInteractiveLog('info', 'Интерактивный прогон завершен'); | |
| if (llmTestModeRef.current) { | |
| appendInteractiveLog('info', `LLM-тест завершен. Лог: ${testRunRef.current?.logPath || 'test log'}`); | |
| llmTestModeRef.current = false; | |
| llmTestStopRequestedRef.current = false; | |
| setLlmTestMode(false); | |
| } | |
| setNotice(`Интерактивный прогон завершен: ${result.state.order.length} нод`); | |
| } else { | |
| setNotice('Ждем ответ пользователя'); | |
| } | |
| } catch (error) { | |
| console.error('Interactive workflow execution failed:', error); | |
| appendInteractiveLog('error', error instanceof Error ? error.message : 'Интерактивный прогон упал'); | |
| if (llmTestModeRef.current) { | |
| llmTestModeRef.current = false; | |
| llmTestStopRequestedRef.current = false; | |
| setLlmTestMode(false); | |
| } | |
| setNotice(error instanceof Error ? error.message : 'Интерактивный прогон упал'); | |
| } finally { | |
| setIsRunning(false); | |
| } | |
| }, | |
| [appendInteractiveLog, backendUrl, handleInteractiveEvent, llmProvider], | |
| ); | |
| const handleRun = useCallback(async () => { | |
| if (isRunning || !workflowDocument) { | |
| return; | |
| } | |
| const executionPath = [...graphPath]; | |
| const graphForRun = getGraphAtPath(workflowDocument, executionPath); | |
| logCounterRef.current = 0; | |
| transcriptCounterRef.current = 0; | |
| interactiveTranscriptRef.current = []; | |
| transcriptLogRef.current = null; | |
| llmSessionIdRef.current = createLlmSessionId(); | |
| lastUserAnswerRef.current = ''; | |
| llmTestModeRef.current = false; | |
| llmTestStopRequestedRef.current = false; | |
| setLlmTestMode(false); | |
| testRunRef.current = null; | |
| testAutoAnswerCountRef.current = 0; | |
| interactiveRunPathRef.current = executionPath; | |
| interactiveRunRef.current = createInteractiveWorkflowRun(graphForRun); | |
| autoAnsweredQuestionRef.current = null; | |
| setPendingQuestion(null); | |
| setInteractiveTranscript([]); | |
| setInteractiveLogs([]); | |
| setAnswerDraft(''); | |
| appendInteractiveLog('info', 'Интерактивный прогон запущен'); | |
| await continueInteractiveRun(); | |
| }, [appendInteractiveLog, continueInteractiveRun, graphPath, isRunning, workflowDocument]); | |
| const handleLlmTestRun = useCallback(async () => { | |
| if (isRunning || llmTestMode || !workflowDocument) { | |
| return; | |
| } | |
| const workflowName = fileHandleRef.current?.name || 'workflow'; | |
| const testRun = await startTestRun(backendUrl, workflowName); | |
| testRunRef.current = { | |
| testRunId: testRun.test_run_id, | |
| workflowName, | |
| logPath: testRun.log_path, | |
| }; | |
| llmTestModeRef.current = true; | |
| llmTestStopRequestedRef.current = false; | |
| setLlmTestMode(true); | |
| testAutoAnswerCountRef.current = 0; | |
| const executionPath = [...graphPath]; | |
| const graphForRun = getGraphAtPath(workflowDocument, executionPath); | |
| logCounterRef.current = 0; | |
| transcriptCounterRef.current = 0; | |
| interactiveTranscriptRef.current = []; | |
| transcriptLogRef.current = null; | |
| llmSessionIdRef.current = createLlmSessionId(); | |
| lastUserAnswerRef.current = ''; | |
| autoAnsweredQuestionRef.current = null; | |
| interactiveRunPathRef.current = executionPath; | |
| interactiveRunRef.current = createInteractiveWorkflowRun(graphForRun); | |
| setPendingQuestion(null); | |
| setInteractiveTranscript([]); | |
| setInteractiveLogs([]); | |
| setAnswerDraft(''); | |
| appendInteractiveLog('info', `LLM-тест запущен. Лог: ${testRun.log_path}`); | |
| await continueInteractiveRun(); | |
| }, [appendInteractiveLog, backendUrl, continueInteractiveRun, graphPath, isRunning, llmTestMode, workflowDocument]); | |
| const handleStopLlmTest = useCallback(() => { | |
| if (!llmTestModeRef.current) { | |
| return; | |
| } | |
| llmTestStopRequestedRef.current = true; | |
| llmTestModeRef.current = false; | |
| setLlmTestMode(false); | |
| autoAnsweredQuestionRef.current = null; | |
| appendInteractiveLog('info', `Останавливаю LLM-тест... Лог: ${testRunRef.current?.logPath || 'test log'}`); | |
| setNotice('Останавливаю LLM-тест...'); | |
| }, [appendInteractiveLog]); | |
| const handleNew = useCallback(() => { | |
| const confirmed = window.confirm( | |
| 'Не забудьте сохранить текущий workflow если нужно.\n\nСоздать новый workflow?', | |
| ); | |
| if (!confirmed) { | |
| return; | |
| } | |
| const nextDocument = createBlankWorkflow(); | |
| const nextDocumentWithSettings = { | |
| ...nextDocument, | |
| settings: { | |
| ...(nextDocument.settings || {}), | |
| llmRolePrompt: DEFAULT_LLM_ROLE_PROMPT, | |
| llmProvider: defaultLlmProvider, | |
| }, | |
| }; | |
| setLlmRolePrompt(DEFAULT_LLM_ROLE_PROMPT); | |
| setLlmProvider(defaultLlmProvider); | |
| setWorkflowDocument(nextDocumentWithSettings); | |
| applyComponentTemplates(componentTemplates, { persist: false }); | |
| setGraphPath([]); | |
| syncViewportForGraph(nextDocumentWithSettings.rootGraph, { fitView: true }); | |
| setNotice('Создан новый workflow'); | |
| }, [applyComponentTemplates, componentTemplates, defaultLlmProvider, syncViewportForGraph]); | |
| const writeWorkflowToHandle = useCallback(async (fileHandle, data) => { | |
| const writable = await fileHandle.createWritable(); | |
| await writable.write(data); | |
| await writable.close(); | |
| }, []); | |
| const downloadWorkflowFallback = useCallback((data) => { | |
| const blob = new Blob([data], { type: 'application/json' }); | |
| const url = URL.createObjectURL(blob); | |
| const link = document.createElement('a'); | |
| link.href = url; | |
| link.download = `workflow_${Date.now()}.json`; | |
| link.click(); | |
| URL.revokeObjectURL(url); | |
| }, []); | |
| const handleSaveAs = useCallback(async () => { | |
| if (!workflowDocument) { | |
| return; | |
| } | |
| const data = JSON.stringify( | |
| serializeWorkflow({ | |
| ...workflowDocument, | |
| settings: { | |
| ...(workflowDocument.settings || {}), | |
| llmRolePrompt, | |
| llmProvider, | |
| }, | |
| }), | |
| null, | |
| 2, | |
| ); | |
| try { | |
| if ('showSaveFilePicker' in window) { | |
| const fileHandle = await window.showSaveFilePicker({ | |
| suggestedName: `workflow_${Date.now()}.json`, | |
| types: [ | |
| { | |
| description: 'JSON-файлы', | |
| accept: { 'application/json': ['.json'] }, | |
| }, | |
| ], | |
| ...(fileHandleRef.current ? { startIn: fileHandleRef.current } : {}), | |
| }); | |
| fileHandleRef.current = fileHandle; | |
| await writeWorkflowToHandle(fileHandle, data); | |
| } else { | |
| downloadWorkflowFallback(data); | |
| } | |
| setNotice('Workflow сохранен'); | |
| } catch (error) { | |
| if (error?.name !== 'AbortError') { | |
| console.error('Failed to save workflow:', error); | |
| setNotice('Не удалось сохранить workflow'); | |
| } | |
| } | |
| }, [downloadWorkflowFallback, llmProvider, llmRolePrompt, workflowDocument, writeWorkflowToHandle]); | |
| const handleSave = useCallback(async () => { | |
| if (!workflowDocument) { | |
| return; | |
| } | |
| const document = serializeWorkflow({ | |
| ...workflowDocument, | |
| settings: { | |
| ...(workflowDocument.settings || {}), | |
| llmRolePrompt, | |
| llmProvider, | |
| }, | |
| }); | |
| try { | |
| const response = await saveDefaultWorkflow(backendUrl, document); | |
| setNotice(response?.path ? `Workflow сохранен на сервере: ${response.path}` : 'Workflow сохранен на сервере'); | |
| } catch (error) { | |
| if (error?.name !== 'AbortError') { | |
| console.error('Failed to save workflow:', error); | |
| setNotice('Не удалось сохранить workflow на сервере'); | |
| } | |
| } | |
| }, [backendUrl, llmProvider, llmRolePrompt, workflowDocument]); | |
| const loadFromText = useCallback( | |
| (contents) => { | |
| const parsedDocument = parseWorkflowData(JSON.parse(contents)); | |
| const mergedTemplates = mergeComponentTemplates(parsedDocument.componentTemplates || [], loadComponentLibrary()); | |
| const parsedLlmProvider = normalizeLlmProvider(parsedDocument.settings?.llmProvider); | |
| setWorkflowDocument({ | |
| ...parsedDocument, | |
| componentTemplates: mergedTemplates, | |
| settings: { | |
| ...(parsedDocument.settings || {}), | |
| llmRolePrompt: parsedDocument.settings?.llmRolePrompt || DEFAULT_LLM_ROLE_PROMPT, | |
| llmProvider: parsedLlmProvider, | |
| }, | |
| }); | |
| setLlmRolePrompt(parsedDocument.settings?.llmRolePrompt || DEFAULT_LLM_ROLE_PROMPT); | |
| setLlmProvider(parsedLlmProvider); | |
| setComponentTemplates(mergedTemplates); | |
| persistComponentLibrary(mergedTemplates); | |
| setGraphPath([]); | |
| syncViewportForGraph(parsedDocument.rootGraph, { fitView: true }); | |
| setNotice('Workflow открыт'); | |
| }, | |
| [syncViewportForGraph], | |
| ); | |
| const handleLoad = useCallback(async () => { | |
| try { | |
| if ('showOpenFilePicker' in window) { | |
| const [fileHandle] = await window.showOpenFilePicker({ | |
| types: [ | |
| { | |
| description: 'JSON-файлы', | |
| accept: { 'application/json': ['.json'] }, | |
| }, | |
| ], | |
| multiple: false, | |
| ...(fileHandleRef.current ? { startIn: fileHandleRef.current } : {}), | |
| }); | |
| fileHandleRef.current = fileHandle; | |
| const file = await fileHandle.getFile(); | |
| loadFromText(await file.text()); | |
| } else { | |
| fileInputRef.current?.click(); | |
| } | |
| } catch (error) { | |
| if (error?.name !== 'AbortError') { | |
| console.error('Failed to load workflow:', error); | |
| setNotice('Не удалось открыть workflow'); | |
| } | |
| } | |
| }, [loadFromText]); | |
| const handleSubmitAnswer = useCallback( | |
| async (event) => { | |
| event.preventDefault(); | |
| const answer = answerDraft.trim(); | |
| if (!answer || !pendingQuestion) { | |
| return; | |
| } | |
| setAnswerDraft(''); | |
| suppressNextUserAnswerLogRef.current = true; | |
| await continueInteractiveRun(answer); | |
| }, | |
| [answerDraft, continueInteractiveRun, pendingQuestion], | |
| ); | |
| const saveTextFile = useCallback(async (suggestedName, data) => { | |
| if ('showSaveFilePicker' in window) { | |
| const fileHandle = await window.showSaveFilePicker({ | |
| suggestedName, | |
| types: [ | |
| { | |
| description: 'Текстовые файлы', | |
| accept: { 'text/plain': ['.txt'] }, | |
| }, | |
| ], | |
| }); | |
| const writable = await fileHandle.createWritable(); | |
| await writable.write(data); | |
| await writable.close(); | |
| return; | |
| } | |
| const blob = new Blob([data], { type: 'text/plain;charset=utf-8' }); | |
| const url = URL.createObjectURL(blob); | |
| const link = document.createElement('a'); | |
| link.href = url; | |
| link.download = suggestedName; | |
| link.click(); | |
| URL.revokeObjectURL(url); | |
| }, []); | |
| const handleSaveSessionLog = useCallback(async () => { | |
| const sessionId = llmSessionIdRef.current; | |
| try { | |
| const llmLog = sessionId ? await getLlmSessionLog(backendUrl, sessionId) : { calls: [] }; | |
| const workflowName = getWorkflowLogName(); | |
| const contents = formatSessionLog({ | |
| workflowName, | |
| sessionId, | |
| provider: llmProvider, | |
| transcript: interactiveTranscriptRef.current, | |
| runtimeLogs: interactiveLogs, | |
| llmCalls: Array.isArray(llmLog.calls) ? llmLog.calls : [], | |
| }); | |
| const filename = `${safeDownloadName(workflowName)}_${sessionId || Date.now()}_session_log.txt`; | |
| await saveTextFile(filename, contents); | |
| setNotice('Лог сессии сохранен'); | |
| } catch (error) { | |
| if (error?.name !== 'AbortError') { | |
| console.error('Failed to save session log:', error); | |
| setNotice(error instanceof Error ? error.message : 'Не удалось сохранить лог сессии'); | |
| } | |
| } | |
| }, [backendUrl, getWorkflowLogName, interactiveLogs, llmProvider, saveTextFile]); | |
| const handleSimulateAnswer = useCallback(async () => { | |
| if (!pendingQuestion || isRunning) { | |
| return; | |
| } | |
| if (llmTestStopRequestedRef.current) { | |
| return; | |
| } | |
| setIsRunning(true); | |
| try { | |
| const answer = await simulateUserAnswer( | |
| backendUrl, | |
| llmProvider, | |
| llmRolePrompt, | |
| pendingQuestion.question, | |
| interactiveTranscript, | |
| llmTestModeRef.current ? testRunRef.current : null, | |
| llmSessionIdRef.current, | |
| ); | |
| if (llmTestStopRequestedRef.current) { | |
| appendInteractiveLog('info', `LLM-тест остановлен. Лог: ${testRunRef.current?.logPath || 'test log'}`); | |
| llmTestStopRequestedRef.current = false; | |
| setNotice('LLM-тест остановлен'); | |
| return; | |
| } | |
| if (!answer) { | |
| appendInteractiveLog('error', 'LLM вернула пустой ответ'); | |
| setNotice('LLM вернула пустой ответ'); | |
| return; | |
| } | |
| setAnswerDraft(''); | |
| await continueInteractiveRun(answer); | |
| } catch (error) { | |
| suppressNextUserAnswerLogRef.current = false; | |
| console.error('Failed to simulate user answer:', error); | |
| appendInteractiveLog('error', error instanceof Error ? error.message : 'Не удалось сгенерировать ответ пользователя'); | |
| setNotice(error instanceof Error ? error.message : 'Не удалось сгенерировать ответ пользователя'); | |
| } finally { | |
| setIsRunning(false); | |
| } | |
| }, [ | |
| appendInteractiveLog, | |
| backendUrl, | |
| continueInteractiveRun, | |
| interactiveTranscript, | |
| isRunning, | |
| llmAutoAnswer, | |
| llmProvider, | |
| llmRolePrompt, | |
| pendingQuestion, | |
| ]); | |
| const pendingQuestionKey = pendingQuestion | |
| ? `${pendingQuestion.nodeId}:${pendingQuestion.retryForNodeId || ''}:${pendingQuestion.question}` | |
| : ''; | |
| useEffect(() => { | |
| const shouldAutoAnswer = llmAutoAnswer || llmTestMode; | |
| if (!shouldAutoAnswer) { | |
| autoAnsweredQuestionRef.current = null; | |
| return; | |
| } | |
| if (llmTestStopRequestedRef.current) { | |
| return; | |
| } | |
| if (!pendingQuestion || !pendingQuestionKey || isRunning || !llmRolePrompt.trim()) { | |
| return; | |
| } | |
| if (autoAnsweredQuestionRef.current === pendingQuestionKey) { | |
| return; | |
| } | |
| if (llmTestMode && testAutoAnswerCountRef.current >= 40) { | |
| appendInteractiveLog('error', 'LLM-тест остановлен: достигнут лимит 40 автоответов. Проверьте цикл сценария или ответы.'); | |
| llmTestModeRef.current = false; | |
| llmTestStopRequestedRef.current = false; | |
| setLlmTestMode(false); | |
| return; | |
| } | |
| testAutoAnswerCountRef.current += 1; | |
| autoAnsweredQuestionRef.current = pendingQuestionKey; | |
| handleSimulateAnswer(); | |
| }, [ | |
| appendInteractiveLog, | |
| handleSimulateAnswer, | |
| isRunning, | |
| llmAutoAnswer, | |
| llmRolePrompt, | |
| llmTestMode, | |
| pendingQuestion, | |
| pendingQuestionKey, | |
| ]); | |
| const contextValue = useMemo( | |
| () => ({ | |
| addNode, | |
| backendUrl, | |
| getNodeHandles, | |
| graphPath, | |
| isRunning, | |
| openComponent, | |
| patchNodeData, | |
| saveComponentNode, | |
| presets, | |
| refreshPresets, | |
| removeHandleConnections, | |
| replaceNodeData, | |
| savePreset, | |
| }), | |
| [ | |
| addNode, | |
| backendUrl, | |
| getNodeHandles, | |
| graphPath, | |
| isRunning, | |
| openComponent, | |
| patchNodeData, | |
| saveComponentNode, | |
| presets, | |
| refreshPresets, | |
| removeHandleConnections, | |
| replaceNodeData, | |
| savePreset, | |
| ], | |
| ); | |
| return ( | |
| <WorkflowProvider value={contextValue}> | |
| <div className={`app-shell ${isDemoMode ? 'app-shell--demo' : ''}`}> | |
| <Toolbar | |
| backendUrl={backendUrl} | |
| liveDebugUrl={liveDebugUrl} | |
| breadcrumbs={breadcrumbs} | |
| canCreateComponent={selectedNodeCount > 0} | |
| isDemo={isDemoMode} | |
| isInsideComponent={graphPath.length > 0} | |
| isRunning={isRunning} | |
| notice={notice} | |
| onCreateComponent={handleCreateComponent} | |
| onGoBack={goBackOneLevel} | |
| onGoRoot={() => navigateToPath([], { fitView: false })} | |
| onOpenSettings={handleOpenSettings} | |
| theme={theme} | |
| onToggleTheme={handleToggleTheme} | |
| onRun={handleRun} | |
| liveDebugStatus={liveDebugStatus} | |
| onToggleLiveDebug={handleToggleLiveDebug} | |
| llmTestMode={llmTestMode} | |
| onLlmTestRun={handleLlmTestRun} | |
| onStopLlmTest={handleStopLlmTest} | |
| onNew={handleNew} | |
| onSave={handleSave} | |
| onSaveAs={handleSaveAs} | |
| onLoad={handleLoad} | |
| /> | |
| {!isDemoMode ? ( | |
| <div className={`app-shell__body ${isPaletteVisible ? '' : 'app-shell__body--palette-hidden'}`}> | |
| {isPaletteVisible ? ( | |
| <NodePalette | |
| definitions={listNodeDefinitions()} | |
| componentTemplates={componentTemplates} | |
| onAddNode={addNode} | |
| onAddComponentNode={addComponentNodeFromLibrary} | |
| onHide={() => setIsPaletteVisible(false)} | |
| /> | |
| ) : null} | |
| <div className="app-shell__canvas"> | |
| <button | |
| type="button" | |
| className="palette-toggle" | |
| onClick={() => setIsPaletteVisible((current) => !current)} | |
| > | |
| {isPaletteVisible ? 'Скрыть библиотеку' : 'Показать библиотеку'} | |
| </button> | |
| <div className="flow-surface"> | |
| <ReactFlow | |
| nodes={nodes} | |
| edges={edges} | |
| nodeTypes={nodeTypes} | |
| onNodesChange={onNodesChange} | |
| onEdgesChange={onEdgesChange} | |
| onConnect={onConnect} | |
| onNodeDoubleClick={(_, node) => { | |
| if (isComponentNodeType(node.type)) { | |
| openComponent(node.id); | |
| } | |
| }} | |
| onMoveEnd={() => { | |
| updateCurrentGraph((graph) => ({ | |
| ...graph, | |
| viewport: reactFlow.getViewport(), | |
| })); | |
| }} | |
| isValidConnection={(connection) => isValidConnection(connection, nodes, edges)} | |
| defaultViewport={DEFAULT_VIEWPORT} | |
| fitView | |
| minZoom={0.25} | |
| maxZoom={1.8} | |
| colorMode={theme} | |
| proOptions={{ hideAttribution: true }} | |
| > | |
| <Background | |
| color={theme === 'dark' ? 'rgba(158, 180, 199, 0.16)' : 'rgba(99, 115, 129, 0.18)'} | |
| gap={18} | |
| size={1} | |
| /> | |
| <MiniMap | |
| pannable | |
| zoomable | |
| maskColor={theme === 'dark' ? 'rgba(12, 16, 22, 0.7)' : 'rgba(244, 248, 251, 0.75)'} | |
| nodeColor={theme === 'dark' ? '#7fe4be' : '#3f7f6c'} | |
| nodeStrokeColor={theme === 'dark' ? '#ebf2f8' : '#20303b'} | |
| /> | |
| <Controls /> | |
| </ReactFlow> | |
| </div> | |
| </div> | |
| </div> | |
| ) : null} | |
| <InteractivePanel | |
| isDemo={isDemoMode} | |
| pendingQuestion={pendingQuestion} | |
| transcript={interactiveTranscript} | |
| inputValue={answerDraft} | |
| isBusy={isRunning} | |
| llmProvider={llmProvider} | |
| llmAutoAnswer={llmAutoAnswer} | |
| writeTranscriptLogs={writeTranscriptLogs} | |
| llmRolePrompt={llmRolePrompt} | |
| onInputChange={setAnswerDraft} | |
| onLlmAutoAnswerChange={setLlmAutoAnswer} | |
| onWriteTranscriptLogsChange={(checked) => { | |
| writeTranscriptLogsRef.current = checked; | |
| setWriteTranscriptLogs(checked); | |
| if (!checked) { | |
| transcriptLogRef.current = null; | |
| } | |
| }} | |
| onLlmRolePromptChange={handleLlmRolePromptChange} | |
| onSubmitAnswer={handleSubmitAnswer} | |
| onSimulateAnswer={handleSimulateAnswer} | |
| onSaveSessionLog={handleSaveSessionLog} | |
| /> | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| accept=".json" | |
| style={{ display: 'none' }} | |
| onChange={async (event) => { | |
| const file = event.target.files?.[0]; | |
| if (!file) { | |
| return; | |
| } | |
| try { | |
| loadFromText(await file.text()); | |
| } catch (error) { | |
| console.error('Failed to parse workflow file:', error); | |
| setNotice('Не удалось прочитать workflow'); | |
| } finally { | |
| event.target.value = ''; | |
| } | |
| }} | |
| /> | |
| <SettingsDialog | |
| isOpen={isSettingsOpen} | |
| backendUrl={settingsBackendUrl} | |
| defaultBackendUrl={defaultBackendUrl} | |
| liveDebugUrl={settingsLiveDebugUrl} | |
| llmProvider={settingsLlmProvider} | |
| deepinfraApiKey={settingsDeepinfraApiKey} | |
| deepinfraHasKey={settingsDeepinfraHasKey} | |
| deepinfraKeyStatus={settingsDeepinfraKeyStatus} | |
| onBackendUrlChange={setSettingsBackendUrl} | |
| onLiveDebugUrlChange={setSettingsLiveDebugUrl} | |
| onLlmProviderChange={setSettingsLlmProvider} | |
| onDeepinfraApiKeyChange={setSettingsDeepinfraApiKey} | |
| onClose={() => setIsSettingsOpen(false)} | |
| onResetToDefault={handleSettingsResetToDefault} | |
| onSave={handleSettingsSave} | |
| /> | |
| </div> | |
| </WorkflowProvider> | |
| ); | |
| } | |
| export default function App() { | |
| return ( | |
| <ReactFlowProvider> | |
| <WorkflowEditor /> | |
| </ReactFlowProvider> | |
| ); | |
| } | |