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>
|
||||
|
||||
Reference in New Issue
Block a user