982 lines
50 KiB
HTML
982 lines
50 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" />
|
||
<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" />
|
||
</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>
|
||
<a href="/" style="text-decoration:none"><button>Домой</button></a>
|
||
</div>
|
||
</header>
|
||
<div id="container">
|
||
<aside id="sidebar">
|
||
<div class="group-title">Ноды</div>
|
||
<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>
|
||
<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="Температура выборки (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>,
|
||
<code title="Вторичный ключ или секрет, если задан">[[incoming.api_keys.secret]]</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>
|
||
<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>
|
||
</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>
|
||
// Типы портов и их имена в нашем контракте
|
||
const NODE_IO = {
|
||
// depends: используется только для порядка выполнения (зависимости), данные не читаются
|
||
ProviderCall: { inputs: ['depends'], outputs: ['result','response_text'] },
|
||
RawForward: { inputs: [], outputs: ['result'] }
|
||
};
|
||
|
||
const editor = new Drawflow(document.getElementById('drawflow'));
|
||
editor.reroute = true;
|
||
editor.start();
|
||
|
||
// Провайдерные пресеты для 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] || {};
|
||
}
|
||
|
||
// Нормализуем/заполняем дефолты конфигов нод, чтобы ключи попадали в сериализацию
|
||
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 = '{}';
|
||
}
|
||
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="${provider}" readonly />
|
||
<label>base_url</label>
|
||
<input type="text" value="${base_url.replace(/"/g,'"')}" readonly />
|
||
<label>endpoint</label>
|
||
<input type="text" value="${endpoint.replace(/"/g,'"')}" readonly />
|
||
<label>headers (preview JSON)</label>
|
||
<textarea readonly>${headers.replace(/</g,'<')}</textarea>
|
||
<label>template (preview JSON)</label>
|
||
<textarea readonly>${template.replace(/</g,'<')}</textarea>
|
||
</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="${base_url.replace(/"/g,'"')}" readonly />
|
||
<label>override_path</label>
|
||
<input type="text" value="${override_path.replace(/"/g,'"')}" readonly />
|
||
<label><input type="checkbox" ${passthrough_headers} disabled/> passthrough_headers</label>
|
||
<label>extra_headers (preview JSON)</label>
|
||
<textarea readonly>${extra_headers.replace(/</g,'<')}</textarea>
|
||
</div>`;
|
||
}
|
||
return `<div class="box"></div>`;
|
||
}
|
||
|
||
function addNode(type, pos = {x: 100, y: 100}, data = {}) {
|
||
const io = NODE_IO[type];
|
||
const dataWithDefaults = applyNodeDefaults(type, data);
|
||
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="${(cfg.base_url||'').replace(/"/g,'"')}" placeholder="https://api.openai.com">
|
||
<label>endpoint</label><input id="f-endpoint" type="text" value="${(cfg.endpoint||'').replace(/"/g,'"')}" placeholder="/v1/chat/completions">
|
||
<label>headers (JSON)</label><textarea id="f-headers">${(cfg.headers||'{}').replace(/</g,'<')}</textarea>
|
||
<label>template (JSON)</label>
|
||
<textarea id="f-template">${(cfg.template||'{}').replace(/</g,'<')}</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 === 'RawForward') {
|
||
html += `
|
||
<label>base_url</label><input id="f-baseurl" type="text" value="${(data.base_url||'').replace(/"/g,'"')}" placeholder="https://api.openai.com">
|
||
<label>override_path</label><input id="f-override" type="text" value="${(data.override_path||'').replace(/"/g,'"')}" 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">${(data.extra_headers||'{}').replace(/</g,'<')}</textarea>
|
||
<div class="hint">Берёт path, query, headers, json из incoming.*</div>
|
||
`;
|
||
}
|
||
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}`);
|
||
if (el) el.__data = node.data; // синхронизация
|
||
document.querySelectorAll('#inspector textarea, #inspector input').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
|
||
} else {
|
||
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;
|
||
});
|
||
});
|
||
|
||
// Поддержка 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) {}
|
||
});
|
||
}
|
||
|
||
// Кнопка сохранить параметры
|
||
const saveBtnNode = document.getElementById('btn-save-node');
|
||
if (saveBtnNode) {
|
||
saveBtnNode.addEventListener('click', () => {
|
||
const n = editor.getNodeFromId(id);
|
||
if (!n) return;
|
||
try { editor.updateNodeDataFromId(id, n.data || {}); } catch (e) {}
|
||
const el = document.querySelector(`#node-${id}`);
|
||
if (el) el.__data = JSON.parse(JSON.stringify(n.data || {}));
|
||
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];
|
||
}
|
||
|
||
// Prompt Manager UI for ProviderCall
|
||
if (type === 'ProviderCall') {
|
||
const n2 = editor.getNodeFromId(id);
|
||
const d2 = n2.data;
|
||
if (!Array.isArray(d2.blocks)) d2.blocks = [];
|
||
// Ensure node.data and DOM __data always reflect latest blocks
|
||
function syncNodeDataBlocks() {
|
||
try {
|
||
const n = editor.getNodeFromId(id);
|
||
if (!n) return;
|
||
// Готовим новые данные с глубокой копией blocks
|
||
const newData = { ...(n.data || {}) , blocks: Array.isArray(d2.blocks) ? d2.blocks.map(b => ({...b})) : [] };
|
||
// 1) Обновляем внутреннее состояние Drawflow, чтобы export() возвращал актуальные данные
|
||
try { editor.updateNodeDataFromId(id, newData); } catch (e) {}
|
||
// 2) Обновляем DOM-отражение (источник правды для toPipelineJSON)
|
||
const el2 = document.querySelector(`#node-${id}`);
|
||
if (el2) el2.__data = JSON.parse(JSON.stringify(newData));
|
||
} catch (e) {}
|
||
}
|
||
// Initial sync to attach blocks into __data for toPipelineJSON
|
||
syncNodeDataBlocks();
|
||
|
||
const listEl = document.getElementById('pm-list');
|
||
const addBtn = document.getElementById('pm-add');
|
||
const editorBox = document.getElementById('pm-editor');
|
||
const nameInp = document.getElementById('pm-name');
|
||
const roleSel = document.getElementById('pm-role');
|
||
const promptTxt = document.getElementById('pm-prompt');
|
||
const saveBtn = document.getElementById('pm-save');
|
||
const cancelBtn = document.getElementById('pm-cancel');
|
||
let editingId = null;
|
||
|
||
// Изменения блока применяются только по кнопке «Сохранить» внутри редактора блока.
|
||
|
||
// --- FIX: Drag&Drop через SortableJS ---
|
||
if (window.Sortable && listEl && !listEl.__sortable) {
|
||
listEl.__sortable = new Sortable(listEl, {
|
||
animation: 150,
|
||
handle: '.pm-handle',
|
||
onEnd(evt) {
|
||
const oldIndex = evt.oldIndex;
|
||
const newIndex = evt.newIndex;
|
||
if (oldIndex === newIndex) return;
|
||
const moved = d2.blocks.splice(oldIndex, 1)[0];
|
||
d2.blocks.splice(newIndex, 0, moved);
|
||
d2.blocks.forEach((b,i)=> b.order = i);
|
||
syncNodeDataBlocks();
|
||
}
|
||
});
|
||
}
|
||
|
||
function sortAndReindex() {
|
||
d2.blocks.sort((a,b)=> (a.order ?? 0) - (b.order ?? 0));
|
||
d2.blocks.forEach((b,i)=> b.order = i);
|
||
}
|
||
|
||
function findBlockByDomId(domId) {
|
||
return d2.blocks.find(b => (b.id || '') === domId);
|
||
}
|
||
|
||
function renderList() {
|
||
sortAndReindex();
|
||
listEl.innerHTML = '';
|
||
d2.blocks.forEach((b,i)=>{
|
||
const domId = b.id || ('b'+i);
|
||
const li = document.createElement('li');
|
||
li.draggable = true;
|
||
li.dataset.id = domId;
|
||
li.style.display = 'flex';
|
||
li.style.alignItems = 'center';
|
||
li.style.gap = '6px';
|
||
li.style.padding = '4px 0';
|
||
li.innerHTML = `
|
||
<span class="pm-handle" style="cursor:grab;">☰</span>
|
||
<input type="checkbox" class="pm-enabled" ${b.enabled!==false?'checked':''} title="enabled"/>
|
||
<span class="pm-name" style="flex:1">${(b.name||('Block '+(i+1))).replace(/</g,'<')}</span>
|
||
<span class="pm-role" style="opacity:.8">${b.role||'user'}</span>
|
||
<button class="pm-edit" title="Редактировать">✎</button>
|
||
<button class="pm-del" title="Удалить">🗑</button>
|
||
`;
|
||
// DnD
|
||
li.addEventListener('dragstart', e => { e.dataTransfer.setData('text/plain', domId); });
|
||
li.addEventListener('dragover', e => { e.preventDefault(); });
|
||
li.addEventListener('drop', e => {
|
||
e.preventDefault();
|
||
const srcId = e.dataTransfer.getData('text/plain');
|
||
const tgtId = domId;
|
||
if (!srcId || srcId === tgtId) return;
|
||
const srcIdx = d2.blocks.findIndex(x => (x.id||'') === srcId);
|
||
const tgtIdx = d2.blocks.findIndex(x => (x.id||'') === tgtId);
|
||
if (srcIdx < 0 || tgtIdx < 0) return;
|
||
const [moved] = d2.blocks.splice(srcIdx, 1);
|
||
d2.blocks.splice(tgtIdx, 0, moved);
|
||
sortAndReindex();
|
||
renderList();
|
||
syncNodeDataBlocks();
|
||
});
|
||
// toggle
|
||
li.querySelector('.pm-enabled').addEventListener('change', ev => {
|
||
b.enabled = ev.target.checked;
|
||
syncNodeDataBlocks();
|
||
});
|
||
// edit
|
||
li.querySelector('.pm-edit').addEventListener('click', () => {
|
||
openEditor(b);
|
||
});
|
||
// delete
|
||
li.querySelector('.pm-del').addEventListener('click', () => {
|
||
const idx = d2.blocks.indexOf(b);
|
||
if (idx >= 0) d2.blocks.splice(idx, 1);
|
||
sortAndReindex();
|
||
renderList();
|
||
syncNodeDataBlocks();
|
||
if (editingId && editingId === (b.id || null)) {
|
||
editorBox.style.display = 'none';
|
||
editingId = null;
|
||
}
|
||
});
|
||
listEl.appendChild(li);
|
||
});
|
||
}
|
||
|
||
function openEditor(b) {
|
||
// Гарантируем наличие id у редактируемого блока
|
||
if (!b.id) {
|
||
b.id = 'b' + Date.now().toString(36);
|
||
syncNodeDataBlocks();
|
||
}
|
||
editingId = b.id;
|
||
editorBox.style.display = '';
|
||
nameInp.value = b.name || '';
|
||
roleSel.value = (b.role || 'user');
|
||
promptTxt.value = b.prompt || '';
|
||
}
|
||
|
||
addBtn?.addEventListener('click', () => {
|
||
const idv = 'b' + Date.now().toString(36);
|
||
const nb = { id: idv, name: 'New Block', role: 'system', prompt: '', enabled: true, order: d2.blocks.length };
|
||
d2.blocks.push(nb);
|
||
sortAndReindex();
|
||
renderList();
|
||
syncNodeDataBlocks();
|
||
openEditor(nb);
|
||
});
|
||
|
||
saveBtn?.addEventListener('click', () => {
|
||
if (!editingId) { editorBox.style.display = 'none'; return; }
|
||
const b = d2.blocks.find(x => (x.id || null) === editingId);
|
||
if (b) {
|
||
b.name = nameInp.value;
|
||
b.role = roleSel.value;
|
||
b.prompt = promptTxt.value;
|
||
// Пересоберём массив, чтобы избежать проблем с мутацией по ссылке
|
||
d2.blocks = d2.blocks.map(x => (x.id === b.id ? ({...b}) : x));
|
||
}
|
||
editorBox.style.display = 'none';
|
||
editingId = null;
|
||
renderList();
|
||
syncNodeDataBlocks();
|
||
try { savePipeline(); } catch (e) {}
|
||
try { status('Блок сохранён в pipeline.json'); } catch (e) {}
|
||
});
|
||
cancelBtn?.addEventListener('click', () => {
|
||
editorBox.style.display = 'none';
|
||
editingId = null;
|
||
});
|
||
|
||
renderList();
|
||
}
|
||
}
|
||
|
||
// Добавление нод из сайдбара
|
||
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});
|
||
});
|
||
});
|
||
|
||
// Сериализация: Drawflow -> наш pipeline JSON
|
||
function toPipelineJSON() {
|
||
const data = editor.export();
|
||
const nodes = [];
|
||
const idMap = {}; // drawflow id -> generated id like n1, n2
|
||
|
||
const dfNodes = (data && data.drawflow && data.drawflow.Home && data.drawflow.Home.data) ? data.drawflow.Home.data : {};
|
||
|
||
// 1) Собираем ноды
|
||
let idx = 1;
|
||
for (const id in dfNodes) {
|
||
const df = dfNodes[id];
|
||
const genId = `n${idx++}`;
|
||
idMap[id] = genId;
|
||
const el = document.querySelector(`#node-${id}`);
|
||
// Берём источник правды из DOM.__data (куда жмём «Сохранить параметры») или из drawflow.data
|
||
const datacopySrc = el && el.__data ? el.__data : (df.data || {});
|
||
const datacopy = applyNodeDefaults(df.name, JSON.parse(JSON.stringify(datacopySrc)));
|
||
nodes.push({
|
||
id: genId,
|
||
type: df.name,
|
||
pos_x: df.pos_x,
|
||
pos_y: df.pos_y,
|
||
config: datacopy,
|
||
in: {}
|
||
});
|
||
}
|
||
|
||
// 2) Восстанавливаем связи по входам (inputs)
|
||
// В Drawflow v0.0.55 inputs/outputs — это объекты вида input_1/output_1
|
||
for (const id in dfNodes) {
|
||
const df = dfNodes[id];
|
||
const targetNode = nodes.find(n => n.id === idMap[id]);
|
||
if (!targetNode) continue;
|
||
const io = NODE_IO[targetNode.type] || { inputs: [], outputs: [] };
|
||
|
||
for (let i = 0; i < io.inputs.length; i++) {
|
||
const inputKey = `input_${i + 1}`;
|
||
const input = df.inputs && df.inputs[inputKey];
|
||
if (!input || !Array.isArray(input.connections) || input.connections.length === 0) continue;
|
||
|
||
// Один вход — одна связь
|
||
const conn = input.connections[0];
|
||
|
||
const sourceDfId = String(conn.node);
|
||
const outKey = String(conn.output ?? '');
|
||
|
||
// conn.output может быть "output_1", "1" (строкой), либо числом 1
|
||
let sourceOutIdx = -1;
|
||
let m = outKey.match(/output_(\d+)/);
|
||
if (m) {
|
||
sourceOutIdx = parseInt(m[1], 10) - 1;
|
||
} else if (/^\d+$/.test(outKey)) {
|
||
sourceOutIdx = parseInt(outKey, 10) - 1;
|
||
} else if (typeof conn.output === 'number') {
|
||
sourceOutIdx = conn.output - 1;
|
||
}
|
||
if (!(sourceOutIdx >= 0)) sourceOutIdx = 0; // safety to avoid -1
|
||
|
||
const sourceNode = nodes.find(n => n.id === idMap[sourceDfId]);
|
||
if (!sourceNode) continue;
|
||
|
||
const sourceIo = NODE_IO[sourceNode.type] || { outputs: [] };
|
||
// Каноничное имя выхода: по NODE_IO, иначе out{0-based}
|
||
const sourceOutName = (sourceIo.outputs && sourceIo.outputs[sourceOutIdx] != null)
|
||
? sourceIo.outputs[sourceOutIdx]
|
||
: `out${sourceOutIdx}`;
|
||
// Каноничное имя входа: по NODE_IO, иначе in{0-based}
|
||
const targetInName = (io.inputs && io.inputs[i] != null)
|
||
? io.inputs[i]
|
||
: `in${i}`;
|
||
|
||
if (!targetNode.in) targetNode.in = {};
|
||
targetNode.in[targetInName] = `${sourceNode.id}.${sourceOutName}`;
|
||
}
|
||
}
|
||
|
||
return { id: 'pipeline_editor', name: 'Edited Pipeline', nodes };
|
||
}
|
||
|
||
// Десериализация: pipeline JSON -> Drawflow
|
||
async function fromPipelineJSON(p) {
|
||
editor.clear();
|
||
let x = 100; let y = 120; // Fallback
|
||
const idMap = {}; // pipeline id -> drawflow id
|
||
const logs = [];
|
||
const $ = (sel) => document.querySelector(sel);
|
||
|
||
const resolveOutIdx = (type, outName) => {
|
||
const outs = (NODE_IO[type]?.outputs) || [];
|
||
let idx = outs.indexOf(outName);
|
||
if (idx < 0 && typeof outName === 'string') {
|
||
// поддержка: out-1, out_1, output_1, out1, out0
|
||
const s = String(outName);
|
||
let m = s.match(/^out(?:put)?[_-]?(\d+)$/);
|
||
if (m) {
|
||
const n = parseInt(m[1], 10);
|
||
idx = n > 0 ? n - 1 : 0;
|
||
} else {
|
||
m = s.match(/^out(\d+)$/); // совместимость со старым out0
|
||
if (m) idx = parseInt(m[1], 10) | 0;
|
||
}
|
||
}
|
||
return idx;
|
||
};
|
||
const resolveInIdx = (type, inName) => {
|
||
const ins = (NODE_IO[type]?.inputs) || [];
|
||
let idx = ins.indexOf(inName);
|
||
if (idx < 0 && typeof inName === 'string') {
|
||
// поддержка: in-1, in_1, in1, in0
|
||
const s = String(inName);
|
||
let m = s.match(/^in[_-]?(\d+)$/);
|
||
if (m) {
|
||
const n = parseInt(m[1], 10);
|
||
idx = n > 0 ? n - 1 : 0;
|
||
} else {
|
||
m = s.match(/^in(\d+)$/); // совместимость со старым in0
|
||
if (m) idx = parseInt(m[1], 10) | 0;
|
||
}
|
||
}
|
||
return idx;
|
||
};
|
||
|
||
// Ожидание появления порта в DOM (устранение гонки рендера)
|
||
async function waitForPort(dfid, kind, idx, tries = 60, delay = 16) {
|
||
// Drawflow создаёт DOM-узел с id="node-${dfid}"
|
||
const sel = `#node-${dfid} .${kind}_${idx}`;
|
||
for (let i = 0; i < tries; i++) {
|
||
if ($(sel)) return true;
|
||
await new Promise(r => setTimeout(r, delay));
|
||
}
|
||
logs.push(`port missing: #${dfid} ${kind}_${idx}`);
|
||
return false;
|
||
}
|
||
// Повторные попытки соединить порты, пока DOM не готов
|
||
async function connectWithRetry(srcDfId, tgtDfId, outNum, inNum, tries = 120, delay = 25) {
|
||
const outClass = `output_${outNum}`;
|
||
const inClass = `input_${inNum}`;
|
||
for (let i = 0; i < tries; i++) {
|
||
const okOut = await waitForPort(srcDfId, 'output', outNum, 1, delay);
|
||
const okIn = await waitForPort(tgtDfId, 'input', inNum, 1, delay);
|
||
if (okOut && okIn) {
|
||
try {
|
||
editor.addConnection(srcDfId, tgtDfId, outClass, inClass);
|
||
return true;
|
||
} catch (e) {
|
||
// retry on next loop
|
||
}
|
||
}
|
||
await new Promise(r => setTimeout(r, delay));
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// 1) Создаём ноды
|
||
for (const n of p.nodes) {
|
||
const pos = { x: n.pos_x || x, y: n.pos_y || y };
|
||
const dfid = addNode(n.type, pos, { ...(n.config || {}), _origId: n.id });
|
||
idMap[n.id] = dfid;
|
||
if (!n.pos_x) x += 260; // раскладываем по горизонтали, если нет сохраненной позиции
|
||
}
|
||
|
||
// 2) Дождёмся полного рендера DOM
|
||
await new Promise(r => setTimeout(r, 0));
|
||
if (typeof requestAnimationFrame === 'function') {
|
||
await new Promise(r => requestAnimationFrame(() => r()));
|
||
await new Promise(r => requestAnimationFrame(() => r())); // двойной rAF для надежности
|
||
} else {
|
||
await new Promise(r => setTimeout(r, 32));
|
||
}
|
||
|
||
// 3) Проставляем связи из in
|
||
for (const n of p.nodes) {
|
||
if (!n.in) continue;
|
||
const targetDfId = idMap[n.id];
|
||
const targetIo = NODE_IO[n.type] || { inputs: [] };
|
||
for (const [inName, ref] of Object.entries(n.in)) {
|
||
if (!ref || typeof ref !== 'string' || !ref.includes('.')) continue;
|
||
const [srcId, outName] = ref.split('.');
|
||
const sourceDfId = idMap[srcId];
|
||
if (!sourceDfId) { logs.push(`skip: src ${srcId} not found`); continue; }
|
||
const srcType = p.nodes.find(nn=>nn.id===srcId)?.type;
|
||
|
||
let outIdx = resolveOutIdx(srcType, outName);
|
||
let inIdx = resolveInIdx(n.type, inName);
|
||
|
||
// Fallback на первый порт, если неизвестные имена, но порт существует
|
||
if (outIdx < 0) outIdx = 0;
|
||
if (inIdx < 0) inIdx = 0;
|
||
|
||
const outClass = `output_${outIdx + 1}`;
|
||
const inClass = `input_${inIdx + 1}`;
|
||
|
||
const ok = await connectWithRetry(sourceDfId, targetDfId, outIdx + 1, inIdx + 1, 200, 25);
|
||
if (ok) {
|
||
logs.push(`connect: ${srcId}.${outName} (#${sourceDfId}.${outClass}) -> ${n.id}.${inName} (#${targetDfId}.${inClass})`);
|
||
} else {
|
||
logs.push(`skip connect (ports not ready after retries): ${srcId}.${outName} -> ${n.id}.${inName}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 4) Обновим линии и выведем лог
|
||
try {
|
||
Object.values(idMap).forEach((dfid) => {
|
||
editor.updateConnectionNodes?.(`node-${dfid}`);
|
||
});
|
||
} catch {}
|
||
if (logs.length) {
|
||
try { status('Загружено (links):\n' + logs.join('\n')); } catch {}
|
||
try { console.debug('[fromPipelineJSON]', logs); } catch {}
|
||
}
|
||
}
|
||
|
||
// Загрузка/сохранение
|
||
async function loadPipeline() {
|
||
const res = await fetch('/admin/pipeline');
|
||
const p = await res.json();
|
||
await fromPipelineJSON(p);
|
||
// Не затираем логи, которые вывел fromPipelineJSON
|
||
const st = document.getElementById('status').textContent;
|
||
if (!st) status('Загружено');
|
||
}
|
||
async function savePipeline() {
|
||
try {
|
||
const p = 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);
|
||
});
|
||
}
|
||
async function savePreset() {
|
||
const name = document.getElementById('preset-name').value.trim();
|
||
if (!name) { status('Укажите имя пресета'); return; }
|
||
try {
|
||
const p = 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 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);
|
||
}
|
||
}
|
||
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;
|
||
loadPipeline();
|
||
refreshPresets();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
|
||
|