Files
HadTavern/static/editor.html
2025-09-11 17:27:15 +03:00

1268 lines
66 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>НадTavern — Визуальный редактор нод</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/drawflow@0.0.55/dist/drawflow.min.css" />
<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; font-family: Inter, Arial, sans-serif; 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 .drawflow-node .title { background: transparent !important; color: inherit !important; }
.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>
<strong>НадTavern</strong> — Визуальный редактор нод
</div>
<div class="actions">
<button id="btn-load">Загрузить пайплайн</button>
<button id="btn-save">Сохранить пайплайн</button>
<input id="preset-name" placeholder="имя пресета" style="width:140px"/>
<button id="btn-save-preset">Сохранить пресет</button>
<select id="preset-select" style="width:160px"></select>
<button id="btn-load-preset">Загрузить пресет</button>
<!-- Pipeline execution settings (no manual JSON edits needed) -->
<select id="loop-mode" title="Режим исполнения" style="width:120px;margin-left:8px">
<option value="dag">dag</option>
<option value="iterative">iterative</option>
</select>
<input id="loop-iters" type="number" min="1" step="1" title="loop_max_iters" placeholder="max iters" style="width:110px" />
<input id="loop-budget" type="number" min="1" step="1" title="loop_time_budget_ms" placeholder="budget ms" style="width:130px" />
<label title="Очищать сторадж переменных перед запуском" style="display:inline-flex;align-items:center;gap:6px;margin-left:6px">
<input id="clear-var-store" type="checkbox" checked />
clear vars
</label>
<button id="btn-vars">Переменные</button>
<a href="/" style="text-decoration:none"><button>Домой</button></a>
</div>
</header>
<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>
<div class="hint">Используйте переменные в шаблонах как <code>[[variable]]</code>. Наведите курсор на имя переменной, чтобы увидеть подсказку.</div>
<div class="hint"><strong>Общие:</strong>
<code title="Системные инструкции для LLM">[[system]]</code>,
<code title="Имя выбранной модели">[[model]]</code>,
<code title="Формат ответа провайдера (например, OpenAI, Gemini)">[[vendor_format]]</code>
</div>
<div class="hint"><strong>Чат:</strong>
<code title="Последнее сообщение пользователя">[[chat.last_user]]</code>,
<code title="Все сообщения в чате">[[chat.messages]]</code>
</div>
<div class="hint"><strong>Параметры:</strong>
<code title="Температура выборки (01)">[[params.temperature]]</code>,
<code title="Максимальное число токенов">[[params.max_tokens]]</code>,
<code title="Вероятностный срез top-p sampling">[[params.top_p]]</code>,
<code title="Стоп-слова/условия обрыва генерации">[[params.stop]]</code>
</div>
<div class="hint"><strong>Входящий запрос:</strong>
<code title="Путь в URL запроса">[[incoming.path]]</code>,
<code title="Query-параметры запроса">[[incoming.query]]</code>
</div>
<div class="hint"><strong>Заголовки/тело:</strong>
<code title="Все заголовки HTTP-запроса">[[incoming.headers]]</code>,
<code title="JSON-тело входящего запроса">[[incoming.json]]</code>
</div>
<div class="hint"><strong>Ключи (API Keys):</strong>
<code title="Основной ключ авторизации (например Authorization: Bearer ...)">[[incoming.api_keys.authorization]]</code>,
<code title="Альтернативное имя ключа, если используется">[[incoming.api_keys.key]]</code>
</div>
<div class="hint"><strong>Быстрые макросы:</strong>
<code title="Единый JSONфрагмент из Prompt Blocks (подставляется провайдер‑специфично)">[[PROMPT]]</code>,
<code title="Текст из выхода ноды n1 (besteffort, вытаскивает content/text из JSON ответа)">[[OUT1]]</code>,
<code title="Текст из выхода ноды n2">[[OUT2]]</code>,
<code title="Пользовательская переменная, заданная в SetVars">[[NAME]]</code>
<span style="opacity:.85"> | Расширенно: <code>[[OUT:n1.result...]]</code> или <code>{{ OUT.n1.result... }}</code></span>
</div>
<div class="group-title">Отладка</div>
<pre id="status"></pre>
</aside>
<main id="canvas">
<div id="drawflow"></div>
<!-- Vars Popover -->
<div id="vars-popover" style="display:none;position:absolute;top:24px;right:24px;z-index:9999;background:#0f141a;border:1px solid #2b3646;border-radius:10px;min-width:420px;max-width:560px;max-height:60vh;overflow:auto;box-shadow:0 6px 28px rgba(0,0,0,.45)">
<div style="display:flex;align-items:center;gap:8px;padding:10px;border-bottom:1px solid #2b3646">
<strong style="flex:1">Переменные (STORE)</strong>
<input id="vars-search" placeholder="поиск по имени/значению" style="flex:2"/>
<select id="vars-scope" title="Источник значений" style="flex:0 0 120px">
<option value="vars">vars</option>
<option value="snapshot">snapshot</option>
<option value="all">all</option>
</select>
<label title="Вставлять макрос фигурными {{ store.KEY }} " style="display:inline-flex;align-items:center;gap:6px;font-size:12px;color:#a7b0bf">
фигурные
<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" style="padding:8px 10px;border-bottom:1px solid #2b3646;color:#a7b0bf">Клик по строке копирует макрос в буфер обмена</div>
<div id="vars-list" style="padding:8px 0"></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=3"></script>
<script src="/ui/js/pm-ui.js?v=3"></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 = [];
}
if (type === 'RawForward') {
if (d.passthrough_headers == null) d.passthrough_headers = true;
if (d.extra_headers == null) d.extra_headers = '{}';
}
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) {}
function makeNodeHtml(type, data) {
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 `<div class="box 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>`;
}
if (type === 'If') {
const expr = data.expr || '';
return `<div class="box preview">
<label>expr</label>
<textarea readonly>${escText(expr)}</textarea>
<div class="hint">Поддерживается: [[...]], {{ ... }}, contains, &&, ||, !, ==, !=, <, <=, >, >=</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 `<div class="box 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>`;
}
if (type === 'SetVars') {
const vars = Array.isArray(data.variables) ? data.variables : [];
const names = vars.map(v => v?.name || '').filter(Boolean);
return `<div class="box preview">
<label>variables</label>
<textarea readonly>${escText(names.length ? names.join(', ') : '(нет переменных)')}</textarea>
<div class="hint">В шаблонах доступны как [[NAME]] и {{ NAME }}.</div>
</div>`;
}
if (type === 'Return') {
const tgt = (data.target_format || 'auto');
const tmpl = (data.text_template != null ? data.text_template : '[[OUT1]]');
return `<div class="box preview">
<label>target_format</label>
<input type="text" value="${escAttr(tgt)}" readonly />
<label>text_template (preview)</label>
<textarea readonly>${escText(tmpl)}</textarea>
</div>`;
}
return `<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) {
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>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">${escText(cfg.headers||'{}')}</textarea>
<label>template (JSON)</label>
<textarea id="f-template">${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>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">${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="vars-list">${rows || '<div class="hint">(нет переменных)</div>'}</div>
<div style="margin-top:8px">
<button id="vars-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;
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
// Синхронизуем в 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;
// Синхронизуем в 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('vars-list');
const addBtn = document.getElementById('vars-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 || {}));
}
if (addBtn) {
addBtn.addEventListener('click', () => {
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) {
root.querySelectorAll('.var-row').forEach(row => {
const idx = parseInt(row.getAttribute('data-idx') || '-1', 10);
const nameInp = row.querySelector('.v-name');
const modeSel = row.querySelector('.v-mode');
const valTxt = row.querySelector('.v-value');
const delBtn = row.querySelector('.v-del');
if (nameInp) nameInp.addEventListener('input', () => {
const nn = editor.getNodeFromId(id); if (!nn) return;
const d = nn.data || {}; if (!Array.isArray(d.variables)) d.variables = [];
if (d.variables[idx]) d.variables[idx].name = nameInp.value;
resync();
});
if (modeSel) modeSel.addEventListener('change', () => {
const nn = editor.getNodeFromId(id); if (!nn) return;
const d = nn.data || {}; if (!Array.isArray(d.variables)) d.variables = [];
if (d.variables[idx]) d.variables[idx].mode = modeSel.value;
resync();
});
if (valTxt) valTxt.addEventListener('input', () => {
const nn = editor.getNodeFromId(id); if (!nn) return;
const d = nn.data || {}; if (!Array.isArray(d.variables)) d.variables = [];
if (d.variables[idx]) d.variables[idx].value = valTxt.value;
resync();
});
if (delBtn) delBtn.addEventListener('click', () => {
const nn = editor.getNodeFromId(id); if (!nn) return;
const d = nn.data || {}; if (!Array.isArray(d.variables)) d.variables = [];
d.variables.splice(idx, 1);
try { editor.updateNodeDataFromId(id, d); } catch (e) {}
resync();
renderInspector(id, editor.getNodeFromId(id));
});
});
}
}
// Поддержка 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('vars-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 { initPipelineMetaControls(); } catch (e) {}
// Не затираем логи, которые вывел fromPipelineJSON
const st = document.getElementById('status').textContent;
if (!st) status('Загружено');
}
async function savePipeline() {
try {
const p = window.AgentUISer.toPipelineJSON();
const res = await fetch('/admin/pipeline', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(p) });
const out = await res.json();
status('Сохранено: ' + JSON.stringify(out) + ' | nodes=' + (p.nodes?.length || 0));
} catch (err) {
status('Ошибка сохранения пайплайна: ' + (err?.message || String(err)));
}
}
async function refreshPresets() {
const res = await fetch('/admin/presets');
const j = await res.json();
const sel = document.getElementById('preset-select');
sel.innerHTML = '';
(j.items||[]).forEach(name => {
const opt = document.createElement('option');
opt.value = name; opt.textContent = name; sel.appendChild(opt);
});
}
// --- Vars popover helpers ---
(function(){
const $ = (sel) => document.querySelector(sel);
const box = $('#vars-popover');
const listEl = $('#vars-list');
const searchEl = $('#vars-search');
const scopeEl = $('#vars-scope');
const bracesEl = $('#vars-mode-braces');
const infoEl = $('#vars-info');
const btnOpen = $('#btn-vars');
const btnClose = $('#vars-close');
const btnRefresh = $('#vars-refresh');
const btnClear = $('#vars-clear');
function setInfo(msg) { try { infoEl.textContent = msg; } catch(e){} }
async function fetchVars() {
try {
const res = await fetch('/admin/vars');
const j = await res.json();
return j && j.store ? j.store : {};
} catch (e) {
setInfo('Ошибка загрузки переменных');
return {};
}
}
function macroFor(path, kind) {
const useBraces = !!(bracesEl && bracesEl.checked);
const p = String(path || '');
// VARS: копируем «канонические» макросы, доступные в шаблонах
if (kind === 'vars') {
return useBraces ? `{{ ${p} }}` : `[[${p}]]`;
}
// SNAPSHOT: маппим на реальные макросы контекста (без STORE)
// OUT1, OUT2, ... → [[OUTx]] или {{ OUT.nX.response_text }}
const mAlias = p.match(/^OUT(\d+)$/i);
if (mAlias) {
const n = mAlias[1];
return useBraces ? `{{ OUT.n${n}.response_text }}` : `[[OUT${n}]]`;
}
// OUT_TEXT.nX → [[OUTx]] или {{ OUT.nX.response_text }}
const mTxt = p.match(/^OUT_TEXT\.n(\d+)$/i);
if (mTxt) {
const n = mTxt[1];
return useBraces ? `{{ OUT.n${n}.response_text }}` : `[[OUT${n}]]`;
}
// OUT.nX.something → {{ OUT.nX.something }} или [[OUT:nX.something]]
if (p.startsWith('OUT.')) {
const body = p.slice(4);
return useBraces ? `{{ OUT.${body} }}` : `[[OUT:${body}]]`;
}
// Общий контекст: incoming.*, params.*, model, vendor_format, system
const roots = ['incoming','params','model','vendor_format','system'];
const root = p.split('.')[0];
if (roots.includes(root)) {
return useBraces ? `{{ ${p} }}` : `[[VAR:${p}]]`;
}
// Fallback: трактуем как путь контекста
return useBraces ? `{{ ${p} }}` : `[[VAR:${p}]]`;
}
function escapeHtml(s) {
return String(s ?? '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, '&#39;');
}
// Утилита: разворачивает объект в пары [путь, строковое значение]
function flattenObject(obj, prefix = '') {
const out = [];
if (obj == null) return out;
if (typeof obj !== 'object') {
out.push([prefix, String(obj)]);
return out;
}
const entries = Object.entries(obj);
for (const [k, v] of entries) {
const p = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === 'object' && !Array.isArray(v)) {
// Спец-форма превью от бекенда
if (Object.prototype.hasOwnProperty.call(v, '__truncated__') && Object.prototype.hasOwnProperty.call(v, 'preview')) {
out.push([p, String(v.preview ?? '')]);
continue;
}
out.push(...flattenObject(v, p));
} else {
try {
const s = (typeof v === 'string') ? v : JSON.stringify(v, null, 0);
out.push([p, s]);
} catch {
out.push([p, String(v)]);
}
}
}
return out;
}
function renderList(storeObj) {
const q = (searchEl && searchEl.value || '').toLowerCase().trim();
const scope = (scopeEl && scopeEl.value) || 'vars';
const items = [];
// vars: все ключи из STORE кроме 'snapshot'
if (scope === 'vars' || scope === 'all') {
const keys = Object.keys(storeObj || {}).filter(k => k !== 'snapshot').sort((a,b)=>a.localeCompare(b,'ru'));
for (const k of keys) {
try {
const v = storeObj[k];
const vStr = typeof v === 'string' ? v : JSON.stringify(v, null, 0);
if (q && !(k.toLowerCase().includes(q) || vStr.toLowerCase().includes(q))) continue;
items.push({k, v: vStr, kind: 'vars'});
} catch { /* ignore */ }
}
}
// snapshot: плоский список из STORE.snapshot
if (scope === 'snapshot' || scope === 'all') {
const snap = (storeObj && storeObj.snapshot) || {};
const flat = flattenObject(snap, '');
for (const [k, v] of flat) {
if (!k) continue;
const vStr = String(v ?? '');
if (q && !(k.toLowerCase().includes(q) || vStr.toLowerCase().includes(q))) continue;
items.push({k, v: vStr, kind: 'snapshot'});
}
}
if (!items.length) {
listEl.innerHTML = `<div class="hint" style="padding:10px;color:#a7b0bf">Переменные не найдены</div>`;
return;
}
const rows = items.map(({k,v,kind}) => {
const macro = macroFor(k, kind);
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\./, '');
})();
return `
<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)}</div>
</div>
`;
}).join('');
listEl.innerHTML = rows;
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 ()=>{
try { box.style.display = 'block'; } catch(_){}
setInfo('Клик по строке копирует макрос. Поиск работает по имени и содержимому.');
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);
})();
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);
}
}
// Bind top-level pipeline meta controls to AgentUISer meta store
function initPipelineMetaControls() {
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 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 (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,
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 (chkClear) chkClear.addEventListener('change', pushMeta);
} catch (e) {}
}
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 { initPipelineMetaControls(); } catch (e) {}
loadPipeline();
refreshPresets();
</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_error') {
el.classList.remove('node-running');
addTempClass(el, 'node-err', 2500);
}
}
// Открываем SSE поток
const es = new EventSource('/admin/trace/stream');
es.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
handleTraceEvent(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>