This commit is contained in:
2025-09-11 17:27:15 +03:00
parent 3c77c3dc2e
commit 11a0535712
32 changed files with 4682 additions and 442 deletions

View File

@@ -68,6 +68,18 @@
<button id="btn-save-preset">Сохранить пресет</button>
<select id="preset-select" style="width:160px"></select>
<button id="btn-load-preset">Загрузить пресет</button>
<!-- Pipeline execution settings (no manual JSON edits needed) -->
<select id="loop-mode" title="Режим исполнения" style="width:120px;margin-left:8px">
<option value="dag">dag</option>
<option value="iterative">iterative</option>
</select>
<input id="loop-iters" type="number" min="1" step="1" title="loop_max_iters" placeholder="max iters" style="width:110px" />
<input id="loop-budget" type="number" min="1" step="1" title="loop_time_budget_ms" placeholder="budget ms" style="width:130px" />
<label title="Очищать сторадж переменных перед запуском" style="display:inline-flex;align-items:center;gap:6px;margin-left:6px">
<input id="clear-var-store" type="checkbox" checked />
clear vars
</label>
<button id="btn-vars">Переменные</button>
<a href="/" style="text-decoration:none"><button>Домой</button></a>
</div>
</header>
@@ -75,6 +87,7 @@
<aside id="sidebar">
<div class="group-title">Ноды</div>
<button title="Задать пользовательские переменные, доступные как [[NAME]] и {{ NAME }}" class="node-btn" data-node="SetVars">SetVars</button>
<button title="Условное ветвление по выражению (true/false)" class="node-btn" data-node="If">If</button>
<button title="Запрос к провайдеру (openai/gemini/claude) с настраиваемым endpoint и JSON" class="node-btn" data-node="ProviderCall">ProviderCall</button>
<button title="Прямой форвард входящего запроса как reverse-proxy" class="node-btn" data-node="RawForward">RawForward</button>
<button title="Финализировать ответ в формате целевого провайдера (auto/openai/gemini/claude)" class="node-btn" data-node="Return">Return</button>
@@ -120,6 +133,27 @@
</aside>
<main id="canvas">
<div id="drawflow"></div>
<!-- Vars Popover -->
<div id="vars-popover" style="display:none;position:absolute;top:24px;right:24px;z-index:9999;background:#0f141a;border:1px solid #2b3646;border-radius:10px;min-width:420px;max-width:560px;max-height:60vh;overflow:auto;box-shadow:0 6px 28px rgba(0,0,0,.45)">
<div style="display:flex;align-items:center;gap:8px;padding:10px;border-bottom:1px solid #2b3646">
<strong style="flex:1">Переменные (STORE)</strong>
<input id="vars-search" placeholder="поиск по имени/значению" style="flex:2"/>
<select id="vars-scope" title="Источник значений" style="flex:0 0 120px">
<option value="vars">vars</option>
<option value="snapshot">snapshot</option>
<option value="all">all</option>
</select>
<label title="Вставлять макрос фигурными {{ store.KEY }} " style="display:inline-flex;align-items:center;gap:6px;font-size:12px;color:#a7b0bf">
фигурные
<input id="vars-mode-braces" type="checkbox"/>
</label>
<button id="vars-refresh" title="Обновить"></button>
<button id="vars-clear" title="Очистить хранилище">🗑</button>
<button id="vars-close" title="Закрыть"></button>
</div>
<div id="vars-info" class="hint" style="padding:8px 10px;border-bottom:1px solid #2b3646;color:#a7b0bf">Клик по строке копирует макрос в буфер обмена</div>
<div id="vars-list" style="padding:8px 0"></div>
</div>
</main>
<aside id="inspector">
<div class="group-title">Свойства ноды</div>
@@ -129,14 +163,15 @@
<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 src="/ui/js/serialization.js?v=3"></script>
<script src="/ui/js/pm-ui.js?v=3"></script>
<script>
// Типы портов и их имена в нашем контракте
const NODE_IO = {
// depends: используется только для порядка выполнения (зависимости), данные не читаются
// Провода не переносят данные; OUT/vars берутся из контекста и снапшота.
SetVars: { inputs: [], outputs: ['done'] },
If: { inputs: ['depends'], outputs: ['true','false'] },
ProviderCall:{ inputs: ['depends'], outputs: ['done'] },
RawForward: { inputs: ['depends'], outputs: ['done'] },
Return: { inputs: ['depends'], outputs: [] }
@@ -246,11 +281,19 @@
// HTML escaping helpers for safe attribute/text insertion
function escAttr(v) {
const s = String(v ?? '');
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
}
return s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
function escText(v) {
const s = String(v ?? '');
return s.replace(/&/g, '&').replace(/</g, '<');
// For text nodes we keep quotes as-is for readability, but escape critical HTML chars
return s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
}
// Нормализуем/заполняем дефолты конфигов нод, чтобы ключи попадали в сериализацию
@@ -269,6 +312,9 @@
if (type === 'SetVars') {
if (!Array.isArray(d.variables)) d.variables = [];
}
if (type === 'If') {
if (d.expr == null) d.expr = '';
}
if (type === 'Return') {
if (d.target_format == null) d.target_format = 'auto';
if (d.text_template == null) d.text_template = '[[OUT1]]';
@@ -323,6 +369,14 @@
<textarea readonly>${escText(template)}</textarea>
</div>`;
}
if (type === 'If') {
const expr = data.expr || '';
return `<div class="box preview">
<label>expr</label>
<textarea readonly>${escText(expr)}</textarea>
<div class="hint">Поддерживается: [[...]], {{ ... }}, contains, &&, ||, !, ==, !=, <, <=, >, >=</div>
</div>`;
}
if (type === 'RawForward') {
const base_url = data.base_url || '';
const override_path = data.override_path || '';
@@ -500,6 +554,12 @@
</div>
</div>
`;
} else if (type === 'If') {
html += `
<label>expr</label>
<textarea id="if-expr" rows="3">${escText(data.expr || '')}</textarea>
<div class="hint">Примеры: [[OUT1]] contains "text" || [[OUT1]] contains [[Var1]]; {{ OUT.n2.result.meta.status|default(0) }} >= 200 && {{ OUT.n2.result.meta.status|default(0) }} < 300; !([[OUT3]] contains "error") && [[LANG]] == "ru"</div>
`;
} else if (type === 'RawForward') {
html += `
<label>base_url</label><input id="f-baseurl" type="text" value="${escAttr(data.base_url||'')}" placeholder="https://api.openai.com">
@@ -553,6 +613,30 @@
<div class="hint" style="margin-top:6px">Переменные доступны в шаблонах как [[NAME]] и {{ NAME }}. Mode=expr — мини‑формулы без доступа к Python.</div>
`;
}
// Подсказка по портам (где IN/OUT и порядок сверху/снизу)
try {
const io = (window.NODE_IO && window.NODE_IO[type]) ? window.NODE_IO[type] : {inputs: [], outputs: []};
const inPorts = Array.isArray(io.inputs) ? io.inputs : [];
const outPorts = Array.isArray(io.outputs) ? io.outputs : [];
let portHint = '<div class="hint" style="margin-top:8px"><strong>Порты:</strong> IN — слева; OUT — справа.';
if (inPorts.length) {
portHint += '<div>IN: ' + inPorts.join(', ') + '</div>';
} else {
portHint += '<div>IN: нет</div>';
}
if (outPorts.length) {
const parts = outPorts.map((name, idx) => {
// OUT1 — верхний, OUT2 — нижний, далее — 3-й, 4-й...
let pos = (idx === 0 ? 'верхний' : (idx === 1 ? 'нижний' : ((idx+1) + '-й')));
return 'OUT' + (idx+1) + ' (' + pos + '): ' + name;
});
portHint += '<div>OUT: ' + parts.join('; ') + '</div>';
} else {
portHint += '<div>OUT: нет</div>';
}
portHint += '</div>';
html += portHint;
} catch (e) {}
html += `
<div style="margin-top:10px">
<button id="btn-save-node">Сохранить параметры</button>
@@ -579,6 +663,11 @@
try { editor.updateNodeDataFromId(id, d); } catch (e) {}
const el = document.querySelector(`#node-${id}`);
if (el) el.__data = d;
} else if (type === 'If') {
if (inp.id === 'if-expr') d.expr = inp.value;
try { editor.updateNodeDataFromId(id, d); } catch (e) {}
const el = document.querySelector(`#node-${id}`);
if (el) el.__data = d;
} else if (type === 'RawForward') {
if (inp.id === 'f-template') d.template = inp.value;
if (inp.id === 'f-model') d.model = inp.value;
@@ -785,6 +874,8 @@
const res = await fetch('/admin/pipeline');
const p = await res.json();
await window.AgentUISer.fromPipelineJSON(p);
// Обновим UI полей метаданных по загруженному pipeline
try { initPipelineMetaControls(); } catch (e) {}
// Не затираем логи, которые вывел fromPipelineJSON
const st = document.getElementById('status').textContent;
if (!st) status('Загружено');
@@ -809,6 +900,209 @@
opt.value = name; opt.textContent = name; sel.appendChild(opt);
});
}
// --- Vars popover helpers ---
(function(){
const $ = (sel) => document.querySelector(sel);
const box = $('#vars-popover');
const listEl = $('#vars-list');
const searchEl = $('#vars-search');
const scopeEl = $('#vars-scope');
const bracesEl = $('#vars-mode-braces');
const infoEl = $('#vars-info');
const btnOpen = $('#btn-vars');
const btnClose = $('#vars-close');
const btnRefresh = $('#vars-refresh');
const btnClear = $('#vars-clear');
function setInfo(msg) { try { infoEl.textContent = msg; } catch(e){} }
async function fetchVars() {
try {
const res = await fetch('/admin/vars');
const j = await res.json();
return j && j.store ? j.store : {};
} catch (e) {
setInfo('Ошибка загрузки переменных');
return {};
}
}
function macroFor(path, kind) {
const useBraces = !!(bracesEl && bracesEl.checked);
const p = String(path || '');
// VARS: копируем «канонические» макросы, доступные в шаблонах
if (kind === 'vars') {
return useBraces ? `{{ ${p} }}` : `[[${p}]]`;
}
// SNAPSHOT: маппим на реальные макросы контекста (без STORE)
// OUT1, OUT2, ... → [[OUTx]] или {{ OUT.nX.response_text }}
const mAlias = p.match(/^OUT(\d+)$/i);
if (mAlias) {
const n = mAlias[1];
return useBraces ? `{{ OUT.n${n}.response_text }}` : `[[OUT${n}]]`;
}
// OUT_TEXT.nX → [[OUTx]] или {{ OUT.nX.response_text }}
const mTxt = p.match(/^OUT_TEXT\.n(\d+)$/i);
if (mTxt) {
const n = mTxt[1];
return useBraces ? `{{ OUT.n${n}.response_text }}` : `[[OUT${n}]]`;
}
// OUT.nX.something → {{ OUT.nX.something }} или [[OUT:nX.something]]
if (p.startsWith('OUT.')) {
const body = p.slice(4);
return useBraces ? `{{ OUT.${body} }}` : `[[OUT:${body}]]`;
}
// Общий контекст: incoming.*, params.*, model, vendor_format, system
const roots = ['incoming','params','model','vendor_format','system'];
const root = p.split('.')[0];
if (roots.includes(root)) {
return useBraces ? `{{ ${p} }}` : `[[VAR:${p}]]`;
}
// Fallback: трактуем как путь контекста
return useBraces ? `{{ ${p} }}` : `[[VAR:${p}]]`;
}
function escapeHtml(s) {
return String(s ?? '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, '&#39;');
}
// Утилита: разворачивает объект в пары [путь, строковое значение]
function flattenObject(obj, prefix = '') {
const out = [];
if (obj == null) return out;
if (typeof obj !== 'object') {
out.push([prefix, String(obj)]);
return out;
}
const entries = Object.entries(obj);
for (const [k, v] of entries) {
const p = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === 'object' && !Array.isArray(v)) {
// Спец-форма превью от бекенда
if (Object.prototype.hasOwnProperty.call(v, '__truncated__') && Object.prototype.hasOwnProperty.call(v, 'preview')) {
out.push([p, String(v.preview ?? '')]);
continue;
}
out.push(...flattenObject(v, p));
} else {
try {
const s = (typeof v === 'string') ? v : JSON.stringify(v, null, 0);
out.push([p, s]);
} catch {
out.push([p, String(v)]);
}
}
}
return out;
}
function renderList(storeObj) {
const q = (searchEl && searchEl.value || '').toLowerCase().trim();
const scope = (scopeEl && scopeEl.value) || 'vars';
const items = [];
// vars: все ключи из STORE кроме 'snapshot'
if (scope === 'vars' || scope === 'all') {
const keys = Object.keys(storeObj || {}).filter(k => k !== 'snapshot').sort((a,b)=>a.localeCompare(b,'ru'));
for (const k of keys) {
try {
const v = storeObj[k];
const vStr = typeof v === 'string' ? v : JSON.stringify(v, null, 0);
if (q && !(k.toLowerCase().includes(q) || vStr.toLowerCase().includes(q))) continue;
items.push({k, v: vStr, kind: 'vars'});
} catch { /* ignore */ }
}
}
// snapshot: плоский список из STORE.snapshot
if (scope === 'snapshot' || scope === 'all') {
const snap = (storeObj && storeObj.snapshot) || {};
const flat = flattenObject(snap, '');
for (const [k, v] of flat) {
if (!k) continue;
const vStr = String(v ?? '');
if (q && !(k.toLowerCase().includes(q) || vStr.toLowerCase().includes(q))) continue;
items.push({k, v: vStr, kind: 'snapshot'});
}
}
if (!items.length) {
listEl.innerHTML = `<div class="hint" style="padding:10px;color:#a7b0bf">Переменные не найдены</div>`;
return;
}
const rows = items.map(({k,v,kind}) => {
const macro = macroFor(k, kind);
const disp = (() => {
const p = String(k || '');
if (kind === 'vars') return p;
// snapshot display names → «актуальные» пути/алиасы
const mAlias = p.match(/^OUT(\d+)$/i);
if (mAlias) return `OUT${mAlias[1]}`;
const mTxt = p.match(/^OUT_TEXT\.n(\d+)$/i);
if (mTxt) return `OUT${mTxt[1]}`;
if (p.startsWith('OUT.')) return p; // OUT.nX.something
// drop leading snapshot.* → show plain context path
return p.replace(/^snapshot\./, '');
})();
return `
<div class="row" data-key="${escapeHtml(k)}" data-kind="${escapeHtml(kind)}" style="display:grid;grid-template-columns: auto 1fr;gap:8px;padding:8px 10px;border-bottom:1px solid #1f2b3b;cursor:pointer">
<code title="${escapeHtml(macro)}" style="color:#60a5fa;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(macro)}</code>
<div title="${escapeHtml(v)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(v)}</div>
</div>
`;
}).join('');
listEl.innerHTML = rows;
listEl.querySelectorAll('.row').forEach(row => {
row.addEventListener('click', async () => {
try {
const key = row.getAttribute('data-key');
const kind = row.getAttribute('data-kind') || 'vars';
const macro = macroFor(key, kind);
await navigator.clipboard.writeText(macro);
setInfo(`Скопировано: ${macro}`);
} catch (e) {
setInfo('Не удалось скопировать макрос');
}
});
});
}
async function refresh() {
const store = await fetchVars();
renderList(store);
}
if (btnOpen) btnOpen.addEventListener('click', async ()=>{
try { box.style.display = 'block'; } catch(_){}
setInfo('Клик по строке копирует макрос. Поиск работает по имени и содержимому.');
await refresh();
});
if (btnClose) btnClose.addEventListener('click', ()=>{ try { box.style.display = 'none'; } catch(_){ } });
if (btnRefresh) btnRefresh.addEventListener('click', refresh);
if (btnClear) btnClear.addEventListener('click', async ()=>{
try {
await fetch('/admin/vars', { method: 'DELETE' });
await refresh();
setInfo('Хранилище очищено');
} catch (e) {
setInfo('Ошибка очистки хранилища');
}
});
if (searchEl) searchEl.addEventListener('input', refresh);
if (scopeEl) scopeEl.addEventListener('change', refresh);
if (bracesEl) bracesEl.addEventListener('change', refresh);
})();
async function savePreset() {
const name = document.getElementById('preset-name').value.trim();
if (!name) { status('Укажите имя пресета'); return; }
@@ -836,10 +1130,46 @@
status('Пресет загружен: ' + name);
}
}
// Bind top-level pipeline meta controls to AgentUISer meta store
function initPipelineMetaControls() {
try {
const meta = (window.AgentUISer && window.AgentUISer.getPipelineMeta) ? window.AgentUISer.getPipelineMeta() : {};
const selMode = document.getElementById('loop-mode');
const inpIters = document.getElementById('loop-iters');
const inpBudget = document.getElementById('loop-budget');
const chkClear = document.getElementById('clear-var-store');
if (selMode) selMode.value = (meta.loop_mode || 'dag');
if (inpIters) inpIters.value = (typeof meta.loop_max_iters === 'number' ? meta.loop_max_iters : 1000);
if (inpBudget) inpBudget.value = (typeof meta.loop_time_budget_ms === 'number' ? meta.loop_time_budget_ms : 10000);
if (chkClear) chkClear.checked = (typeof meta.clear_var_store === 'boolean' ? meta.clear_var_store : true);
function pushMeta() {
try {
const payload = {
loop_mode: selMode ? selMode.value : undefined,
loop_max_iters: inpIters ? parseInt(inpIters.value || '0', 10) : undefined,
loop_time_budget_ms: inpBudget ? parseInt(inpBudget.value || '0', 10) : undefined,
clear_var_store: chkClear ? !!chkClear.checked : undefined,
};
if (window.AgentUISer && window.AgentUISer.updatePipelineMeta) {
window.AgentUISer.updatePipelineMeta(payload);
}
} catch (e) {}
}
if (selMode) selMode.addEventListener('change', pushMeta);
if (inpIters) inpIters.addEventListener('change', pushMeta);
if (inpBudget) inpBudget.addEventListener('change', pushMeta);
if (chkClear) chkClear.addEventListener('change', pushMeta);
} catch (e) {}
}
document.getElementById('btn-load').onclick = loadPipeline;
document.getElementById('btn-save').onclick = savePipeline;
document.getElementById('btn-save-preset').onclick = savePreset;
document.getElementById('btn-load-preset').onclick = loadPreset;
// Initialize controls once, then refresh values after loadPipeline() pulls JSON
try { initPipelineMetaControls(); } catch (e) {}
loadPipeline();
refreshPresets();
</script>

View File

@@ -40,6 +40,17 @@
const cancelBtn = document.getElementById('pm-cancel');
let editingId = null;
// Безопасное экранирование HTML для вставок в UI
function pmEscapeHtml(s) {
const str = String(s ?? '');
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Изменения блока применяются только по кнопке «Сохранить» внутри редактора блока.
// Drag&Drop через SortableJS (если доступен)
@@ -77,11 +88,13 @@
li.style.alignItems = 'center';
li.style.gap = '6px';
li.style.padding = '4px 0';
const nameDisp = pmEscapeHtml(b.name || ('Block ' + (i + 1)));
const roleDisp = pmEscapeHtml(b.role || 'user');
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>
<span class="pm-name" style="flex:1">${nameDisp}</span>
<span class="pm-role" style="opacity:.8">${roleDisp}</span>
<button class="pm-edit" title="Редактировать">✎</button>
<button class="pm-del" title="Удалить">🗑</button>
`;

View File

@@ -10,6 +10,39 @@
if (!w.NODE_IO) throw new Error('AgentUISer: global NODE_IO is not available');
}
// Top-level pipeline meta kept in memory and included into JSON on save.
// Allows UI to edit loop parameters without manual JSON edits.
let _pipelineMeta = {
id: 'pipeline_editor',
name: 'Edited Pipeline',
parallel_limit: 8,
loop_mode: 'dag',
loop_max_iters: 1000,
loop_time_budget_ms: 10000,
clear_var_store: true,
};
function getPipelineMeta() {
return { ..._pipelineMeta };
}
function updatePipelineMeta(p) {
if (!p || typeof p !== 'object') return;
const keys = ['id','name','parallel_limit','loop_mode','loop_max_iters','loop_time_budget_ms','clear_var_store'];
for (const k of keys) {
if (Object.prototype.hasOwnProperty.call(p, k) && p[k] !== undefined && p[k] !== null && (k === 'clear_var_store' ? true : p[k] !== '')) {
if (k === 'parallel_limit' || k === 'loop_max_iters' || k === 'loop_time_budget_ms') {
const v = parseInt(p[k], 10);
if (!Number.isNaN(v) && v > 0) _pipelineMeta[k] = v;
} else if (k === 'clear_var_store') {
_pipelineMeta[k] = !!p[k];
} else {
_pipelineMeta[k] = String(p[k]);
}
}
}
}
// Drawflow -> pipeline JSON
function toPipelineJSON() {
ensureDeps();
@@ -22,20 +55,53 @@
const dfNodes = (data && data.drawflow && data.drawflow.Home && data.drawflow.Home.data) ? data.drawflow.Home.data : {};
// 1) Собираем ноды
let idx = 1;
// 1) Собираем ноды с устойчивыми id на основе _origId (если валиден/уникален)
const usedIds = new Set();
const wantIds = {}; // drawflow id -> желаемый/финальный nX
const isValidNid = (s) => typeof s === 'string' && /^n\d+$/i.test(s.trim());
// Первый проход: резервируем существующие валидные _origId
for (const id in dfNodes) {
const df = dfNodes[id];
const el = document.querySelector(`#node-${id}`);
const datacopySrc = el && el.__data ? el.__data : (df.data || {});
const tmp = typeof w.applyNodeDefaults === 'function'
? w.applyNodeDefaults(df.name, JSON.parse(JSON.stringify(datacopySrc)))
: (JSON.parse(JSON.stringify(datacopySrc)));
let desired = (tmp && typeof tmp._origId === 'string') ? String(tmp._origId).trim() : '';
if (isValidNid(desired) && !usedIds.has(desired)) {
wantIds[id] = desired;
usedIds.add(desired);
} else {
wantIds[id] = null; // назначим позже
}
}
// Поиск ближайшего свободного nX
function nextFreeId() {
let x = 1;
while (usedIds.has('n' + x)) x += 1;
return 'n' + x;
}
// Второй проход: назначаем конфликты/пустые
for (const id in dfNodes) {
if (!wantIds[id]) {
const nid = nextFreeId();
wantIds[id] = nid;
usedIds.add(nid);
}
idMap[id] = wantIds[id];
}
// Финальный проход: формируем массив нод, синхронизируя _origId
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)));
try { datacopy._origId = idMap[id]; } catch (e) {}
nodes.push({
id: genId,
id: idMap[id],
type: df.name,
pos_x: df.pos_x,
pos_y: df.pos_y,
@@ -43,6 +109,7 @@
in: {}
});
}
try { console.debug('[AgentUISer.toPipelineJSON] idMap drawflowId->nX', idMap); } catch (e) {}
// 2) Восстанавливаем связи по входам (inputs)
// В Drawflow v0.0.55 inputs/outputs — это объекты вида input_1/output_1
@@ -56,15 +123,15 @@
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
// 1) Попробуем определить индекс выхода из conn.output
let sourceOutIdx = -1;
let m = outKey.match(/output_(\d+)/);
if (m) {
@@ -74,28 +141,72 @@
} 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}`;
// 2) Fallback: если индекс не распознан или вне диапазона — проверим dfNodes[source].outputs
if (!(sourceOutIdx >= 0) || !(Array.isArray(sourceIo.outputs) && sourceIo.outputs[sourceOutIdx] != null)) {
try {
const srcDf = dfNodes[sourceDfId];
const outsObj = (srcDf && srcDf.outputs) ? srcDf.outputs : {};
let found = -1;
// Текущая целевая drawflow-нода — это id (внешняя переменная цикла по dfNodes)
const tgtDfId = id;
for (const k of Object.keys(outsObj || {})) {
const conns = (outsObj[k] && Array.isArray(outsObj[k].connections)) ? outsObj[k].connections : [];
if (conns.some(c => String(c && c.node) === String(tgtDfId))) {
const m2 = String(k).match(/output_(\d+)/);
if (m2) { found = parseInt(m2[1], 10) - 1; break; }
}
}
if (found >= 0) sourceOutIdx = found;
} catch (e) {
// ignore
}
}
// 3) Ещё один safety: если до сих пор индекс невалидный — зажмём в границы
if (!(sourceOutIdx >= 0)) sourceOutIdx = 0;
if (Array.isArray(sourceIo.outputs) && sourceIo.outputs.length > 0) {
if (sourceOutIdx >= sourceIo.outputs.length) sourceOutIdx = sourceIo.outputs.length - 1;
}
// 4) Вычислим каноническое имя выхода по NODE_IO
let sourceOutName;
if (Array.isArray(sourceIo.outputs) && sourceIo.outputs[sourceOutIdx] != null) {
sourceOutName = sourceIo.outputs[sourceOutIdx];
} else {
// Fallback на технические имена (совместимость со старыми out0/out1)
sourceOutName = `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 };
// 3) Собираем итоговый pipeline JSON с метаданными
const meta = getPipelineMeta();
return {
id: meta.id || 'pipeline_editor',
name: meta.name || 'Edited Pipeline',
parallel_limit: (typeof meta.parallel_limit === 'number' ? meta.parallel_limit : 8),
loop_mode: (meta.loop_mode || 'dag'),
loop_max_iters: (typeof meta.loop_max_iters === 'number' ? meta.loop_max_iters : 1000),
loop_time_budget_ms: (typeof meta.loop_time_budget_ms === 'number' ? meta.loop_time_budget_ms : 10000),
clear_var_store: (typeof meta.clear_var_store === 'boolean' ? meta.clear_var_store : true),
nodes
};
}
// pipeline JSON -> Drawflow
@@ -104,6 +215,19 @@
const editor = w.editor;
const NODE_IO = w.NODE_IO;
// Сохраняем метаданные пайплайна для UI
try {
updatePipelineMeta({
id: p && p.id ? p.id : 'pipeline_editor',
name: p && p.name ? p.name : 'Edited Pipeline',
parallel_limit: (p && typeof p.parallel_limit === 'number') ? p.parallel_limit : 8,
loop_mode: p && p.loop_mode ? p.loop_mode : 'dag',
loop_max_iters: (p && typeof p.loop_max_iters === 'number') ? p.loop_max_iters : 1000,
loop_time_budget_ms: (p && typeof p.loop_time_budget_ms === 'number') ? p.loop_time_budget_ms : 10000,
clear_var_store: (p && typeof p.clear_var_store === 'boolean') ? p.clear_var_store : true,
});
} catch (e) {}
editor.clear();
let x = 100; let y = 120; // Fallback
const idMap = {}; // pipeline id -> drawflow id
@@ -243,5 +367,7 @@
w.AgentUISer = {
toPipelineJSON,
fromPipelineJSON,
getPipelineMeta,
updatePipelineMeta,
};
})(window);