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 (
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 ? (
{isPaletteVisible ? ( setIsPaletteVisible(false)} /> ) : null}
{ 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 }} >
) : null} { writeTranscriptLogsRef.current = checked; setWriteTranscriptLogs(checked); if (!checked) { transcriptLogRef.current = null; } }} onLlmRolePromptChange={handleLlmRolePromptChange} onSubmitAnswer={handleSubmitAnswer} onSimulateAnswer={handleSimulateAnswer} onSaveSessionLog={handleSaveSessionLog} /> { 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 = ''; } }} /> setIsSettingsOpen(false)} onResetToDefault={handleSettingsResetToDefault} onSave={handleSettingsSave} />
); } export default function App() { return ( ); }