1034 lines
55 KiB
HTML
1034 lines
55 KiB
HTML
<!doctype html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>НадTavern — Визуальный редактор нод</title>
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/drawflow@0.0.55/dist/drawflow.min.css" />
|
||
<style>
|
||
:root {
|
||
color-scheme: dark;
|
||
--bg: #0b0d10;
|
||
--panel: #11151a;
|
||
--muted: #a7b0bf;
|
||
--border: #1f2937;
|
||
--accent: #6ee7b7;
|
||
--accent-2: #60a5fa;
|
||
--node: #0e1116;
|
||
--node-border: #334155;
|
||
--node-selected: #1f2937;
|
||
--connector: #94a3b8;
|
||
}
|
||
body { margin: 0; font-family: Inter, Arial, sans-serif; background: var(--bg); color: #e5e7eb; }
|
||
header { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; border-bottom: 1px solid var(--border); background: var(--panel); }
|
||
header .actions { display: flex; gap: 8px; }
|
||
button { background: #1f2937; border: 1px solid #334155; color: #e5e7eb; padding: 6px 10px; border-radius: 8px; cursor: pointer; }
|
||
button:hover { background: #273246; }
|
||
#container { display: grid; grid-template-columns: 260px 1fr 360px; height: calc(100vh - 52px); }
|
||
#sidebar { border-right: 1px solid var(--border); padding: 12px; background: var(--panel); overflow: auto; }
|
||
#canvas { position: relative; }
|
||
#inspector { border-left: 1px solid var(--border); padding: 12px; overflow: auto; background: var(--panel); }
|
||
#drawflow { width: 100%; height: 100%; }
|
||
.group-title { font-size: 12px; text-transform: uppercase; color: var(--muted); margin: 12px 0 6px; letter-spacing: .08em; }
|
||
.node-btn { width: 100%; text-align: left; margin-bottom: 6px; border-left: 3px solid transparent; }
|
||
.node-btn:hover { border-left-color: var(--accent-2); }
|
||
.hint { color: var(--muted); font-size: 12px; margin-top: 4px; }
|
||
.df-node .title-box { background: var(--node); border: 1px solid var(--node-border); color: #e5e7eb; border-radius: 10px 10px 0 0; }
|
||
.df-node .box { background: var(--node); border: 1px solid var(--node-border); border-top: 0; color: #e5e7eb; border-radius: 0 0 10px 10px; }
|
||
/* Override default blue styles from drawflow */
|
||
.drawflow .drawflow-node .title { background: transparent !important; color: inherit !important; }
|
||
.drawflow .drawflow-node { background: transparent !important; }
|
||
/* moved to external CSS: ports styling is defined in editor.css */
|
||
.drawflow .drawflow-node.selected { box-shadow: none; }
|
||
.drawflow .connection .main-path { stroke: var(--connector); }
|
||
.drawflow .connection .point { stroke: var(--connector); fill: var(--panel); }
|
||
.drawflow .drawflow-node.selected .title-box, .drawflow .drawflow-node.selected .box { border-color: var(--accent); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 50%, transparent); }
|
||
.df-node .input, .df-node .output { color: var(--muted); }
|
||
textarea, input[type=text] { width: 100%; background: #0f141a; color: #e5e7eb; border: 1px solid #2b3646; border-radius: 8px; padding: 8px; }
|
||
label { font-size: 12px; color: var(--muted); display: block; margin: 8px 0 4px; }
|
||
pre { background: #0f141a; border: 1px solid #2b3646; padding: 10px; border-radius: 8px; overflow: auto; }
|
||
.preview { pointer-events: none; opacity: .85; }
|
||
/* Help popups */
|
||
details.help { margin: 6px 0; }
|
||
details.help summary { list-style: none; cursor: pointer; display: inline-block; width: 20px; height: 20px; border-radius: 50%; background: #334155; color: #e5e7eb; text-align: center; line-height: 20px; font-weight: 700; border: 1px solid #2b3646; }
|
||
details.help summary::-webkit-details-marker { display: none; }
|
||
details.help .panel { margin-top: 8px; background: #0f141a; border: 1px solid #2b3646; padding: 10px; border-radius: 8px; }
|
||
</style>
|
||
<link rel="stylesheet" href="/ui/editor.css?v=2" />
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<div>
|
||
<strong>НадTavern</strong> — Визуальный редактор нод
|
||
</div>
|
||
<div class="actions">
|
||
<button id="btn-load">Загрузить пайплайн</button>
|
||
<button id="btn-save">Сохранить пайплайн</button>
|
||
<input id="preset-name" placeholder="имя пресета" style="width:140px"/>
|
||
<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" />
|
||
<a href="/" style="text-decoration:none"><button>Домой</button></a>
|
||
</div>
|
||
</header>
|
||
<div id="container">
|
||
<aside id="sidebar">
|
||
<div class="group-title">Ноды</div>
|
||
<button title="Задать пользовательские переменные, доступные как [[NAME]] и {{ NAME }}" class="node-btn" data-node="SetVars">SetVars</button>
|
||
<button title="Условное ветвление по выражению (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>
|
||
<div class="hint">Подсказка: соедините выход предыдущей ноды с входом следующей, сохраните и тестируйте через /ui.</div>
|
||
<div class="group-title">Переменные и макросы</div>
|
||
<div class="hint">Используйте переменные в шаблонах как <code>[[variable]]</code>. Наведите курсор на имя переменной, чтобы увидеть подсказку.</div>
|
||
<div class="hint"><strong>Общие:</strong>
|
||
<code title="Системные инструкции для LLM">[[system]]</code>,
|
||
<code title="Имя выбранной модели">[[model]]</code>,
|
||
<code title="Формат ответа провайдера (например, OpenAI, Gemini)">[[vendor_format]]</code>
|
||
</div>
|
||
<div class="hint"><strong>Чат:</strong>
|
||
<code title="Последнее сообщение пользователя">[[chat.last_user]]</code>,
|
||
<code title="Все сообщения в чате">[[chat.messages]]</code>
|
||
</div>
|
||
<div class="hint"><strong>Параметры:</strong>
|
||
<code title="Температура выборки (0–1)">[[params.temperature]]</code>,
|
||
<code title="Максимальное число токенов">[[params.max_tokens]]</code>,
|
||
<code title="Вероятностный срез top-p sampling">[[params.top_p]]</code>,
|
||
<code title="Стоп-слова/условия обрыва генерации">[[params.stop]]</code>
|
||
</div>
|
||
<div class="hint"><strong>Входящий запрос:</strong>
|
||
<code title="Путь в URL запроса">[[incoming.path]]</code>,
|
||
<code title="Query-параметры запроса">[[incoming.query]]</code>
|
||
</div>
|
||
<div class="hint"><strong>Заголовки/тело:</strong>
|
||
<code title="Все заголовки HTTP-запроса">[[incoming.headers]]</code>,
|
||
<code title="JSON-тело входящего запроса">[[incoming.json]]</code>
|
||
</div>
|
||
<div class="hint"><strong>Ключи (API Keys):</strong>
|
||
<code title="Основной ключ авторизации (например Authorization: Bearer ...)">[[incoming.api_keys.authorization]]</code>,
|
||
<code title="Альтернативное имя ключа, если используется">[[incoming.api_keys.key]]</code>
|
||
</div>
|
||
<div class="hint"><strong>Быстрые макросы:</strong>
|
||
<code title="Единый JSON‑фрагмент из Prompt Blocks (подставляется провайдер‑специфично)">[[PROMPT]]</code>,
|
||
<code title="Текст из выхода ноды n1 (best‑effort, вытаскивает content/text из JSON ответа)">[[OUT1]]</code>,
|
||
<code title="Текст из выхода ноды n2">[[OUT2]]</code>,
|
||
<code title="Пользовательская переменная, заданная в SetVars">[[NAME]]</code>
|
||
<span style="opacity:.85"> | Расширенно: <code>[[OUT:n1.result...]]</code> или <code>{{ OUT.n1.result... }}</code></span>
|
||
</div>
|
||
<div class="group-title">Отладка</div>
|
||
<pre id="status"></pre>
|
||
</aside>
|
||
<main id="canvas">
|
||
<div id="drawflow"></div>
|
||
</main>
|
||
<aside id="inspector">
|
||
<div class="group-title">Свойства ноды</div>
|
||
<div id="inspector-content">Выберите ноду…</div>
|
||
</aside>
|
||
</div>
|
||
|
||
<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=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: [] }
|
||
};
|
||
window.NODE_IO = NODE_IO;
|
||
|
||
const editor = new Drawflow(document.getElementById('drawflow'));
|
||
editor.reroute = true;
|
||
editor.start();
|
||
window.editor = editor;
|
||
|
||
// Провайдерные пресеты для ProviderCall (редактируемые пользователем).
|
||
// Шаблоны используют {{ pm.* }} — это JSON-структуры, которые сервер собирает из Prompt Blocks.
|
||
// Поэтому подстановки в template дадут корректный JSON (массивы/объекты без кавычек).
|
||
function providerDefaults(provider) {
|
||
const p = (provider || 'openai').toLowerCase();
|
||
const T_OPENAI = `{
|
||
"model": "{{ model }}",
|
||
[[PROMPT]],
|
||
"temperature": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},
|
||
"top_p": {{ incoming.json.top_p|default(params.top_p|default(1)) }},
|
||
"max_tokens": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},
|
||
"max_completion_tokens": {{ incoming.json.max_completion_tokens|default(params.max_tokens|default(256)) }},
|
||
"presence_penalty": {{ incoming.json.presence_penalty|default(0) }},
|
||
"frequency_penalty": {{ incoming.json.frequency_penalty|default(0) }},
|
||
"stop": {{ incoming.json.stop|default(params.stop|default([])) }},
|
||
"stream": {{ incoming.json.stream|default(false) }}
|
||
}`;
|
||
const T_GEMINI = `{
|
||
"model": "{{ model }}",
|
||
[[PROMPT]],
|
||
"safetySettings": {{ incoming.json.safetySettings|default([]) }},
|
||
"generationConfig": {
|
||
"temperature": {{ incoming.json.generationConfig.temperature|default(params.temperature|default(0.7)) }},
|
||
"topP": {{ incoming.json.generationConfig.topP|default(params.top_p|default(1)) }},
|
||
"maxOutputTokens": {{ incoming.json.generationConfig.maxOutputTokens|default(params.max_tokens|default(256)) }},
|
||
"stopSequences": {{ incoming.json.generationConfig.stopSequences|default(params.stop|default([])) }},
|
||
"candidateCount": {{ incoming.json.generationConfig.candidateCount|default(1) }},
|
||
"thinkingConfig": {
|
||
"includeThoughts": {{ incoming.json.generationConfig.thinkingConfig.includeThoughts|default(false) }},
|
||
"thinkingBudget": {{ incoming.json.generationConfig.thinkingConfig.thinkingBudget|default(0) }}
|
||
}
|
||
}
|
||
}`;
|
||
const T_CLAUDE = `{
|
||
"model": "{{ model }}",
|
||
[[PROMPT]],
|
||
"temperature": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},
|
||
"top_p": {{ incoming.json.top_p|default(params.top_p|default(1)) }},
|
||
"max_tokens": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},
|
||
"stop_sequences": {{ incoming.json.stop_sequences|default(params.stop|default([])) }},
|
||
"stream": {{ incoming.json.stream|default(false) }},
|
||
"thinking": {
|
||
"type": "{{ incoming.json.thinking.type|default('disabled') }}",
|
||
"budget_tokens": {{ incoming.json.thinking.budget_tokens|default(0) }}
|
||
},
|
||
"anthropic_version": "{{ anthropic_version|default('2023-06-01') }}"
|
||
}`;
|
||
|
||
if (p === 'openai') {
|
||
return {
|
||
base_url: 'https://api.openai.com',
|
||
endpoint: '/v1/chat/completions',
|
||
headers: `{"Authorization":"Bearer [[VAR:incoming.headers.authorization]]"}`,
|
||
template: T_OPENAI
|
||
};
|
||
}
|
||
if (p === 'gemini') {
|
||
// По умолчанию ключ часто идёт в query (?key=..). Заголовок оставляем пустым.
|
||
return {
|
||
base_url: 'https://generativelanguage.googleapis.com',
|
||
endpoint: '/v1beta/models/{{ model }}:generateContent?key=[[VAR:incoming.api_keys.key]]',
|
||
headers: `{}`,
|
||
template: T_GEMINI
|
||
};
|
||
}
|
||
if (p === 'claude') {
|
||
return {
|
||
base_url: 'https://api.anthropic.com',
|
||
endpoint: '/v1/messages',
|
||
headers: `{"x-api-key":"[[VAR:incoming.headers.x-api-key]]","anthropic-version":"2023-06-01","anthropic-beta":"[[VAR:incoming.headers.anthropic-beta]]"}`,
|
||
template: T_CLAUDE
|
||
};
|
||
}
|
||
// Unknown — пустые значения, чтобы пользователь всё заполнил руками
|
||
return { base_url: '', endpoint: '', headers: `{}`, template: `{}` };
|
||
}
|
||
|
||
// Helpers for provider-specific configs
|
||
function ensureProviderConfigs(d) {
|
||
if (!d) return;
|
||
if (!d.provider) d.provider = 'openai';
|
||
if (!d.provider_configs || typeof d.provider_configs !== 'object') d.provider_configs = {};
|
||
['openai','gemini','claude'].forEach(p=>{
|
||
if (!d.provider_configs[p]) d.provider_configs[p] = providerDefaults(p);
|
||
});
|
||
}
|
||
function getActiveProv(d) {
|
||
return (d && d.provider ? String(d.provider) : 'openai').toLowerCase();
|
||
}
|
||
function getActiveCfg(d) {
|
||
ensureProviderConfigs(d);
|
||
const p = getActiveProv(d);
|
||
return d.provider_configs[p] || {};
|
||
}
|
||
|
||
/* HTML escaping helpers for safe attribute/text insertion */
|
||
function escAttr(v) {
|
||
const s = String(v ?? '');
|
||
return s
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
function escText(v) {
|
||
const s = String(v ?? '');
|
||
return s
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>');
|
||
}
|
||
|
||
// Нормализуем/заполняем дефолты конфигов нод, чтобы ключи попадали в сериализацию
|
||
function applyNodeDefaults(type, data) {
|
||
const d = { ...(data || {}) };
|
||
if (type === 'ProviderCall') {
|
||
if (d.provider == null) d.provider = 'openai';
|
||
ensureProviderConfigs(d);
|
||
// Back-compat: top-level fields may exist, but UI prefers provider_configs
|
||
if (!Array.isArray(d.blocks)) d.blocks = [];
|
||
}
|
||
if (type === 'RawForward') {
|
||
if (d.passthrough_headers == null) d.passthrough_headers = true;
|
||
if (d.extra_headers == null) d.extra_headers = '{}';
|
||
}
|
||
if (type === 'SetVars') {
|
||
if (!Array.isArray(d.variables)) d.variables = [];
|
||
}
|
||
if (type === '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]]';
|
||
}
|
||
return d;
|
||
}
|
||
|
||
const status = (t) => { document.getElementById('status').textContent = t; };
|
||
// Runtime CSS availability check
|
||
try {
|
||
const linkEl = document.querySelector('link[href$="editor.css"]');
|
||
if (linkEl) {
|
||
const href = linkEl.getAttribute('href');
|
||
fetch(href, { method: 'HEAD' })
|
||
.then(r => {
|
||
console.debug('[НадTavern] CSS HEAD', href, r.status);
|
||
if (!r.ok) {
|
||
try { status('CSS not reachable: ' + href + ' ' + r.status); } catch (e) {}
|
||
}
|
||
})
|
||
.catch(e => { try { status('CSS load error: ' + String(e)); } catch (e2) {} });
|
||
}
|
||
} catch (e) {}
|
||
|
||
function makeNodeHtml(type, data) {
|
||
if (type === 'ProviderCall') {
|
||
const provider = data.provider || 'openai';
|
||
const cfg = getActiveCfg(data);
|
||
const base_url = cfg.base_url || '';
|
||
const endpoint = cfg.endpoint || '';
|
||
const headers = (cfg.headers != null ? cfg.headers : '{"Authorization":"Bearer YOUR_KEY"}');
|
||
let tmpl;
|
||
if (cfg.template != null) {
|
||
tmpl = cfg.template;
|
||
} else if (provider === 'openai') {
|
||
tmpl = '{"model":"{{ model }}","messages":[{"role":"system","content":"{{ system }}"},{"role":"user","content":"{{ chat.last_user }}"}],"temperature":{{ params.temperature|default(0.7) }}}';
|
||
} else if (provider === 'gemini') {
|
||
tmpl = '{"contents":[{"role":"user","parts":[{"text":"{{ chat.last_user }}"}]}]}';
|
||
} else {
|
||
tmpl = '{"model":"{{ model }}","messages":[{"role":"user","content":[{"type":"text","text":"{{ chat.last_user }}"}]}],"max_tokens":256}';
|
||
}
|
||
const template = tmpl;return `<div class="box preview">
|
||
<label>provider</label>
|
||
<input type="text" value="${escAttr(provider)}" readonly />
|
||
<label>base_url</label>
|
||
<input type="text" value="${escAttr(base_url)}" readonly />
|
||
<label>endpoint</label>
|
||
<input type="text" value="${escAttr(endpoint)}" readonly />
|
||
<label>headers (preview JSON)</label>
|
||
<textarea readonly>${escText(headers)}</textarea>
|
||
<label>template (preview JSON)</label>
|
||
<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 || '';
|
||
const passthrough_headers = (data.passthrough_headers ?? true) ? 'checked' : '';
|
||
const extra_headers = data.extra_headers || '{}';
|
||
return `<div class="box preview">
|
||
<label>base_url</label>
|
||
<input type="text" value="${escAttr(base_url)}" readonly />
|
||
<label>override_path</label>
|
||
<input type="text" value="${escAttr(override_path)}" readonly />
|
||
<label><input type="checkbox" ${passthrough_headers} disabled/> passthrough_headers</label>
|
||
<label>extra_headers (preview JSON)</label>
|
||
<textarea readonly>${escText(extra_headers)}</textarea>
|
||
</div>`;
|
||
}
|
||
if (type === 'SetVars') {
|
||
const vars = Array.isArray(data.variables) ? data.variables : [];
|
||
const names = vars.map(v => v?.name || '').filter(Boolean);
|
||
return `<div class="box preview">
|
||
<label>variables</label>
|
||
<textarea readonly>${escText(names.length ? names.join(', ') : '(нет переменных)')}</textarea>
|
||
<div class="hint">В шаблонах доступны как [[NAME]] и {{ NAME }}.</div>
|
||
</div>`;
|
||
}
|
||
if (type === 'Return') {
|
||
const tgt = (data.target_format || 'auto');
|
||
const tmpl = (data.text_template != null ? data.text_template : '[[OUT1]]');
|
||
return `<div class="box preview">
|
||
<label>target_format</label>
|
||
<input type="text" value="${escAttr(tgt)}" readonly />
|
||
<label>text_template (preview)</label>
|
||
<textarea readonly>${escText(tmpl)}</textarea>
|
||
</div>`;
|
||
}
|
||
return `<div class="box"></div>`;
|
||
}
|
||
|
||
// Helpers to manage human-readable original ids (nX)
|
||
function collectUsedOrigNums() {
|
||
try {
|
||
const data = window.editor && window.editor.export ? window.editor.export() : null;
|
||
const dfNodes = (data && data.drawflow && data.drawflow.Home && data.drawflow.Home.data) ? data.drawflow.Home.data : {};
|
||
const used = new Set();
|
||
for (const dfid in dfNodes) {
|
||
try {
|
||
const n = window.editor.getNodeFromId(parseInt(dfid, 10));
|
||
const orig = n && n.data && n.data._origId;
|
||
if (typeof orig === 'string') {
|
||
const m = orig.match(/^n(\d+)$/i);
|
||
if (m) used.add(parseInt(m[1], 10));
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
return used;
|
||
} catch (e) {
|
||
return new Set();
|
||
}
|
||
}
|
||
|
||
function nextFreeOrigId() {
|
||
const used = collectUsedOrigNums();
|
||
let x = 1;
|
||
while (used.has(x)) x += 1;
|
||
return 'n' + x;
|
||
}
|
||
|
||
function addNode(type, pos = {x: 100, y: 100}, data = {}) {
|
||
const io = NODE_IO[type];
|
||
const dataWithDefaults = applyNodeDefaults(type, data);
|
||
if (!dataWithDefaults._origId) {
|
||
try { dataWithDefaults._origId = nextFreeOrigId(); } catch (e) { dataWithDefaults._origId = ''; }
|
||
}
|
||
const html = makeNodeHtml(type, dataWithDefaults);
|
||
const id = editor.addNode(
|
||
type,
|
||
io.inputs.length,
|
||
io.outputs.length,
|
||
pos.x,
|
||
pos.y,
|
||
type,
|
||
dataWithDefaults,
|
||
html
|
||
);
|
||
// Привяжем данные к DOM для inline-редакторов
|
||
const el = document.querySelector(`#node-${id}`);
|
||
if (el) el.__data = editor.getNodeFromId(id).data;
|
||
return id;
|
||
}
|
||
|
||
// Инспектор
|
||
editor.on('nodeSelected', function(id) {
|
||
const n = editor.getNodeFromId(id);
|
||
renderInspector(id, n);
|
||
// Обновим визуальные классы для лучшей читабельности
|
||
const el = document.querySelector(`#node-${id}`);
|
||
if (el) {
|
||
el.style.background = 'transparent';
|
||
el.style.borderRadius = '10px';
|
||
}
|
||
});
|
||
editor.on('nodeCreated', function(id) {
|
||
const el = document.querySelector(`#node-${id}`);
|
||
if (el) el.__data = editor.getNodeFromId(id).data;
|
||
});
|
||
editor.on('nodeRemoved', function(id) {
|
||
document.getElementById('inspector-content').innerHTML = 'Выберите ноду…';
|
||
});
|
||
|
||
function renderInspector(id, node) {
|
||
const type = node.name;
|
||
node.data = applyNodeDefaults(type, node.data || {});
|
||
const data = node.data;
|
||
try { editor.updateNodeDataFromId(id, node.data); } catch(e) {}
|
||
const shownId = node.data?._origId || id;
|
||
let html = `<div><strong>${type}</strong> (#${shownId})</div>`;
|
||
if (type === 'ProviderCall') {
|
||
const cfg = getActiveCfg(data);
|
||
html += `
|
||
<label>provider</label>
|
||
<select id="f-provider">
|
||
<option value="openai">openai</option>
|
||
<option value="gemini">gemini</option>
|
||
<option value="claude">claude</option>
|
||
</select>
|
||
<label>base_url</label><input id="f-baseurl" type="text" value="${escAttr(cfg.base_url||'')}" placeholder="https://api.openai.com">
|
||
<label>endpoint</label><input id="f-endpoint" type="text" value="${escAttr(cfg.endpoint||'')}" placeholder="/v1/chat/completions">
|
||
<label>headers (JSON)</label><textarea id="f-headers">${escText(cfg.headers||'{}')}</textarea>
|
||
<label>template (JSON)</label>
|
||
<textarea id="f-template">${escText(cfg.template||'{}')}</textarea>
|
||
<div style="margin-top:6px">
|
||
<details class="help">
|
||
<summary title="Подсказка по шаблону">?</summary>
|
||
<div class="panel">
|
||
Шаблон поддерживает макросы и вставки из Prompt Blocks.
|
||
Рекомендуется использовать единый фрагмент <code>[[PROMPT]]</code> — он разворачивается провайдер‑специфично:
|
||
<br/>• openai → <code>"messages": [...]</code>
|
||
<br/>• gemini → <code>"contents": [...], "systemInstruction": {...}</code>
|
||
<br/>• claude → <code>"system": "...", "messages": [...]</code>
|
||
<br/>Также доступны структуры <code>{{ pm.* }}</code> для тонкой настройки (напр. <code>{{ pm.messages }}</code>).
|
||
<br/><strong>Важно:</strong> вход <code>depends</code> используется только для порядка выполнения, данные из него не читаются. Данные предыдущих нод вставляйте макросами <code>[[OUTx]]</code> или <code>[[OUT:nId...]]</code>.
|
||
</div>
|
||
</details>
|
||
</div>
|
||
`;
|
||
html += `
|
||
<div class="group-title" style="margin-top:16px">Prompt Blocks</div>
|
||
<div style="display:flex; gap:8px; align-items:center; margin-bottom:6px">
|
||
<button id="pm-add">Создать блок</button>
|
||
<details class="help" style="margin-left:4px">
|
||
<summary title="Подсказка по Prompt Blocks">?</summary>
|
||
<div class="panel">
|
||
Перетаскивайте блоки для изменения порядка. Включайте/выключайте тумблером.
|
||
<br/>Доступна переменная <code>[[PROMPT]]</code> — единый JSON‑фрагмент из этих блоков. Вставьте её в template объекта, например:
|
||
<br/><code>{ "model":"{{ model }}", [[PROMPT]], "temperature": {{ params.temperature|default(0.7) }} }</code>
|
||
<br/>Она автоматически формируется в зависимости от выбранного провайдера ноды.
|
||
</div>
|
||
</details>
|
||
</div>
|
||
<ul id="pm-list" style="list-style:none; padding-left:0; margin:0;"></ul>
|
||
<div id="pm-editor" style="margin-top:10px; display:none">
|
||
<label>Name</label>
|
||
<input id="pm-name" type="text" value="">
|
||
<label>Role</label>
|
||
<select id="pm-role">
|
||
<option value="system">system</option>
|
||
<option value="user">user</option>
|
||
<option value="assistant">assistant</option>
|
||
<option value="tool">tool</option>
|
||
</select>
|
||
<label>Prompt</label>
|
||
<textarea id="pm-prompt" rows="6"></textarea>
|
||
<div style="display:flex; gap:8px; margin-top:8px">
|
||
<button id="pm-save">Сохранить</button>
|
||
<button id="pm-cancel">Отмена</button>
|
||
</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">
|
||
<label>override_path</label><input id="f-override" type="text" value="${escAttr(data.override_path||'')}" placeholder="переопределить путь (опционально)">
|
||
<label><input id="f-pass" type="checkbox" ${(data.passthrough_headers??true)?'checked':''}> passthrough_headers</label>
|
||
<label>extra_headers (JSON)</label><textarea id="f-extra">${escText(data.extra_headers||'{}')}</textarea>
|
||
<div class="hint">Берёт path, query, headers, json из incoming.*</div>
|
||
`;
|
||
} else if (type === 'Return') {
|
||
html += `
|
||
<label>target_format</label>
|
||
<select id="ret-target">
|
||
<option value="auto">auto (из исходного запроса)</option>
|
||
<option value="openai">openai</option>
|
||
<option value="gemini">gemini</option>
|
||
<option value="claude">claude</option>
|
||
</select>
|
||
<label>text_template</label>
|
||
<textarea id="ret-template" rows="4">${escText(data.text_template ?? '[[OUT1]]')}</textarea>
|
||
<div class="hint">Финализирует ответ в выбранный протокол. Макросы [[VAR:...]], [[OUT:...]], [[OUTx]], {{ ... }} поддерживаются.</div>
|
||
`;
|
||
} else if (type === 'SetVars') {
|
||
const list = Array.isArray(data.variables) ? data.variables : [];
|
||
const rows = list.map((v, i) => {
|
||
const name = escAttr(v?.name || '');
|
||
const mode = (v?.mode || 'string');
|
||
const value = escText(v?.value || '');
|
||
return `
|
||
<div class="var-row" data-idx="${i}" style="border:1px solid #2b3646;border-radius:6px;padding:8px;margin:6px 0">
|
||
<div style="display:flex;gap:8px;align-items:center">
|
||
<label style="min-width:60px">name</label>
|
||
<input class="v-name" type="text" value="${name}" placeholder="MY_VAR" style="flex:1">
|
||
<label style="min-width:56px">mode</label>
|
||
<select class="v-mode">
|
||
<option value="string"${mode==='string'?' selected':''}>string</option>
|
||
<option value="expr"${mode==='expr'?' selected':''}>expr</option>
|
||
</select>
|
||
<button class="v-del" title="Удалить">🗑</button>
|
||
</div>
|
||
<label style="margin-top:6px;display:block">value</label>
|
||
<textarea class="v-value" rows="3">${value}</textarea>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
html += `
|
||
<div class="group-title" style="margin-top:8px">Переменные</div>
|
||
<div id="vars-list">${rows || '<div class="hint">(нет переменных)</div>'}</div>
|
||
<div style="margin-top:8px">
|
||
<button id="vars-add">Добавить переменную</button>
|
||
</div>
|
||
<div class="hint" style="margin-top:6px">Переменные доступны в шаблонах как [[NAME]] и {{ NAME }}. Mode=expr — мини‑формулы без доступа к Python.</div>
|
||
`;
|
||
}
|
||
// Подсказка по портам (где 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>
|
||
</div>
|
||
`;
|
||
// html += makeNodeHtml(type, data); // Убираем дублирование превью в инспекторе
|
||
document.getElementById('inspector-content').innerHTML = html;
|
||
const el = document.querySelector(`#node-${id}`);
|
||
document.querySelectorAll('#inspector textarea, #inspector input, #inspector select').forEach(inp => {
|
||
inp.addEventListener('input', () => {
|
||
const n = editor.getNodeFromId(id);
|
||
if (!n) return;
|
||
const d = n.data || {};
|
||
if (type === 'ProviderCall') {
|
||
ensureProviderConfigs(d);
|
||
const p = (d.provider || 'openai').toLowerCase();
|
||
const cfg = d.provider_configs[p] || (d.provider_configs[p] = providerDefaults(p));
|
||
if (inp.id === 'f-template') cfg.template = inp.value;
|
||
if (inp.id === 'f-baseurl') cfg.base_url = inp.value;
|
||
if (inp.id === 'f-endpoint') cfg.endpoint = inp.value;
|
||
if (inp.id === 'f-headers') cfg.headers = inp.value;
|
||
if (inp.id === 'f-provider') d.provider = inp.value; // select changes provider
|
||
// Синхронизуем в Drawflow, чтобы export() видел обновления
|
||
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;
|
||
if (inp.id === 'f-extra') d.extra_headers = inp.value;
|
||
if (inp.id === 'f-override') d.override_path = inp.value;
|
||
if (inp.id === 'f-pass') d.passthrough_headers = inp.checked;
|
||
// Синхронизуем в Drawflow, чтобы export() видел обновления
|
||
try { editor.updateNodeDataFromId(id, d); } catch (e) {}
|
||
const el = document.querySelector(`#node-${id}`);
|
||
if (el) el.__data = d;
|
||
} else if (type === 'Return') {
|
||
if (inp.id === 'ret-target') d.target_format = inp.value;
|
||
if (inp.id === 'ret-template') d.text_template = inp.value;
|
||
try { editor.updateNodeDataFromId(id, d); } catch (e) {}
|
||
const el = document.querySelector(`#node-${id}`);
|
||
if (el) el.__data = d;
|
||
} else if (type === 'SetVars') {
|
||
// Для SetVars синхронизацию выполняют специализированные обработчики ниже (resync).
|
||
// Здесь ничего не делаем, чтобы не затереть значения.
|
||
return;
|
||
} else {
|
||
// Прочие типы — на будущее: безопасная синхронизация без изменений
|
||
try { editor.updateNodeDataFromId(id, d); } catch (e) {}
|
||
const el = document.querySelector(`#node-${id}`);
|
||
if (el) el.__data = d;
|
||
}
|
||
});
|
||
});
|
||
|
||
// Обработчики для SetVars
|
||
if (type === 'SetVars') {
|
||
const n = editor.getNodeFromId(id);
|
||
if (n) {
|
||
if (!Array.isArray(n.data.variables)) n.data.variables = [];
|
||
// Начальный sync, чтобы DOM.__data сразу содержал variables для сериализации
|
||
try { editor.updateNodeDataFromId(id, n.data || {}); } catch (e) {}
|
||
const el0 = document.querySelector(`#node-${id}`);
|
||
if (el0) el0.__data = JSON.parse(JSON.stringify(n.data || {}));
|
||
}
|
||
const root = document.getElementById('vars-list');
|
||
const addBtn = document.getElementById('vars-add');
|
||
function resync() {
|
||
const nn = editor.getNodeFromId(id);
|
||
if (!nn) return;
|
||
try { editor.updateNodeDataFromId(id, nn.data || {}); } catch (e) {}
|
||
const el = document.querySelector(`#node-${id}`);
|
||
if (el) el.__data = JSON.parse(JSON.stringify(nn.data || {}));
|
||
}
|
||
if (addBtn) {
|
||
addBtn.addEventListener('click', () => {
|
||
const nn = editor.getNodeFromId(id); if (!nn) return;
|
||
const d = nn.data || {}; if (!Array.isArray(d.variables)) d.variables = [];
|
||
d.variables.push({ id: 'v'+Date.now().toString(36), name: 'NAME', mode: 'string', value: '' });
|
||
try { editor.updateNodeDataFromId(id, d); } catch (e) {}
|
||
resync();
|
||
renderInspector(id, editor.getNodeFromId(id));
|
||
});
|
||
}
|
||
if (root) {
|
||
root.querySelectorAll('.var-row').forEach(row => {
|
||
const idx = parseInt(row.getAttribute('data-idx') || '-1', 10);
|
||
const nameInp = row.querySelector('.v-name');
|
||
const modeSel = row.querySelector('.v-mode');
|
||
const valTxt = row.querySelector('.v-value');
|
||
const delBtn = row.querySelector('.v-del');
|
||
if (nameInp) nameInp.addEventListener('input', () => {
|
||
const nn = editor.getNodeFromId(id); if (!nn) return;
|
||
const d = nn.data || {}; if (!Array.isArray(d.variables)) d.variables = [];
|
||
if (d.variables[idx]) d.variables[idx].name = nameInp.value;
|
||
resync();
|
||
});
|
||
if (modeSel) modeSel.addEventListener('change', () => {
|
||
const nn = editor.getNodeFromId(id); if (!nn) return;
|
||
const d = nn.data || {}; if (!Array.isArray(d.variables)) d.variables = [];
|
||
if (d.variables[idx]) d.variables[idx].mode = modeSel.value;
|
||
resync();
|
||
});
|
||
if (valTxt) valTxt.addEventListener('input', () => {
|
||
const nn = editor.getNodeFromId(id); if (!nn) return;
|
||
const d = nn.data || {}; if (!Array.isArray(d.variables)) d.variables = [];
|
||
if (d.variables[idx]) d.variables[idx].value = valTxt.value;
|
||
resync();
|
||
});
|
||
if (delBtn) delBtn.addEventListener('click', () => {
|
||
const nn = editor.getNodeFromId(id); if (!nn) return;
|
||
const d = nn.data || {}; if (!Array.isArray(d.variables)) d.variables = [];
|
||
d.variables.splice(idx, 1);
|
||
try { editor.updateNodeDataFromId(id, d); } catch (e) {}
|
||
resync();
|
||
renderInspector(id, editor.getNodeFromId(id));
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
// Поддержка select#f-provider + автоподстановка пресетов (без жесткого перезаписывания ручных правок)
|
||
const provSel = document.getElementById('f-provider');
|
||
if (provSel) {
|
||
// Установим текущее значение
|
||
provSel.value = (node.data?.provider || 'openai');
|
||
provSel.addEventListener('change', () => {
|
||
const n = editor.getNodeFromId(id);
|
||
if (!n) return;
|
||
const d = n.data || {};
|
||
d.provider = provSel.value;
|
||
ensureProviderConfigs(d);
|
||
const cfg = getActiveCfg(d);
|
||
const baseEl = document.getElementById('f-baseurl');
|
||
const endEl = document.getElementById('f-endpoint');
|
||
const headEl = document.getElementById('f-headers');
|
||
const tmplEl = document.getElementById('f-template');
|
||
|
||
// Отображаем сохранённые значения выбранного провайдера
|
||
if (baseEl) baseEl.value = cfg.base_url || '';
|
||
if (endEl) endEl.value = cfg.endpoint || '';
|
||
if (headEl) headEl.value = (cfg.headers != null ? cfg.headers : '{}');
|
||
if (tmplEl) tmplEl.value = (cfg.template != null ? cfg.template : '{}');
|
||
|
||
try { editor.updateNodeDataFromId(id, d); } catch (e) {}
|
||
const el = document.querySelector(`#node-${id}`);
|
||
if (el) el.__data = d;
|
||
try { console.debug('[ProviderCall] provider switched to', d.provider, cfg); } catch (e) {}
|
||
});
|
||
}
|
||
// Init Return selects defaults if present
|
||
try {
|
||
const tgtSel = document.getElementById('ret-target');
|
||
if (tgtSel) {
|
||
tgtSel.value = (node.data?.target_format || 'auto');
|
||
}
|
||
} catch (e) {}
|
||
|
||
// Кнопка сохранить параметры
|
||
const saveBtnNode = document.getElementById('btn-save-node');
|
||
if (saveBtnNode) {
|
||
saveBtnNode.addEventListener('click', () => {
|
||
const n = editor.getNodeFromId(id);
|
||
if (!n) return;
|
||
// Для SetVars дополнительно читаем текущие значения из DOM, чтобы гарантированно не потерять value
|
||
if (type === 'SetVars') {
|
||
const root = document.getElementById('vars-list');
|
||
const varsNew = [];
|
||
if (root) {
|
||
root.querySelectorAll('.var-row').forEach(row => {
|
||
const idx = parseInt(row.getAttribute('data-idx') || '-1', 10);
|
||
const name = (row.querySelector('.v-name')?.value ?? '').trim();
|
||
const mode = (row.querySelector('.v-mode')?.value ?? 'string');
|
||
const value = (row.querySelector('.v-value')?.value ?? '');
|
||
if (name) {
|
||
// сохраняем прежний id при наличии, чтобы не мигали идентификаторы
|
||
const prevId = (n.data?.variables && n.data.variables[idx] && n.data.variables[idx].id) ? n.data.variables[idx].id : ('v'+Date.now().toString(36)+idx);
|
||
varsNew.push({ id: prevId, name, mode, value });
|
||
}
|
||
});
|
||
}
|
||
if (!Array.isArray(n.data.variables)) n.data.variables = [];
|
||
n.data.variables = varsNew;
|
||
}
|
||
// Синхронизируем данные узла в Drawflow и в DOM.__data — это источник правды для toPipelineJSON()
|
||
try { editor.updateNodeDataFromId(id, n.data || {}); } catch (e) {}
|
||
const el = document.querySelector(`#node-${id}`);
|
||
if (el) el.__data = JSON.parse(JSON.stringify(n.data || {}));
|
||
// Отладочный статус, чтобы видеть, что реально уйдёт в pipeline.json
|
||
try {
|
||
if (type === 'SetVars') {
|
||
status('SetVars saved: ' + JSON.stringify((n.data && n.data.variables) ? n.data.variables : []));
|
||
}
|
||
} catch (e) {}
|
||
try { savePipeline(); } catch (e) {}
|
||
status("Параметры ноды сохранены в pipeline.json");
|
||
});
|
||
}
|
||
|
||
// ensure blocks explicitly kept in node data
|
||
const ncheck = editor.getNodeFromId(id);
|
||
if (ncheck && Array.isArray(ncheck.data.blocks)) {
|
||
ncheck.data.blocks = [...ncheck.data.blocks];
|
||
}
|
||
// ensure variables explicitly kept in node data (for SetVars)
|
||
if (ncheck && Array.isArray(ncheck.data.variables)) {
|
||
// глубокая копия, чтобы serialization взяла актуальные значения
|
||
ncheck.data.variables = ncheck.data.variables.map(v => ({ ...(v || {}) }));
|
||
try { editor.updateNodeDataFromId(id, ncheck.data); } catch (e) {}
|
||
const elN = document.querySelector(`#node-${id}`);
|
||
if (elN) elN.__data = JSON.parse(JSON.stringify(ncheck.data || {}));
|
||
}
|
||
// Prompt Manager UI for ProviderCall
|
||
if (type === 'ProviderCall') { PM.setupProviderCallPMUI(editor, id); }
|
||
}
|
||
// Добавление нод из сайдбара
|
||
document.querySelectorAll('.node-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const type = btn.dataset.node;
|
||
addNode(type, {x: 120 + Math.random()*60, y: 120 + Math.random()*40});
|
||
});
|
||
});
|
||
|
||
// Сериализация вынесена во внешний файл /ui/js/serialization.js
|
||
|
||
// Десериализация вынесена во внешний файл /ui/js/serialization.js
|
||
|
||
// Загрузка/сохранение
|
||
async function loadPipeline() {
|
||
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('Загружено');
|
||
}
|
||
async function savePipeline() {
|
||
try {
|
||
const p = window.AgentUISer.toPipelineJSON();
|
||
const res = await fetch('/admin/pipeline', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(p) });
|
||
const out = await res.json();
|
||
status('Сохранено: ' + JSON.stringify(out) + ' | nodes=' + (p.nodes?.length || 0));
|
||
} catch (err) {
|
||
status('Ошибка сохранения пайплайна: ' + (err?.message || String(err)));
|
||
}
|
||
}
|
||
async function refreshPresets() {
|
||
const res = await fetch('/admin/presets');
|
||
const j = await res.json();
|
||
const sel = document.getElementById('preset-select');
|
||
sel.innerHTML = '';
|
||
(j.items||[]).forEach(name => {
|
||
const opt = document.createElement('option');
|
||
opt.value = name; opt.textContent = name; sel.appendChild(opt);
|
||
});
|
||
}
|
||
async function savePreset() {
|
||
const name = document.getElementById('preset-name').value.trim();
|
||
if (!name) { status('Укажите имя пресета'); return; }
|
||
try {
|
||
const p = window.AgentUISer.toPipelineJSON();
|
||
const res = await fetch('/admin/presets/' + encodeURIComponent(name), { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(p) });
|
||
const out = await res.json();
|
||
status('Пресет сохранён: ' + JSON.stringify(out) + ' | nodes=' + (p.nodes?.length || 0));
|
||
refreshPresets();
|
||
} catch (err) {
|
||
status('Ошибка сохранения пресета: ' + (err?.message || String(err)));
|
||
}
|
||
}
|
||
async function loadPreset() {
|
||
const name = document.getElementById('preset-select').value;
|
||
if (!name) { status('Выберите пресет'); return; }
|
||
const res = await fetch('/admin/presets/' + encodeURIComponent(name));
|
||
const p = await res.json();
|
||
await window.AgentUISer.fromPipelineJSON(p);
|
||
// Сделаем загруженный пресет активным пайплайном (сохранение в pipeline.json)
|
||
await fetch('/admin/pipeline', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(p) });
|
||
// Не затираем логи соединений, если они уже выведены
|
||
const st = document.getElementById('status').textContent || '';
|
||
if (!st || !st.includes('(links)')) {
|
||
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');
|
||
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);
|
||
|
||
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,
|
||
};
|
||
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);
|
||
} 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>
|
||
<!-- SSE highlight script -->
|
||
<script>
|
||
(function() {
|
||
try {
|
||
const timers = new Map();
|
||
|
||
function getStatusEl() {
|
||
return document.getElementById('status');
|
||
}
|
||
function setStatus(txt) {
|
||
try { const el = getStatusEl(); if (el) el.textContent = txt; } catch (e) {}
|
||
}
|
||
|
||
function findNodeElByOrigId(origId) {
|
||
if (!origId && origId !== 0) return null;
|
||
// 1) Прямая попытка по DOM id (Drawflow id)
|
||
const byDfId = document.getElementById('node-' + origId);
|
||
if (byDfId) return byDfId;
|
||
|
||
// 2) По _origId, хранящемуся в DOM.__data
|
||
const nodes = document.querySelectorAll('.drawflow .drawflow-node');
|
||
for (const el of nodes) {
|
||
const d = el && el.__data;
|
||
if (!d) continue;
|
||
if (String(d._origId) === String(origId)) return el;
|
||
// fallback: иногда id дублируется как d.id
|
||
if (String(d.id) === String(origId)) return el;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function clearTempTimer(el) {
|
||
const t = timers.get(el);
|
||
if (t) {
|
||
clearTimeout(t);
|
||
timers.delete(el);
|
||
}
|
||
}
|
||
|
||
function addTempClass(el, cls, ms) {
|
||
clearTempTimer(el);
|
||
el.classList.add(cls);
|
||
const t = setTimeout(() => {
|
||
el.classList.remove(cls);
|
||
timers.delete(el);
|
||
}, ms);
|
||
timers.set(el, t);
|
||
}
|
||
|
||
function handleTraceEvent(evt) {
|
||
if (!evt || typeof evt !== 'object') return;
|
||
const nodeId = evt.node_id;
|
||
const el = findNodeElByOrigId(nodeId);
|
||
if (!el) return;
|
||
|
||
// Сбрасываем конфликтующие временные классы
|
||
if (evt.event === 'node_start') {
|
||
clearTempTimer(el);
|
||
el.classList.add('node-running');
|
||
el.classList.remove('node-ok', 'node-err');
|
||
} else if (evt.event === 'node_done') {
|
||
el.classList.remove('node-running');
|
||
addTempClass(el, 'node-ok', 1500);
|
||
} else if (evt.event === 'node_error') {
|
||
el.classList.remove('node-running');
|
||
addTempClass(el, 'node-err', 2500);
|
||
}
|
||
}
|
||
|
||
// Открываем SSE поток
|
||
const es = new EventSource('/admin/trace/stream');
|
||
es.onmessage = (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
handleTraceEvent(data);
|
||
} catch (_) {
|
||
// игнорируем мусор
|
||
}
|
||
};
|
||
es.onerror = () => {
|
||
// Можно тихо игнорировать; при необходимости — вывести статус
|
||
// setStatus('SSE: disconnected');
|
||
};
|
||
|
||
// Экспорт для отладки из консоли
|
||
window.__TraceSSE = { es, handleTraceEvent, findNodeElByOrigId };
|
||
} catch (e) {
|
||
try { console.error('SSE highlight init error', e); } catch (_) {}
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html> |