nodes-ui-flow / src /App.jsx
markitzeroo
Save default workflow on server
530209e
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>
);
}