had
This commit is contained in:
@@ -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, ''');
|
||||
}
|
||||
|
||||
// Утилита: разворачивает объект в пары [путь, строковое значение]
|
||||
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>
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Изменения блока применяются только по кнопке «Сохранить» внутри редактора блока.
|
||||
|
||||
// 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>
|
||||
`;
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user