4809 lines
238 KiB
HTML
4809 lines
238 KiB
HTML
<!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="Температура выборки (0–1)">[[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 (best‑effort, вытаскивает 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); // 1‑based
|
||
}
|
||
|
||
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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>');
|
||
}
|
||
|
||
// Нормализуем/заполняем дефолты конфигов нод, чтобы ключи попадали в сериализацию
|
||
function applyNodeDefaults(type, data) {
|
||
const d = { ...(data || {}) };
|
||
if (type === 'ProviderCall') {
|
||
if (d.provider == null) d.provider = 'openai';
|
||
ensureProviderConfigs(d);
|
||
// Back-compat: top-level fields may exist, but UI prefers provider_configs
|
||
if (!Array.isArray(d.blocks)) d.blocks = [];
|
||
// sleep_ms: не выставляем по умолчанию, чтобы «выключенный» sleep отсутствовал в config
|
||
}
|
||
if (type === 'RawForward') {
|
||
if (d.passthrough_headers == null) d.passthrough_headers = true;
|
||
if (d.extra_headers == null) d.extra_headers = '{}';
|
||
// sleep_ms: не выставляем по умолчанию, чтобы «выключенный» sleep отсутствовал в config
|
||
}
|
||
if (type === 'SetVars') {
|
||
if (!Array.isArray(d.variables)) d.variables = [];
|
||
}
|
||
if (type === 'If') {
|
||
if (d.expr == null) d.expr = '';
|
||
}
|
||
if (type === 'Return') {
|
||
if (d.target_format == null) d.target_format = 'auto';
|
||
if (d.text_template == null) d.text_template = '[[OUT1]]';
|
||
}
|
||
return d;
|
||
}
|
||
|
||
const status = (t) => { document.getElementById('status').textContent = t; };
|
||
// Runtime CSS availability check
|
||
try {
|
||
const linkEl = document.querySelector('link[href$="editor.css"]');
|
||
if (linkEl) {
|
||
const href = linkEl.getAttribute('href');
|
||
fetch(href, { method: 'HEAD' })
|
||
.then(r => {
|
||
console.debug('[НадTavern] CSS HEAD', href, r.status);
|
||
if (!r.ok) {
|
||
try { status('CSS not reachable: ' + href + ' ' + r.status); } catch (e) {}
|
||
}
|
||
})
|
||
.catch(e => { try { status('CSS load error: ' + String(e)); } catch (e2) {} });
|
||
}
|
||
} catch (e) {}
|
||
// Diagnostics to validate branding and font override
|
||
try {
|
||
console.debug('[Brand] document.title =', document.title);
|
||
const ff = getComputedStyle(document.body).fontFamily;
|
||
console.debug('[Font] body.fontFamily =', ff);
|
||
} catch (_) {}
|
||
|
||
function makeNodeHtml(type, data) {
|
||
const shownId = escText(data && data._origId ? String(data._origId) : '');
|
||
const head = `<div class="title-box" data-title="${escAttr(type)}${shownId ? ' #'+shownId : ''}"><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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
// Утилита: разворачивает объект в пары [путь, строковое значение]
|
||
// 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+16A0–U+16FF (Runic)
|
||
const RUNE_RE = /[\u16A0-\u16FF]+/g;
|
||
function escHtml(s) {
|
||
return String(s ?? '')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>');
|
||
}
|
||
// Оборачиваем рунические символы в уже экранированной строке
|
||
function wrapRunesEscaped(escaped) {
|
||
return escaped.replace(RUNE_RE, (m) => '<span class="rune">' + m + '</span>');
|
||
}
|
||
|
||
// Розовый хайлайт для фразы «сестричек сисунь» + случайные «девчачьи» эмодзи
|
||
const EMOJI_GIRLY = ['💖','✨','🌸','💅','👑','🩷','🌷','🦋'];
|
||
function pickTwo(arr) {
|
||
const pool = arr.slice();
|
||
const a = pool.splice((Math.random()*pool.length)|0, 1)[0] || arr[0];
|
||
const b = pool.splice((Math.random()*pool.length)|0, 1)[0] || a;
|
||
return [a, b];
|
||
}
|
||
function markSisters(src) {
|
||
try {
|
||
return String(src || '').replace(/сестричек\s+сисунь/gi, m => `[sisters]${m}[/sisters]`);
|
||
} catch { return String(src || ''); }
|
||
}
|
||
|
||
// Рендер безопасного HTML, поддержка маркеров [rune]...[/rune], [sisters]...[/sisters]
|
||
function renderBulletHTML(text) {
|
||
const raw = markSisters(String(text ?? ''));
|
||
const hasMarkers = /\[(rune|sisters)\]/i.test(raw);
|
||
if (hasMarkers) {
|
||
let out = '';
|
||
let last = 0;
|
||
const re = /\[(rune|sisters)\]([\s\S]+?)\[\/(rune|sisters)\]/gi;
|
||
let m;
|
||
while ((m = re.exec(raw)) !== null) {
|
||
// незамеченная часть — экранировать и автоподсветить руны
|
||
out += wrapRunesEscaped(escHtml(raw.slice(last, m.index)));
|
||
const open = (m[1] || '').toLowerCase();
|
||
const close = (m[3] || '').toLowerCase();
|
||
const inner = String(m[2] ?? '');
|
||
if (open === close) {
|
||
if (open === 'rune') {
|
||
out += '<span class="rune">' + escHtml(inner) + '</span>';
|
||
} else if (open === 'sisters') {
|
||
const [e1, e2] = pickTwo(EMOJI_GIRLY);
|
||
out += '<span class="sisters">' + escHtml(inner) + ' ' + e1 + ' ' + e2 + '</span>';
|
||
}
|
||
} else {
|
||
// на всякий случай — экранируем весь блок, если теги не совпали
|
||
out += wrapRunesEscaped(escHtml(m[0]));
|
||
}
|
||
last = m.index + m[0].length;
|
||
}
|
||
out += wrapRunesEscaped(escHtml(raw.slice(last)));
|
||
return out;
|
||
}
|
||
// Без маркеров — экранировать и автоподсветить руны
|
||
return wrapRunesEscaped(escHtml(raw));
|
||
}
|
||
|
||
function spawn(text) {
|
||
const el = document.createElement('div');
|
||
el.className = 'danmaku-bullet ' + sizes[(Math.random()*sizes.length)|0] + ' ' + tints[(Math.random()*tints.length)|0];
|
||
// Вставляем безопасный HTML с подсветкой рун и розовым акцентом фразы «сестричек сисунь»
|
||
el.innerHTML = renderBulletHTML(text);
|
||
const vpH = (layer && layer.clientHeight) || window.innerHeight || document.documentElement.clientHeight || 600;
|
||
const top = Math.random() * Math.max(40, vpH - 40);
|
||
el.style.top = Math.max(2, Math.min(vpH - 24, top)) + 'px';
|
||
const dur = 9 + Math.random() * 7; // 9–16s
|
||
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; // базовый шаг сетки (по ТЗ 8–12px → берём 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> |