Files
HadTavern/static/editor.html
2025-10-03 21:55:24 +03:00

4809 lines
238 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="icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#ffffff" />
<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; }
/* Split STOP button styling */
.chip-btn.split {
position: relative;
display: inline-flex;
padding: 0;
overflow: hidden;
border-radius: 8px;
}
.chip-btn.split .seg {
padding: 6px 10px;
display: inline-block;
line-height: 1.2;
user-select: none;
}
.chip-btn.split .seg-left {
border-right: 1px solid rgba(255,255,255,0.08);
}
.chip-btn.split.hover-left .seg-left {
background: color-mix(in srgb, #f59e0b 28%, transparent);
}
.chip-btn.split.hover-right .seg-right {
background: color-mix(in srgb, #ef4444 28%, transparent);
}
.chip-btn.split.is-busy {
opacity: .7;
pointer-events: none;
}
#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; }
/* Logs selection isolation (simple): only active pre is selectable */
#logs-detail { user-select: none; -webkit-user-select: none; }
#logs-detail pre { user-select: none; -webkit-user-select: none; }
#logs-detail[data-active-pre="logs-req"] #logs-req,
#logs-detail[data-active-pre="logs-resp"] #logs-resp,
#logs-detail[data-active-pre="logs-data"] #logs-data { user-select: text; -webkit-user-select: text; }
</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-tidy" title="Авто‑раскладка графа (повторный клик — отмена)">РАСКЛАДКА</button>
<button class="chip-btn" id="btn-logs" title="Журнал HTTP запросов/ответов">ЛОГИ</button>
<!-- Split STOP button: left=graceful, right=abort -->
<button class="chip-btn split" id="btn-cancel" title="СТОП: левая половина — мягкая (ждать), правая — жёсткая (обрыв)">
<span class="seg seg-left" aria-label="мягкая">СТ</span>
<span class="seg seg-right" aria-label="жёсткая">ОП</span>
</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>
<div style="display:flex;align-items:center;gap:8px;justify-content:space-between">
<strong>Request</strong>
<div class="logs-req-actions" style="display:flex;gap:8px">
<button id="logs-send" title="Отправить отредактированный запрос">Отправить</button>
<button id="logs-revert" title="Вернуть оригинальный запрос">Вернуть</button>
</div>
</div>
<pre id="logs-req" contenteditable="true" tabindex="0" 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>
<div id="wire-labels" aria-hidden="true" style="display:block"></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/utils.js?v=1"></script>
<script src="/ui/js/providerTemplates.js?v=1"></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: ['depends'], 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;
// Визуальная стилизация линий соединений (устойчиво к разным классам Drawflow)
// - If:true / If:false → пунктир + спокойные цвета
// - Цвет по типу родителя для остальных (ProviderCall, RawForward, SetVars, Return)
(function(){
const DBG_CONN = false; // диагностика отключена по ТЗ
function tokenNum(tokens, prefixes) {
for (const t of tokens) {
for (const p of prefixes) {
if (t.toLowerCase().startsWith(p)) {
const n = parseInt(t.slice(p.length), 10);
if (Number.isFinite(n)) return n;
}
}
}
return NaN;
}
function classNumRe(cls, reArr) {
for (const re of reArr) {
const m = cls.match(re);
if (m) return parseInt(m[1], 10);
}
return NaN;
}
function computeOutIdxFromModel(outId, inId) {
try {
const data = (window.editor && window.editor.export) ? window.editor.export() : null;
const nodes = data?.drawflow?.Home?.data || {};
const src = nodes[String(outId)];
if (!src || !src.outputs) return NaN;
let idx = NaN;
let i = 1;
for (const k of Object.keys(src.outputs)) {
const conns = src.outputs[k]?.connections || [];
if (conns.some(c => String(c?.node) === String(inId))) { idx = i; break; }
i += 1;
}
return idx;
} catch { return NaN; }
}
function styleAllConnections() {
try {
if (window.__suspendHud) return;
const conns = document.querySelectorAll('#drawflow .connection');
let logCount = 0;
conns.forEach((el) => {
try {
const cls = String(el.getAttribute('class') || '');
const tokens = cls.split(/\s+/).filter(Boolean);
// Извлекаем исходную/целевую ноду и индекс выхода
// Поддерживаем реальные классы Drawflow: node_out_node-<id>, node_in_node-<id>, output_<n>
let outId =
tokenNum(tokens, ['node_out_node-']) ||
classNumRe(cls, [/node[_-]?out[_-]?node-(\d+)/i]) ||
tokenNum(tokens, ['node_out_', 'node-out-', 'node_out-']) ||
classNumRe(cls, [/node[_-]?out[_-]?(\d+)/i, /node-out-(\d+)/i]) || NaN;
let inId =
tokenNum(tokens, ['node_in_node-']) ||
classNumRe(cls, [/node[_-]?in[_-]?node-(\d+)/i]) ||
tokenNum(tokens, ['node_in_', 'node-in-', 'node_in-']) ||
classNumRe(cls, [/node[_-]?in[_-]?(\d+)/i, /node-in-(\d+)/i]) || NaN;
let outIdx =
tokenNum(tokens, ['output_']) ||
classNumRe(cls, [/output[_-]?(\d+)/i]) ||
tokenNum(tokens, ['output-', 'out_', 'out-']) ||
classNumRe(cls, [/out[_-]?(\d+)/i]) || NaN;
if (!Number.isFinite(outIdx) && Number.isFinite(outId) && Number.isFinite(inId)) {
outIdx = computeOutIdxFromModel(outId, inId); // 1based
}
const path = el.querySelector('.main-path');
if (!path) return;
// Тип исходной ноды
let t = '';
try {
const n = (Number.isFinite(outId) && window.editor)
? window.editor.getNodeFromId(outId)
: null;
t = (n && n.name ? String(n.name) : '');
} catch {}
let colorVar = '--connector';
let dashed = false;
// Сначала очистим предыдущие «метки» классов, если они были
try {
el.classList.remove('conn-if-true','conn-if-false','conn-provider','conn-raw','conn-setvars','conn-return');
} catch(_){}
if (t === 'If') {
// Отображаем по имени выхода из NODE_IO, а не «жёстко по индексу»
const outs = (window.NODE_IO && window.NODE_IO.If && Array.isArray(window.NODE_IO.If.outputs))
? window.NODE_IO.If.outputs : ['true','false'];
const outName = (Number.isFinite(outIdx) && outIdx >= 1 && outIdx <= outs.length)
? String(outs[outIdx - 1]).toLowerCase()
: '';
if (outName === 'true') {
colorVar = '--wire-true'; dashed = true;
try { el.classList.add('conn-if-true'); } catch(_){}
} else if (outName === 'false') {
colorVar = '--wire-false'; dashed = true;
try { el.classList.add('conn-if-false'); } catch(_){}
} else {
// fallback: хотя бы пунктир, если это If, чтобы визуально отличалось
dashed = true;
colorVar = '--connector';
}
} else if (t === 'ProviderCall') {
colorVar = '--wire-provider';
try { el.classList.add('conn-provider'); } catch(_){}
} else if (t === 'RawForward') {
colorVar = '--wire-raw';
try { el.classList.add('conn-raw'); } catch(_){}
} else if (t === 'SetVars') {
colorVar = '--wire-setvars';
try { el.classList.add('conn-setvars'); } catch(_){}
} else if (t === 'Return') {
colorVar = '--wire-return';
try { el.classList.add('conn-return'); } catch(_){}
}
// Применяем стиль
path.style.stroke = `var(${colorVar})`;
path.style.opacity = '0.9';
path.style.strokeDasharray = dashed ? '6 6' : 'none';
path.style.strokeWidth = 'clamp(1px, calc(2.2px / var(--zoom, 1)), 4.5px)';
path.style.strokeLinecap = 'round';
path.style.strokeLinejoin = 'round';
} catch (e) { /* ignore one conn */ }
});
} catch (e) { /* ignore batch */ }
}
// Экспортируем и вешаем хуки
try { window.styleAllConnections = styleAllConnections; } catch(_) {}
try {
editor.on('connectionCreated', () => setTimeout(styleAllConnections, 0));
editor.on('connectionRemoved', () => setTimeout(styleAllConnections, 0));
editor.on('nodeMoved', () => setTimeout(styleAllConnections, 0));
editor.on('nodeCreated', () => setTimeout(styleAllConnections, 0));
editor.on('nodeRemoved', () => setTimeout(styleAllConnections, 0));
editor.on('zoom', () => setTimeout(styleAllConnections, 0));
editor.on('translated', () => setTimeout(styleAllConnections, 0));
} catch(_) {}
// Первичный прогон
setTimeout(styleAllConnections, 0);
})();
// --- Overlay: wire labels, arrows; grouping; dimming; tidy layout ---
(function(){
const canvasEl = document.getElementById('canvas');
const dfRoot = document.getElementById('drawflow');
const wireLayer = document.getElementById('wire-labels');
const groupLayer = document.getElementById('group-layer');
function updateWireLabelsAndArrows() {
try {
if (window.__suspendHud) return;
if (!wireLayer) return;
wireLayer.innerHTML = '';
const rc = canvasEl.getBoundingClientRect();
const conns = document.querySelectorAll('#drawflow .connection');
conns.forEach((el) => {
const path = el.querySelector('.main-path');
if (!path) return;
const hasTrue = el.classList.contains('conn-if-true');
const hasFalse = el.classList.contains('conn-if-false');
// Label: только для ветвей If, чтобы не перегружать канвас
if (hasTrue || hasFalse) {
try {
const bb = path.getBoundingClientRect();
const lx = Math.round(bb.left - rc.left + bb.width / 2);
const ly = Math.round(bb.top - rc.top + bb.height / 2);
const pill = document.createElement('div');
pill.className = 'wire-label';
pill.textContent = hasTrue ? 'true' : 'false';
pill.style.left = lx + 'px';
pill.style.top = ly + 'px';
wireLayer.appendChild(pill);
} catch(_) {}
}
// Arrow near end, oriented along curve (source -> target)
try {
if (path.getTotalLength) {
const L = path.getTotalLength();
// Evaluate tangent close to the end; use reversed vector if library draws path target->source
const pNear = path.getPointAtLength(Math.max(0, L * 0.80));
const pBack = path.getPointAtLength(Math.max(0, L * 0.78));
const ctm = path.getScreenCTM && path.getScreenCTM();
const toScreen = (pt) => {
if (ctm && window.DOMPoint) {
const dp = new DOMPoint(pt.x, pt.y).matrixTransform(ctm);
return { x: dp.x, y: dp.y };
}
return { x: pt.x, y: pt.y };
};
const sNear = toScreen(pNear), sBack = toScreen(pBack);
const ax = Math.round(sNear.x - rc.left);
const ay = Math.round(sNear.y - rc.top);
// Reverse vector to flip direction if Drawflow path goes target->source
const angle = Math.atan2(sBack.y - sNear.y, sBack.x - sNear.x) * 180 / Math.PI;
const arr = document.createElement('div');
arr.className = 'wire-arrow';
const col = getComputedStyle(path).stroke || 'var(--connector)';
arr.style.borderTopColor = col;
arr.style.left = ax + 'px';
arr.style.top = ay + 'px';
arr.style.transform = 'translate(-50%, -50%) rotate(' + Math.round(angle + 90) + 'deg)';
wireLayer.appendChild(arr);
}
} catch(_) {}
});
} catch(_) {}
}
window.updateWireLabelsAndArrows = updateWireLabelsAndArrows;
// Dimming of non-related connections when a node is focused
function applyConnectionDimming(focusId) {
try {
const conns = document.querySelectorAll('#drawflow .connection');
conns.forEach(el => el.classList.remove('dim'));
if (!focusId) return;
const tagOut = 'node_out_node-' + focusId;
const tagIn = 'node_in_node-' + focusId;
conns.forEach(el => {
const cls = el.getAttribute('class') || '';
if (cls.indexOf(tagOut) === -1 && cls.indexOf(tagIn) === -1) {
el.classList.add('dim');
}
});
} catch(_) {}
}
window.applyConnectionDimming = applyConnectionDimming;
// Mark upstream edges of errored node
function markUpstreamError(nid) {
try {
document.querySelectorAll('#drawflow .connection').forEach(el => {
const cls = el.getAttribute('class') || '';
if (cls.includes('node_in_node-' + nid)) el.classList.add('conn-upstream-err');
});
} catch(_) {}
}
function clearUpstreamError(nid) {
try {
document.querySelectorAll('#drawflow .connection.conn-upstream-err').forEach(el => {
const cls = el.getAttribute('class') || '';
if (!nid || cls.includes('node_in_node-' + nid)) el.classList.remove('conn-upstream-err');
});
} catch(_) {}
}
window.markUpstreamError = markUpstreamError;
window.clearUpstreamError = clearUpstreamError;
/* renderGroups removed */
// Hook events for overlays/refresh
try {
const refreshAll = () => { try { window.styleAllConnections && window.styleAllConnections(); } catch(_) {}
try { updateWireLabelsAndArrows(); } catch(_) {} };
editor.on('connectionCreated', () => setTimeout(refreshAll, 0));
editor.on('connectionRemoved', () => setTimeout(refreshAll, 0));
editor.on('nodeMoved', () => setTimeout(refreshAll, 0));
editor.on('nodeCreated', () => setTimeout(refreshAll, 0));
editor.on('nodeRemoved', () => setTimeout(refreshAll, 0));
editor.on('zoom', () => setTimeout(refreshAll, 0));
editor.on('translated', () => setTimeout(refreshAll, 0));
} catch(_) {}
// Initial
setTimeout(() => { try { updateWireLabelsAndArrows(); } catch(_) {} }, 0);
/* Group frame dragging removed */
// Realtime HUD refresh: rAF-throttled scheduler + observers
let __hudRaf = 0;
function scheduleOverlayRefresh() {
if (__hudRaf) return;
try {
__hudRaf = requestAnimationFrame(() => {
__hudRaf = 0;
try { updateWireLabelsAndArrows(); } catch(_) {}
});
} catch(_) {}
}
// Pointer move while dragging nodes/handles
try { dfRoot.addEventListener('pointermove', scheduleOverlayRefresh, { passive: true }); } catch(_) {}
// Window resized
try { window.addEventListener('resize', scheduleOverlayRefresh, { passive: true }); } catch(_) {}
// Observe SVG path geometry/class changes from Drawflow
try {
const target = dfRoot;
if (window.__wireMo && typeof window.__wireMo.disconnect === 'function') {
try { window.__wireMo.disconnect(); } catch(_) {}
}
const mo = new MutationObserver((list) => {
// Any change on path/class/style/transform → refresh overlay cheaply
for (const m of list) {
const n = m.target;
if (!n) continue;
if (n.nodeName === 'path' || (m.attributeName && /^(d|transform|style|class)$/i.test(m.attributeName))) {
scheduleOverlayRefresh();
break;
}
}
});
mo.observe(target, { subtree: true, attributes: true, attributeFilter: ['d','transform','style','class'] });
window.__wireMo = mo;
} catch(_) {}
// Clear dimming on blank click
try {
dfRoot.addEventListener('click', (e) => {
const hitNode = e.target && e.target.closest && e.target.closest('.drawflow-node');
if (!hitNode) applyConnectionDimming(null);
});
} catch(_) {}
// Tidy layout toggle (with guards, timing and coordinate sanitization)
(function(){
const btn = document.getElementById('btn-tidy');
if (!btn) return;
let saved = null;
const XG = 320, YG = 180, PADX = 40, PADY = 40;
const DBG_TIDY = true;
let tidyBusy = false;
// Хард‑установка позиции узла: API + внутренняя модель + DOM
function setNodePosHard(id, x, y) {
try { editor.updateNodePosition(id, x, y); } catch(_){}
try {
const dfm = editor && editor.drawflow && editor.drawflow.drawflow && editor.drawflow.drawflow.Home && editor.drawflow.drawflow.Home.data;
if (dfm && dfm[String(id)]) {
dfm[String(id)].pos_x = x;
dfm[String(id)].pos_y = y;
}
} catch(_){}
try {
const el = document.getElementById('node-' + id);
if (el) { el.style.left = x + 'px'; el.style.top = y + 'px'; }
} catch(_){}
try { editor.updateConnectionNodes && editor.updateConnectionNodes('node-' + id); } catch(_){}
}
function buildGraph() {
try {
const data = editor.export()?.drawflow?.Home?.data || {};
const ids = Object.keys(data).map(x => parseInt(x,10)).filter(Number.isFinite);
const outs = {}, ins = {};
ids.forEach(id => {
outs[id] = [];
ins[id] = [];
const o = (data[id] && data[id].outputs) || {};
Object.keys(o).forEach(k => (o[k]?.connections || []).forEach(c => {
const v = parseInt(c.node, 10);
if (Number.isFinite(v)) { outs[id].push(v); (ins[v]||(ins[v]=[])).push(id); }
}));
});
return { ids, outs, ins };
} catch(e) {
try { console.debug('[Tidy] buildGraph error:', e); } catch(_) {}
return { ids: [], outs:{}, ins:{} };
}
}
function levels(g) {
// Kahn + робастный доклей для циклов/скрещиваний
const indeg = {}; const lev = {}; const seen = new Set();
g.ids.forEach(id => { indeg[id] = (g.ins[id] || []).length; lev[id] = 0; });
const q = g.ids.filter(id => indeg[id] === 0);
while (q.length) {
const u = q.shift();
seen.add(u);
(g.outs[u] || []).forEach(v => {
const lu = Number(lev[u] || 0);
lev[v] = Math.max(lev[v] || 0, (Number.isFinite(lu) ? lu : 0) + 1);
indeg[v] = Math.max(0, (indeg[v] || 0) - 1);
if (indeg[v] === 0) q.push(v);
});
}
// Если остались непройденные (возможные циклы) — расклеиваем по минимальной полустепени входа
const rest = g.ids.filter(id => !seen.has(id));
if (rest.length) {
let safety = 0;
const left = new Set(rest);
while (left.size && safety++ < 10000) {
const pick = Array.from(left).sort((a,b)=> (indeg[a]||0) - (indeg[b]||0)).shift();
left.delete(pick);
const preds = g.ins[pick] || [];
let lv = 0; preds.forEach(p => { lv = Math.max(lv, (lev[p]||0) + 1); });
lev[pick] = Math.max(lev[pick]||0, lv);
(g.outs[pick]||[]).forEach(v => { indeg[v] = Math.max(0, (indeg[v]||0) - 1); });
}
}
return lev;
}
function applyLayout() {
const t0 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
// Отключаем наблюдатель путей и HUD, чтобы исключить лавину перерисовок
const mo = (typeof window !== 'undefined' ? window.__wireMo : null);
try { mo && typeof mo.disconnect === 'function' && mo.disconnect(); } catch(_) {}
try { window.__suspendHud = true; } catch(_) {}
// reuse outer setNodePosHard declared earlier
// 1) Снимем один раз снапшот графа
let df = {};
try { df = editor.export()?.drawflow?.Home?.data || {}; } catch(_) { df = {}; }
const ids = Object.keys(df).map(x => parseInt(x,10)).filter(Number.isFinite);
// 2) Построим граф зависимостей и уровни (устойчиво к циклам)
const outs = new Map(); const indeg = new Map();
const predsByNode = new Map(); // targetId -> [{ parent, outIdx }]
ids.forEach(id => { outs.set(id, []); indeg.set(id, 0); });
ids.forEach(id => {
try {
const o = df[id]?.outputs || {};
Object.keys(o).forEach(k => {
const conns = (o[k]?.connections || []);
// k like "output_1" → outIdx = 1
let outIdx = 0;
try {
const m = String(k).match(/output_(\d+)/);
if (m) outIdx = Math.max(1, parseInt(m[1], 10));
} catch(_) {}
conns.forEach(c => {
const v = parseInt(c.node,10);
if (Number.isFinite(v)) {
outs.get(id).push(v);
indeg.set(v, (indeg.get(v) || 0) + 1);
// remember parent and which branch index connected
const arr = predsByNode.get(v) || [];
arr.push({ parent: id, outIdx });
predsByNode.set(v, arr);
}
});
});
} catch(_) {}
});
const q = [];
ids.forEach(id => { if ((indeg.get(id)||0) === 0) q.push(id); });
if (!q.length && ids.length) q.push(ids[0]);
const level = new Map(); ids.forEach(id => level.set(id, 0));
const topo = [];
while (q.length) {
const u = q.shift();
topo.push(u);
const lu = Number(level.get(u) || 0);
for (const v of (outs.get(u) || [])) {
level.set(v, Math.max(Number(level.get(v) || 0), lu + 1));
indeg.set(v, Math.max(0, (indeg.get(v) || 0) - 1));
if ((indeg.get(v) || 0) === 0) q.push(v);
}
}
ids.forEach(id => {
if (!topo.includes(id)) {
topo.push(id);
const preds = [];
outs.forEach((arr, u) => { if (arr.includes(id)) preds.push(u); });
let lv = 0; preds.forEach(p => { lv = Math.max(lv, (level.get(p)||0) + 1); });
level.set(id, lv);
}
});
// 3) Стабильная сортировка: по уровню, затем по текущей pos_y
const sorted = topo.slice().sort((a,b) => {
const la = Number(level.get(a) || 0);
const lb = Number(level.get(b) || 0);
if (la !== lb) return la - lb;
try {
const na = editor.getNodeFromId(a), nb = editor.getNodeFromId(b);
return (Number(na?.pos_y)||0) - (Number(nb?.pos_y)||0);
} catch(_) { return 0; }
});
// 4) Вычислим целевые координаты, порционно применим
const targets = [];
const colRows = new Map(); // level -> next row idx
const XG = 320, YG = 180, PADX = 40, PADY = 40;
const GRID = Number((window && window.__GRID_STEP) || 12); // единый шаг сетки со снапом
// Сгруппируем ноды по уровням
const maxLevel = (() => { let m = 0; level.forEach(v => { if (v > m) m = v; }); return m; })();
const nodesByLevel = new Map();
for (let L = 0; L <= maxLevel; L++) nodesByLevel.set(L, []);
topo.forEach(id => {
const L = Number(level.get(id) || 0);
const arr = nodesByLevel.get(L) || [];
arr.push(id);
nodesByLevel.set(L, arr);
});
// Запомним назначенные «ряды» для уже разложенных (нужны для вычисления медианы родителей)
const rowPos = new Map();
for (let L = 0; L <= maxLevel; L++) {
const list = (nodesByLevel.get(L) || []).slice();
// для L==0 оставим текущую сортировку (топологическая + pos_y), а для L>0 — упорядочим по родителям
const ordered = list.map(id => {
const preds = predsByNode.get(id) || [];
if (!preds.length) {
// без родителей — используем текущую pos_y как эвристику
let py = 0;
try { py = Number(editor.getNodeFromId(id)?.pos_y) || 0; } catch(_) {}
return { id, score: py, tie: id };
}
// Вычислим медиану по строкам родителей + сдвиг по ветке If (true вверх, false вниз)
const scores = preds.map(p => {
const base = Number(rowPos.get(p.parent) || 0);
// небольшой сдвиг для ветвей: outIdx=1 (true) → вверх; outIdx=2 (false) → вниз
const delta = (p.outIdx === 1 ? -0.35 : (p.outIdx === 2 ? 0.35 : 0));
return base + delta;
}).sort((a,b)=>a-b);
const mid = (scores.length % 2 === 1)
? scores[(scores.length-1)/2]
: (scores[scores.length/2 - 1] + scores[scores.length/2]) / 2;
return { id, score: mid, tie: id };
}).sort((a,b) => (a.score - b.score) || (a.tie - b.tie));
// Расставим по порядку, с привязкой к сетке
let row = 0;
for (const item of ordered) {
const id = item.id;
const rawx = PADX + L * XG;
const rawy = PADY + row * YG;
const x = Math.round(rawx / GRID) * GRID;
const y = Math.round(rawy / GRID) * GRID;
targets.push({ id, x, y });
rowPos.set(id, row);
row += 1;
}
colRows.set(L, row);
}
let i = 0;
const BATCH = 40;
let changed = 0, patched = 0;
const placeBatch = () => {
try {
for (let n = 0; n < BATCH && i < targets.length; n++, i++) {
const t = targets[i];
if (Number.isFinite(t.x) && Number.isFinite(t.y)) {
try {
const before = editor.getNodeFromId(t.id);
const bx = Number(before?.pos_x)||0, by = Number(before?.pos_y)||0;
setNodePosHard(t.id, t.x, t.y);
const after = editor.getNodeFromId(t.id);
const ax = Number(after?.pos_x)||0, ay = Number(after?.pos_y)||0;
const ok = (ax === t.x && ay === t.y);
if ((bx !== t.x) || (by !== t.y)) changed++;
if (!ok) {
// ещё одна попытка хард‑патча, если библиотека перезаписала
setNodePosHard(t.id, t.x, t.y);
const aft2 = editor.getNodeFromId(t.id);
const ax2 = Number(aft2?.pos_x)||0, ay2 = Number(aft2?.pos_y)||0;
if (ax2 === t.x && ay2 === t.y) patched++;
}
try {
console.debug('[Tidy:set]', { id: t.id, target: {x:t.x,y:t.y}, before:{x:bx,y:by}, after:{x:ax,y:ay} });
} catch(_) {}
} catch(_) {}
}
}
if (i < targets.length) {
return requestAnimationFrame(placeBatch);
}
} catch(_) {}
// Финализация
try { editor.updateConnectionNodesAll && editor.updateConnectionNodesAll(); } catch(_) {}
try { window.__suspendHud = false; } catch(_) {}
setTimeout(()=>{ try {
window.styleAllConnections && window.styleAllConnections();
updateWireLabelsAndArrows && updateWireLabelsAndArrows();
} catch(_) {} }, 0);
const t1 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
const dt = Math.max(0, Math.round(t1 - t0));
try { console.debug('[Tidy:new] nodes=%d levels=%d time=%dms changed=%d patched=%d', targets.length, colRows.size, dt, changed, patched); } catch(_){}
try { status('Tidy(new): nodes=' + targets.length + ' levels=' + colRows.size + ' time=' + dt + 'ms changed=' + changed + ' patched=' + patched); } catch(_){}
// Возобновим наблюдатель
try {
const dfRootLocal = document.getElementById('drawflow');
mo && typeof mo.observe === 'function' && dfRootLocal && mo.observe(dfRootLocal, { subtree: true, attributes: true, attributeFilter: ['d','transform','style','class'] });
} catch(_) {}
};
requestAnimationFrame(placeBatch);
}
btn.addEventListener('click', () => {
if (tidyBusy) { try { status('Tidy: busy, игнор клика'); } catch(_) {} return; }
// Отмена раскладки — восстановление saved позиций (с подавлением снапа и HUD)
if (saved) {
tidyBusy = true;
try {
// Подавим HUD/снап на время массового перемещения, чтобы узлы не «отщёлкивались» обратно
try { window.__suspendHud = true; } catch(_) {}
Object.keys(saved).forEach(k => {
const p = saved[k];
try { setNodePosHard(parseInt(k,10), p.x, p.y); } catch(_) {}
});
try { editor.updateConnectionNodesAll(); } catch(_) {}
// Дадим библиотеке завершить свои mouseup/translate апдейты (2 rAF), затем включим HUD
try {
try {
if (typeof window.nextRaf2 === 'function') {
window.nextRaf2(() => {
try {
try { editor.updateConnectionNodesAll(); } catch(_) {}
setTimeout(()=>{ try {
window.styleAllConnections && window.styleAllConnections();
updateWireLabelsAndArrows && updateWireLabelsAndArrows();
} catch(_) {} }, 0);
} finally {
try { window.__suspendHud = false; } catch(_) {}
}
});
} else {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
try {
try { editor.updateConnectionNodesAll(); } catch(_) {}
setTimeout(()=>{ try {
window.styleAllConnections && window.styleAllConnections();
updateWireLabelsAndArrows && updateWireLabelsAndArrows();
} catch(_) {} }, 0);
} finally {
try { window.__suspendHud = false; } catch(_) {}
}
});
});
}
} catch(_) {}
} catch(_) {
// Fallback на немедленное включение
try { window.__suspendHud = false; } catch(_) {}
}
saved = null;
btn.textContent = 'РАСКЛАДКА';
try { status('Tidy: отменено'); } catch(_) {}
} finally {
tidyBusy = false;
}
return;
}
// Запуск раскладки — сохранить исходные позиции, применить layout
tidyBusy = true;
try {
try {
const data = editor.export()?.drawflow?.Home?.data || {};
saved = {};
Object.keys(data).forEach(k => {
const id = parseInt(k,10);
try {
const n = editor.getNodeFromId(id);
if (n && Number.isFinite(n.pos_x) && Number.isFinite(n.pos_y)) {
saved[k] = { x: n.pos_x, y: n.pos_y };
}
} catch(_) {}
});
} catch(_) { saved = {}; }
try {
console.debug('[Tidy] click: nodes snapshot saved=', Object.keys(saved).length);
applyLayout();
console.debug('[Tidy] applyLayout completed');
} catch(e) {
console.error('[Tidy] applyLayout error', e);
try { status('Tidy error: ' + (e && e.message ? e.message : String(e))); } catch(_){}
}
btn.textContent = 'ОТМЕНИТЬ';
} finally {
tidyBusy = false;
}
});
})();
})();
// Провайдерные пресеты для ProviderCall (редактируемые пользователем).
// Шаблоны используют {{ pm.* }} — это JSON-структуры, которые сервер собирает из Prompt Blocks.
// Провайдерные пресеты → делегируем в ProviderTemplates (централизованный модуль)
function providerDefaults(provider) {
try {
if (window.ProviderTemplates && typeof window.ProviderTemplates.defaults === 'function') {
return window.ProviderTemplates.defaults(provider);
}
} catch (_) {}
// Fallback: пустые значения
return { base_url: '', endpoint: '', headers: `{}`, template: `{}` };
}
// Helpers for provider-specific configs (delegated to ProviderTemplates)
function ensureProviderConfigs(d) {
try { return window.ProviderTemplates && window.ProviderTemplates.ensureConfigs(d); } catch (_) {}
}
function getActiveProv(d) {
try { return window.ProviderTemplates && window.ProviderTemplates.getActiveProv(d); } catch (_) {}
return (d && d.provider ? String(d.provider) : 'openai').toLowerCase();
}
function getActiveCfg(d) {
try { return window.ProviderTemplates && window.ProviderTemplates.getActiveCfg(d); } catch (_) {}
ensureProviderConfigs(d);
const p = getActiveProv(d);
return (d && d.provider_configs) ? (d.provider_configs[p] || {}) : {};
}
// HTML escaping helpers for safe attribute/text insertion (delegate to AU if available)
function escAttr(v) {
try {
if (window.AU && typeof window.AU.escAttr === 'function') return window.AU.escAttr(v);
} catch (_) {}
const s = String(v ?? '');
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function escText(v) {
try {
if (window.AU && typeof window.AU.escText === 'function') return window.AU.escText(v);
} catch (_) {}
const s = String(v ?? '');
// For text nodes we keep quotes as-is for readability, but escape critical HTML chars
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// Нормализуем/заполняем дефолты конфигов нод, чтобы ключи попадали в сериализацию
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 : ''}"><span class="node-ico node-ico-${type}"></span><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 {
try {
tmpl = (typeof providerDefaults === 'function') ? providerDefaults(provider).template : '{}';
try { console.debug('[ProviderCall] tmpl via ProviderTemplates', provider); } catch (_) {}
} catch (_) { tmpl = '{}'; }
}
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 class="np-url" style="font-size:12px" type="text" value="${escAttr(base_url)}" readonly />
<label>endpoint</label>
<input class="np-endpoint" style="font-size:12px" type="text" value="${escAttr(endpoint)}" readonly />
<details class="np-coll" open>
<summary>headers (preview JSON)</summary>
<textarea readonly>${escText(headers)}</textarea>
</details>
<details class="np-coll">
<summary>template (preview JSON)</summary>
<textarea readonly>${escText(template)}</textarea>
</details>
</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 class="np-url" style="font-size:12px" type="text" value="${escAttr(base_url)}" readonly />
<label>override_path</label>
<input class="np-endpoint" style="font-size:12px" type="text" value="${escAttr(override_path)}" readonly />
<label><input type="checkbox" ${passthrough_headers} disabled/> passthrough_headers</label>
<details class="np-coll">
<summary>extra_headers (preview JSON)</summary>
<textarea readonly>${escText(extra_headers)}</textarea>
</details>
</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';
}
try { window.applyConnectionDimming && window.applyConnectionDimming(id); } catch(_) {}
});
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);
const provs = (window.ProviderTemplates && typeof window.ProviderTemplates.providers === 'function')
? window.ProviderTemplates.providers()
: ['openai','gemini','gemini_image','claude'];
const provOptions = provs.map(p => `<option value="${escAttr(p)}">${escText(p)}</option>`).join('');
html += `
<label>provider</label>
<select id="f-provider">
${provOptions}
</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 style="display:inline-flex;align-items:center;gap:6px"><input id="f-while-en" type="checkbox" ${((String(data.while_expr||'').trim()) || (data.ignore_errors===true) || (Number(data.while_max_iters||0)>0))?'checked':''}> while?</label>
<div id="while-box" style="display:${((String(data.while_expr||'').trim()) || (data.ignore_errors===true) || (Number(data.while_max_iters||0)>0))?'block':'none'};border:1px dashed #2b3646;border-radius:6px;padding:6px;margin:6px 0">
<label>while expr</label>
<textarea id="f-while-expr" rows="3">${escText(data.while_expr || '')}</textarea>
<div style="display:flex;align-items:center;gap:12px;margin-top:6px">
<label style="display:inline-flex;align-items:center;gap:6px"><input id="f-ignore-errors" type="checkbox" ${(data.ignore_errors===true)?'checked':''}> ignore_errors</label>
<label style="min-width:140px">while max iters</label>
<input id="f-while-max" type="number" min="1" step="1" value="${escAttr(String(Number(data.while_max_iters||0) || 50))}">
</div>
<div class="hint">Синтаксис как в If: [[...]], {{ ... }}, contains, &&, ||, !, ==, !=, <, <=, >, >=. Локальные: cycleindex, WAS_ERROR.</div>
</div>
<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>
<label id="row-claude-no-system" style="display:${((data.provider||'openai').toLowerCase()==='claude') ? 'inline-flex' : 'none'}; align-items:center; gap:6px"><input id="f-claude-no-system" type="checkbox" ${(data.claude_no_system===true)?'checked':''}> claude_no_system</label>
<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>
<label>prompt_combine (DSL &)</label>
<textarea id="f-prompt-combine" rows="3">${escText(data.prompt_combine || '')}</textarea>
<div class="hint">
Пример: <code>[[VAR:incoming.json.contents]] & [[PROMPT]]</code>. Пусто — выключено. Итог автоматически приводится к структуре выбранного провайдера (messages/contents/system).
</div>
<label>prompt_preprocess (pre-merge DSL)</label>
<textarea id="f-prompt-preprocess" rows="3">${escText(data.prompt_preprocess || '')}</textarea>
<div class="hint">
Каждая строка: <code>SEGMENT [delKeyContains "строка"] [delpos=prepend|append|N|-1] [case=ci|cs] [pruneEmpty]</code>.
По умолчанию: case=ci, pruneEmpty=false, без delpos → append. Примеры:
<br/><code>[[VAR:incoming.json.contents]] delKeyContains "Текст" delpos=-1</code>
<br/><code>[[VAR:incoming.json.messages]] delKeyContains "debug" case=cs</code>
<br/>SEGMENT поддерживает макросы [[...]] и {{ ... }}. Выполняется ДО prompt_combine.
</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 style="display:inline-flex;align-items:center;gap:6px"><input id="f-while-en" type="checkbox" ${((String(data.while_expr||'').trim()) || (data.ignore_errors===true) || (Number(data.while_max_iters||0)>0))?'checked':''}> while?</label>
<div id="while-box" style="display:${((String(data.while_expr||'').trim()) || (data.ignore_errors===true) || (Number(data.while_max_iters||0)>0))?'block':'none'};border:1px dashed #2b3646;border-radius:6px;padding:6px;margin:6px 0">
<label>while expr</label>
<textarea id="f-while-expr" rows="3">${escText(data.while_expr || '')}</textarea>
<div style="display:flex;align-items:center;gap:12px;margin-top:6px">
<label style="display:inline-flex;align-items:center;gap:6px"><input id="f-ignore-errors" type="checkbox" ${(data.ignore_errors===true)?'checked':''}> ignore_errors</label>
<label style="min-width:140px">while max iters</label>
<input id="f-while-max" type="number" min="1" step="1" value="${escAttr(String(Number(data.while_max_iters||0) || 50))}">
</div>
<div class="hint">Синтаксис как в If: [[...]], {{ ... }}, contains, &&, ||, !, ==, !=, <, <=, >, >=. Локальные: cycleindex, WAS_ERROR.</div>
</div>
<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-claude-no-system') d.claude_no_system = !!inp.checked;
if (inp.id === 'f-provider') d.provider = inp.value; // select changes provider
if (inp.id === 'f-prompt-combine') {
const val = String(inp.value || '').trim();
if (val) {
d.prompt_combine = inp.value;
} else {
delete d.prompt_combine;
}
}
if (inp.id === 'f-prompt-preprocess') {
const val = String(inp.value || '').trim();
if (val) {
d.prompt_preprocess = inp.value;
} else {
delete d.prompt_preprocess;
}
}
// 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;
}
}
// While controls (enable + params)
if (inp.id === 'f-while-en') {
const box = document.getElementById('while-box');
const en = !!inp.checked;
if (box) box.style.display = en ? 'block' : 'none';
if (!en) {
delete d.while_expr;
delete d.while_max_iters;
delete d.ignore_errors;
} else {
const exprEl = document.getElementById('f-while-expr');
const ignEl = document.getElementById('f-ignore-errors');
const maxEl = document.getElementById('f-while-max');
if (exprEl) d.while_expr = exprEl.value || '';
if (ignEl) d.ignore_errors = !!ignEl.checked;
if (maxEl) {
const n = parseInt(maxEl.value || '0', 10);
if (Number.isFinite(n) && n > 0) d.while_max_iters = n; else delete d.while_max_iters;
}
}
try { console.debug('[Inspector] while_en(ProviderCall)', { en, while_expr: d.while_expr, while_max_iters: d.while_max_iters, ignore_errors: d.ignore_errors }); } catch(_){}
}
if (inp.id === 'f-while-expr') {
d.while_expr = inp.value;
}
if (inp.id === 'f-ignore-errors') {
d.ignore_errors = !!inp.checked;
}
if (inp.id === 'f-while-max') {
const nval = parseInt(inp.value || '0', 10);
if (Number.isFinite(nval) && nval > 0) d.while_max_iters = nval; else delete d.while_max_iters;
}
// Синхронизуем в Drawflow, чтобы export() видел обновления
try {
if (window.AU && typeof window.AU.updateNodeDataAndDom === 'function') {
window.AU.updateNodeDataAndDom(editor, id, d);
} else {
editor.updateNodeDataFromId(id, d);
const el = document.querySelector(`#node-${id}`);
if (el) el.__data = d;
}
} catch (e) {}
} else if (type === 'If') {
if (inp.id === 'if-expr') d.expr = inp.value;
try {
if (window.AU && typeof window.AU.updateNodeDataAndDom === 'function') {
window.AU.updateNodeDataAndDom(editor, id, d);
} else {
editor.updateNodeDataFromId(id, d);
const el = document.querySelector(`#node-${id}`);
if (el) el.__data = d;
}
} catch (e) {}
} 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-baseurl') d.base_url = 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;
}
}
// While controls (enable + params)
if (inp.id === 'f-while-en') {
const box = document.getElementById('while-box');
const en = !!inp.checked;
if (box) box.style.display = en ? 'block' : 'none';
if (!en) {
delete d.while_expr;
delete d.while_max_iters;
delete d.ignore_errors;
} else {
const exprEl = document.getElementById('f-while-expr');
const ignEl = document.getElementById('f-ignore-errors');
const maxEl = document.getElementById('f-while-max');
if (exprEl) d.while_expr = exprEl.value || '';
if (ignEl) d.ignore_errors = !!ignEl.checked;
if (maxEl) {
const n = parseInt(maxEl.value || '0', 10);
if (Number.isFinite(n) && n > 0) d.while_max_iters = n; else delete d.while_max_iters;
}
}
try { console.debug('[Inspector] while_en(RawForward)', { en, while_expr: d.while_expr, while_max_iters: d.while_max_iters, ignore_errors: d.ignore_errors }); } catch(_){}
}
if (inp.id === 'f-while-expr') {
d.while_expr = inp.value;
}
if (inp.id === 'f-ignore-errors') {
d.ignore_errors = !!inp.checked;
}
if (inp.id === 'f-while-max') {
const nval = parseInt(inp.value || '0', 10);
if (Number.isFinite(nval) && nval > 0) d.while_max_iters = nval; else delete d.while_max_iters;
}
// Синхронизуем в Drawflow, чтобы export() видел обновления
try {
if (window.AU && typeof window.AU.updateNodeDataAndDom === 'function') {
window.AU.updateNodeDataAndDom(editor, id, d);
} else {
editor.updateNodeDataFromId(id, d);
const el = document.querySelector(`#node-${id}`);
if (el) el.__data = d;
}
} catch (e) {}
} 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 {
if (window.AU && typeof window.AU.updateNodeDataAndDom === 'function') {
window.AU.updateNodeDataAndDom(editor, id, d);
} else {
editor.updateNodeDataFromId(id, d);
const el = document.querySelector(`#node-${id}`);
if (el) el.__data = d;
}
} catch (e) {}
} else if (type === 'SetVars') {
// Для SetVars синхронизацию выполняют специализированные обработчики ниже (resync).
// Здесь ничего не делаем, чтобы не затереть значения.
return;
} else {
// Прочие типы — на будущее: безопасная синхронизация без изменений
try {
if (window.AU && typeof window.AU.updateNodeDataAndDom === 'function') {
window.AU.updateNodeDataAndDom(editor, id, d);
} else {
editor.updateNodeDataFromId(id, d);
const el = document.querySelector(`#node-${id}`);
if (el) el.__data = d;
}
} catch (e) {}
}
});
});
// Обработчики для 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 {
if (window.AU && typeof window.AU.updateNodeDataAndDom === 'function') {
window.AU.updateNodeDataAndDom(editor, id, d);
} else {
editor.updateNodeDataFromId(id, d);
const el = document.querySelector(`#node-${id}`);
if (el) el.__data = d;
}
} catch (e) {}
try {
const rowCns = document.getElementById('row-claude-no-system');
if (rowCns) rowCns.style.display = (d.provider === 'claude' ? 'inline-flex' : 'none');
} catch (_){}
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 || {}));
}
// JSON5 validation for headers/extra_headers (normalize to strict JSON)
try {
function __attachJsonValidation(el, opts) {
if (!el) return;
const wantObject = !!(opts && opts.wantObject);
const normalize = !!(opts && opts.normalize !== false);
const good = () => { try { el.style.borderColor=''; el.title=''; } catch(_){} };
const bad = (msg) => { try { el.style.borderColor = '#e11d48'; el.title = msg || 'Invalid JSON'; } catch(_){} };
const parseAndMark = () => {
try {
const txt = String(el.value || '').trim();
if (!txt) { good(); return; }
let obj = JSON5.parse(txt);
if (wantObject && (typeof obj !== 'object' || obj === null || Array.isArray(obj))) { bad('Ожидается JSON-объект { ... }'); return; }
if (normalize) {
try { el.value = JSON.stringify(obj, null, 2); } catch(_){}
}
good();
} catch (e) {
bad('Ошибка JSON: ' + (e && e.message ? e.message : 'parse error'));
}
};
el.addEventListener('blur', parseAndMark);
el.addEventListener('input', () => { try { el.style.borderColor=''; el.title=''; } catch(_){} });
}
if (type === 'ProviderCall') {
__attachJsonValidation(document.getElementById('f-headers'), { wantObject: true, normalize: true });
} else if (type === 'RawForward') {
__attachJsonValidation(document.getElementById('f-extra'), { wantObject: true, normalize: true });
}
} catch (_) {}
// JSON5 validation for template (macro-aware)
try {
(function(){
const tplEl = document.getElementById('f-template');
if (tplEl) {
const good = () => { try { tplEl.style.borderColor=''; tplEl.title=''; } catch(_){} };
const bad = (msg) => { try { tplEl.style.borderColor='#e11d48'; tplEl.title=(msg||'Invalid JSON template'); } catch(_){} };
const parseAndMark = () => {
try {
let txt = String(tplEl.value || '').trim();
if (!txt) { good(); return; }
let s = txt;
// Neutralize templating macros so JSON5.parse won't choke:
// 1) Replace any {{ ... }} with a scalar value
s = s.replace(/{{[\s\S]*?}}/g, '0');
// 2) Replace [[PROMPT]] with a dummy property to keep object shape valid
s = s.replace(/\[\[\s*PROMPT\s*\]\]/g, '"__PROMPT__":true');
// Tolerant parse (JSON5 supports unquoted keys, trailing commas, etc.)
const obj = JSON5.parse(s);
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
bad('Шаблон должен быть JSON-объектом');
return;
}
good();
} catch (e) {
bad('Ошибка JSON шаблона: ' + (e && e.message ? e.message : 'parse error'));
}
};
tplEl.addEventListener('blur', parseAndMark);
tplEl.addEventListener('input', () => { try { tplEl.style.borderColor=''; tplEl.title=''; } catch(_){} });
}
})();
} catch (_) {}
// 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() {
try {
const p = await (window.AU && typeof AU.apiFetch === 'function'
? AU.apiFetch('/admin/pipeline')
: (await fetch('/admin/pipeline')).json()
);
await window.AgentUISer.fromPipelineJSON(p);
// Обновим UI полей метаданных по загруженному pipeline
try { initRunMetaOnlyBasics(); } catch (e) {}
// Не затираем логи, которые вывел fromPipelineJSON
const st = document.getElementById('status').textContent;
if (!st) status('Загружено');
} catch (e) {
try { status('Ошибка загрузки пайплайна: ' + (e && e.message ? e.message : String(e))); } catch (_){}
}
}
// Перед сохранением синхронизируем все __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 out = await (window.AU && typeof AU.apiFetch === 'function'
? AU.apiFetch('/admin/pipeline', { method: 'POST', body: p })
: (await (await fetch('/admin/pipeline', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(p) })).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 j = await (window.AU && typeof AU.apiFetch === 'function'
? AU.apiFetch('/admin/vars')
: (await fetch('/admin/vars')).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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Утилита: разворачивает объект в пары [путь, строковое значение]
// Fallback на локальную реализацию, если AU недоступен
function flattenObject(obj, prefix = '') {
try {
if (window.AU && typeof window.AU.flattenObject === 'function') {
return window.AU.flattenObject(obj, prefix);
}
} catch {}
const out = [];
if (obj == null) return out;
if (typeof obj !== 'object') {
out.push([prefix, String(obj)]);
return out;
}
try {
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)]);
}
}
}
} catch {
try { out.push([prefix, JSON.stringify(obj)]); } catch { out.push([prefix, String(obj)]); }
}
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 {
if (window.AU && typeof AU.apiFetch === 'function') {
await AU.apiFetch('/admin/vars', { method: 'DELETE', expectJson: false });
} else {
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 split STOP button (left=graceful, right=abort)
try {
const btnSplit = document.getElementById('btn-cancel');
if (btnSplit) {
const leftSeg = btnSplit.querySelector('.seg-left');
const rightSeg = btnSplit.querySelector('.seg-right');
function sideFromEvent(ev) {
const r = btnSplit.getBoundingClientRect();
const x = (ev.touches && ev.touches[0] ? ev.touches[0].clientX : ev.clientX) - r.left;
return (x < r.width / 2) ? 'left' : 'right';
}
function setHover(side) {
btnSplit.classList.toggle('hover-left', side === 'left');
btnSplit.classList.toggle('hover-right', side === 'right');
}
async function postCancel(url, side) {
try {
btnSplit.classList.add('is-busy');
if (side === 'left' && leftSeg) leftSeg.textContent = 'СТ…';
if (side === 'right' && rightSeg) rightSeg.textContent = 'ОП…';
const res = await fetch(url, { method: 'POST' });
if (res.ok) {
const mode = url.includes('/abort') ? 'обрыв' : 'мягкая отмена';
status('Отмена исполнения: ' + mode + ' запрошена');
} else {
status('Ошибка запроса отмены: ' + res.status);
}
} catch (e) {
status('Ошибка запроса отмены');
} finally {
setTimeout(()=>{
try {
btnSplit.classList.remove('is-busy');
if (leftSeg) leftSeg.textContent = 'СТ ⏹';
if (rightSeg) rightSeg.textContent = 'ОП';
} catch(_){}
}, 600);
}
}
btnSplit.addEventListener('mousemove', (ev) => {
setHover(sideFromEvent(ev));
}, { passive: true });
btnSplit.addEventListener('mouseleave', () => {
btnSplit.classList.remove('hover-left','hover-right');
}, { passive: true });
btnSplit.addEventListener('click', async (ev) => {
const side = sideFromEvent(ev);
if (side === 'left') {
await postCancel('/admin/cancel', 'left');
} else {
await postCancel('/admin/cancel/abort', 'right');
}
});
// Touch support
btnSplit.addEventListener('touchstart', (ev) => {
setHover(sideFromEvent(ev));
}, { passive: true });
btnSplit.addEventListener('touchend', async (ev) => {
const side = sideFromEvent(ev);
btnSplit.classList.remove('hover-left','hover-right');
if (side === 'left') {
await postCancel('/admin/cancel', 'left');
} else {
await postCancel('/admin/cancel/abort', 'right');
}
}, { passive: false });
}
} 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);
setTimeout(()=>{ try { window.styleAllConnections && window.styleAllConnections(); } catch(_) {} }, 0);
setTimeout(()=>{ try { window.updateWireLabelsAndArrows && window.updateWireLabelsAndArrows(); } catch(_) {} }, 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// Оборачиваем рунические символы в уже экранированной строке
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();
// Busy favicon controller (APNG during pipeline execution)
const __busyFav = (function(){
// Robust favicon toggler:
// - Keeps one "busy" icon element (APNG)
// - Keeps one "restore" icon element (default)
// - Ensures browsers actually refresh by cache-busting default href
let count = 0;
let elBusy = null;
let elRestore = null;
// Determine default favicon href from existing links or fallback
function pickDefaultHref() {
try {
const links = Array.from(document.querySelectorAll('link[rel*="icon"]'));
// Prefer 32x32 png if present
const prefer = links.find(l => /icon/i.test(l.rel) && /32x32/.test(l.sizes || '') && /png/i.test(l.type || '') );
if (prefer && prefer.href) return prefer.getAttribute('href') || prefer.href;
const any = links.find(l => /icon/i.test(l.rel));
if (any && any.href) return any.getAttribute('href') || any.href;
} catch(_) {}
return '/favicon-32x32.png';
}
const DEFAULT_HREF = pickDefaultHref();
const BUSY_HREF = '/saya1.png';
function ensureBusy() {
if (elBusy && elBusy.parentNode) return elBusy;
elBusy = document.createElement('link');
elBusy.id = 'dynamic-favicon-busy';
elBusy.rel = 'icon';
elBusy.type = 'image/png';
document.head.appendChild(elBusy);
return elBusy;
}
function ensureRestore() {
if (elRestore && elRestore.parentNode) return elRestore;
elRestore = document.createElement('link');
elRestore.id = 'dynamic-favicon-restore';
elRestore.rel = 'icon';
elRestore.type = 'image/png';
document.head.appendChild(elRestore);
return elRestore;
}
function enableBusy() {
try {
// Remove/disable restore element so busy becomes last applied
if (elRestore && elRestore.parentNode) {
elRestore.parentNode.removeChild(elRestore);
}
elRestore = null;
const ln = ensureBusy();
ln.href = BUSY_HREF + '?t=' + Date.now();
} catch(_) {}
}
function disableBusy() {
try {
// Remove busy element
if (elBusy && elBusy.parentNode) {
elBusy.parentNode.removeChild(elBusy);
}
elBusy = null;
// Add/refresh restore element with cache-busting to force browser update
const ln = ensureRestore();
const base = DEFAULT_HREF || '/favicon-32x32.png';
ln.href = base + (base.includes('?') ? '&' : '?') + 't=' + Date.now();
} catch(_) {}
}
function inc(){ count = Math.max(0, count + 1); enableBusy(); }
function dec(){ count = Math.max(0, count - 1); if (count === 0) disableBusy(); }
function reset(){ count = 0; disableBusy(); }
return { inc, dec, reset };
})();
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;
// Busy-favicon bookkeeping regardless of DOM presence
try {
if (evt.event === 'node_start') { __busyFav.inc(); }
else if (evt.event === 'node_done' || evt.event === 'node_error') { __busyFav.dec(); }
} catch(_) {}
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);
try { window.markUpstreamError && window.markUpstreamError(nodeId); } catch(_) {}
}
}
// --- Мини‑панель логов HTTP -------------------------------------------------
const logs = [];
const logsById = new Map();
const pendingExtractByNode = new Map(); // временное хранилище Extract → прикрепим при node_done
let selectedLogId = null;
// Run-level aggregation state (между pipeline_start и pipeline_done)
let __runSeq = 0;
let __inRun = false;
let __stats = { perNode: new Map(), startedAt: 0, finishedAt: 0, durationMs: 0 };
function resetRunStats() {
__stats = { perNode: new Map(), startedAt: 0, finishedAt: 0, durationMs: 0 };
}
function incNodeStat(id, type, duration_ms) {
try {
if (!__inRun) return;
const key = String(id || '');
const cur = __stats.perNode.get(key) || { id: key, type: String(type || 'Node'), count: 0, duration_ms: 0 };
cur.count += 1;
if (typeof duration_ms === 'number' && Number.isFinite(duration_ms)) {
cur.duration_ms += Math.max(0, Math.round(duration_ms));
}
__stats.perNode.set(key, cur);
} catch (_) {}
}
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 logsDetail = document.getElementById('logs-detail');
// Manual resend editor state (edited/original per-log)
const btnReqSend = document.getElementById('logs-send');
const btnReqRevert = document.getElementById('logs-revert');
const __reqOriginalById = new Map();
const __reqEditedById = new Map();
function getSelectedLog() {
try { return selectedLogId ? logsById.get(selectedLogId) : null; } catch(_) { return null; }
}
function isHttpLog(it) {
try { return !!(it && (it.kind === 'http' || it.req)); } catch(_) { return false; }
}
function updateReqButtons() {
try {
const it = getSelectedLog();
const en = isHttpLog(it);
if (btnReqSend) btnReqSend.disabled = !en;
if (btnReqRevert) btnReqRevert.disabled = !en;
} catch(_) {}
}
if (reqPre) {
try { reqPre.setAttribute('contenteditable','true'); } catch(_) {}
reqPre.addEventListener('input', () => {
try { if (selectedLogId) __reqEditedById.set(selectedLogId, reqPre.innerText); } catch(_){}
});
}
if (btnReqSend) btnReqSend.addEventListener('click', async () => {
const it = getSelectedLog();
if (!isHttpLog(it)) return;
const reqText = (reqPre && reqPre.innerText!=null) ? reqPre.innerText : '';
const body = { req_id: it.id, request_text: reqText, prefer_registry_original: true };
try {
const res = await fetch('/admin/http/manual-send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
let j = null; try { j = await res.json(); } catch(_){}
try { status('Manual send: ' + (res.ok ? 'ok' : ('error ' + res.status)) + (j && j.req_id ? (' • new req=' + j.req_id) : '')); } catch(_){}
} catch (e) {
try { status('Manual send error'); } catch(_){}
}
});
if (btnReqRevert) btnReqRevert.addEventListener('click', () => {
const it = getSelectedLog();
if (!isHttpLog(it)) return;
try {
const orig = __reqOriginalById.get(it.id) || (it && it.req ? buildReqText(it.req) : '');
__reqEditedById.delete(it.id);
if (reqPre) reqPre.textContent = orig || '';
} catch(_) {}
});
// Простая изоляция выделения для Request/Response/Data: без pointer-events, без «замков»
(function simpleLogsSelectionIsolation(){
const detail = logsDetail || document.getElementById('logs-detail');
const req = reqPre, resp = respPre, data = dataPre;
if (!detail) return;
const DBG_SEL = false;
function setActive(pre) {
try {
if (!pre || !pre.id) return;
detail.setAttribute('data-active-pre', pre.id);
if (DBG_SEL) console.debug('[LogsSelect] active=', pre.id);
} catch(_){}
}
// По mousedown/click фиксируем активный pre
[req, resp, data].forEach(pre => {
if (!pre) return;
try { pre.setAttribute('tabindex','0'); } catch(_){}
pre.addEventListener('mousedown', () => setActive(pre), true);
pre.addEventListener('click', () => setActive(pre), true);
});
// Ctrl/Cmd+A — выбираем текст в активном pre (если панель логов открыта)
document.addEventListener('keydown', (e) => {
try {
const key = (e.key || '').toLowerCase();
if (!(e.ctrlKey || e.metaKey) || key !== 'a') return;
const t = e.target || document.activeElement;
const tag = t && t.tagName ? t.tagName.toLowerCase() : '';
const ce = t && (t.isContentEditable || (t.getAttribute && t.getAttribute('contenteditable') === 'true'));
if (tag === 'input' || tag === 'textarea' || ce) return;
const panelOpen = (typeof isLogsOpen === 'function') ? isLogsOpen() : false;
if (!panelOpen) return;
const activeId = detail.getAttribute('data-active-pre') || 'logs-req';
const pre = document.getElementById(activeId) || req || resp || data;
if (!pre) return;
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
const sel = window.getSelection && window.getSelection();
if (!sel) return;
const r = document.createRange();
r.selectNodeContents(pre);
sel.removeAllRanges();
sel.addRange(r);
if (DBG_SEL) console.debug('[LogsSelect] Ctrl+A →', activeId);
} catch(_){}
}, true);
// ESC — снимает «активный pre» (возвращает дефолт)
document.addEventListener('keydown', (e) => {
try {
if ((e.key || '') === 'Escape') {
detail.removeAttribute('data-active-pre');
if (DBG_SEL) console.debug('[LogsSelect] ESC clear');
}
} catch(_){}
}, true);
})();
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'); try { if (logsDetail) logsDetail.setAttribute('data-active-pre','logs-req'); } catch(_){} renderLogsList(); renderLogsDetail(selectedLogId); try { updateReqButtons && updateReqButtons(); } catch(_){} } }
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;
// Сброс агрегатов итераций
__inRun = false;
__runSeq = 0;
resetRunStats();
renderLogsList();
renderLogsDetail(null);
});
function fmtHeaders(h){
try {
if (window.AU && typeof window.AU.fmtHeaders === 'function') return window.AU.fmtHeaders(h);
} catch {}
try {
const keys = Object.keys(h||{});
return keys.map(k=>`${k}: ${String(h[k])}`).join('\n');
} catch { return ''; }
}
function buildReqText(x){
try {
if (window.AU && typeof window.AU.buildReqText === 'function') return window.AU.buildReqText(x);
} catch {}
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){
try {
if (window.AU && typeof window.AU.buildRespText === 'function') return window.AU.buildRespText(x);
} catch {}
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; }
function fmtMs(ms){
try {
const v = Number(ms);
if (!Number.isFinite(v) || v < 0) return '';
if (v >= 1000) return (v >= 10000 ? Math.round(v/1000) : (v/1000).toFixed(1)) + 's';
return Math.round(v) + 'ms';
} catch { return ''; }
}
function fmtTime(ts){
try {
const d = new Date(Number(ts || 0));
if (!(d instanceof Date) || isNaN(d.getTime())) return '';
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch { return ''; }
}
const rows = logs.slice().reverse().map(item=>{
const id = item.id;
const timeStr = fmtTime(item.ts);
const nm = (timeStr ? `<span class="t" style="opacity:.72;font-size:11px;margin-right:6px">${timeStr}</span>` : '') + `${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 === 'divider') {
classes.push('kind-divider');
const timeStr = item.ts ? new Date(item.ts).toLocaleTimeString() : '';
const dur = fmtMs(item.duration_ms);
return `<div class="${classes.join(' ')} logs-divider" data-id="${id}" style="padding:6px 10px;border-bottom:1px dashed #2b3646;color:#a7b0bf">
<div class="title">— Запуск #${item.run || '?'} завершён • ${dur || ''} —</div>
<div class="sub">${timeStr}</div>
</div>`;
}
if (item.kind === 'http') {
const hasResp = !!(item.res && item.res.status != null);
const stc = hasResp ? Number(item.res.status) : 0;
const ok = hasResp && stc >= 200 && stc < 400;
// Показать индикатор ожидания для активного HTTP
st = hasResp ? `${stc}` : ' • …';
classes.push('kind-http');
if (!hasResp) classes.push('http-pending');
else if (ok) classes.push('http-ok');
else classes.push('http-err');
} else if (item.kind === 'node') {
const ev = (item.ev ? String(item.ev) : '').toLowerCase();
const dur = (item.duration_ms!=null) ? ` (${fmtMs(item.duration_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 === 'while') {
const r = (item.result === true || item.result === false) ? (item.result ? 'true' : 'false') : '';
const idxStr = (typeof item.index === 'number') ? ` (#${item.index})` : '';
st = ` • while ${r}${idxStr}`;
classes.push('kind-while');
} else if (item.kind === 'return') {
st = ` • return ${item.target || ''}`;
classes.push('kind-return');
}
// время уже добавлено слева в nm
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; }
// Сводка по итерации (divider)
if (it.kind === 'divider') {
const lines = [];
lines.push(`Итерация #${it.run || '?'} завершена`);
if (typeof it.duration_ms === 'number') lines.push(`Время: ${Math.round(it.duration_ms)}ms`);
if (it.started_ts) lines.push(`Старт: ${new Date(it.started_ts).toLocaleTimeString()}`);
if (it.ts) lines.push(`Финиш: ${new Date(it.ts).toLocaleTimeString()}`);
const per = Array.isArray(it.per) ? it.per : [];
if (per.length) {
lines.push('');
lines.push('Статистика по нодам:');
per.slice(0, 20).forEach(x=>{
const cnt = (typeof x.count === 'number') ? x.count : 0;
const dur = (typeof x.duration_ms === 'number') ? `${Math.round(x.duration_ms)}ms` : '';
lines.push(` ${x.id} (${x.type||'Node'}): ${cnt}×, суммарно ${dur}`);
});
if (per.length > 20) lines.push(` ... ещё ${per.length - 20}`);
}
reqPre.textContent = lines.join('\n');
respPre.textContent = '';
if (dataPre) dataPre.textContent = '';
return;
}
if (it.kind === 'http' || (!it.kind && it.req)) {
// HTTP logs: show full structure, trim only base64 values; render images in Data
try {
const __origTxt = buildReqText(it && it.req);
try { __reqOriginalById.set(it.id, __origTxt); } catch(_){}
const __editedTxt = (typeof __reqEditedById !== 'undefined' && __reqEditedById && __reqEditedById.get) ? __reqEditedById.get(it.id) : null;
reqPre.textContent = (typeof __editedTxt === 'string') ? __editedTxt : __origTxt;
} catch(_) {
reqPre.textContent = buildReqText(it && it.req);
}
try { updateReqButtons && updateReqButtons(); } catch(_){}
if (it && it.res) {
const raw = it.res.body_text || '';
let shown = raw;
try {
const obj = JSON.parse(raw);
const isB64 = (s) => (typeof s === 'string' && s.length > 128 && /^[A-Za-z0-9+/=\r\n]+$/.test(s));
const trimB64 = (s) => s.length > 180 ? s.slice(0,180) + `... (trimmed ${s.length-180})` : s;
const walk = (x) => {
if (Array.isArray(x)) return x.map(walk);
if (x && typeof x === 'object') {
const out = {};
for (const k of Object.keys(x)) {
const v = x[k];
if (typeof v === 'string' && /data$/i.test(k) && isB64(v)) out[k] = trimB64(v);
else out[k] = walk(v);
}
return out;
}
return x;
};
shown = JSON.stringify(walk(obj), null, 2);
if (dataPre) {
// 1) Предпочитаем необрезанные картинки из SSE (it.res.images)
let imgs = (it && it.res && Array.isArray(it.res.images)) ? it.res.images.slice(0) : [];
// 2) Если SSE-изображений нет — пробуем разобрать тело JSON (может быть уже триммлено)
if (!imgs.length) {
const collected = [];
const collect = (o) => {
if (Array.isArray(o)) { o.forEach(collect); return; }
if (o && typeof o === 'object') {
if (o.inlineData && typeof o.inlineData === 'object' && typeof o.inlineData.data === 'string') {
const mime = o.inlineData.mimeType || o.inlineData.mime_type || 'image/png';
collected.push({ mime, data: o.inlineData.data });
}
if (o.inline_data && typeof o.inline_data === 'object' && typeof o.inline_data.data === 'string') {
const mime = o.inline_data.mimeType || o.inline_data.mime_type || 'image/png';
collected.push({ mime, data: o.inline_data.data });
}
Object.values(o).forEach(collect);
}
};
collect(obj);
imgs = collected;
}
// 3) Рендерим безопасно через DOM-узлы (не строковым HTML), чтобы исключить экранирование
try { dataPre.replaceChildren(); } catch (_) { dataPre.innerHTML = ''; }
if (imgs && imgs.length) {
const frag = document.createDocumentFragment();
imgs.slice(0, 6).forEach(im => {
const img = document.createElement('img');
img.src = `data:${(im && im.mime) || 'image/png'};base64,${(im && im.data) || ''}`;
img.style.maxWidth = '100%';
img.style.height = 'auto';
img.style.display = 'block';
img.style.margin = '6px 0';
frag.appendChild(img);
});
dataPre.appendChild(frag);
} else {
const p = (it.res && it.res.data_preview) ? String(it.res.data_preview) : '';
dataPre.textContent = p;
}
}
} catch {
if (dataPre) {
const p = (it.res && it.res.data_preview) ? String(it.res.data_preview) : '';
dataPre.textContent = p;
}
}
respPre.textContent = buildRespText({ ...it.res, body_text: shown });
} else {
respPre.textContent = '';
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 === 'while') {
const lines = [
`Node: ${it.node_type||'Node'} #${it.node_id||''}`,
`While: ${it.expr||''}`,
`Expanded: ${it.expanded||''}`,
(typeof it.index==='number' ? `Index: ${it.index}` : ''),
`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 === 'pipeline_start') {
__runSeq += 1;
__inRun = true;
resetRunStats();
__stats.startedAt = evt.ts || Date.now();
return;
}
if (evt.event === 'pipeline_done') {
__inRun = false;
__stats.finishedAt = evt.ts || Date.now();
__stats.durationMs = (typeof evt.duration_ms === 'number' && Number.isFinite(evt.duration_ms))
? Math.max(0, Math.round(evt.duration_ms))
: Math.max(0, (__stats.finishedAt - (__stats.startedAt || __stats.finishedAt)));
// Свернём карту в массив
const per = Array.from(__stats.perNode.values())
.sort((a,b)=> (b.count - a.count) || (b.duration_ms - a.duration_ms) || String(a.id).localeCompare(String(b.id)));
const row = {
id: `run-${__runSeq}-${__stats.finishedAt}`,
kind: 'divider',
run: __runSeq,
pipeline_id: evt.pipeline_id || '',
ts: __stats.finishedAt,
started_ts: __stats.startedAt,
duration_ms: __stats.durationMs,
per
};
logsById.set(row.id, row);
logs.push(row);
if (isLogsOpen()) renderLogsList();
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: evt.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, data_preview: evt.data_preview, images: evt.images, ts: evt.ts || Date.now() };
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: evt.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 === 'while_result') {
const id = `${evt.node_id}-while-${evt.ts || Date.now()}`;
const obj = {
id,
kind: 'while',
node_id: evt.node_id,
node_type: evt.node_type || 'Node',
ts: evt.ts || Date.now(),
expr: evt.expr,
expanded: evt.expanded,
result: !!evt.result,
index: (typeof evt.index === 'number' ? evt.index : undefined)
};
logsById.set(id, obj); logs.push(obj);
if (isLogsOpen()) renderLogsList();
} else if (evt.event === 'return_detail') {
try { __busyFav.reset(); } catch(_) {}
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))
};
// clear upstream error tint on start/done
try {
if (evt.event === 'node_start' || evt.event === 'node_done') {
window.clearUpstreamError && window.clearUpstreamError(evt.node_id);
}
} catch(_) {}
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
};
// Агрегация: учитываем выполненные ноды
if (obj.ev === 'done') {
try { incNodeStat(evt.node_id, obj.node_type, evt.duration_ms); } catch(_) {}
}
// Если это 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(_) {}
try { __busyFav.reset(); } catch(_) {}
// Завершаем все висящие HTTP-записи (убираем анимацию ожидания)
try {
const now = Date.now();
for (const it of logs) {
if (it && it.kind === 'http' && !it.res) {
it.res = { status: 0, headers: {}, body_text: 'Cancelled by user (abort)', ts: now, data_preview: 'cancelled' };
}
}
if (isLogsOpen()) { renderLogsList(); if (selectedLogId) renderLogsDetail(selectedLogId); }
} 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>
<script>
(function() {
try {
const dfRoot = document.getElementById('drawflow');
const ed = window.editor;
if (!dfRoot || !ed || typeof ed.getNodeFromId !== 'function') return;
// --- Soft Snap-to-Grid (Alt disables), Group Drag (Shift holds) ---
const SNAP_STEP = 12; // базовый шаг сетки (по ТЗ 812px → берём 12)
const SNAP_THRESH = SNAP_STEP; // сильный режим: во время драга всегда округлять к сетке
const STRONG_SNAP = true; // «жёсткий» снап в режиме перетаскивания
try { window.__GRID_STEP = SNAP_STEP; } catch(_) {} // проброс шага сетки в глобал для других модулей (Tidy)
let lastMods = { alt: false, ctrl: false, shift: false };
let dragging = false;
let snapApplying = false;
let movedIds = new Set(); const DBG_SNAP = true;
// Diagnostics: count nodeMoved frequency to detect storms after layout
let __mvCount = 0;
setInterval(() => {
const c = __mvCount;
if (c > 250) {
try { console.warn('[Diag] nodeMoved storm', c, '/0.5s'); } catch(_){}
try { (typeof status === 'function') && status('diag: nodeMoved storm ' + c + '/0.5s'); } catch(_){}
}
__mvCount = 0;
}, 500);
/* groupSession removed */
function updateModsFromEvent(e) {
try {
lastMods.alt = !!e.altKey;
lastMods.ctrl = !!e.ctrlKey;
lastMods.shift = !!e.shiftKey;
} catch (_) {}
}
// Безопасное обновление соединений и оверлеев после батча перемещений
function refreshAfterMove() {
try { if (typeof ed.updateConnectionNodesAll === 'function') ed.updateConnectionNodesAll(); } catch(_) {}
try { window.styleAllConnections && window.styleAllConnections(); } catch(_) {}
try { window.updateWireLabelsAndArrows && window.updateWireLabelsAndArrows(); } catch(_) {}
}
function snapNode(id) {
if (snapApplying) return;
if (lastMods.alt) return; // удержание Alt — отключает снап
try {
const n = ed.getNodeFromId(id);
if (!n) return;
const x = Number(n.pos_x), y = Number(n.pos_y);
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
const sx = Math.round(x / SNAP_STEP) * SNAP_STEP;
const sy = Math.round(y / SNAP_STEP) * SNAP_STEP;
// В «сильном» режиме во время перетаскивания всегда прилипать ровно к сетке
let nx, ny;
if (STRONG_SNAP && dragging) {
nx = sx; ny = sy;
} else {
const needX = Math.abs(sx - x) <= SNAP_THRESH;
const needY = Math.abs(sy - y) <= SNAP_THRESH;
nx = needX ? sx : x;
ny = needY ? sy : y;
}
if (nx === x && ny === y) return;
snapApplying = true;
try {
try { console.debug('[Snap]', { id, from: {x, y}, to: {nx, ny}, step: SNAP_STEP, thresh: SNAP_THRESH, strong: !!(STRONG_SNAP && dragging) }); } catch(_){}
ed.updateNodePosition(id, nx, ny);
try { ed.updateConnectionNodes && ed.updateConnectionNodes('node-' + id); } catch(_) {}
} finally {
snapApplying = false;
}
setTimeout(refreshAfterMove, 0);
} catch (_) {}
}
// Жёсткий снап при окончании перетаскивания: всегда округляем без порога
function snapNodeFinal(id) {
try {
if (lastMods.alt) return;
// Приведём id к числу для совместимости с Drawflow API
const intId = (typeof id === 'number') ? id : parseInt(id, 10);
if (!Number.isFinite(intId)) return;
const n = ed.getNodeFromId(intId);
if (!n) return;
const x = Number(n.pos_x) || 0;
const y = Number(n.pos_y) || 0;
const nx = Math.round(x / SNAP_STEP) * SNAP_STEP;
const ny = Math.round(y / SNAP_STEP) * SNAP_STEP;
const unchanged = (nx === x && ny === y);
try {
if (DBG_SNAP) console.debug('[Snap] final', {
id: intId,
from: { x, y },
to: { nx, ny },
alt: lastMods.alt,
unchanged
});
} catch(_) {}
if (unchanged) return;
// Жёсткое применение с усиленной верификацией:
// удерживаем snapApplying=true на всё окно ретраев, чтобы игнорировать промежуточные nodeMoved
const MAX_ATTEMPTS = 8; // усилили до 8 попыток
const RETRY_DELAY_MS = 18; // небольшой интервал между циклами
let attempt = 0;
const domRectForLog = () => {
try {
const el = document.getElementById('node-' + intId);
if (!el) return null;
const r = el.getBoundingClientRect();
return { l: Math.round(r.left), t: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) };
} catch (_) { return null; }
};
function forcePatchInternals() {
// 1) Патч модели Drawflow (если доступна)
try {
const df = ed && ed.drawflow && ed.drawflow.drawflow && ed.drawflow.drawflow.Home && ed.drawflow.drawflow.Home.data;
if (df && df[String(intId)]) {
df[String(intId)].pos_x = nx;
df[String(intId)].pos_y = ny;
}
} catch(_) {}
// 2) Прямой патч DOM-стиля (на случай гонки с библиотекой)
try {
const el = document.getElementById('node-' + intId);
if (el) { el.style.left = nx + 'px'; el.style.top = ny + 'px'; }
} catch(_) {}
}
function applyOnce() {
attempt += 1;
// Базовая установка позиций через публичный API
try {
ed.updateNodePosition(intId, nx, ny);
} catch(_) {}
try { ed.updateConnectionNodes && ed.updateConnectionNodes('node-' + intId); } catch(_) {}
// Двойной rAF — дожидаемся, пока библиотека применит свой финальный mouseup-апдейт
if (typeof window.nextRaf2 === 'function') {
window.nextRaf2(() => {
try {
const n2 = ed.getNodeFromId(intId);
const ax = Number(n2?.pos_x) || 0;
const ay = Number(n2?.pos_y) || 0;
let ok = (ax === nx && ay === ny);
const br = domRectForLog();
try {
if (DBG_SNAP) console.debug('[Snap] post-check try', {
id: intId,
attempt,
expected: { x: nx, y: ny },
actual: { x: ax, y: ay },
dom: br,
ok
});
} catch(_) {}
if (!ok) {
// Пытаемся «перебить» поздний write библиотеки прямым патчем модели и DOM
forcePatchInternals();
// Прочтём ещё раз актуальные координаты модели после патча
try {
const n3 = ed.getNodeFromId(intId);
const bx = Number(n3?.pos_x) || 0;
const by = Number(n3?.pos_y) || 0;
ok = (bx === nx && by === ny);
if (DBG_SNAP) console.debug('[Snap] force-patch check', { id: intId, expected: { x: nx, y: ny }, actual: { x: bx, y: by }, ok });
} catch(_) {}
}
if (!ok && attempt < MAX_ATTEMPTS) {
// Следующая попытка через небольшой интервал — даём библиотеке полностью «выписаться»
setTimeout(() => { applyOnce(); }, RETRY_DELAY_MS);
} else {
if (!ok) {
try { console.warn('[Snap] post-check failed after attempts', { id: intId, attempts: attempt }); } catch(_) {}
} else {
// Успех: финальный рефреш соединений и HUD
setTimeout(refreshAfterMove, 0);
}
snapApplying = false;
}
} catch(_) {
snapApplying = false;
}
});
} else {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
try {
const n2 = ed.getNodeFromId(intId);
const ax = Number(n2?.pos_x) || 0;
const ay = Number(n2?.pos_y) || 0;
let ok = (ax === nx && ay === ny);
const br = domRectForLog();
try {
if (DBG_SNAP) console.debug('[Snap] post-check try', {
id: intId,
attempt,
expected: { x: nx, y: ny },
actual: { x: ax, y: ay },
dom: br,
ok
});
} catch(_) {}
if (!ok) {
// Пытаемся «перебить» поздний write библиотеки прямым патчем модели и DOM
forcePatchInternals();
// Прочтём ещё раз актуальные координаты модели после патча
try {
const n3 = ed.getNodeFromId(intId);
const bx = Number(n3?.pos_x) || 0;
const by = Number(n3?.pos_y) || 0;
ok = (bx === nx && by === ny);
if (DBG_SNAP) console.debug('[Snap] force-patch check', { id: intId, expected: { x: nx, y: ny }, actual: { x: bx, y: by }, ok });
} catch(_) {}
}
if (!ok && attempt < MAX_ATTEMPTS) {
// Следующая попытка через небольшой интервал — даём библиотеке полностью «выписаться»
setTimeout(() => { applyOnce(); }, RETRY_DELAY_MS);
} else {
if (!ok) {
try { console.warn('[Snap] post-check failed after attempts', { id: intId, attempts: attempt }); } catch(_) {}
} else {
// Успех: финальный рефреш соединений и HUD
setTimeout(refreshAfterMove, 0);
}
snapApplying = false;
}
} catch(_) {
snapApplying = false;
}
});
});
}
}
// Стартуем с флагом защиты от re-entrancy и многошаговой проверкой
snapApplying = true;
applyOnce();
} catch(_) {
// safety: сбросить флаг в случае неожиданного исключения
try { snapApplying = false; } catch(__) {}
}
}
/* Group drag removed */
// Слежение за модификаторами
window.addEventListener('keydown', (e) => updateModsFromEvent(e), { passive: true });
window.addEventListener('keyup', (e) => updateModsFromEvent(e), { passive: true });
// Pointer: фиксируем модификаторы и фазы драга
dfRoot.addEventListener('pointerdown', (e) => {
dragging = true;
updateModsFromEvent(e);
try { if (DBG_SNAP) console.debug('[Snap] pointerdown', { alt: lastMods.alt, ctrl: lastMods.ctrl, shift: lastMods.shift }); } catch(_){}
}, { passive: true, capture: true });
dfRoot.addEventListener('pointermove', (e) => { updateModsFromEvent(e); }, { passive: true, capture: true });
function onPointerUp(e) {
updateModsFromEvent(e || {});
dragging = false;
// Логируем состояние на момент pointerup (могут быть пустые ids из‑за запаздывающих nodeMoved)
try {
const pre = Array.from(movedIds);
try { if (DBG_SNAP) console.debug('[Snap] pointerup', { movedCount: pre.length, ids: pre.slice(0, 20) }); } catch(_){}
} catch(_) {}
// Отложенный «флаш» через два rAF: даём Drawflow доэмитить tail nodeMoved
try { if (typeof window.__snapFlushPlanned !== 'boolean') window.__snapFlushPlanned = false; } catch(_) {}
if (window.__snapFlushPlanned) return;
window.__snapFlushPlanned = true;
try {
try {
if (typeof window.nextRaf2 === 'function') {
window.nextRaf2(() => {
try {
const idsNow = Array.from(movedIds);
try { if (DBG_SNAP) console.debug('[Snap] flush@rAF2', { movedCount: idsNow.length, ids: idsNow.slice(0, 20) }); } catch(_){}
movedIds.clear();
idsNow.forEach(snapNodeFinal);
} catch(_) {}
try { window.__snapFlushPlanned = false; } catch(_) {}
});
} else {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
try {
const idsNow = Array.from(movedIds);
try { if (DBG_SNAP) console.debug('[Snap] flush@rAF2', { movedCount: idsNow.length, ids: idsNow.slice(0, 20) }); } catch(_){}
movedIds.clear();
idsNow.forEach(snapNodeFinal);
} catch(_) {}
try { window.__snapFlushPlanned = false; } catch(_) {}
});
});
}
} catch(_) {}
} catch(_) {
// Fallback: если rAF недоступен — выполняем немедленный флаш
try {
const idsNow = Array.from(movedIds);
movedIds.clear();
idsNow.forEach(snapNodeFinal);
try { if (DBG_SNAP) console.debug('[Snap] flush@fallback', { movedCount: idsNow.length }); } catch(_){}
} catch(__) {}
try { window.__snapFlushPlanned = false; } catch(_) {}
}
}
dfRoot.addEventListener('pointerup', onPointerUp, { passive: true, capture: true });
window.addEventListener('pointerup', onPointerUp, { passive: true, capture: true });
// Хук на перемещение ноды: копим id для снапа и обрабатываем групповой сдвиг
try {
ed.on('nodeMoved', (id) => {
try {
__mvCount++;
if (window.__suspendHud) return;
if (snapApplying) return;
// Копим для финального снапа
movedIds.add(id);
// «Жёсткий» liveснап во время перетаскивания (если Alt не зажат)
if (STRONG_SNAP && !lastMods.alt && dragging && !snapApplying) {
try { snapNode(id); } catch(_) {}
}
try {
if (DBG_SNAP) console.debug('[Snap] nodeMoved', {
id,
suspend: !!window.__suspendHud,
snapApplying,
movedCount: movedIds.size,
live: !!(STRONG_SNAP && !lastMods.alt && dragging)
});
} catch(_){}
} catch (_) {}
});
} catch (_) {}
// На всякий случай: если библиотека не даёт pointerup (редко) — периодический фолбэк снапа вне драга
setInterval(() => {
try {
if (dragging || window.__suspendHud) return;
if (!movedIds.size) return;
const ids = Array.from(movedIds);
try { if (DBG_SNAP) console.debug('[Snap] fallback', { movedCount: ids.length }); } catch(_){}
movedIds.clear();
ids.forEach(snapNodeFinal);
} catch (_) {}
}, 350);
} catch (_) {}
})();
</script>
</body>
</html>