Files
HadTavern/static/editor.html

2973 lines
149 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>НАДTAVERN</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/drawflow@0.0.55/dist/drawflow.min.css" />
<!-- JSON5 for tolerant JSON parsing/formatting in editors -->
<script src="https://cdn.jsdelivr.net/npm/json5@2.2.3/dist/index.min.js"></script>
<style>
:root {
color-scheme: dark;
--bg: #0b0d10;
--panel: #11151a;
--muted: #a7b0bf;
--border: #1f2937;
--accent: #6ee7b7;
--accent-2: #60a5fa;
--node: #0e1116;
--node-border: #334155;
--node-selected: #1f2937;
--connector: #94a3b8;
}
body { margin: 0; background: var(--bg); color: #e5e7eb; }
header { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; border-bottom: 1px solid var(--border); background: var(--panel); }
header .actions { display: flex; gap: 8px; }
button { background: #1f2937; border: 1px solid #334155; color: #e5e7eb; padding: 6px 10px; border-radius: 8px; cursor: pointer; }
button:hover { background: #273246; }
#container { display: grid; grid-template-columns: 260px 1fr 360px; height: calc(100vh - 52px); }
#sidebar { border-right: 1px solid var(--border); padding: 12px; background: var(--panel); overflow: auto; }
#canvas { position: relative; }
#inspector { border-left: 1px solid var(--border); padding: 12px; overflow: auto; background: var(--panel); }
#drawflow { width: 100%; height: 100%; }
.group-title { font-size: 12px; text-transform: uppercase; color: var(--muted); margin: 12px 0 6px; letter-spacing: .08em; }
.node-btn { width: 100%; text-align: left; margin-bottom: 6px; border-left: 3px solid transparent; }
.node-btn:hover { border-left-color: var(--accent-2); }
.hint { color: var(--muted); font-size: 12px; margin-top: 4px; }
.df-node .title-box { background: var(--node); border: 1px solid var(--node-border); color: #e5e7eb; border-radius: 10px 10px 0 0; }
.df-node .box { background: var(--node); border: 1px solid var(--node-border); border-top: 0; color: #e5e7eb; border-radius: 0 0 10px 10px; }
/* Override default blue styles from drawflow */
/* В обычном режиме убираем дефолтный фон заголовка drawflow,
но в LOD compact/tiny оставляем фон, чтобы ноды были видимы при отдалении */
#canvas:not(.lod-compact):not(.lod-tiny) .drawflow .drawflow-node .title {
background: transparent !important;
color: inherit !important;
}
#canvas:not(.lod-compact):not(.lod-tiny) .drawflow .drawflow-node {
background: transparent !important;
}
/* moved to external CSS: ports styling is defined in editor.css */
.drawflow .drawflow-node.selected { box-shadow: none; }
.drawflow .connection .main-path { stroke: var(--connector); }
.drawflow .connection .point { stroke: var(--connector); fill: var(--panel); }
.drawflow .drawflow-node.selected .title-box, .drawflow .drawflow-node.selected .box { border-color: var(--accent); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 50%, transparent); }
.df-node .input, .df-node .output { color: var(--muted); }
textarea, input[type=text] { width: 100%; background: #0f141a; color: #e5e7eb; border: 1px solid #2b3646; border-radius: 8px; padding: 8px; }
label { font-size: 12px; color: var(--muted); display: block; margin: 8px 0 4px; }
pre { background: #0f141a; border: 1px solid #2b3646; padding: 10px; border-radius: 8px; overflow: auto; }
.preview { pointer-events: none; opacity: .85; }
/* Help popups */
details.help { margin: 6px 0; }
details.help summary { list-style: none; cursor: pointer; display: inline-block; width: 20px; height: 20px; border-radius: 50%; background: #334155; color: #e5e7eb; text-align: center; line-height: 20px; font-weight: 700; border: 1px solid #2b3646; }
details.help summary::-webkit-details-marker { display: none; }
details.help .panel { margin-top: 8px; background: #0f141a; border: 1px solid #2b3646; padding: 10px; border-radius: 8px; }
</style>
<link rel="stylesheet" href="/ui/editor.css?v=2" />
</head>
<body>
<header>
<div class="brand" aria-label="НадTavern">НАДTAVERN</div>
<div class="actions">
<button class="chip-btn" id="btn-load">ЗАГРУЗИТЬ ПАЙПЛАЙН</button>
<button class="chip-btn" id="btn-save">СОХРАНИТЬ ПАЙПЛАЙН</button>
<input class="top-input" id="preset-name" placeholder="ИМЯ ПРЕСЕТА" />
<button class="chip-btn" id="btn-save-preset">СОХРАНИТЬ ПРЕСЕТ</button>
<select class="top-input" id="preset-select"></select>
<button class="chip-btn" id="btn-load-preset">ЗАГРУЗИТЬ ПРЕСЕТ</button>
<button class="chip-btn" id="btn-open-run" title="Параметры запуска">ЗАПУСК ⚙️</button>
<button class="chip-btn" id="btn-vars">ПЕРЕМЕННЫЕ</button>
<button class="chip-btn" id="btn-scheme" title="Показать мини‑схему">СХЕМА</button>
<button class="chip-btn" id="btn-logs" title="Журнал HTTP запросов/ответов">ЛОГИ</button>
<button class="chip-btn" id="btn-cancel" title="Прервать текущее исполнение пайплайна">СТОП ⏹</button>
<a class="chip-btn" href="/" role="button">ДОМОЙ</a>
</div>
<!-- Danmaku overlay layer -->
<div id="danmaku-layer" aria-hidden="true"></div>
</header>
<!-- Run Drawer -->
<aside id="run-drawer" aria-hidden="true">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<h3 style="flex:1;margin:0">Параметры запуска</h3>
<button id="run-close" title="Закрыть"></button>
</div>
<div class="row">
<label style="min-width:120px">Режим</label>
<select id="loop-mode">
<option value="dag">dag</option>
<option value="iterative">iterative</option>
</select>
</div>
<div class="row">
<label style="min-width:120px">Макс. итераций</label>
<input id="loop-iters" type="number" min="1" step="1" placeholder="1000" />
</div>
<div class="row">
<label style="min-width:120px">Бюджет времени (мс)</label>
<input id="loop-budget" type="number" min="1" step="1" placeholder="10000" />
</div>
<div class="row">
<label style="min-width:120px">HTTP timeout (сек)</label>
<input id="run-http-timeout" type="number" min="1" step="0.5" placeholder="60" />
</div>
<!-- OUTx стратегия (удалено по ТЗ) -->
<!-- v2: Менеджер пресетов парсинга OUTx (только JSONPath + Добавить) -->
<div class="group-title" style="margin-top:8px">Пресеты парсинга OUTx</div>
<div id="run-presets-ui" style="border:1px solid #2b3646;border-radius:8px;padding:8px">
<div class="row">
<label style="min-width:120px">JSON Path</label>
<input id="run-preset-jsonpath" type="text" placeholder="candidates.0.content.parts.0.text" />
</div>
<div class="actions" style="margin-top:6px">
<button id="run-preset-add" title="Добавить пресет с указанным JSONPath">Добавить</button>
</div>
<div class="hint" style="margin-top:6px">Вводите JSONPath и нажимайте «Добавить». Выбор пресета выполняется в каждой ноде.</div>
<div id="run-presets-list" style="margin-top:8px;border-top:1px dashed #2b3646;padding-top:8px"></div>
</div>
<div class="row">
<label title="Очищать сторадж переменных перед запуском" style="display:inline-flex;align-items:center;gap:6px">
<input id="clear-var-store" type="checkbox" />
clear vars
</label>
</div>
<div class="actions">
<button id="run-apply" title="Сохранить метаданные">Сохранить</button>
<button id="run-close-2" title="Закрыть панель">Закрыть</button>
</div>
</aside>
<!-- Mini-Scheme panel -->
<aside id="scheme-panel" aria-hidden="true" style="display:none">
<div class="row" style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<h3 style="flex:1;margin:0">Мини‑схема</h3>
<button id="scheme-refresh" title="Обновить"></button>
<button id="scheme-close" title="Закрыть"></button>
</div>
<canvas id="scheme-canvas" width="360" height="240"></canvas>
<div class="hint" style="margin-top:6px">Схема — обзор графа; открывается и закрывается по кнопке «Схема».</div>
</aside>
<!-- HTTP Logs panel -->
<aside id="logs-panel" aria-hidden="true" style="display:none">
<div class="row" style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<h3 style="flex:1;margin:0">ЛОГИ (запросы/ответы)</h3>
<button id="logs-clear" title="Очистить список">🗑</button>
<button id="logs-close" title="Закрыть"></button>
</div>
<div id="logs-body" style="display:grid;grid-template-columns: 360px 1fr;gap:10px;min-height:240px;max-height:70vh;overflow:auto">
<div id="logs-list" style="border:1px solid #2b3646;border-radius:8px;overflow:auto;background:#0f141a"></div>
<div id="logs-detail" style="display:flex;flex-direction:column;gap:8px">
<div>
<strong>Request</strong>
<pre id="logs-req" style="min-height:120px;max-height:36vh;overflow:auto;white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere"></pre>
</div>
<div>
<strong>Response</strong>
<pre id="logs-resp" style="min-height:120px;max-height:36vh;overflow:auto;white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere"></pre>
</div>
<div>
<strong>Data</strong>
<pre id="logs-data" style="min-height:120px;max-height:36vh;overflow:auto;white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere"></pre>
</div>
</div>
</div>
<div class="hint" style="margin-top:6px">Список слева синхронизируется в реальном времени. Кликните запись, чтобы увидеть полный HTTPтрафик как в Burp.</div>
</aside>
<div id="container">
<aside id="sidebar">
<div class="group-title">Ноды</div>
<button title="Задать пользовательские переменные, доступные как [[NAME]] и {{ NAME }}" class="node-btn" data-node="SetVars">SetVars</button>
<button title="Условное ветвление по выражению (true/false)" class="node-btn" data-node="If">If</button>
<button title="Запрос к провайдеру (openai/gemini/claude) с настраиваемым endpoint и JSON" class="node-btn" data-node="ProviderCall">ProviderCall</button>
<button title="Прямой форвард входящего запроса как reverse-proxy" class="node-btn" data-node="RawForward">RawForward</button>
<button title="Финализировать ответ в формате целевого провайдера (auto/openai/gemini/claude)" class="node-btn" data-node="Return">Return</button>
<div class="hint">Подсказка: соедините выход предыдущей ноды с входом следующей, сохраните и тестируйте через /ui.</div>
<div class="group-title">Переменные и макросы</div>
<details class="help sidebar-help">
<summary class="sidebar-help-toggle" title="Показать/скрыть справку">Справка по переменным и макросам</summary>
<div class="panel">
<div class="hint">Используйте переменные в шаблонах как <code>[[variable]]</code>. Наведите курсор на имя переменной, чтобы увидеть подсказку.</div>
<div class="hint"><strong>Общие:</strong>
<code title="Системные инструкции для LLM">[[system]]</code>,
<code title="Имя выбранной модели">[[model]]</code>,
<code title="Формат ответа провайдера (например, OpenAI, Gemini)">[[vendor_format]]</code>
</div>
<div class="hint"><strong>Чат:</strong>
<code title="Последнее сообщение пользователя">[[chat.last_user]]</code>,
<code title="Все сообщения в чате">[[chat.messages]]</code>
</div>
<div class="hint"><strong>Параметры:</strong>
<code title="Температура выборки (01)">[[params.temperature]]</code>,
<code title="Максимальное число токенов">[[params.max_tokens]]</code>,
<code title="Вероятностный срез top-p sampling">[[params.top_p]]</code>,
<code title="Стоп-слова/условия обрыва генерации">[[params.stop]]</code>
</div>
<div class="hint"><strong>Входящий запрос:</strong>
<code title="Путь в URL запроса">[[incoming.path]]</code>,
<code title="Query-параметры запроса">[[incoming.query]]</code>
</div>
<div class="hint"><strong>Заголовки/тело:</strong>
<code title="Все заголовки HTTP-запроса">[[incoming.headers]]</code>,
<code title="JSON-тело входящего запроса">[[incoming.json]]</code>
</div>
<div class="hint"><strong>Ключи (API Keys):</strong>
<code title="Основной ключ авторизации (например Authorization: Bearer ...)">[[incoming.api_keys.authorization]]</code>,
<code title="Альтернативное имя ключа, если используется">[[incoming.api_keys.key]]</code>
</div>
<div class="hint"><strong>Быстрые макросы:</strong>
<code title="Единый JSONфрагмент из Prompt Blocks (подставляется провайдер‑специфично)">[[PROMPT]]</code>,
<code title="Текст из выхода ноды n1 (besteffort, вытаскивает content/text из JSON ответа)">[[OUT1]]</code>,
<code title="Текст из выхода ноды n2">[[OUT2]]</code>,
<code title="Пользовательская переменная, заданная в SetVars">[[NAME]]</code>
<span style="opacity:.85"> | Расширенно: <code>[[OUT:n1.result...]]</code> или <code>{{ OUT.n1.result... }}</code></span>
</div>
</div>
</details>
<div class="group-title">Отладка</div>
<pre id="status"></pre>
</aside>
<!-- Panel toggles as absolute children of container -->
<button id="toggle-left" class="panel-toggle left" title="Свернуть/развернуть левую панель" aria-label="Свернуть/развернуть левую панель"></button>
<button id="toggle-right" class="panel-toggle right" title="Свернуть/развернуть правую панель" aria-label="Свернуть/развернуть правую панель"></button>
<main id="canvas">
<div id="drawflow"></div>
<div id="lod-tooltip" aria-hidden="true" class="lod-tooltip" style="display:none"></div>
<div id="lod-hints" aria-hidden="true"></div>
<!-- Vars Popover -->
<div id="vars-popover" class="vars-popover" style="display:none">
<div class="vars-head">
<strong>Переменные (STORE)</strong>
<input id="vars-search" placeholder="поиск по имени/значению"/>
<select id="vars-scope" title="Источник значений">
<option value="vars">vars</option>
<option value="snapshot">snapshot</option>
<option value="all">all</option>
</select>
<label title="Вставлять макрос фигурными {{ store.KEY }} " class="vars-braces">
фигурные
<input id="vars-mode-braces" type="checkbox"/>
</label>
<button id="vars-refresh" title="Обновить"></button>
<button id="vars-clear" title="Очистить хранилище">🗑</button>
<button id="vars-close" title="Закрыть"></button>
</div>
<div id="vars-info" class="hint">Клик по строке копирует макрос в буфер обмена</div>
<div id="vars-list"></div>
</div>
</main>
<aside id="inspector">
<div class="group-title">Свойства ноды</div>
<div id="inspector-content">Выберите ноду…</div>
</aside>
</div>
<script src="https://cdn.jsdelivr.net/npm/drawflow@0.0.55/dist/drawflow.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
<script src="/ui/js/serialization.js?v=4"></script>
<script src="/ui/js/pm-ui.js?v=4"></script>
<script>
// Типы портов и их имена в нашем контракте
const NODE_IO = {
// depends: используется только для порядка выполнения (зависимости), данные не читаются
// Провода не переносят данные; OUT/vars берутся из контекста и снапшота.
SetVars: { inputs: [], outputs: ['done'] },
If: { inputs: ['depends'], outputs: ['true','false'] },
ProviderCall:{ inputs: ['depends'], outputs: ['done'] },
RawForward: { inputs: ['depends'], outputs: ['done'] },
Return: { inputs: ['depends'], outputs: [] }
};
window.NODE_IO = NODE_IO;
const editor = new Drawflow(document.getElementById('drawflow'));
editor.reroute = true;
editor.start();
window.editor = editor;
// Провайдерные пресеты для ProviderCall (редактируемые пользователем).
// Шаблоны используют {{ pm.* }} — это JSON-структуры, которые сервер собирает из Prompt Blocks.
// Поэтому подстановки в template дадут корректный JSON (массивы/объекты без кавычек).
function providerDefaults(provider) {
const p = (provider || 'openai').toLowerCase();
const T_OPENAI = `{
"model": "{{ model }}",
[[PROMPT]],
"temperature": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},
"top_p": {{ incoming.json.top_p|default(params.top_p|default(1)) }},
"max_tokens": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},
"max_completion_tokens": {{ incoming.json.max_completion_tokens|default(params.max_tokens|default(256)) }},
"presence_penalty": {{ incoming.json.presence_penalty|default(0) }},
"frequency_penalty": {{ incoming.json.frequency_penalty|default(0) }},
"stop": {{ incoming.json.stop|default(params.stop|default([])) }},
"stream": {{ incoming.json.stream|default(false) }}
}`;
const T_GEMINI = `{
"model": "{{ model }}",
[[PROMPT]],
"safetySettings": {{ incoming.json.safetySettings|default([]) }},
"generationConfig": {
"temperature": {{ incoming.json.generationConfig.temperature|default(params.temperature|default(0.7)) }},
"topP": {{ incoming.json.generationConfig.topP|default(params.top_p|default(1)) }},
"maxOutputTokens": {{ incoming.json.generationConfig.maxOutputTokens|default(params.max_tokens|default(256)) }},
"stopSequences": {{ incoming.json.generationConfig.stopSequences|default(params.stop|default([])) }},
"candidateCount": {{ incoming.json.generationConfig.candidateCount|default(1) }},
"thinkingConfig": {
"includeThoughts": {{ incoming.json.generationConfig.thinkingConfig.includeThoughts|default(false) }},
"thinkingBudget": {{ incoming.json.generationConfig.thinkingConfig.thinkingBudget|default(0) }}
}
}
}`;
const T_CLAUDE = `{
"model": "{{ model }}",
[[PROMPT]],
"temperature": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},
"top_p": {{ incoming.json.top_p|default(params.top_p|default(1)) }},
"max_tokens": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},
"stop_sequences": {{ incoming.json.stop_sequences|default(params.stop|default([])) }},
"stream": {{ incoming.json.stream|default(false) }},
"thinking": {
"type": "{{ incoming.json.thinking.type|default('disabled') }}",
"budget_tokens": {{ incoming.json.thinking.budget_tokens|default(0) }}
},
"anthropic_version": "{{ anthropic_version|default('2023-06-01') }}"
}`;
if (p === 'openai') {
return {
base_url: 'https://api.openai.com',
endpoint: '/v1/chat/completions',
headers: `{"Authorization":"Bearer [[VAR:incoming.headers.authorization]]"}`,
template: T_OPENAI
};
}
if (p === 'gemini') {
// По умолчанию ключ часто идёт в query (?key=..). Заголовок оставляем пустым.
return {
base_url: 'https://generativelanguage.googleapis.com',
endpoint: '/v1beta/models/{{ model }}:generateContent?key=[[VAR:incoming.api_keys.key]]',
headers: `{}`,
template: T_GEMINI
};
}
if (p === 'claude') {
return {
base_url: 'https://api.anthropic.com',
endpoint: '/v1/messages',
headers: `{"x-api-key":"[[VAR:incoming.headers.x-api-key]]","anthropic-version":"2023-06-01","anthropic-beta":"[[VAR:incoming.headers.anthropic-beta]]"}`,
template: T_CLAUDE
};
}
// Unknown — пустые значения, чтобы пользователь всё заполнил руками
return { base_url: '', endpoint: '', headers: `{}`, template: `{}` };
}
// Helpers for provider-specific configs
function ensureProviderConfigs(d) {
if (!d) return;
if (!d.provider) d.provider = 'openai';
if (!d.provider_configs || typeof d.provider_configs !== 'object') d.provider_configs = {};
['openai','gemini','claude'].forEach(p=>{
if (!d.provider_configs[p]) d.provider_configs[p] = providerDefaults(p);
});
}
function getActiveProv(d) {
return (d && d.provider ? String(d.provider) : 'openai').toLowerCase();
}
function getActiveCfg(d) {
ensureProviderConfigs(d);
const p = getActiveProv(d);
return d.provider_configs[p] || {};
}
// HTML escaping helpers for safe attribute/text insertion
function escAttr(v) {
const s = String(v ?? '');
return s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
}
function escText(v) {
const s = String(v ?? '');
// For text nodes we keep quotes as-is for readability, but escape critical HTML chars
return s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
}
// Нормализуем/заполняем дефолты конфигов нод, чтобы ключи попадали в сериализацию
function applyNodeDefaults(type, data) {
const d = { ...(data || {}) };
if (type === 'ProviderCall') {
if (d.provider == null) d.provider = 'openai';
ensureProviderConfigs(d);
// Back-compat: top-level fields may exist, but UI prefers provider_configs
if (!Array.isArray(d.blocks)) d.blocks = [];
// sleep_ms: не выставляем по умолчанию, чтобы «выключенный» sleep отсутствовал в config
}
if (type === 'RawForward') {
if (d.passthrough_headers == null) d.passthrough_headers = true;
if (d.extra_headers == null) d.extra_headers = '{}';
// sleep_ms: не выставляем по умолчанию, чтобы «выключенный» sleep отсутствовал в config
}
if (type === 'SetVars') {
if (!Array.isArray(d.variables)) d.variables = [];
}
if (type === 'If') {
if (d.expr == null) d.expr = '';
}
if (type === 'Return') {
if (d.target_format == null) d.target_format = 'auto';
if (d.text_template == null) d.text_template = '[[OUT1]]';
}
return d;
}
const status = (t) => { document.getElementById('status').textContent = t; };
// Runtime CSS availability check
try {
const linkEl = document.querySelector('link[href$="editor.css"]');
if (linkEl) {
const href = linkEl.getAttribute('href');
fetch(href, { method: 'HEAD' })
.then(r => {
console.debug('[НадTavern] CSS HEAD', href, r.status);
if (!r.ok) {
try { status('CSS not reachable: ' + href + ' ' + r.status); } catch (e) {}
}
})
.catch(e => { try { status('CSS load error: ' + String(e)); } catch (e2) {} });
}
} catch (e) {}
// Diagnostics to validate branding and font override
try {
console.debug('[Brand] document.title =', document.title);
const ff = getComputedStyle(document.body).fontFamily;
console.debug('[Font] body.fontFamily =', ff);
} catch (_) {}
function makeNodeHtml(type, data) {
const shownId = escText(data && data._origId ? String(data._origId) : '');
const head = `<div class="title-box" data-title="${escAttr(type)}${shownId ? ' #'+shownId : ''}"><strong>${type}</strong>${shownId ? ' <span style="opacity:.7">#'+shownId+'</span>' : ''}</div>`;
if (type === 'ProviderCall') {
const provider = data.provider || 'openai';
const cfg = getActiveCfg(data);
const base_url = cfg.base_url || '';
const endpoint = cfg.endpoint || '';
const headers = (cfg.headers != null ? cfg.headers : '{"Authorization":"Bearer YOUR_KEY"}');
let tmpl;
if (cfg.template != null) {
tmpl = cfg.template;
} else if (provider === 'openai') {
tmpl = '{"model":"{{ model }}","messages":[{"role":"system","content":"{{ system }}"},{"role":"user","content":"{{ chat.last_user }}"}],"temperature":{{ params.temperature|default(0.7) }}}';
} else if (provider === 'gemini') {
tmpl = '{"contents":[{"role":"user","parts":[{"text":"{{ chat.last_user }}"}]}]}';
} else {
tmpl = '{"model":"{{ model }}","messages":[{"role":"user","content":[{"type":"text","text":"{{ chat.last_user }}"}]}],"max_tokens":256}';
}
const template = tmpl;return `${head}<div class="box"><div class="node-preview">
<label>provider</label>
<input type="text" value="${escAttr(provider)}" readonly />
<label>base_url</label>
<input type="text" value="${escAttr(base_url)}" readonly />
<label>endpoint</label>
<input type="text" value="${escAttr(endpoint)}" readonly />
<label>headers (preview JSON)</label>
<textarea readonly>${escText(headers)}</textarea>
<label>template (preview JSON)</label>
<textarea readonly>${escText(template)}</textarea>
</div></div>`;
}
if (type === 'If') {
const expr = data.expr || '';
return `${head}<div class="box"><div class="node-preview">
<label>expr</label>
<textarea readonly>${escText(expr)}</textarea>
<div class="hint">Поддерживается: [[...]], {{ ... }}, contains, &&, ||, !, ==, !=, <, <=, >, >=</div>
</div></div>`;
}
if (type === 'RawForward') {
const base_url = data.base_url || '';
const override_path = data.override_path || '';
const passthrough_headers = (data.passthrough_headers ?? true) ? 'checked' : '';
const extra_headers = data.extra_headers || '{}';
return `${head}<div class="box"><div class="node-preview">
<label>base_url</label>
<input type="text" value="${escAttr(base_url)}" readonly />
<label>override_path</label>
<input type="text" value="${escAttr(override_path)}" readonly />
<label><input type="checkbox" ${passthrough_headers} disabled/> passthrough_headers</label>
<label>extra_headers (preview JSON)</label>
<textarea readonly>${escText(extra_headers)}</textarea>
</div></div>`;
}
if (type === 'SetVars') {
const vars = Array.isArray(data.variables) ? data.variables : [];
const names = vars.map(v => v?.name || '').filter(Boolean);
return `${head}<div class="box"><div class="node-preview">
<label>variables</label>
<textarea readonly>${escText(names.length ? names.join(', ') : '(нет переменных)')}</textarea>
<div class="hint">В шаблонах доступны как [[NAME]] и {{ NAME }}.</div>
</div></div>`;
}
if (type === 'Return') {
const tgt = (data.target_format || 'auto');
const tmpl = (data.text_template != null ? data.text_template : '[[OUT1]]');
return `${head}<div class="box"><div class="node-preview">
<label>target_format</label>
<input type="text" value="${escAttr(tgt)}" readonly />
<label>text_template (preview)</label>
<textarea readonly>${escText(tmpl)}</textarea>
</div></div>`;
}
return `${head}<div class="box"></div>`;
}
// Helpers to manage human-readable original ids (nX)
function collectUsedOrigNums() {
try {
const data = window.editor && window.editor.export ? window.editor.export() : null;
const dfNodes = (data && data.drawflow && data.drawflow.Home && data.drawflow.Home.data) ? data.drawflow.Home.data : {};
const used = new Set();
for (const dfid in dfNodes) {
try {
const n = window.editor.getNodeFromId(parseInt(dfid, 10));
const orig = n && n.data && n.data._origId;
if (typeof orig === 'string') {
const m = orig.match(/^n(\d+)$/i);
if (m) used.add(parseInt(m[1], 10));
}
} catch (e) {}
}
return used;
} catch (e) {
return new Set();
}
}
function nextFreeOrigId() {
const used = collectUsedOrigNums();
let x = 1;
while (used.has(x)) x += 1;
return 'n' + x;
}
function addNode(type, pos = {x: 100, y: 100}, data = {}) {
const io = NODE_IO[type];
const dataWithDefaults = applyNodeDefaults(type, data);
if (!dataWithDefaults._origId) {
try { dataWithDefaults._origId = nextFreeOrigId(); } catch (e) { dataWithDefaults._origId = ''; }
}
const html = makeNodeHtml(type, dataWithDefaults);
const id = editor.addNode(
type,
io.inputs.length,
io.outputs.length,
pos.x,
pos.y,
type,
dataWithDefaults,
html
);
// Привяжем данные к DOM для inline-редакторов
const el = document.querySelector(`#node-${id}`);
if (el) el.__data = editor.getNodeFromId(id).data;
return id;
}
// Инспектор
editor.on('nodeSelected', function(id) {
// запоминаем текущую ноду инспектора для коммита перед сохранением пайплайна
try { window.__currentInspectorNodeId = id; } catch(_){}
const n = editor.getNodeFromId(id);
renderInspector(id, n);
// Обновим визуальные классы для лучшей читабельности
const el = document.querySelector(`#node-${id}`);
if (el) {
el.style.background = 'transparent';
el.style.borderRadius = '10px';
}
});
editor.on('nodeCreated', function(id) {
const el = document.querySelector(`#node-${id}`);
if (el) el.__data = editor.getNodeFromId(id).data;
});
editor.on('nodeRemoved', function(id) {
document.getElementById('inspector-content').innerHTML = 'Выберите ноду…';
});
function renderInspector(id, node) {
const type = node.name;
node.data = applyNodeDefaults(type, node.data || {});
const data = node.data;
try { editor.updateNodeDataFromId(id, node.data); } catch(e) {}
const shownId = node.data?._origId || id;
let html = `<div><strong>${type}</strong> (#${shownId})</div>`;
if (type === 'ProviderCall') {
const cfg = getActiveCfg(data);
html += `
<label>provider</label>
<select id="f-provider">
<option value="openai">openai</option>
<option value="gemini">gemini</option>
<option value="claude">claude</option>
</select>
<label style="display:inline-flex;align-items:center;gap:6px"><input id="f-sleep-en" type="checkbox" ${(Number(data.sleep_ms||0)>0)?'checked':''}> sleep?</label>
<label id="lbl-sleep-sec" style="min-width:140px; display:${(Number(data.sleep_ms||0)>0)?'block':'none'}">пауза (сек)</label><input id="f-sleep-sec" type="number" min="0" step="0.5" value="${escAttr(String((Number(data.sleep_ms||0)||0)/1000))}" style="display:${(Number(data.sleep_ms||0)>0)?'block':'none'}">
<label>base_url</label><input id="f-baseurl" type="text" value="${escAttr(cfg.base_url||'')}" placeholder="https://api.openai.com">
<label>endpoint</label><input id="f-endpoint" type="text" value="${escAttr(cfg.endpoint||'')}" placeholder="/v1/chat/completions">
<label>headers (JSON)</label><textarea id="f-headers" rows="4">${escText(cfg.headers||'{}')}</textarea>
<label>template (JSON)</label>
<textarea id="f-template" rows="10">${escText(cfg.template||'{}')}</textarea>
<div style="margin-top:6px">
<details class="help">
<summary title="Подсказка по шаблону">?</summary>
<div class="panel">
Шаблон поддерживает макросы и вставки из Prompt Blocks.
Рекомендуется использовать единый фрагмент <code>[[PROMPT]]</code> — он разворачивается провайдер‑специфично:
<br/>• openai → <code>"messages": [...]</code>
<br/>• gemini → <code>"contents": [...], "systemInstruction": {...}</code>
<br/>• claude → <code>"system": "...", "messages": [...]</code>
<br/>Также доступны структуры <code>{{ pm.* }}</code> для тонкой настройки (напр. <code>{{ pm.messages }}</code>).
<br/><strong>Важно:</strong> вход <code>depends</code> используется только для порядка выполнения, данные из него не читаются. Данные предыдущих нод вставляйте макросами <code>[[OUTx]]</code> или <code>[[OUT:nId...]]</code>.
</div>
</details>
</div>
`;
html += `
<div class="group-title" style="margin-top:16px">Prompt Blocks</div>
<div style="display:flex; gap:8px; align-items:center; margin-bottom:6px">
<button id="pm-add">Создать блок</button>
<details class="help" style="margin-left:4px">
<summary title="Подсказка по Prompt Blocks">?</summary>
<div class="panel">
Перетаскивайте блоки для изменения порядка. Включайте/выключайте тумблером.
<br/>Доступна переменная <code>[[PROMPT]]</code> — единый JSONфрагмент из этих блоков. Вставьте её в template объекта, например:
<br/><code>{ "model":"{{ model }}", [[PROMPT]], "temperature": {{ params.temperature|default(0.7) }} }</code>
<br/>Она автоматически формируется в зависимости от выбранного провайдера ноды.
</div>
</details>
</div>
<ul id="pm-list" style="list-style:none; padding-left:0; margin:0;"></ul>
<div id="pm-editor" style="margin-top:10px; display:none">
<label>Name</label>
<input id="pm-name" type="text" value="">
<label>Role</label>
<select id="pm-role">
<option value="system">system</option>
<option value="user">user</option>
<option value="assistant">assistant</option>
<option value="tool">tool</option>
</select>
<label>Prompt</label>
<textarea id="pm-prompt" rows="6"></textarea>
<div style="display:flex; gap:8px; margin-top:8px">
<button id="pm-save">Сохранить</button>
<button id="pm-cancel">Отмена</button>
</div>
</div>
`;
} else if (type === 'If') {
html += `
<label>expr</label>
<textarea id="if-expr" rows="3">${escText(data.expr || '')}</textarea>
<div class="hint">Примеры: [[OUT1]] contains "text" || [[OUT1]] contains [[Var1]]; {{ OUT.n2.result.meta.status|default(0) }} >= 200 && {{ OUT.n2.result.meta.status|default(0) }} < 300; !([[OUT3]] contains "error") && [[LANG]] == "ru"</div>
`;
} else if (type === 'RawForward') {
html += `
<label style="display:inline-flex;align-items:center;gap:6px"><input id="f-sleep-en" type="checkbox" ${(Number(data.sleep_ms||0)>0)?'checked':''}> sleep?</label>
<label id="lbl-sleep-sec" style="min-width:140px; display:${(Number(data.sleep_ms||0)>0)?'block':'none'}">пауза (сек)</label><input id="f-sleep-sec" type="number" min="0" step="0.5" value="${escAttr(String((Number(data.sleep_ms||0)||0)/1000))}" style="display:${(Number(data.sleep_ms||0)>0)?'block':'none'}">
<label>base_url</label><input id="f-baseurl" type="text" value="${escAttr(data.base_url||'')}" placeholder="https://api.openai.com">
<label>override_path</label><input id="f-override" type="text" value="${escAttr(data.override_path||'')}" placeholder="переопределить путь (опционально)">
<label><input id="f-pass" type="checkbox" ${(data.passthrough_headers??true)?'checked':''}> passthrough_headers</label>
<label>extra_headers (JSON)</label><textarea id="f-extra" rows="4">${escText(data.extra_headers||'{}')}</textarea>
<div class="hint">Берёт path, query, headers, json из incoming.*</div>
`;
} else if (type === 'Return') {
html += `
<label>target_format</label>
<select id="ret-target">
<option value="auto">auto (из исходного запроса)</option>
<option value="openai">openai</option>
<option value="gemini">gemini</option>
<option value="claude">claude</option>
</select>
<label>text_template</label>
<textarea id="ret-template" rows="4">${escText(data.text_template ?? '[[OUT1]]')}</textarea>
<div class="hint">Финализирует ответ в выбранный протокол. Макросы [[VAR:...]], [[OUT:...]], [[OUTx]], {{ ... }} поддерживаются.</div>
`;
} else if (type === 'SetVars') {
const list = Array.isArray(data.variables) ? data.variables : [];
const rows = list.map((v, i) => {
const name = escAttr(v?.name || '');
const mode = (v?.mode || 'string');
const value = escText(v?.value || '');
return `
<div class="var-row" data-idx="${i}" style="border:1px solid #2b3646;border-radius:6px;padding:8px;margin:6px 0">
<div style="display:flex;gap:8px;align-items:center">
<label style="min-width:60px">name</label>
<input class="v-name" type="text" value="${name}" placeholder="MY_VAR" style="flex:1">
<label style="min-width:56px">mode</label>
<select class="v-mode">
<option value="string"${mode==='string'?' selected':''}>string</option>
<option value="expr"${mode==='expr'?' selected':''}>expr</option>
</select>
<button class="v-del" title="Удалить">🗑</button>
</div>
<label style="margin-top:6px;display:block">value</label>
<textarea class="v-value" rows="3">${value}</textarea>
</div>
`;
}).join('');
html += `
<div class="group-title" style="margin-top:8px">Переменные</div>
<div id="sv-list">${rows || '<div class="hint">(нет переменных)</div>'}</div>
<div style="margin-top:8px">
<button id="sv-add">Добавить переменную</button>
</div>
<div class="hint" style="margin-top:6px">Переменные доступны в шаблонах как [[NAME]] и {{ NAME }}. Mode=expr — мини‑формулы без доступа к Python.</div>
`;
}
// Подсказка по портам (где IN/OUT и порядок сверху/снизу)
try {
const io = (window.NODE_IO && window.NODE_IO[type]) ? window.NODE_IO[type] : {inputs: [], outputs: []};
const inPorts = Array.isArray(io.inputs) ? io.inputs : [];
const outPorts = Array.isArray(io.outputs) ? io.outputs : [];
let portHint = '<div class="hint" style="margin-top:8px"><strong>Порты:</strong> IN — слева; OUT — справа.';
if (inPorts.length) {
portHint += '<div>IN: ' + inPorts.join(', ') + '</div>';
} else {
portHint += '<div>IN: нет</div>';
}
if (outPorts.length) {
const parts = outPorts.map((name, idx) => {
// OUT1 — верхний, OUT2 — нижний, далее — 3-й, 4-й...
let pos = (idx === 0 ? 'верхний' : (idx === 1 ? 'нижний' : ((idx+1) + '-й')));
return 'OUT' + (idx+1) + ' (' + pos + '): ' + name;
});
portHint += '<div>OUT: ' + parts.join('; ') + '</div>';
} else {
portHint += '<div>OUT: нет</div>';
}
portHint += '</div>';
html += portHint;
} catch (e) {}
html += `
<div style="margin-top:10px">
<button id="btn-save-node">Сохранить параметры</button>
</div>
`;
// html += makeNodeHtml(type, data); // Убираем дублирование превью в инспекторе
document.getElementById('inspector-content').innerHTML = html;
// Вставка UI выбора пресета парсинга OUTx на уровне ноды (v2)
try { wireTextExtractInspectorV2(editor, id, node); } catch(e) {}
const el = document.querySelector(`#node-${id}`);
document.querySelectorAll('#inspector textarea, #inspector input, #inspector select').forEach(inp => {
inp.addEventListener('input', () => {
const n = editor.getNodeFromId(id);
if (!n) return;
const d = n.data || {};
if (type === 'ProviderCall') {
ensureProviderConfigs(d);
const p = (d.provider || 'openai').toLowerCase();
const cfg = d.provider_configs[p] || (d.provider_configs[p] = providerDefaults(p));
if (inp.id === 'f-template') cfg.template = inp.value;
if (inp.id === 'f-baseurl') cfg.base_url = inp.value;
if (inp.id === 'f-endpoint') cfg.endpoint = inp.value;
if (inp.id === 'f-headers') cfg.headers = inp.value;
if (inp.id === 'f-provider') d.provider = inp.value; // select changes provider
// Sleep controls (seconds + enable checkbox)
if (inp.id === 'f-sleep-en') {
const secEl = document.getElementById('f-sleep-sec');
const lblEl = document.getElementById('lbl-sleep-sec');
const en = !!inp.checked;
if (secEl) secEl.style.display = en ? 'block' : 'none';
if (lblEl) lblEl.style.display = en ? 'block' : 'none';
if (!en) {
delete d.sleep_ms; // полностью выключаем сон
} else {
const val = secEl ? parseFloat(secEl.value || '0') : 0;
const ms = Math.max(0, Math.round((Number.isFinite(val) ? val : 0) * 1000));
d.sleep_ms = ms;
}
}
if (inp.id === 'f-sleep-sec') {
const enEl = document.getElementById('f-sleep-en');
const en = !!(enEl && enEl.checked);
const val = parseFloat(inp.value || '0');
if (en) {
const ms = Math.max(0, Math.round((Number.isFinite(val) ? val : 0) * 1000));
d.sleep_ms = ms;
}
}
// Синхронизуем в Drawflow, чтобы export() видел обновления
try { editor.updateNodeDataFromId(id, d); } catch (e) {}
const el = document.querySelector(`#node-${id}`);
if (el) el.__data = d;
} else if (type === 'If') {
if (inp.id === 'if-expr') d.expr = inp.value;
try { editor.updateNodeDataFromId(id, d); } catch (e) {}
const el = document.querySelector(`#node-${id}`);
if (el) el.__data = d;
} else if (type === 'RawForward') {
if (inp.id === 'f-template') d.template = inp.value;
if (inp.id === 'f-model') d.model = inp.value;
if (inp.id === 'f-extra') d.extra_headers = inp.value;
if (inp.id === 'f-override') d.override_path = inp.value;
if (inp.id === 'f-pass') d.passthrough_headers = inp.checked;
// Sleep controls (seconds + enable checkbox)
if (inp.id === 'f-sleep-en') {
const secEl = document.getElementById('f-sleep-sec');
const lblEl = document.getElementById('lbl-sleep-sec');
const en = !!inp.checked;
if (secEl) secEl.style.display = en ? 'block' : 'none';
if (lblEl) lblEl.style.display = en ? 'block' : 'none';
if (!en) {
delete d.sleep_ms; // полностью выключаем сон
} else {
const val = secEl ? parseFloat(secEl.value || '0') : 0;
const ms = Math.max(0, Math.round((Number.isFinite(val) ? val : 0) * 1000));
d.sleep_ms = ms;
}
}
if (inp.id === 'f-sleep-sec') {
const enEl = document.getElementById('f-sleep-en');
const en = !!(enEl && enEl.checked);
const val = parseFloat(inp.value || '0');
if (en) {
const ms = Math.max(0, Math.round((Number.isFinite(val) ? val : 0) * 1000));
d.sleep_ms = ms;
}
}
// Синхронизуем в Drawflow, чтобы export() видел обновления
try { editor.updateNodeDataFromId(id, d); } catch (e) {}
const el = document.querySelector(`#node-${id}`);
if (el) el.__data = d;
} else if (type === 'Return') {
if (inp.id === 'ret-target') d.target_format = inp.value;
if (inp.id === 'ret-template') d.text_template = inp.value;
try { editor.updateNodeDataFromId(id, d); } catch (e) {}
const el = document.querySelector(`#node-${id}`);
if (el) el.__data = d;
} else if (type === 'SetVars') {
// Для SetVars синхронизацию выполняют специализированные обработчики ниже (resync).
// Здесь ничего не делаем, чтобы не затереть значения.
return;
} else {
// Прочие типы — на будущее: безопасная синхронизация без изменений
try { editor.updateNodeDataFromId(id, d); } catch (e) {}
const el = document.querySelector(`#node-${id}`);
if (el) el.__data = d;
}
});
});
// Обработчики для SetVars
if (type === 'SetVars') {
const n = editor.getNodeFromId(id);
if (n) {
if (!Array.isArray(n.data.variables)) n.data.variables = [];
// Начальный sync, чтобы DOM.__data сразу содержал variables для сериализации
try { editor.updateNodeDataFromId(id, n.data || {}); } catch (e) {}
const el0 = document.querySelector(`#node-${id}`);
if (el0) el0.__data = JSON.parse(JSON.stringify(n.data || {}));
}
const root = document.getElementById('sv-list');
const addBtn = document.getElementById('sv-add');
function resync() {
const nn = editor.getNodeFromId(id);
if (!nn) return;
try { editor.updateNodeDataFromId(id, nn.data || {}); } catch (e) {}
const el = document.querySelector(`#node-${id}`);
if (el) el.__data = JSON.parse(JSON.stringify(nn.data || {}));
}
// Коммитим значения из DOM в n.data.variables, сохраняя id и порядок.
function commitDomToData() {
const nn = editor.getNodeFromId(id); if (!nn) return;
const d = nn.data || {};
if (!Array.isArray(d.variables)) d.variables = [];
const rows = Array.from(document.querySelectorAll('#sv-list .var-row'));
const newVars = [];
rows.forEach((row, i) => {
const idx = parseInt(row.getAttribute('data-idx') || String(i), 10);
const name = (row.querySelector('.v-name')?.value ?? '').trim();
const mode = (row.querySelector('.v-mode')?.value ?? 'string');
const value = (row.querySelector('.v-value')?.value ?? '');
const prev = (Array.isArray(d.variables) && d.variables[idx]) ? d.variables[idx] : {};
const vid = prev.id || ('v' + Date.now().toString(36) + i);
// Сохраняем строку даже если имя пустое — чтобы не терять введённый value при дальнейших правках
newVars.push({ id: vid, name, mode, value });
});
d.variables = newVars;
try { editor.updateNodeDataFromId(id, d); } catch (e) {}
const el = document.querySelector(`#node-${id}`);
if (el) el.__data = JSON.parse(JSON.stringify(d));
}
if (addBtn) {
addBtn.addEventListener('click', () => {
// Перед добавлением — коммитим текущие значения из DOM, чтобы не потерять уже введённые поля
try { commitDomToData(); } catch(_) {}
const nn = editor.getNodeFromId(id); if (!nn) return;
const d = nn.data || {}; if (!Array.isArray(d.variables)) d.variables = [];
d.variables.push({ id: 'v'+Date.now().toString(36), name: 'NAME', mode: 'string', value: '' });
try { editor.updateNodeDataFromId(id, d); } catch (e) {}
resync();
renderInspector(id, editor.getNodeFromId(id));
});
}
if (root) {
// Делегируем ввод/изменение — всегда коммитим весь список из DOM в данные ноды,
// чтобы не терять значения и не зависеть от индексов row.data-idx
const onChange = () => { try { commitDomToData(); resync(); } catch(_) {} };
root.addEventListener('input', (e) => {
const t = e.target;
if (!t) return;
if (t.classList && (t.classList.contains('v-name') || t.classList.contains('v-value'))) onChange();
});
root.addEventListener('change', (e) => {
const t = e.target;
if (!t) return;
if (t.classList && t.classList.contains('v-mode')) onChange();
});
// Кнопки удаления — вычисляем индекс по текущему DOM-порядку, затем коммитим и переотрисовываем
root.querySelectorAll('.var-row .v-del').forEach((btn) => {
btn.addEventListener('click', () => {
try { commitDomToData(); } catch(_) {}
const nn = editor.getNodeFromId(id); if (!nn) return;
const d = nn.data || {}; if (!Array.isArray(d.variables)) d.variables = [];
const row = btn.closest('.var-row');
const rows = Array.from(root.querySelectorAll('.var-row'));
const idx = Math.max(0, rows.indexOf(row));
d.variables.splice(idx, 1);
try { editor.updateNodeDataFromId(id, d); } catch (e) {}
resync();
renderInspector(id, editor.getNodeFromId(id));
});
});
}
}
// Глобальная функция: коммит переменных из открытого инспектора SetVars в данные ноды
try {
window.__commitSetVarsInspector = function() {
try {
const selId = window.__currentInspectorNodeId;
if (!selId) return;
const n = editor.getNodeFromId(selId);
if (!n || n.name !== 'SetVars') return;
// перечитываем текущие значения из DOM инспектора
const root = document.getElementById('sv-list');
if (!root) return;
const rows = Array.from(root.querySelectorAll('.var-row'));
const varsNew = [];
rows.forEach((row, i) => {
const idx = parseInt(row.getAttribute('data-idx') || String(i), 10);
const name = (row.querySelector('.v-name')?.value ?? '').trim();
const mode = (row.querySelector('.v-mode')?.value ?? 'string');
const value = (row.querySelector('.v-value')?.value ?? '');
const prev = (Array.isArray(n.data?.variables) && n.data.variables[idx]) ? n.data.variables[idx] : {};
const vid = prev.id || ('v' + Date.now().toString(36) + i);
varsNew.push({ id: vid, name, mode, value });
});
if (!Array.isArray(n.data.variables)) n.data.variables = [];
n.data.variables = varsNew;
try { editor.updateNodeDataFromId(selId, n.data || {}); } catch (_){}
const el = document.getElementById('node-' + selId);
if (el) el.__data = JSON.parse(JSON.stringify(n.data || {}));
} catch(_){}
};
} catch(_){}
// Поддержка select#f-provider + автоподстановка пресетов (без жесткого перезаписывания ручных правок)
const provSel = document.getElementById('f-provider');
if (provSel) {
// Установим текущее значение
provSel.value = (node.data?.provider || 'openai');
provSel.addEventListener('change', () => {
const n = editor.getNodeFromId(id);
if (!n) return;
const d = n.data || {};
d.provider = provSel.value;
ensureProviderConfigs(d);
const cfg = getActiveCfg(d);
const baseEl = document.getElementById('f-baseurl');
const endEl = document.getElementById('f-endpoint');
const headEl = document.getElementById('f-headers');
const tmplEl = document.getElementById('f-template');
// Отображаем сохранённые значения выбранного провайдера
if (baseEl) baseEl.value = cfg.base_url || '';
if (endEl) endEl.value = cfg.endpoint || '';
if (headEl) headEl.value = (cfg.headers != null ? cfg.headers : '{}');
if (tmplEl) tmplEl.value = (cfg.template != null ? cfg.template : '{}');
try { editor.updateNodeDataFromId(id, d); } catch (e) {}
const el = document.querySelector(`#node-${id}`);
if (el) el.__data = d;
try { console.debug('[ProviderCall] provider switched to', d.provider, cfg); } catch (e) {}
});
}
// Init Return selects defaults if present
try {
const tgtSel = document.getElementById('ret-target');
if (tgtSel) {
tgtSel.value = (node.data?.target_format || 'auto');
}
} catch (e) {}
// Кнопка сохранить параметры
const saveBtnNode = document.getElementById('btn-save-node');
if (saveBtnNode) {
saveBtnNode.addEventListener('click', () => {
const n = editor.getNodeFromId(id);
if (!n) return;
// Для SetVars дополнительно читаем текущие значения из DOM, чтобы гарантированно не потерять value
if (type === 'SetVars') {
const root = document.getElementById('sv-list');
const varsNew = [];
if (root) {
root.querySelectorAll('.var-row').forEach(row => {
const idx = parseInt(row.getAttribute('data-idx') || '-1', 10);
const name = (row.querySelector('.v-name')?.value ?? '').trim();
const mode = (row.querySelector('.v-mode')?.value ?? 'string');
const value = (row.querySelector('.v-value')?.value ?? '');
if (name) {
// сохраняем прежний id при наличии, чтобы не мигали идентификаторы
const prevId = (n.data?.variables && n.data.variables[idx] && n.data.variables[idx].id) ? n.data.variables[idx].id : ('v'+Date.now().toString(36)+idx);
varsNew.push({ id: prevId, name, mode, value });
}
});
}
if (!Array.isArray(n.data.variables)) n.data.variables = [];
n.data.variables = varsNew;
}
// Синхронизируем данные узла в Drawflow и в DOM.__data — это источник правды для toPipelineJSON()
try { editor.updateNodeDataFromId(id, n.data || {}); } catch (e) {}
const el = document.querySelector(`#node-${id}`);
if (el) el.__data = JSON.parse(JSON.stringify(n.data || {}));
// Отладочный статус, чтобы видеть, что реально уйдёт в pipeline.json
try {
if (type === 'SetVars') {
status('SetVars saved: ' + JSON.stringify((n.data && n.data.variables) ? n.data.variables : []));
}
} catch (e) {}
try { savePipeline(); } catch (e) {}
status("Параметры ноды сохранены в pipeline.json");
});
}
// ensure blocks explicitly kept in node data
const ncheck = editor.getNodeFromId(id);
if (ncheck && Array.isArray(ncheck.data.blocks)) {
ncheck.data.blocks = [...ncheck.data.blocks];
}
// ensure variables explicitly kept in node data (for SetVars)
if (ncheck && Array.isArray(ncheck.data.variables)) {
// глубокая копия, чтобы serialization взяла актуальные значения
ncheck.data.variables = ncheck.data.variables.map(v => ({ ...(v || {}) }));
try { editor.updateNodeDataFromId(id, ncheck.data); } catch (e) {}
const elN = document.querySelector(`#node-${id}`);
if (elN) elN.__data = JSON.parse(JSON.stringify(ncheck.data || {}));
}
// Prompt Manager UI for ProviderCall
if (type === 'ProviderCall') { PM.setupProviderCallPMUI(editor, id); }
}
// Добавление нод из сайдбара
document.querySelectorAll('.node-btn').forEach(btn => {
btn.addEventListener('click', () => {
const type = btn.dataset.node;
addNode(type, {x: 120 + Math.random()*60, y: 120 + Math.random()*40});
});
});
// Сериализация вынесена во внешний файл /ui/js/serialization.js
// Десериализация вынесена во внешний файл /ui/js/serialization.js
// Загрузка/сохранение
async function loadPipeline() {
const res = await fetch('/admin/pipeline');
const p = await res.json();
await window.AgentUISer.fromPipelineJSON(p);
// Обновим UI полей метаданных по загруженному pipeline
try { initRunMetaOnlyBasics(); } catch (e) {}
// Не затираем логи, которые вывел fromPipelineJSON
const st = document.getElementById('status').textContent;
if (!st) status('Загружено');
}
// Перед сохранением синхронизируем все __data в DOM с актуальными editor.getNodeFromId().data
// Иначе toPipelineJSON предпочтёт устаревший el.__data вместо свежих значений из drawflow.
function syncDomDataAll() {
try {
const data = window.editor && window.editor.export ? window.editor.export() : null;
const dfNodes = (data && data.drawflow && data.drawflow.Home && data.drawflow.Home.data) ? data.drawflow.Home.data : {};
for (const id in dfNodes) {
try {
const nid = parseInt(id, 10);
const n = window.editor.getNodeFromId(nid);
const el = document.getElementById('node-' + id);
if (n && el) {
el.__data = JSON.parse(JSON.stringify(n.data || {}));
}
} catch (_) {}
}
} catch (_) {}
}
async function savePipeline() {
try {
// Если сейчас открыт SetVars в инспекторе — сначала коммитим его значения из DOM
try { if (typeof window.__commitSetVarsInspector === 'function') window.__commitSetVarsInspector(); } catch(_) {}
// Важно: подтянуть в DOM.__data самые свежие данные всех нод (в т.ч. SetVars)
syncDomDataAll();
// Соберём карту origId(nX) -> drawflowId
const origToDf = {};
try {
const data = window.editor && window.editor.export ? window.editor.export() : null;
const dfNodes = (data && data.drawflow && data.drawflow.Home && data.drawflow.Home.data) ? data.drawflow.Home.data : {};
for (const dfid in dfNodes) {
try {
const n = window.editor.getNodeFromId(parseInt(dfid,10));
const orig = n && n.data && n.data._origId;
if (orig) origToDf[String(orig)] = parseInt(dfid,10);
} catch(_) {}
}
} catch(_) {}
// Диагностика: покажем для всех SetVars в редакторе длину variables напрямую из editor
try {
const dbg = [];
for (const orig in origToDf) {
const dfid = origToDf[orig];
try {
const n = window.editor.getNodeFromId(dfid);
if (n && n.name === 'SetVars') {
const len = Array.isArray(n.data?.variables) ? n.data.variables.length : 0;
dbg.push({ id: String(orig), dfid, vars_live: len });
}
} catch(_) {}
}
if (dbg.length) {
console.debug('[Save] SetVars live editor data', dbg);
}
} catch(_) {}
let p = window.AgentUISer.toPipelineJSON();
// Патчим payload: для всех SetVars берём живые variables напрямую из editor.getNodeFromId по карте origToDf
try {
(p.nodes || []).forEach(n => {
if (n && n.type === 'SetVars') {
const dfid = origToDf[String(n.id)];
let liveVars = [];
try {
if (dfid != null) {
const liveNode = window.editor.getNodeFromId(dfid);
const arr = liveNode && liveNode.data && Array.isArray(liveNode.data.variables) ? liveNode.data.variables : [];
liveVars = arr.map(x => ({ ...(x || {}) }));
}
} catch(_) {}
// fallback на DOM.__data если editor недоступен
if (!liveVars.length) {
try {
const el = document.querySelector(`.drawflow .drawflow-node[id="node-${dfid}"]`);
const d = el && el.__data;
if (d && Array.isArray(d.variables)) {
liveVars = d.variables.map(x => ({ ...(x || {}) }));
}
} catch(_) {}
}
if (!n.config) n.config = {};
n.config.variables = liveVars;
}
});
} catch (_) {}
// Диагностика сохранения SetVars: покажем количество переменных по нодам (после патча)
try {
const sv = (p.nodes || []).filter(n => n && n.type === 'SetVars');
const meta = sv.map(n => ({ id: n.id, vars: Array.isArray(n.config?.variables) ? n.config.variables.length : 0 }));
console.debug('[Save] SetVars snapshot before POST (patched v2)', meta);
if (typeof status === 'function') status('Save(SetVars): ' + JSON.stringify(meta));
} catch (_) {}
const res = await fetch('/admin/pipeline', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(p) });
const out = await res.json();
status('Сохранено: ' + JSON.stringify(out) + ' | nodes=' + (p.nodes?.length || 0));
} catch (err) {
status('Ошибка сохранения пайплайна: ' + (err?.message || String(err)));
}
}
async function refreshPresets() {
const res = await fetch('/admin/presets');
const j = await res.json();
const sel = document.getElementById('preset-select');
sel.innerHTML = '';
(j.items||[]).forEach(name => {
const opt = document.createElement('option');
opt.value = name; opt.textContent = name; sel.appendChild(opt);
});
}
// --- Vars popover helpers ---
(function(){
const $ = (sel) => document.querySelector(sel);
const box = $('#vars-popover');
const listEl = $('#vars-list');
const searchEl = $('#vars-search');
const scopeEl = $('#vars-scope');
const bracesEl = $('#vars-mode-braces');
const infoEl = $('#vars-info');
const btnOpen = $('#btn-vars');
const btnClose = $('#vars-close');
const btnRefresh = $('#vars-refresh');
const btnClear = $('#vars-clear');
function setInfo(msg) { try { infoEl.textContent = msg; } catch(e){} }
async function fetchVars() {
try {
const res = await fetch('/admin/vars');
const j = await res.json();
return j && j.store ? j.store : {};
} catch (e) {
setInfo('Ошибка загрузки переменных');
return {};
}
}
function macroFor(path, kind) {
const useBraces = !!(bracesEl && bracesEl.checked);
const p = String(path || '');
// VARS: копируем «канонические» макросы, доступные в шаблонах
if (kind === 'vars') {
return useBraces ? `{{ ${p} }}` : `[[${p}]]`;
}
// SNAPSHOT: маппим на реальные макросы контекста (без STORE)
// OUT1, OUT2, ... → [[OUTx]] или {{ OUT.nX.response_text }}
const mAlias = p.match(/^OUT(\d+)$/i);
if (mAlias) {
const n = mAlias[1];
return useBraces ? `{{ OUT.n${n}.response_text }}` : `[[OUT${n}]]`;
}
// OUT_TEXT.nX → [[OUTx]] или {{ OUT.nX.response_text }}
const mTxt = p.match(/^OUT_TEXT\.n(\d+)$/i);
if (mTxt) {
const n = mTxt[1];
return useBraces ? `{{ OUT.n${n}.response_text }}` : `[[OUT${n}]]`;
}
// OUT.nX.something → {{ OUT.nX.something }} или [[OUT:nX.something]]
if (p.startsWith('OUT.')) {
const body = p.slice(4);
return useBraces ? `{{ OUT.${body} }}` : `[[OUT:${body}]]`;
}
// Общий контекст: incoming.*, params.*, model, vendor_format, system
const roots = ['incoming','params','model','vendor_format','system'];
const root = p.split('.')[0];
if (roots.includes(root)) {
return useBraces ? `{{ ${p} }}` : `[[VAR:${p}]]`;
}
// Fallback: трактуем как путь контекста
return useBraces ? `{{ ${p} }}` : `[[VAR:${p}]]`;
}
function escapeHtml(s) {
return String(s ?? '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, '&#39;');
}
// Утилита: разворачивает объект в пары [путь, строковое значение]
function flattenObject(obj, prefix = '') {
const out = [];
if (obj == null) return out;
if (typeof obj !== 'object') {
out.push([prefix, String(obj)]);
return out;
}
const entries = Object.entries(obj);
for (const [k, v] of entries) {
const p = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === 'object' && !Array.isArray(v)) {
// Спец-форма превью от бекенда
if (Object.prototype.hasOwnProperty.call(v, '__truncated__') && Object.prototype.hasOwnProperty.call(v, 'preview')) {
out.push([p, String(v.preview ?? '')]);
continue;
}
out.push(...flattenObject(v, p));
} else {
try {
const s = (typeof v === 'string') ? v : JSON.stringify(v, null, 0);
out.push([p, s]);
} catch {
out.push([p, String(v)]);
}
}
}
return out;
}
function renderList(storeObj) {
const q = (searchEl && searchEl.value || '').toLowerCase().trim();
const scope = (scopeEl && scopeEl.value) || 'vars';
const items = [];
// vars: все ключи из STORE кроме 'snapshot'
if (scope === 'vars' || scope === 'all') {
const keys = Object.keys(storeObj || {}).filter(k => k !== 'snapshot').sort((a,b)=>a.localeCompare(b,'ru'));
for (const k of keys) {
try {
const v = storeObj[k];
const vStr = typeof v === 'string' ? v : JSON.stringify(v, null, 0);
if (q && !(k.toLowerCase().includes(q) || vStr.toLowerCase().includes(q))) continue;
items.push({k, v: vStr, kind: 'vars'});
} catch { /* ignore */ }
}
}
// snapshot: плоский список из STORE.snapshot
if (scope === 'snapshot' || scope === 'all') {
const snap = (storeObj && storeObj.snapshot) || {};
const flat = flattenObject(snap, '');
for (const [k, v] of flat) {
if (!k) continue;
const vStr = String(v ?? '');
if (q && !(k.toLowerCase().includes(q) || vStr.toLowerCase().includes(q))) continue;
items.push({k, v: vStr, kind: 'snapshot'});
}
}
if (!items.length) {
listEl.innerHTML = `<div class="hint" style="padding:10px;color:#a7b0bf">Переменные не найдены</div>`;
return;
}
// Дедупликация по вычисленному макросу (чтобы OUTx не дублировались из OUT_TEXT.nX и OUT.nX.response_text)
const seenMacros = new Set();
const rowsArr = [];
for (const {k, v, kind} of items) {
const macro = macroFor(k, kind);
if (seenMacros.has(macro)) continue;
seenMacros.add(macro);
const disp = (() => {
const p = String(k || '');
if (kind === 'vars') return p;
// snapshot display names → «актуальные» пути/алиасы
const mAlias = p.match(/^OUT(\d+)$/i);
if (mAlias) return `OUT${mAlias[1]}`;
const mTxt = p.match(/^OUT_TEXT\.n(\d+)$/i);
if (mTxt) return `OUT${mTxt[1]}`;
if (p.startsWith('OUT.')) return p; // OUT.nX.something
// drop leading snapshot.* → show plain context path
return p.replace(/^snapshot\./, '');
})();
rowsArr.push(`
<div class="row" data-key="${escapeHtml(k)}" data-kind="${escapeHtml(kind)}" style="display:grid;grid-template-columns: auto 1fr;gap:8px;padding:8px 10px;border-bottom:1px solid #1f2b3b;cursor:pointer">
<code title="${escapeHtml(macro)}" style="color:#60a5fa;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(macro)}</code>
<div title="${escapeHtml(v)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml((v.length > 160 ? (v.slice(0,157) + '…') : v))}</div>
</div>
`);
}
listEl.innerHTML = rowsArr.join('');
listEl.querySelectorAll('.row').forEach(row => {
row.addEventListener('click', async () => {
try {
const key = row.getAttribute('data-key');
const kind = row.getAttribute('data-kind') || 'vars';
const macro = macroFor(key, kind);
await navigator.clipboard.writeText(macro);
setInfo(`Скопировано: ${macro}`);
} catch (e) {
setInfo('Не удалось скопировать макрос');
}
});
});
}
async function refresh() {
const store = await fetchVars();
renderList(store);
}
if (btnOpen) btnOpen.addEventListener('click', async ()=>{
const isOpen = box && box.style.display !== 'none';
if (isOpen) {
try { box.style.display = 'none'; } catch(_){}
return;
}
try { box.style.display = 'block'; } catch(_){}
try { if (scopeEl) scopeEl.value = 'snapshot'; } catch(_){}
setInfo('Клик по строке копирует макрос. Поиск работает по имени и содержимому.');
try { searchEl && searchEl.focus(); } catch(_){}
await refresh();
});
if (btnClose) btnClose.addEventListener('click', ()=>{ try { box.style.display = 'none'; } catch(_){ } });
if (btnRefresh) btnRefresh.addEventListener('click', refresh);
if (btnClear) btnClear.addEventListener('click', async ()=>{
try {
await fetch('/admin/vars', { method: 'DELETE' });
await refresh();
setInfo('Хранилище очищено');
} catch (e) {
setInfo('Ошибка очистки хранилища');
}
});
if (searchEl) searchEl.addEventListener('input', refresh);
if (scopeEl) scopeEl.addEventListener('change', refresh);
if (bracesEl) bracesEl.addEventListener('change', refresh);
// Сделать заголовок «Переменные и макросы» кликабельным — открывает ту же панель
try {
const sidebarTitle = Array.from(document.querySelectorAll('#sidebar .group-title'))
.find(el => String((el.textContent || '')).trim().toLowerCase().startsWith('переменные и макросы'));
if (sidebarTitle) {
sidebarTitle.style.cursor = 'pointer';
sidebarTitle.title = 'Открыть панель переменных';
sidebarTitle.addEventListener('click', async ()=>{
try { box.style.display = 'block'; } catch(_){}
try { if (scopeEl) scopeEl.value = 'snapshot'; } catch(_){}
try { searchEl && searchEl.focus(); } catch(_){}
await refresh();
});
}
} catch(_) {}
})();
async function savePreset() {
const name = document.getElementById('preset-name').value.trim();
if (!name) { status('Укажите имя пресета'); return; }
try {
const p = window.AgentUISer.toPipelineJSON();
const res = await fetch('/admin/presets/' + encodeURIComponent(name), { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(p) });
const out = await res.json();
status('Пресет сохранён: ' + JSON.stringify(out) + ' | nodes=' + (p.nodes?.length || 0));
refreshPresets();
} catch (err) {
status('Ошибка сохранения пресета: ' + (err?.message || String(err)));
}
}
async function loadPreset() {
const name = document.getElementById('preset-select').value;
if (!name) { status('Выберите пресет'); return; }
const res = await fetch('/admin/presets/' + encodeURIComponent(name));
const p = await res.json();
await window.AgentUISer.fromPipelineJSON(p);
// Сделаем загруженный пресет активным пайплайном (сохранение в pipeline.json)
await fetch('/admin/pipeline', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(p) });
// Не затираем логи соединений, если они уже выведены
const st = document.getElementById('status').textContent || '';
if (!st || !st.includes('(links)')) {
status('Пресет загружен: ' + name);
}
}
// --- V2: Простые контролы meta без OUTx-стратегии ---
function initRunMetaOnlyBasics() {
try {
const meta = (window.AgentUISer && window.AgentUISer.getPipelineMeta) ? window.AgentUISer.getPipelineMeta() : {};
const selMode = document.getElementById('loop-mode');
const inpIters = document.getElementById('loop-iters');
const inpBudget = document.getElementById('loop-budget');
const inpHttpTo = document.getElementById('run-http-timeout');
const chkClear = document.getElementById('clear-var-store');
if (selMode) selMode.value = (meta.loop_mode || 'dag');
if (inpIters) inpIters.value = (typeof meta.loop_max_iters === 'number' ? meta.loop_max_iters : 1000);
if (inpBudget) inpBudget.value = (typeof meta.loop_time_budget_ms === 'number' ? meta.loop_time_budget_ms : 10000);
if (inpHttpTo) inpHttpTo.value = (typeof meta.http_timeout_sec === 'number' ? meta.http_timeout_sec : 60);
if (chkClear) chkClear.checked = (typeof meta.clear_var_store === 'boolean' ? meta.clear_var_store : true);
function pushMeta() {
try {
const payload = {
loop_mode: selMode ? selMode.value : undefined,
loop_max_iters: inpIters ? parseInt(inpIters.value || '0', 10) : undefined,
loop_time_budget_ms: inpBudget ? parseInt(inpBudget.value || '0', 10) : undefined,
http_timeout_sec: inpHttpTo ? parseFloat(inpHttpTo.value || '0') : undefined,
clear_var_store: chkClear ? !!chkClear.checked : undefined,
};
if (window.AgentUISer && window.AgentUISer.updatePipelineMeta) {
window.AgentUISer.updatePipelineMeta(payload);
}
} catch (e) {}
}
if (selMode) selMode.addEventListener('change', pushMeta);
if (inpIters) inpIters.addEventListener('change', pushMeta);
if (inpBudget) inpBudget.addEventListener('change', pushMeta);
if (inpHttpTo) inpHttpTo.addEventListener('change', pushMeta);
if (chkClear) chkClear.addEventListener('change', pushMeta);
} catch (e) {}
}
// --- V2: Менеджер пресетов в "Запуск": JSONPath + список с удалением ---
function setupRunPresetUI2() {
try {
const inpJson = document.getElementById('run-preset-jsonpath');
const btnAdd = document.getElementById('run-preset-add');
const listEl = document.getElementById('run-presets-list');
if (!inpJson || !btnAdd) return;
function getPresets() {
const meta = (window.AgentUISer && window.AgentUISer.getPipelineMeta) ? window.AgentUISer.getPipelineMeta() : {};
return Array.isArray(meta.text_extract_presets) ? meta.text_extract_presets.slice() : [];
}
function savePresets(presets) {
try {
if (window.AgentUISer && window.AgentUISer.updatePipelineMeta) {
window.AgentUISer.updatePipelineMeta({ text_extract_presets: presets });
}
// уведомим инспекторы о смене набора пресетов
try { window.dispatchEvent(new CustomEvent('meta:presetsChanged', { detail: { presets } })); } catch(_) {}
} catch(_) {}
}
function labelFor(p) {
const nm = (p && p.name != null) ? String(p.name) : '';
const jp = (p && p.json_path != null) ? String(p.json_path) : '';
const lbl = (nm && nm.trim()) ? nm : jp;
return lbl || '(preset)';
}
function renderList() {
try {
if (!listEl) return;
const presets = getPresets();
if (!presets.length) {
listEl.innerHTML = '<div class="hint" style="opacity:.85">Пока нет пресетов</div>';
return;
}
const rows = presets.map(p => {
const id = String(p.id || '');
const lbl = labelFor(p);
const jp = String(p.json_path || '');
return `
<div class="row" data-id="${id}" style="display:grid;grid-template-columns: 1fr auto;gap:8px;align-items:center;padding:6px 0;border-bottom:1px solid #1f2b3b">
<div style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
<code title="${jp}" style="color:#60a5fa">${lbl}</code>
</div>
<button class="btn-del" title="Удалить пресет">🗑</button>
</div>
`;
}).join('');
listEl.innerHTML = rows;
// Bind delete handlers
listEl.querySelectorAll('.row .btn-del').forEach(btn => {
btn.addEventListener('click', () => {
try {
const row = btn.closest('.row');
const id = row ? row.getAttribute('data-id') : '';
if (!id) return;
const arr = getPresets();
const idx = arr.findIndex(x => String(x && x.id || '') === String(id));
if (idx >= 0) {
arr.splice(idx, 1);
savePresets(arr);
renderList();
}
} catch(_) {}
});
});
} catch(_) {}
}
btnAdd.onclick = () => {
const path = String(inpJson.value || '').trim();
if (!path) return;
const presets = getPresets();
// не плодить дубликаты по json_path
if (presets.some(p => String(p && p.json_path || '') === path)) {
inpJson.value = '';
return;
}
const id = 'p' + Date.now().toString(36) + Math.random().toString(36).slice(2);
const newP = {
id,
name: path, // показываем путь как имя
strategy: 'jsonpath',
json_path: path,
join_sep: '\n',
};
presets.push(newP);
savePresets(presets);
inpJson.value = '';
renderList();
};
// Initial render
renderList();
} catch(_) {}
}
// --- V2: Инспектор ноды: только выбор пресета (либо дефолт) ---
function wireTextExtractInspectorV2(editor, dfid, node) {
try {
const type = node && node.name;
if (!type || (type !== 'ProviderCall' && type !== 'RawForward')) return;
const container = document.getElementById('inspector-content');
if (!container) return;
// Вставка компактного блока
const wrap = document.createElement('div');
wrap.className = 'group';
wrap.style.marginTop = '12px';
wrap.innerHTML = `
<div class="group-title" style="margin-top:12px">Парсинг OUTx</div>
<div class="row">
<label style="min-width:140px">Пресет</label>
<select id="tx-preset"></select>
</div>
`;
container.appendChild(wrap);
const selPreset = wrap.querySelector('#tx-preset');
function getMeta() {
return (window.AgentUISer && window.AgentUISer.getPipelineMeta) ? window.AgentUISer.getPipelineMeta() : {};
}
function buildPresetOptions(keepValue) {
const meta = getMeta();
const presets = Array.isArray(meta.text_extract_presets) ? meta.text_extract_presets : [];
const cur = keepValue != null ? String(keepValue) : (selPreset ? selPreset.value : '');
selPreset.innerHTML = '';
// по умолчанию (auto)
const optDefault = document.createElement('option');
optDefault.value = '';
optDefault.textContent = 'по умолчанию (auto)';
selPreset.appendChild(optDefault);
// перечисление пресетов (имя = json_path)
presets.forEach(p => {
if (!p) return;
const opt = document.createElement('option');
opt.value = String(p.id || '');
// если name есть — показываем его, иначе json_path
const label = (p.name && String(p.name).trim()) ? String(p.name) : String(p.json_path || '');
opt.textContent = label || '(preset)';
selPreset.appendChild(opt);
});
if (cur && Array.from(selPreset.options).some(o => o.value === cur)) {
selPreset.value = cur;
}
}
function applyNodeData(mutator) {
const n = editor.getNodeFromId(dfid);
if (!n) return;
const d = n.data || {};
mutator(d);
try { editor.updateNodeDataFromId(dfid, d); } catch (_) {}
const el = document.querySelector('#node-' + dfid);
if (el) el.__data = JSON.parse(JSON.stringify(d));
}
// Init state
buildPresetOptions();
const d = node.data || {};
const presetId = String(d.text_extract_preset_id || '');
selPreset.value = presetId && Array.from(selPreset.options).some(o => o.value === presetId) ? presetId : '';
// Events
selPreset.addEventListener('change', () => {
const val = selPreset.value || '';
if (!val) {
// дефолт → убираем привязку пресета и старые кастомы (если были)
applyNodeData(dd => {
delete dd.text_extract_preset_id;
delete dd.text_extract_strategy;
delete dd.text_extract_json_path;
delete dd.text_join_sep;
});
} else {
applyNodeData(dd => {
dd.text_extract_preset_id = val;
// вытрем устаревшие кастомные ключи
delete dd.text_extract_strategy;
delete dd.text_extract_json_path;
delete dd.text_join_sep;
});
}
});
// Реагируем на добавление пресетов в "Запуск"
window.addEventListener('meta:presetsChanged', () => {
const keep = selPreset.value;
buildPresetOptions(keep);
});
} catch (_) {}
}
document.getElementById('btn-load').onclick = loadPipeline;
document.getElementById('btn-save').onclick = savePipeline;
document.getElementById('btn-save-preset').onclick = savePreset;
document.getElementById('btn-load-preset').onclick = loadPreset;
// Initialize controls once, then refresh values after loadPipeline() pulls JSON
try { initRunMetaOnlyBasics(); } catch (e) {}
loadPipeline();
refreshPresets();
// Wire manual cancel button
try {
const btnCancel = document.getElementById('btn-cancel');
if (btnCancel) {
btnCancel.addEventListener('click', async () => {
try {
btnCancel.disabled = true;
btnCancel.textContent = 'СТОП…';
const res = await fetch('/admin/cancel', { method: 'POST' });
if (res.ok) {
status('Отмена исполнения запрошена');
} else {
status('Ошибка запроса отмены: ' + res.status);
}
} catch (e) {
status('Ошибка запроса отмены');
} finally {
setTimeout(()=>{ try { btnCancel.disabled = false; btnCancel.textContent = 'СТОП ⏹'; } catch(_){} }, 600);
}
});
}
} catch(_) {}
// Run drawer wiring
(function(){
const openBtn = document.getElementById('btn-open-run');
const drawer = document.getElementById('run-drawer');
const close1 = document.getElementById('run-close');
const close2 = document.getElementById('run-close-2');
const apply = document.getElementById('run-apply');
function open(){ if (drawer) drawer.classList.add('open'); }
function close(){ if (drawer) drawer.classList.remove('open'); }
if (openBtn) openBtn.addEventListener('click', () => {
if (drawer && drawer.classList.contains('open')) {
close();
} else {
open();
try {
initRunMetaOnlyBasics();
setupRunPresetUI2();
} catch(e){}
}
});
if (close1) close1.addEventListener('click', close);
if (close2) close2.addEventListener('click', close);
if (apply) apply.addEventListener('click', () => {
try {
initRunMetaOnlyBasics();
setupRunPresetUI2();
} catch(e){}
close();
});
})();
// Side panels collapse/expand toggles
(function(){
const container = document.getElementById('container');
const sidebar = document.getElementById('sidebar');
const inspector = document.getElementById('inspector');
const tLeft = document.getElementById('toggle-left');
const tRight = document.getElementById('toggle-right');
// Diagnostics flag: enable/disable layout tracing in console
const DBG_LAYOUT = false;
if (!container || !sidebar || !inspector || !tLeft || !tRight) return;
// Compute absolute X for toggle buttons relative to #container
function placeToggles() {
try {
const rc = container.getBoundingClientRect();
const ownWL = (tLeft.offsetWidth || 22);
const ownWR = (tRight.offsetWidth || 22);
const GAP = 8; // минимальный зазор между стрелками
// Сбрасываем конфликтующие свойства, чтобы не накапливался stale right/left
tLeft.style.right = 'auto';
tRight.style.right = 'auto';
// Left toggle: если сайдбар скрыт/схлопнут — прижимаем к левому краю контейнера
let lx;
const sbDisp = getComputedStyle(sidebar).display;
if (leftCollapsed || sbDisp === 'none' || sidebar.getBoundingClientRect().width === 0) {
lx = 2;
} else {
const rsb = sidebar.getBoundingClientRect();
lx = Math.round(rsb.right - rc.left + 2);
}
// В пределах контейнера
const lxMin = 2;
const lxMax = Math.max(2, Math.round(rc.width - ownWL - 2));
lx = Math.min(Math.max(lxMin, lx), lxMax);
// Right toggle: если инспектор скрыт/схлопнут — прижимаем к правому краю контейнера
let rx;
const inDisp = getComputedStyle(inspector).display;
if (rightCollapsed || inDisp === 'none' || inspector.getBoundingClientRect().width === 0) {
rx = Math.round(rc.width - ownWR - 2);
} else {
const rin = inspector.getBoundingClientRect();
rx = Math.round(rin.left - rc.left - ownWR - 2);
}
// Границы контейнера для правой кнопки
const rxMin = 2;
const rxMax = Math.max(2, Math.round(rc.width - ownWR - 2));
// Обеспечиваем неизбежный разнос от левой кнопки
rx = Math.max(rx, lx + ownWL + GAP);
// И всё равно зажимаем в пределы
rx = Math.min(Math.max(rxMin, rx), rxMax);
tLeft.style.left = lx + 'px';
tRight.style.left = rx + 'px';
} catch (e) {}
}
// Verbose diagnostics: inspect grid columns and rects
function debugLayout(tag) {
if (!DBG_LAYOUT) return;
try {
const c = getComputedStyle(container);
const cols = c.gridTemplateColumns;
const rsb = sidebar.getBoundingClientRect();
const rin = inspector.getBoundingClientRect();
const rcv = document.getElementById('canvas')?.getBoundingClientRect();
const sbc = getComputedStyle(sidebar);
console.debug('[UI]', tag, {
cols,
leftCollapsed,
rightCollapsed,
sidebarRect: { left: Math.round(rsb.left), right: Math.round(rsb.right), width: Math.round(rsb.width) },
canvasRect: rcv ? { left: Math.round(rcv.left), right: Math.round(rcv.right), width: Math.round(rcv.width) } : null,
inspectorRect: { left: Math.round(rin.left), right: Math.round(rin.right), width: Math.round(rin.width) },
sidebarStyles: { display: sbc.display, overflow: sbc.overflow, bg: sbc.backgroundColor, z: sbc.zIndex }
});
} catch (_) {}
}
let leftCollapsed = (localStorage.getItem('ui.collapseLeft') === '1');
let rightCollapsed = (localStorage.getItem('ui.collapseRight') === '1');
function applyState() {
try {
container.classList.toggle('collapse-left', leftCollapsed);
container.classList.toggle('collapse-right', rightCollapsed);
// aria-hidden hints for a11y
sidebar.setAttribute('aria-hidden', leftCollapsed ? 'true' : 'false');
inspector.setAttribute('aria-hidden', rightCollapsed ? 'true' : 'false');
// Safety: всегда сбрасываем возможные dev-стили у html/body (могли остаться из прошлой диагностики)
try {
document.documentElement.style.pointerEvents = '';
document.body.style.pointerEvents = '';
} catch(_) {}
// HARD HIDE inspector when collapsed to remove from hit-testing/stacking
try {
if (rightCollapsed) {
inspector.style.display = 'none';
inspector.style.pointerEvents = 'none';
inspector.style.width = '0px';
inspector.style.padding = '0px';
} else {
inspector.style.display = '';
inspector.style.pointerEvents = '';
inspector.style.width = '';
inspector.style.padding = '';
}
} catch (_) {}
// toggle labels
tLeft.textContent = leftCollapsed ? '' : ''; // collapsed → show expand arrow
tRight.textContent = rightCollapsed ? '' : '';
// titles
tLeft.title = leftCollapsed ? 'Развернуть левую панель' : 'Свернуть левую панель';
tRight.title = rightCollapsed ? 'Развернуть правую панель' : 'Свернуть правую панель';
// Reposition toggles to the current panel edges
placeToggles();
} catch(e) {}
}
function toggleLeft() {
// Guard against phantom drags: briefly disable drawflow hit-test and force mouseup
try {
const df = document.getElementById('drawflow');
if (df) {
df.style.pointerEvents = 'none';
setTimeout(()=>{ try { df.style.pointerEvents=''; } catch(_){} }, 180);
}
try {
['pointerup','mouseup'].forEach(type => {
try { window.dispatchEvent(new MouseEvent(type, { bubbles: true })); } catch(_){}
try { document.dispatchEvent(new MouseEvent(type, { bubbles: true })); } catch(_){}
});
} catch(_){}
} catch(_){}
leftCollapsed = !leftCollapsed;
try { localStorage.setItem('ui.collapseLeft', leftCollapsed ? '1' : '0'); } catch(_) {}
applyState();
// уведомим подсистему LOD/чипов о смене лэйаута
try { window.dispatchEvent(new Event('ui:layoutChanged')); } catch(_) {}
// при изменении ширины — перепривяжем соединения и чипы
try { setTimeout(()=>{ if (window.editor) {
try { window.editor.updateConnectionNodesAll(); } catch(_) {}
}}, 0); } catch(_) {}
}
function toggleRight() {
// Guard against phantom drags: briefly disable drawflow hit-test and force mouseup
try {
const df = document.getElementById('drawflow');
if (df) {
df.style.pointerEvents = 'none';
setTimeout(()=>{ try { df.style.pointerEvents=''; } catch(_){} }, 180);
}
try {
['pointerup','mouseup'].forEach(type => {
try { window.dispatchEvent(new MouseEvent(type, { bubbles: true })); } catch(_){}
try { document.dispatchEvent(new MouseEvent(type, { bubbles: true })); } catch(_){}
});
} catch(_){}
} catch(_){}
rightCollapsed = !rightCollapsed;
try { localStorage.setItem('ui.collapseRight', rightCollapsed ? '1' : '0'); } catch(_) {}
applyState();
// уведомим подсистему LOD/чипов о смене лэйаута
try { window.dispatchEvent(new Event('ui:layoutChanged')); } catch(_) {}
try { setTimeout(()=>{ if (window.editor) {
try { window.editor.updateConnectionNodesAll(); } catch(_) {}
}}, 0); } catch(_) {}
}
// Suppress pointer events from toggles propagating into canvas (prevents phantom drags)
(function(){
const suppress = (ev) => { try { ev.preventDefault(); ev.stopPropagation(); ev.stopImmediatePropagation(); } catch(_){} };
['pointerdown','mousedown','mouseup','dragstart'].forEach(type => {
try { tLeft.addEventListener(type, suppress, true); } catch(_){}
try { tRight.addEventListener(type, suppress, true); } catch(_){}
});
tLeft.addEventListener('click', (e)=>{ suppress(e); toggleLeft(); }, true);
tRight.addEventListener('click', (e)=>{ suppress(e); toggleRight(); }, true);
})();
// клавиатурные шорткаты: Ctrl+Alt+[ / ]
window.addEventListener('keydown', (e)=>{
try {
if (e.ctrlKey && e.altKey && !e.shiftKey) {
if (e.key === '[') { e.preventDefault(); toggleLeft(); }
if (e.key === ']') { e.preventDefault(); toggleRight(); }
}
} catch(_) {}
});
// live reposition on resize/layout changes
window.addEventListener('resize', placeToggles);
try {
const ro = new ResizeObserver(()=>placeToggles());
ro.observe(container);
ro.observe(sidebar);
ro.observe(inspector);
} catch(_) {}
// initial
applyState();
})();
// LOD (level of detail) based on zoom + Mini-scheme toggle/draw
(function(){
// --- LOD detection ---
const canvasEl = document.getElementById('canvas');
const drawRoot = document.getElementById('drawflow');
const tipEl = document.getElementById('lod-tooltip');
const hintsLayer = document.getElementById('lod-hints');
// Quiet LOD logs by default
const DBG_LOD = false;
// --- LOD persistent chips layer ---
function hintsActive() {
try {
return isLODActive() && hintsLayer && hintsLayer.children && hintsLayer.children.length > 0 && hintsLayer.getAttribute('aria-hidden') !== 'true';
} catch { return false; }
}
function clearLODHints() {
try {
if (!hintsLayer) return;
hintsLayer.innerHTML = '';
hintsLayer.setAttribute('aria-hidden', 'true');
} catch {}
}
function renderLODHints() {
try {
if (!hintsLayer) return;
if (!isLODActive()) { clearLODHints(); return; }
const rc = canvasEl.getBoundingClientRect();
const nodes = drawRoot.querySelectorAll('.drawflow-node');
const frag = document.createDocumentFragment();
for (const el of nodes) {
const rn = el.getBoundingClientRect();
let x = rn.left - rc.left + rn.width / 2;
let y = rn.top - rc.top - 8;
// No clamping: allow chips to flow beyond canvas; CSS/z-index handle layering under panels
const chip = document.createElement('div');
chip.className = 'lod-chip';
try { chip.textContent = getNodeLabel(el); } catch { chip.textContent = 'Node'; }
chip.style.left = Math.round(x) + 'px';
chip.style.top = Math.round(y) + 'px';
frag.appendChild(chip);
}
hintsLayer.innerHTML = '';
hintsLayer.appendChild(frag);
hintsLayer.setAttribute('aria-hidden', 'false');
try { if (DBG_LOD) console.debug('[LOD] renderLODHints nodes=', nodes.length, 'rc=', { left: Math.round(rc.left), top: Math.round(rc.top), width: Math.round(rc.width), height: Math.round(rc.height) }); } catch (_) {}
} catch (_) {}
}
function getScale() {
try {
// Drawflow применяет transform на .precanvas (а не всегда на .drawflow)
const el = drawRoot.querySelector('.precanvas') || drawRoot.querySelector('.drawflow') || drawRoot;
const cs = getComputedStyle(el);
const tr = cs.transform || cs.webkitTransform || '';
if (!tr || tr === 'none') return 1;
// matrix(a, b, c, d, tx, ty) → scale = sqrt(a^2 + b^2)
let m = tr.match(/matrix\(([^)]+)\)/);
if (m) {
const parts = m[1].split(',').map(parseFloat);
const a = parts[0] || 1;
const b = parts[1] || 0;
return Math.sqrt(a*a + b*b) || 1;
}
// matrix3d(m11, m12, m13, ..., m22 at index 5)
m = tr.match(/matrix3d\(([^)]+)\)/);
if (m) {
const p = m[1].split(',').map(parseFloat);
const a = p[0] || 1; // m11
const b = p[1] || 0; // m12
return Math.sqrt(a*a + b*b) || Math.abs(a) || 1;
}
return 1;
} catch { return 1; }
}
// --- LOD tooltip (hover-only) ---
function isLODActive() {
try {
return canvasEl.classList.contains('lod-compact') || canvasEl.classList.contains('lod-tiny');
} catch { return false; }
}
function getNodeLabel(el) {
try {
const idAttr = (el && el.id ? el.id : '').match(/^node-(\d+)$/);
const nid = idAttr ? parseInt(idAttr[1], 10) : 0;
const n = (nid && window.editor && window.editor.getNodeFromId) ? window.editor.getNodeFromId(nid) : null;
const shownId = (n && n.data && (n.data._origId || n.data.id)) ? (n.data._origId || n.data.id) : (nid || '?');
const name = (n && n.name) ? n.name : 'Node';
return name + ' #' + shownId;
} catch { return 'Node'; }
}
function positionTipForNode(el) {
if (!tipEl || !el) return;
try {
const rc = canvasEl.getBoundingClientRect();
const rn = el.getBoundingClientRect();
const x = rn.left - rc.left + rn.width / 2;
const y = rn.top - rc.top - 8;
tipEl.style.left = Math.round(x) + 'px';
tipEl.style.top = Math.round(y) + 'px';
} catch {}
}
function showTipForNode(el) {
// Tooltip только в микромасштабе (LOD tiny)
if (!tipEl) return;
if (!canvasEl.classList.contains('lod-tiny')) { return; }
try {
tipEl.textContent = getNodeLabel(el);
positionTipForNode(el);
tipEl.style.display = 'block';
tipEl.setAttribute('aria-hidden', 'false');
tipEl.__node = el;
} catch {}
}
function hideTip() {
if (!tipEl) return;
try {
tipEl.style.display = 'none';
tipEl.setAttribute('aria-hidden', 'true');
tipEl.__node = null;
} catch {}
}
// Delegated hover handlers
try {
drawRoot.addEventListener('mouseover', (e) => {
const el = e.target && e.target.closest ? e.target.closest('.drawflow-node') : null;
if (el) showTipForNode(el);
});
drawRoot.addEventListener('mousemove', (e) => {
const el = tipEl && tipEl.__node;
// Перемещаем tooltip только в микромасштабе (LOD tiny)
if (el && canvasEl.classList.contains('lod-tiny')) positionTipForNode(el);
});
drawRoot.addEventListener('mouseout', (e) => {
const to = e.relatedTarget;
if (to && to.closest && to.closest('.drawflow-node')) return;
hideTip();
});
} catch(_) {}
// Обновление всех линий после изменения LOD/размеров
function refreshConnections() {
try {
const data = window.editor && window.editor.export ? window.editor.export() : null;
const dfNodes = (data && data.drawflow && data.drawflow.Home && data.drawflow.Home.data) ? data.drawflow.Home.data : {};
let cntRepos = 0, cntConns = 0;
// 1) Принудительно «переустановим» позицию каждой ноды, чтобы Drawflow пересчитал размеры/порты
for (const id in dfNodes) {
const nid = parseInt(id, 10);
try {
if (window.editor && typeof window.editor.getNodeFromId === 'function') {
const n = window.editor.getNodeFromId(nid);
if (n && typeof window.editor.updateNodePosition === 'function') {
// Форсируем перерасчёт геометрии без фактического смещения
window.editor.updateNodePosition(nid, n.pos_x, n.pos_y);
cntRepos++;
}
}
} catch (_) {}
}
// 2) Пересчёт соединений (глобально, если доступно; иначе — по нодам)
if (window.editor && typeof window.editor.updateConnectionNodesAll === 'function') {
try { window.editor.updateConnectionNodesAll(); } catch (_) {}
try { if (DBG_LOD) console.debug('[LOD] refreshConnections: repos=', cntRepos, 'mode=All'); } catch (_) {}
return;
}
for (const id in dfNodes) {
try { window.editor.updateConnectionNodes('node-' + id); cntConns++; } catch (_) {}
}
try { if (DBG_LOD) console.debug('[LOD] refreshConnections: repos=', cntRepos, 'updated nodes =', cntConns); } catch (_) {}
} catch (_) {}
}
let lastLOD = '';
let lastScale = 1;
const SCALE_EPS = 0.01; // повысили частоту пересчёта (чувствительность к зуму)
function applyLOD() {
if (!canvasEl) return;
const s = getScale();
// Пробрасываем масштаб как CSSпеременную, чтобы подстроить визуальные размеры
try { canvasEl.style.setProperty('--zoom', String(s || 1)); } catch {}
let cls = 'lod-normal';
if (s < 0.55) cls = 'lod-tiny';
else if (s < 0.9) cls = 'lod-compact';
const lodChanged = (cls !== lastLOD);
const scaleChanged = Math.abs(s - lastScale) > SCALE_EPS;
if (lodChanged) {
canvasEl.classList.remove('lod-normal','lod-compact','lod-tiny');
canvasEl.classList.add(cls);
lastLOD = cls;
// Пересчёт соединений после смены режима детализации
setTimeout(refreshConnections, 0);
try { requestAnimationFrame(() => refreshConnections()); } catch(_) {}
setTimeout(refreshConnections, 50);
try { hideTip(); } catch(_) {}
try { renderLODHints(); } catch(_) {}
}
// Даже при неизменном LOD, при ощутимой смене зума пересчитываем соединения
if (scaleChanged) {
setTimeout(refreshConnections, 0);
try { requestAnimationFrame(() => refreshConnections()); } catch(_) {}
setTimeout(refreshConnections, 50);
try { hideTip(); } catch(_) {}
try { renderLODHints(); } catch(_) {}
}
try { if (DBG_LOD) console.debug('[LOD] applyLOD scale=', (s||1).toFixed(3), 'lod=', cls, 'changed:', { lodChanged, scaleChanged }); } catch (_) {}
lastScale = s;
}
// Повышаем FPS обновления LOD/чипов
setInterval(applyLOD, 120);
try { drawRoot.addEventListener('wheel', () => setTimeout(applyLOD, 0), { passive: true }); } catch {}
// Дополнительно — хуки событий Drawflow, влияющих на геометрию:
try {
if (window.editor && typeof window.editor.on === 'function') {
const rcc = () => { setTimeout(refreshConnections, 0); try { hideTip(); renderLODHints(); } catch(_) {} };
window.editor.on('zoom', rcc);
// библиотека эмитит translate / translated в разных версиях
window.editor.on('translate', rcc);
window.editor.on('translated', rcc);
window.editor.on('nodeMoved', rcc);
window.editor.on('nodeCreated', rcc);
window.editor.on('nodeRemoved', rcc);
}
} catch(_) {}
// Реакция на изменения лэйаута (сворачивание панелей и пр.)
try {
window.addEventListener('ui:layoutChanged', () => {
setTimeout(refreshConnections, 0);
try { hideTip(); renderLODHints(); placeToggles(); } catch(_) {}
});
} catch(_) {}
// --- Mini-scheme ---
const btnOpen = document.getElementById('btn-scheme');
const panel = document.getElementById('scheme-panel');
const btnClose = document.getElementById('scheme-close');
const btnRefr = document.getElementById('scheme-refresh');
const cvs = document.getElementById('scheme-canvas');
const ctx = cvs ? cvs.getContext('2d') : null;
function getDfData() {
try {
return (window.editor && window.editor.export) ? window.editor.export() : null;
} catch { return null; }
}
// Resize canvas to panel with DPR awareness.
function sizeCanvasToPanel() {
if (!panel || !cvs) return 1;
const dpr = Math.max(1, Math.min(2.5, window.devicePixelRatio || 1));
// учитываем внутренние отступы панели (≈ 24px)
const box = panel.getBoundingClientRect();
const cssW = Math.max(280, Math.floor(box.width - 24));
const cssH = Math.max(180, Math.min(360, Math.floor(box.height - 90))); // адаптивная высота
// CSS size
cvs.style.width = cssW + 'px';
cvs.style.height = cssH + 'px';
// Backing store size
const needW = Math.floor(cssW * dpr);
const needH = Math.floor(cssH * dpr);
if (cvs.width !== needW) cvs.width = needW;
if (cvs.height !== needH) cvs.height = needH;
// prepare ctx
if (ctx) {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
return dpr;
}
function renderMiniScheme() {
if (!panel || panel.style.display === 'none' || !ctx) return;
const dpr = sizeCanvasToPanel();
// рабочие размеры в CSS-пикселях (не физические)
const cw = cvs.width / dpr;
const ch = cvs.height / dpr;
// фон
ctx.clearRect(0, 0, cw, ch);
ctx.fillStyle = '#0f141a';
ctx.fillRect(0, 0, cw, ch);
const data = getDfData();
if (!data || !data.drawflow || !data.drawflow.Home || !data.drawflow.Home.data) {
ctx.fillStyle = '#a7b0bf';
ctx.font = '12px Inter, Arial';
ctx.fillText('нет данных', 10, 20);
return;
}
const nodesObj = data.drawflow.Home.data;
const nodes = [];
for (const id in nodesObj) {
const n = nodesObj[id];
nodes.push({ id, x: n.pos_x || 0, y: n.pos_y || 0, name: n.name || '' });
}
if (!nodes.length) return;
// Границы графа
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
nodes.forEach(n => {
minX = Math.min(minX, n.x);
minY = Math.min(minY, n.y);
maxX = Math.max(maxX, n.x);
maxY = Math.max(maxY, n.y);
});
// Константы «виртуального» прямоугольника ноды в мини‑схеме
const NODE_W = 120;
const NODE_H = 28;
const PADDING = 16;
const w = Math.max(1, maxX - minX);
const h = Math.max(1, maxY - minY);
const sx = (cw - PADDING * 2 - NODE_W) / w;
const sy = (ch - PADDING * 2 - NODE_H) / h;
const s = Math.max(0.1, Math.min(sx, sy)); // не даём схлопнуться
function center(n) {
const x = PADDING + (n.x - minX) * s + NODE_W / 2;
const y = PADDING + (n.y - minY) * s + NODE_H / 2;
return [x, y];
}
// Клиппинг — ничего не вылезет за границы
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, cw, ch);
ctx.clip();
// Рёбра
ctx.strokeStyle = '#7aa2f7';
ctx.globalAlpha = 0.9;
ctx.lineWidth = 1.25;
for (const id in nodesObj) {
const src = nodes.find(n => n.id === id);
if (!src) continue;
const outs = nodesObj[id].outputs || {};
const [sx0, sy0] = center(src);
for (const k in outs) {
const conns = outs[k]?.connections || [];
for (const c of conns) {
const tgt = nodes.find(n => n.id === String(c.node));
if (!tgt) continue;
const [tx0, ty0] = center(tgt);
ctx.beginPath();
ctx.moveTo(sx0, sy0);
ctx.lineTo(tx0, ty0);
ctx.stroke();
}
}
}
ctx.globalAlpha = 1;
// Ноды
ctx.font = '11px Inter, Arial';
for (const n of nodes) {
const [cx, cy] = center(n);
const x = Math.round(cx - NODE_W / 2) + 0.5; // crisp
const y = Math.round(cy - NODE_H / 2) + 0.5;
ctx.fillStyle = '#0e1116';
ctx.strokeStyle = '#334155';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.rect(x, y, NODE_W, NODE_H);
ctx.fill();
ctx.stroke();
// подпись с усечением
const idLabel = (nodesObj[n.id]?.data?._origId || n.id);
let label = (n.name || 'Node') + ' #' + idLabel;
const maxTextW = NODE_W - 12;
while (ctx.measureText(label).width > maxTextW && label.length > 3) {
label = label.slice(0, -2) + '…';
}
ctx.fillStyle = '#e5e7eb';
ctx.fillText(label, x + 6, y + Math.round(NODE_H / 2) + 4);
}
ctx.restore();
}
let timer = null;
function openPanel() {
if (!panel) return;
panel.style.display = 'block';
sizeCanvasToPanel();
renderMiniScheme();
if (timer) clearInterval(timer);
timer = setInterval(() => { renderMiniScheme(); }, 800);
}
function closePanel() {
if (!panel) return;
panel.style.display = 'none';
if (timer) { clearInterval(timer); timer = null; }
}
if (btnOpen) btnOpen.addEventListener('click', () => {
if (panel && panel.style.display !== 'none') {
closePanel();
} else {
openPanel();
}
});
if (btnClose) btnClose.addEventListener('click', closePanel);
if (btnRefr) btnRefr.addEventListener('click', () => { sizeCanvasToPanel(); renderMiniScheme(); });
window.addEventListener('resize', () => { sizeCanvasToPanel(); renderMiniScheme(); });
// repaint hooks
try {
window.editor.on('nodeCreated', renderMiniScheme);
window.editor.on('nodeRemoved', renderMiniScheme);
window.editor.on('connectionCreated', renderMiniScheme);
window.editor.on('connectionRemoved', renderMiniScheme);
} catch(_) {}
// initial LOD
setTimeout(applyLOD, 0);
})();
</script>
<script>
(function(){
try {
const brand = document.querySelector('header .brand');
const layer = document.getElementById('danmaku-layer');
if (!brand || !layer) return;
// Пулевая лента (弾幕) — набор фраз
// Поддерживает маркеры [rune]...[/rune] для явной подсветки рун.
const msgs = [
'Сделано гпт-5 с любовью для сестричек сисунь',
'Играй и побеждай',
'[rune]ᚨᛞᚨ[/rune] ✦ ваши чаты защищены оберегом НадТаверны от слопа',
'РП‑магия заряжена ⚡',
'Пиши, как дышишь — и мир откликнется',
'Сюжет идёт по плану… или нет?',
'NPC шепчет: «/roll d20»',
// от автора
'От Roo: берегите свои миры — а я присмотрю за багами 🛡️✨',
// новые странные фразы
'Бард шепчет кружке: «ты — артефакт +1 к вдохновению» 🍺✨',
'Где-то в подвале убежал printf, ищем следы по логам… 🐾',
'[rune]ᚠᚱᛁᛞᚨ[/rune] на кости выпала — к криту и печенькам 🍪',
'Сюжет идёт по плану… пока кубик не решит иначе 🎲',
'NPC недоволен: «кто украл мою функцию?» function() {…} 🤖',
'Только для сестричек сисунь — VIPпроход в НадТаверну',
'РП‑магия на максимуме, щиты подняты, слова летят ⚡',
'Играй сердцем, кодь с умом, логи — наш оракул 📜',
'Пиши, как дышишь, пей, как гном — но не смешивай 🍻',
'[rune]ᚨᛚᚷᛟ[/rune] говорит: «tabs vs spaces?» ␣'
];
const tints = ['tint-blue','tint-green','tint-pink','tint-amber'];
const sizes = ['sm','md','lg'];
// Автоподсветка рун: U+16A0U+16FF (Runic)
const RUNE_RE = /[\u16A0-\u16FF]+/g;
function escHtml(s) {
return String(s ?? '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
}
// Оборачиваем рунические символы в уже экранированной строке
function wrapRunesEscaped(escaped) {
return escaped.replace(RUNE_RE, (m) => '<span class="rune">' + m + '</span>');
}
// Розовый хайлайт для фразы «сестричек сисунь» + случайные «девчачьи» эмодзи
const EMOJI_GIRLY = ['💖','✨','🌸','💅','👑','🩷','🌷','🦋'];
function pickTwo(arr) {
const pool = arr.slice();
const a = pool.splice((Math.random()*pool.length)|0, 1)[0] || arr[0];
const b = pool.splice((Math.random()*pool.length)|0, 1)[0] || a;
return [a, b];
}
function markSisters(src) {
try {
return String(src || '').replace(/сестричек\s+сисунь/gi, m => `[sisters]${m}[/sisters]`);
} catch { return String(src || ''); }
}
// Рендер безопасного HTML, поддержка маркеров [rune]...[/rune], [sisters]...[/sisters]
function renderBulletHTML(text) {
const raw = markSisters(String(text ?? ''));
const hasMarkers = /\[(rune|sisters)\]/i.test(raw);
if (hasMarkers) {
let out = '';
let last = 0;
const re = /\[(rune|sisters)\]([\s\S]+?)\[\/(rune|sisters)\]/gi;
let m;
while ((m = re.exec(raw)) !== null) {
// незамеченная часть — экранировать и автоподсветить руны
out += wrapRunesEscaped(escHtml(raw.slice(last, m.index)));
const open = (m[1] || '').toLowerCase();
const close = (m[3] || '').toLowerCase();
const inner = String(m[2] ?? '');
if (open === close) {
if (open === 'rune') {
out += '<span class="rune">' + escHtml(inner) + '</span>';
} else if (open === 'sisters') {
const [e1, e2] = pickTwo(EMOJI_GIRLY);
out += '<span class="sisters">' + escHtml(inner) + ' ' + e1 + ' ' + e2 + '</span>';
}
} else {
// на всякий случай — экранируем весь блок, если теги не совпали
out += wrapRunesEscaped(escHtml(m[0]));
}
last = m.index + m[0].length;
}
out += wrapRunesEscaped(escHtml(raw.slice(last)));
return out;
}
// Без маркеров — экранировать и автоподсветить руны
return wrapRunesEscaped(escHtml(raw));
}
function spawn(text) {
const el = document.createElement('div');
el.className = 'danmaku-bullet ' + sizes[(Math.random()*sizes.length)|0] + ' ' + tints[(Math.random()*tints.length)|0];
// Вставляем безопасный HTML с подсветкой рун и розовым акцентом фразы «сестричек сисунь»
el.innerHTML = renderBulletHTML(text);
const vpH = (layer && layer.clientHeight) || window.innerHeight || document.documentElement.clientHeight || 600;
const top = Math.random() * Math.max(40, vpH - 40);
el.style.top = Math.max(2, Math.min(vpH - 24, top)) + 'px';
const dur = 9 + Math.random() * 7; // 916s
el.style.animationDuration = dur + 's';
layer.appendChild(el);
el.addEventListener('animationend', () => { try { el.remove(); } catch(_){} });
}
let timer = null;
let last = -1;
function start() {
if (timer) return;
layer.classList.add('is-on');
// Небольшой «всплеск» при старте
for (let i=0;i<5;i++){
setTimeout(()=>{ last = (last+1) % msgs.length; spawn(msgs[last]); }, i*160);
}
timer = setInterval(()=>{ spawn(msgs[Math.floor(Math.random()*msgs.length)]); }, 700);
}
function stop() {
if (timer) { clearInterval(timer); timer=null; }
setTimeout(()=>{
layer.classList.remove('is-on');
try { Array.from(layer.querySelectorAll('.danmaku-bullet')).forEach(n=>n.remove()); } catch(_){}
}, 200);
}
brand.addEventListener('mouseenter', start);
brand.addEventListener('mouseleave', stop);
// Тач-устройства: короткое автозакрытие
brand.addEventListener('touchstart', ()=>{ start(); setTimeout(stop, 2800); }, {passive:true});
} catch(_) {}
})();
</script>
<!-- SSE highlight script -->
<script>
(function() {
try {
const timers = new Map();
function getStatusEl() {
return document.getElementById('status');
}
function setStatus(txt) {
try { const el = getStatusEl(); if (el) el.textContent = txt; } catch (e) {}
}
function findNodeElByOrigId(origId) {
if (!origId && origId !== 0) return null;
// 1) Прямая попытка по DOM id (Drawflow id)
const byDfId = document.getElementById('node-' + origId);
if (byDfId) return byDfId;
// 2) По _origId, хранящемуся в DOM.__data
const nodes = document.querySelectorAll('.drawflow .drawflow-node');
for (const el of nodes) {
const d = el && el.__data;
if (!d) continue;
if (String(d._origId) === String(origId)) return el;
// fallback: иногда id дублируется как d.id
if (String(d.id) === String(origId)) return el;
}
return null;
}
function clearTempTimer(el) {
const t = timers.get(el);
if (t) {
clearTimeout(t);
timers.delete(el);
}
}
function addTempClass(el, cls, ms) {
clearTempTimer(el);
el.classList.add(cls);
const t = setTimeout(() => {
el.classList.remove(cls);
timers.delete(el);
}, ms);
timers.set(el, t);
}
function handleTraceEvent(evt) {
if (!evt || typeof evt !== 'object') return;
const nodeId = evt.node_id;
const el = findNodeElByOrigId(nodeId);
if (!el) return;
// Сбрасываем конфликтующие временные классы
if (evt.event === 'node_start') {
clearTempTimer(el);
el.classList.add('node-running');
el.classList.remove('node-ok', 'node-err');
} else if (evt.event === 'node_done') {
el.classList.remove('node-running');
addTempClass(el, 'node-ok', 1500);
} else if (evt.event === 'node_sleep') {
const ms = (()=>{ try { return Math.max(0, parseInt(evt.sleep_ms || 0, 10)); } catch(_) { return 0; } })();
// Highlight as sleeping with amber color; keep running class, override in CSS by node-sleep
addTempClass(el, 'node-sleep', Math.min(Math.max(ms + 200, 400), 60000));
} else if (evt.event === 'node_error') {
el.classList.remove('node-running');
addTempClass(el, 'node-err', 2500);
}
}
// --- Мини‑панель логов HTTP -------------------------------------------------
const logs = [];
const logsById = new Map();
const pendingExtractByNode = new Map(); // временное хранилище Extract → прикрепим при node_done
let selectedLogId = null;
const listEl = document.getElementById('logs-list');
const reqPre = document.getElementById('logs-req');
const respPre = document.getElementById('logs-resp');
const dataPre = document.getElementById('logs-data');
const panelLogs = document.getElementById('logs-panel');
const btnLogsOpen = document.getElementById('btn-logs');
const btnLogsClose = document.getElementById('logs-close');
const btnLogsClear = document.getElementById('logs-clear');
function isLogsOpen(){ return panelLogs && panelLogs.style.display !== 'none'; }
function openLogs(){ if (panelLogs) { panelLogs.style.display='block'; panelLogs.setAttribute('aria-hidden','false'); renderLogsList(); renderLogsDetail(selectedLogId); } }
function closeLogs(){ if (panelLogs) { panelLogs.style.display='none'; panelLogs.setAttribute('aria-hidden','true'); } }
if (btnLogsOpen) btnLogsOpen.addEventListener('click', () => { if (isLogsOpen()) { closeLogs(); } else { openLogs(); } });
if (btnLogsClose) btnLogsClose.addEventListener('click', closeLogs);
if (btnLogsClear) btnLogsClear.addEventListener('click', ()=>{
logs.length = 0;
logsById.clear();
pendingExtractByNode.clear();
selectedLogId = null;
renderLogsList();
renderLogsDetail(null);
});
function fmtHeaders(h){
try {
const keys = Object.keys(h||{});
return keys.map(k=>`${k}: ${String(h[k])}`).join('\n');
} catch { return ''; }
}
function buildReqText(x){
if (!x) return '';
const head = `${x.method||'POST'} ${x.url||'/'} HTTP/1.1`;
const host = (()=>{ try { const u=new URL(x.url); return `Host: ${u.host}`; } catch { return ''; } })();
const hs = fmtHeaders(x.headers||{});
const body = (x.body_text||'').trim();
return [head, host, hs, '', body].filter(Boolean).join('\n');
}
function buildRespText(x){
if (!x) return '';
const head = `HTTP/1.1 ${x.status||0}`;
const hs = fmtHeaders(x.headers||{});
const body = (x.body_text||'').trim();
return [head, hs, '', body].filter(Boolean).join('\n');
}
function renderLogsList(){
if (!listEl) return;
if (!logs.length){ listEl.innerHTML = '<div class="hint" style="padding:8px;color:#a7b0bf">Пока пусто</div>'; return; }
const rows = logs.slice().reverse().map(item=>{
const id = item.id;
const nm = `${item.node_type||'Node'} #${item.node_id||''}`;
const url = (item.req && item.req.url) ? item.req.url : '';
let st = '';
const classes = ['logs-row'];
if (item.kind === 'http') {
st = (item.res && item.res.status!=null) ? `${item.res.status}` : '';
classes.push('kind-http');
const stc = (item.res && typeof item.res.status === 'number') ? item.res.status : 0;
if (stc >= 200 && stc < 400) classes.push('http-ok'); else if (stc) classes.push('http-err');
} else if (item.kind === 'node') {
const ev = (item.ev ? String(item.ev) : '').toLowerCase();
const dur = (item.duration_ms!=null) ? ` (${item.duration_ms}ms)` : '';
st = `${ev}${dur}`;
classes.push('kind-node', 'ev-' + (ev || 'unknown'));
// Dim start/done; keep other node events visible
if (ev === 'start' || ev === 'done') classes.push('dim');
} else if (item.kind === 'vars') {
const cnt = (typeof item.count === 'number') ? item.count : (Array.isArray(item.vars) ? item.vars.length : undefined);
st = ' • vars_set' + (cnt!=null ? ` (${cnt})` : '');
classes.push('kind-vars', 'dim');
} else if (item.kind === 'if') {
const r = (item.result === true || item.result === false) ? (item.result ? 'true' : 'false') : '';
st = ` • if ${r}`;
classes.push('kind-if');
} else if (item.kind === 'return') {
st = ` • return ${item.target || ''}`;
classes.push('kind-return');
}
if (id === selectedLogId) classes.push('selected');
return `<div class="${classes.join(' ')}" data-id="${id}">
<div class="title">${nm}${st}</div>
<div class="sub" title="${url}">${url}</div>
</div>`;
}).join('');
listEl.innerHTML = rows;
listEl.querySelectorAll('.logs-row').forEach(el=>{
el.addEventListener('click', ()=>{
selectedLogId = el.getAttribute('data-id');
renderLogsList();
renderLogsDetail(selectedLogId);
});
});
}
function renderLogsDetail(id){
if (!reqPre || !respPre) return;
const it = id ? logsById.get(id) : null;
if (!it) { reqPre.textContent=''; respPre.textContent=''; return; }
if (it.kind === 'http' || (!it.kind && it.req)) {
// Восстанавливаем полный HTTP: Request + Response; без Extract (перенесён в node_done → Data)
reqPre.textContent = buildReqText(it && it.req);
respPre.textContent = buildRespText(it && it.res);
if (dataPre) dataPre.textContent = '';
} else if (it.kind === 'if') {
const lines = [
`Node: ${it.node_type||'Node'} #${it.node_id||''}`,
`If: ${it.expr||''}`,
`Expanded: ${it.expanded||''}`,
`Result: ${it.result ? 'true' : 'false'}`,
(it.ts?`Time: ${new Date(it.ts).toLocaleTimeString()}`:''),
].filter(Boolean).join('\n');
reqPre.textContent = lines;
respPre.textContent = '';
if (dataPre) dataPre.textContent = '';
} else if (it.kind === 'return') {
const tmpl = String(it.template_used ?? '');
const trimmed = tmpl.length > 800 ? (tmpl.slice(0,800) + '…') : tmpl;
const lines = [
`Node: ${it.node_type||'Node'} #${it.node_id||''}`,
`Return target: ${it.target||''}`,
(typeof it.text_len==='number'?`Text length: ${it.text_len}`:''),
`Template: ${trimmed}`,
(tmpl.length>800 ? '(template truncated in view)' : ''),
(it.ts?`Time: ${new Date(it.ts).toLocaleTimeString()}`:''),
].filter(Boolean).join('\n');
reqPre.textContent = lines;
respPre.textContent = '';
if (dataPre) dataPre.textContent = '';
} else if (it.kind === 'vars') {
const vp = it.values_preview && typeof it.values_preview === 'object' ? it.values_preview : null;
const lines = [
`SetVars: ${Array.isArray(it.vars)? it.vars.join(', ') : ''}`,
(typeof it.count==='number' ? `Count: ${it.count}` : ''),
];
if (vp) {
lines.push('Values preview:');
try {
for (const k of Object.keys(vp)) {
lines.push(` ${k} = ${String(vp[k])}`);
}
} catch {}
}
reqPre.textContent = lines.filter(Boolean).join('\n');
respPre.textContent = '';
if (dataPre) dataPre.textContent = '';
} else if (it.kind === 'node') {
const header = [
`Node: ${it.node_type||'Node'} #${it.node_id||''}`,
`Event: ${it.ev||''}`,
(it.wave!=null?`Wave: ${it.wave}`:''),
(it.duration_ms!=null?`Duration: ${it.duration_ms}ms`:''),
(it.ts?`Time: ${new Date(it.ts).toLocaleTimeString()}`:''),
].filter(Boolean).join('\n');
reqPre.textContent = header;
respPre.textContent = '';
// Если это node_done и есть Extract — выводим в отдельной секции Data
if (dataPre) {
if (String(it.ev||'').toLowerCase() === 'done' && it.extract) {
const ex = it.extract || {};
const raw = typeof ex.extracted_text === 'string' ? ex.extracted_text : '';
const trimmed = raw.length > 1200 ? (raw.slice(0, 1200) + '…') : raw;
const macOut = ex.to_macro_outx || '';
const macBr = ex.to_macro_braces || '';
const lines = [
'Extract:',
`extracted: ${JSON.stringify(trimmed)}`,
`on ${macOut || '(no [[OUTx]])'} and ${macBr || '(no {{ }})'}`,
(ex.to_path ? `path: ${ex.to_path}` : ''),
(typeof ex.text_len === 'number' ? `text_len: ${ex.text_len}` : ''),
(ex.strategy ? `strategy: ${ex.strategy}` : ''),
(ex.json_path ? `json_path: ${ex.json_path}` : ''),
(ex.join_sep ? `join_sep: ${String(ex.join_sep).replace(/\\n/g,'\\\\n').replace(/\n/g,'\\n')}` : ''),
].filter(Boolean).join('\n');
dataPre.textContent = lines;
} else {
dataPre.textContent = '';
}
}
} else {
reqPre.textContent=''; respPre.textContent='';
if (dataPre) dataPre.textContent = '';
}
}
function handleLogEvent(evt){
if (!evt || typeof evt!=='object') return;
if (evt.event === 'http_req'){
const id = String(evt.req_id || `${evt.node_id}-${Date.now()}`);
const obj = {
id,
kind: 'http',
node_id: evt.node_id,
node_type: evt.node_type,
provider: evt.provider,
ts: Date.now(),
req: { method: evt.method, url: evt.url, headers: evt.headers, body_text: evt.body_text }
};
logsById.set(id, obj);
logs.push(obj);
if (isLogsOpen()) renderLogsList();
} else if (evt.event === 'http_resp'){
const id = String(evt.req_id || '');
const obj = logsById.get(id);
const patch = { status: evt.status, headers: evt.headers, body_text: evt.body_text };
if (obj){ obj.res = patch; if (isLogsOpen()) { renderLogsList(); if (selectedLogId===obj.id) renderLogsDetail(obj.id); } }
} else if (evt.event === 'provider_done' || evt.event === 'rawforward_done') {
// Не создаём отдельной HTTP-строки и не приклеиваем к Response.
// Кладём Extract в pending, затем прикрепим к ближайшему node_done.
const ext = {
provider: evt.provider,
method: evt.method,
url: evt.url,
status: evt.status,
strategy: evt.strategy,
json_path: evt.json_path,
join_sep: evt.join_sep,
text_len: evt.text_len,
extracted_text: evt.extracted_text,
to_path: evt.to_path,
to_macro_outx: evt.to_macro_outx,
to_macro_braces: evt.to_macro_braces
};
pendingExtractByNode.set(evt.node_id, ext);
// Если node_done уже есть — прикрепим сразу
try {
for (let i = logs.length - 1; i >= 0; i--) {
const it = logs[i];
if (it && it.kind === 'node' && it.node_id === evt.node_id && String(it.ev||'').toLowerCase() === 'done') {
it.extract = ext;
if (isLogsOpen()) { renderLogsList(); if (selectedLogId===it.id) renderLogsDetail(it.id); }
pendingExtractByNode.delete(evt.node_id);
break;
}
}
} catch (_) {}
} else if (evt.event === 'vars_set'){
const id = `${evt.node_id}-${Date.now()}`;
const obj = {
id,
kind:'vars',
node_id: evt.node_id,
node_type: evt.node_type,
ts: Date.now(),
vars: Array.isArray(evt.vars) ? evt.vars.slice() : undefined,
count: typeof evt.count === 'number' ? evt.count : undefined,
values_preview: (evt.values_preview && typeof evt.values_preview === 'object') ? evt.values_preview : undefined,
req: { method:'VARS', url:'', headers:{}, body_text:`vars: ${Array.isArray(evt.vars)?evt.vars.join(', '):''}` }
};
logsById.set(id, obj); logs.push(obj);
if (isLogsOpen()) renderLogsList();
} else if (evt.event === 'if_result') {
const id = `${evt.node_id}-if-${evt.ts || Date.now()}`;
const obj = {
id,
kind: 'if',
node_id: evt.node_id,
node_type: evt.node_type || 'If',
ts: evt.ts || Date.now(),
expr: evt.expr,
expanded: evt.expanded,
result: !!evt.result
};
logsById.set(id, obj); logs.push(obj);
if (isLogsOpen()) renderLogsList();
} else if (evt.event === 'return_detail') {
const id = `${evt.node_id}-return-${evt.ts || Date.now()}`;
const obj = {
id,
kind: 'return',
node_id: evt.node_id,
node_type: evt.node_type || 'Return',
ts: evt.ts || Date.now(),
target: evt.target,
text_len: evt.text_len,
template_used: evt.template_used
};
logsById.set(id, obj); logs.push(obj);
if (isLogsOpen()) renderLogsList();
} else if (evt.event === 'node_sleep') {
const id = `${evt.node_id}-node_sleep-${evt.ts || Date.now()}`;
const obj = {
id,
kind: 'node',
node_id: evt.node_id,
node_type: evt.node_type || 'Node',
ts: evt.ts || Date.now(),
wave: evt.wave,
ev: 'sleep',
duration_ms: (typeof evt.sleep_ms === 'number' ? evt.sleep_ms : parseInt(evt.sleep_ms || 0, 10))
};
logsById.set(id, obj); logs.push(obj);
if (isLogsOpen()) renderLogsList();
} else if (evt.event === 'node_start' || evt.event === 'node_done' || evt.event === 'node_error') {
const id = `${evt.node_id}-${evt.event}-${evt.ts || Date.now()}`;
const obj = {
id,
kind: 'node',
node_id: evt.node_id,
node_type: evt.node_type || 'Node',
ts: evt.ts || Date.now(),
wave: evt.wave,
ev: String(evt.event || '').replace(/^node_/, ''),
duration_ms: evt.duration_ms
};
// Если это node_done и есть отложенный Extract — прикрепим его сюда
if (obj.ev === 'done' && pendingExtractByNode.has(evt.node_id)) {
try { obj.extract = pendingExtractByNode.get(evt.node_id); } catch(_) {}
try { pendingExtractByNode.delete(evt.node_id); } catch(_) {}
}
logsById.set(id, obj); logs.push(obj);
if (isLogsOpen()) renderLogsList();
}
}
// Открываем SSE поток
const es = new EventSource('/admin/trace/stream');
es.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
// Special handling for manual cancel notification
if (data && data.event === 'cancelled') {
try { status('Исполнение остановлено пользователем'); } catch(_){}
}
handleTraceEvent(data);
handleLogEvent(data);
} catch (_) {
// игнорируем мусор
}
};
es.onerror = () => {
// Можно тихо игнорировать; при необходимости — вывести статус
// setStatus('SSE: disconnected');
};
// Экспорт для отладки из консоли
window.__TraceSSE = { es, handleTraceEvent, findNodeElByOrigId };
} catch (e) {
try { console.error('SSE highlight init error', e); } catch (_) {}
}
})();
</script>
</body>
</html>