HadTavern 0.01: Gemini/Claude fixes; UI _origId reuse; docs; .bat open

This commit is contained in:
2025-09-09 15:42:10 +03:00
parent e3d0f6246e
commit 64c9451d98
12 changed files with 2095 additions and 863 deletions

View File

@@ -301,4 +301,27 @@ button:hover { background: #273246; }
#drawflow .drawflow-delete:active,
.drawflow-delete:active {
transform: translate(-50%, -50%) scale(0.97) !important;
}
/* Execution highlight states (SSE-driven) */
.drawflow .drawflow-node .title-box,
.drawflow .drawflow-node .box {
transition: border-color .12s ease, box-shadow .12s ease, background-color .12s ease;
}
.drawflow .drawflow-node.node-running .title-box,
.drawflow .drawflow-node.node-running .box {
border-color: #60a5fa !important; /* blue */
box-shadow: 0 0 0 2px rgba(96,165,250,.35) !important;
}
.drawflow .drawflow-node.node-ok .title-box,
.drawflow .drawflow-node.node-ok .box {
border-color: #34d399 !important; /* green */
box-shadow: 0 0 0 2px rgba(52,211,153,.35) !important;
}
.drawflow .drawflow-node.node-err .title-box,
.drawflow .drawflow-node.node-err .box {
border-color: #ef4444 !important; /* red */
box-shadow: 0 0 0 2px rgba(239,68,68,.35) !important;
}

View File

@@ -54,7 +54,7 @@
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" />
<link rel="stylesheet" href="/ui/editor.css?v=2" />
</head>
<body>
<header>
@@ -74,8 +74,10 @@
<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="Запрос к провайдеру (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>
@@ -104,13 +106,13 @@
</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>
<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="Текст из выхода ноды 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>
@@ -127,17 +129,24 @@
<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=2"></script>
<script src="/ui/js/pm-ui.js?v=2"></script>
<script>
// Типы портов и их имена в нашем контракте
const NODE_IO = {
// depends: используется только для порядка выполнения (зависимости), данные не читаются
ProviderCall: { inputs: ['depends'], outputs: ['result','response_text'] },
RawForward: { inputs: [], outputs: ['result'] }
// Провода не переносят данные; OUT/vars берутся из контекста и снапшота.
SetVars: { inputs: [], outputs: ['done'] },
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.
@@ -233,7 +242,17 @@
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, '<');
}
function escText(v) {
const s = String(v ?? '');
return s.replace(/&/g, '&').replace(/</g, '<');
}
// Нормализуем/заполняем дефолты конфигов нод, чтобы ключи попадали в сериализацию
function applyNodeDefaults(type, data) {
const d = { ...(data || {}) };
@@ -247,6 +266,13 @@
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 === 'Return') {
if (d.target_format == null) d.target_format = 'auto';
if (d.text_template == null) d.text_template = '[[OUT1]]';
}
return d;
}
@@ -286,15 +312,15 @@
}
const template = tmpl;return `<div class="box preview">
<label>provider</label>
<input type="text" value="${provider}" readonly />
<input type="text" value="${escAttr(provider)}" readonly />
<label>base_url</label>
<input type="text" value="${base_url.replace(/"/g,'&quot;')}" readonly />
<input type="text" value="${escAttr(base_url)}" readonly />
<label>endpoint</label>
<input type="text" value="${endpoint.replace(/"/g,'&quot;')}" readonly />
<input type="text" value="${escAttr(endpoint)}" readonly />
<label>headers (preview JSON)</label>
<textarea readonly>${headers.replace(/</g,'&lt;')}</textarea>
<textarea readonly>${escText(headers)}</textarea>
<label>template (preview JSON)</label>
<textarea readonly>${template.replace(/</g,'&lt;')}</textarea>
<textarea readonly>${escText(template)}</textarea>
</div>`;
}
if (type === 'RawForward') {
@@ -304,20 +330,71 @@
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 />
<input type="text" value="${escAttr(base_url)}" readonly />
<label>override_path</label>
<input type="text" value="${override_path.replace(/"/g,'&quot;')}" readonly />
<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>${extra_headers.replace(/</g,'&lt;')}</textarea>
<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,
@@ -370,11 +447,11 @@
<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>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">${(cfg.template||'{}').replace(/</g,'<')}</textarea>
<textarea id="f-template">${escText(cfg.template||'{}')}</textarea>
<div style="margin-top:6px">
<details class="help">
<summary title="Подсказка по шаблону">?</summary>
@@ -425,12 +502,56 @@
`;
} 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>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">${(data.extra_headers||'{}').replace(/</g,'&lt;')}</textarea>
<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>
`;
}
html += `
<div style="margin-top:10px">
@@ -440,8 +561,7 @@
// 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 => {
document.querySelectorAll('#inspector textarea, #inspector input, #inspector select').forEach(inp => {
inp.addEventListener('input', () => {
const n = editor.getNodeFromId(id);
if (!n) return;
@@ -455,19 +575,104 @@
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 {
// Синхронизуем в Drawflow, чтобы export() видел обновления
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;
}
// Синхронизуем в Drawflow, чтобы export() видел обновления
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');
@@ -498,6 +703,13 @@
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');
@@ -505,9 +717,36 @@
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");
});
@@ -518,178 +757,17 @@
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();
// 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', () => {
@@ -698,232 +776,22 @@
});
});
// Сериализация: Drawflow -> наш pipeline JSON
function toPipelineJSON() {
const data = editor.export();
const nodes = [];
const idMap = {}; // drawflow id -> generated id like n1, n2
// Сериализация вынесена во внешний файл /ui/js/serialization.js
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 {}
}
}
// Десериализация вынесена во внешний файл /ui/js/serialization.js
// Загрузка/сохранение
async function loadPipeline() {
const res = await fetch('/admin/pipeline');
const p = await res.json();
await fromPipelineJSON(p);
await window.AgentUISer.fromPipelineJSON(p);
// Не затираем логи, которые вывел fromPipelineJSON
const st = document.getElementById('status').textContent;
if (!st) status('Загружено');
}
async function savePipeline() {
try {
const p = toPipelineJSON();
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));
@@ -945,7 +813,7 @@ async function connectWithRetry(srcDfId, tgtDfId, outNum, inNum, tries = 120, de
const name = document.getElementById('preset-name').value.trim();
if (!name) { status('Укажите имя пресета'); return; }
try {
const p = toPipelineJSON();
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));
@@ -959,7 +827,7 @@ async function connectWithRetry(srcDfId, tgtDfId, outNum, inNum, tries = 120, de
if (!name) { status('Выберите пресет'); return; }
const res = await fetch('/admin/presets/' + encodeURIComponent(name));
const p = await res.json();
await fromPipelineJSON(p);
await window.AgentUISer.fromPipelineJSON(p);
// Сделаем загруженный пресет активным пайплайном (сохранение в pipeline.json)
await fetch('/admin/pipeline', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(p) });
// Не затираем логи соединений, если они уже выведены
@@ -975,7 +843,96 @@ async function connectWithRetry(srcDfId, tgtDfId, outNum, inNum, tries = 120, de
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>
</html>

191
static/js/pm-ui.js Normal file
View File

@@ -0,0 +1,191 @@
/* global window, document */
// AgentUI Prompt Manager UI extracted from editor.html
// Exposes window.PM.setupProviderCallPMUI(editor, id)
// Depends on DOM elements rendered by editor.html inspector:
// #pm-list, #pm-editor, #pm-name, #pm-role, #pm-prompt, #pm-save, #pm-cancel
(function (w) {
'use strict';
function setupProviderCallPMUI(editor, id) {
try {
const n2 = editor.getNodeFromId(id);
if (!n2) return;
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;
// Изменения блока применяются только по кнопке «Сохранить» внутри редактора блока.
// Drag&Drop через SortableJS (если доступен)
if (w.Sortable && listEl && !listEl.__sortable) {
listEl.__sortable = new w.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 renderList() {
sortAndReindex();
if (!listEl) return;
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 via HTML5 fallback as well (kept for compatibility)
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)) {
if (editorBox) 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;
if (editorBox) editorBox.style.display = '';
if (nameInp) nameInp.value = b.name || '';
if (roleSel) roleSel.value = (b.role || 'user');
if (promptTxt) promptTxt.value = b.prompt || '';
}
if (addBtn) {
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);
});
}
if (saveBtn) {
saveBtn.addEventListener('click', () => {
if (!editingId) { if (editorBox) editorBox.style.display = 'none'; return; }
const b = d2.blocks.find(x => (x.id || null) === editingId);
if (b) {
b.name = nameInp ? nameInp.value : b.name;
b.role = roleSel ? roleSel.value : b.role || 'user';
b.prompt = promptTxt ? promptTxt.value : b.prompt;
// Пересоберём массив, чтобы избежать проблем с мутацией по ссылке
d2.blocks = d2.blocks.map(x => (x.id === b.id ? ({ ...b }) : x));
}
if (editorBox) editorBox.style.display = 'none';
editingId = null;
renderList();
syncNodeDataBlocks();
// попытка автосохранения пайплайна, если доступна глобальная функция
try { (typeof w.savePipeline === 'function') && w.savePipeline(); } catch (e) {}
try { (typeof w.status === 'function') && w.status('Блок сохранён в pipeline.json'); } catch (e) {}
});
}
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
if (editorBox) editorBox.style.display = 'none';
editingId = null;
});
}
// Первичная отрисовка
renderList();
} catch (e) {}
}
w.PM = {
setupProviderCallPMUI
};
})(window);

247
static/js/serialization.js Normal file
View File

@@ -0,0 +1,247 @@
/* global window, document, requestAnimationFrame */
// AgentUI serialization helpers extracted from editor.html
// Exposes window.AgentUISer.{toPipelineJSON, fromPipelineJSON}
// Depends on globals defined by editor.html: editor, NODE_IO, addNode, applyNodeDefaults, status
(function (w) {
'use strict';
function ensureDeps() {
if (!w || !w.editor) throw new Error('AgentUISer: global editor is not available');
if (!w.NODE_IO) throw new Error('AgentUISer: global NODE_IO is not available');
}
// Drawflow -> pipeline JSON
function toPipelineJSON() {
ensureDeps();
const editor = w.editor;
const NODE_IO = w.NODE_IO;
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 = typeof w.applyNodeDefaults === 'function'
? w.applyNodeDefaults(df.name, JSON.parse(JSON.stringify(datacopySrc)))
: (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;
// Собираем все связи этого входа и сохраняем строку либо массив строк (для depends поддерживаем мульти-коннекты)
const refs = [];
for (const conn of (input.connections || [])) {
if (!conn) continue;
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
const sourceNode = nodes.find(n => n.id === idMap[sourceDfId]);
if (!sourceNode) continue;
const sourceIo = NODE_IO[sourceNode.type] || { outputs: [] };
const sourceOutName = (sourceIo.outputs && sourceIo.outputs[sourceOutIdx] != null)
? sourceIo.outputs[sourceOutIdx]
: `out${sourceOutIdx}`;
refs.push(`${sourceNode.id}.${sourceOutName}`);
}
// Каноничное имя входа: по 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] = (refs.length <= 1 ? refs[0] : refs);
}
}
return { id: 'pipeline_editor', name: 'Edited Pipeline', nodes };
}
// pipeline JSON -> Drawflow
async function fromPipelineJSON(p) {
ensureDeps();
const editor = w.editor;
const NODE_IO = w.NODE_IO;
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] && 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] && 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 data = { ...(n.config || {}), _origId: n.id };
const dfid = typeof w.addNode === 'function'
? w.addNode(n.type, pos, data)
: (function () { throw new Error('AgentUISer: addNode() is not defined'); })();
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)) {
const refs = Array.isArray(ref) ? ref : [ref];
for (const oneRef of refs) {
if (!oneRef || typeof oneRef !== 'string' || !oneRef.includes('.')) continue;
const [srcId, outName] = oneRef.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 ok = await connectWithRetry(sourceDfId, targetDfId, outIdx + 1, inIdx + 1, 200, 25);
if (ok) {
logs.push(`connect: ${srcId}.${outName} (#${sourceDfId}.output_${outIdx + 1}) -> ${n.id}.${inName} (#${targetDfId}.input_${inIdx + 1})`);
} else {
logs.push(`skip connect (ports not ready after retries): ${srcId}.${outName} -> ${n.id}.${inName}`);
}
}
}
}
// 4) Обновим линии и выведем лог
try {
Object.values(idMap).forEach((dfid) => {
if (editor.updateConnectionNodes) {
editor.updateConnectionNodes(`node-${dfid}`);
}
});
} catch {}
if (logs.length) {
try { (typeof w.status === 'function') && w.status('Загружено (links):\n' + logs.join('\n')); } catch {}
try { console.debug('[fromPipelineJSON]', logs); } catch {}
}
}
w.AgentUISer = {
toPipelineJSON,
fromPipelineJSON,
};
})(window);