Files
HadTavern/static/editor.html
2025-09-07 22:33:51 +03:00

982 lines
50 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" />
</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="Температура выборки (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>,
<code title="Вторичный ключ или секрет, если задан">[[incoming.api_keys.secret]]</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>
<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,'&quot;')}" readonly />
<label>endpoint</label>
<input type="text" value="${endpoint.replace(/"/g,'&quot;')}" readonly />
<label>headers (preview JSON)</label>
<textarea readonly>${headers.replace(/</g,'&lt;')}</textarea>
<label>template (preview JSON)</label>
<textarea readonly>${template.replace(/</g,'&lt;')}</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,'&quot;')}" readonly />
<label>override_path</label>
<input type="text" value="${override_path.replace(/"/g,'&quot;')}" readonly />
<label><input type="checkbox" ${passthrough_headers} disabled/> passthrough_headers</label>
<label>extra_headers (preview JSON)</label>
<textarea readonly>${extra_headers.replace(/</g,'&lt;')}</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,'&quot;')}" placeholder="https://api.openai.com">
<label>override_path</label><input id="f-override" type="text" value="${(data.override_path||'').replace(/"/g,'&quot;')}" 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,'&lt;')}</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>