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>