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