Initial import

This commit is contained in:
2025-09-07 22:33:51 +03:00
commit 727cb2a4e3
23 changed files with 3480 additions and 0 deletions

304
static/editor.css Normal file
View File

@@ -0,0 +1,304 @@
:root {
/* Цвета темы (совпадают с editor.html) */
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: #7aa2f7;
--connector-muted: #3b82f6;
}
/* Узлы: аккуратные контейнеры + предотвращение вылезания текста */
.drawflow .drawflow-node {
background: transparent !important;
box-shadow: none !important;
}
.drawflow .drawflow-node .title-box {
background: var(--node);
border: 1px solid var(--node-border);
color: #e5e7eb;
border-radius: 12px 12px 0 0;
padding: 6px 10px;
}
.drawflow .drawflow-node .box {
background: var(--node);
border: 1px solid var(--node-border);
border-top: 0;
color: #e5e7eb;
border-radius: 0 0 12px 12px;
overflow: hidden; /* не даём контенту вылезать за края */
}
.drawflow .drawflow-node .box textarea,
.drawflow .drawflow-node .box pre,
.drawflow .drawflow-node .box input[type="text"] {
background: #0f141a;
border: 1px solid #2b3646;
border-radius: 8px;
color: #e5e7eb;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.df-node .box textarea {
white-space: pre-wrap;
word-break: break-word;
overflow: auto;
max-height: 180px; /* предотвращаем бесконечную высоту */
}
/* Выделение выбранного узла — мягкое */
.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) 40%, transparent);
}
/* Порты: более аккуратные, без «оранжевого» */
.drawflow .drawflow-node .inputs .input,
.drawflow .drawflow-node .outputs .output {
background: var(--accent-2) !important;
border: 2px solid color-mix(in srgb, var(--accent-2) 70%, white 0%) !important;
width: 12px !important;
height: 12px !important;
box-shadow: 0 0 0 2px rgba(0,0,0,.25);
}
/* Линии соединений: плавные, аккуратные цвета */
.drawflow .connection .main-path {
stroke: var(--connector) !important;
stroke-width: 2.5px !important;
opacity: 0.95 !important;
}
.drawflow .connection .main-path.selected,
.drawflow .connection:hover .main-path {
stroke: var(--accent-2) !important;
stroke-width: 3px !important;
}
/* Точки изгибов/ручки */
.drawflow .connection .point {
stroke: var(--connector-muted) !important;
fill: var(--panel) !important;
}
/* Убираем «уродливый крестик» удаления соединений (оставляем удаление через контекст-меню/клавиши) */
.drawflow .connection .delete,
.drawflow .connection .remove,
.drawflow .connection .connection-remove,
.drawflow .connection [class*="remove"],
.drawflow .connection [class*="delete"] {
display: none !important;
}
/* Сайдбар: выравнивание и аккуратные подсказки */
.group-title {
font-size: 12px;
text-transform: uppercase;
color: var(--muted);
margin: 12px 0 6px;
letter-spacing: .08em;
}
.hint {
color: var(--muted);
font-size: 12px;
margin-top: 4px;
}
details.help { margin: 6px 0; }
details.help summary {
list-style: none;
cursor: pointer;
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: #334155;
color: #e5e7eb;
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;
}
/* Инпуты/тексты внутри нод — одинаковые отступы и скругления */
textarea, input[type=text] {
width: 100%;
background: #0f141a;
color: #e5e7eb;
border: 1px solid #2b3646;
border-radius: 8px;
padding: 8px;
}
/* Кнопки */
button {
background: #1f2937;
border: 1px solid #334155;
color: #e5e7eb;
padding: 6px 10px;
border-radius: 8px;
cursor: pointer;
}
button:hover { background: #273246; }
/* Внутренние заголовки в блоке ноды */
#inspector label { font-size: 12px; color: var(--muted); display: block; margin: 8px 0 4px; }
/* Мелкие фиксы */
.drawflow .drawflow-node .input, .drawflow .drawflow-node .output { color: var(--muted); }
/* Connection delete control — show and restyle (kept functional) */
.drawflow .connection foreignObject,
.drawflow .connection [class*="remove"],
.drawflow .connection [class*="delete"],
.drawflow .connection .connection-remove {
display: inline-flex !important;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 999px;
background: #0f141a;
color: #e5e7eb;
border: 1px solid #334155;
box-shadow: 0 2px 6px rgba(0,0,0,.35);
cursor: pointer;
opacity: .85;
transition: transform .12s ease, opacity .12s ease, box-shadow .12s ease, border-color .12s ease, background-color .12s ease;
}
.drawflow .connection:hover foreignObject,
.drawflow .connection:hover [class*="remove"],
.drawflow .connection:hover [class*="delete"],
.drawflow .connection:hover .connection-remove {
opacity: 1;
transform: scale(1.05);
border-color: var(--accent-2);
box-shadow: 0 0 0 3px rgba(96,165,250,.20), 0 4px 10px rgba(0,0,0,.35);
}
/* If delete control is rendered inside foreignObject, normalize inner box */
.drawflow .connection foreignObject div,
.drawflow .connection foreignObject span {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 999px;
background: #0f141a;
color: #e5e7eb;
border: 1px solid #334155;
}
/* If delete control is rendered as SVG text "x" */
.drawflow .connection text {
font-family: Inter, system-ui, Arial, sans-serif;
font-size: 12px;
font-weight: 700;
fill: #e5e7eb;
}
/* Subtle canvas background (lightweight dot grid) */
#canvas {
background-color: var(--bg);
background-image: radial-gradient(circle at 1px 1px, rgba(255,255,255,0.06) 1px, transparent 0);
background-size: 24px 24px;
}
/* Port hover affordance (no heavy effects) */
.drawflow .drawflow-node .inputs .input,
.drawflow .drawflow-node .outputs .output {
transition: transform .08s ease;
will-change: transform;
}
.drawflow .drawflow-node .inputs .input:hover,
.drawflow .drawflow-node .outputs .output:hover {
transform: scale(1.25);
box-shadow: 0 0 0 3px rgba(96,165,250,.25);
}
/* Node delete "X" — minimal, clean, consistent with theme (kept functional) */
.drawflow .drawflow-node .close {
position: absolute !important; /* stays in node corner */
top: -8px !important;
right: -8px !important;
width: 18px !important;
height: 18px !important;
display: grid !important;
place-items: center !important;
border-radius: 999px !important;
font-size: 12px !important;
line-height: 1 !important;
font-weight: 700 !important;
background: #0f141a !important; /* dark chip */
color: #e5e7eb !important;
border: 1px solid #334155 !important; /* subtle border */
box-shadow: 0 2px 6px rgba(0,0,0,.35) !important;
cursor: pointer !important;
z-index: 10 !important;
transition: transform .12s ease, box-shadow .12s ease, background-color .12s ease, border-color .12s ease, color .12s ease !important;
}
.drawflow .drawflow-node .close:hover {
transform: scale(1.06) !important;
background: #1f2937 !important;
border-color: var(--accent-2) !important;
color: #f8fafc !important;
box-shadow: 0 0 0 3px rgba(96,165,250,.22), 0 4px 10px rgba(0,0,0,.35) !important;
}
.drawflow .drawflow-node .close:active {
transform: scale(0.98) !important;
box-shadow: 0 0 0 2px rgba(96,165,250,.20), 0 2px 6px rgba(0,0,0,.35) !important;
}
/* Drawflow floating delete handle (class: .drawflow-delete) — restyle but keep behavior */
#drawflow .drawflow-delete,
.drawflow-delete {
position: absolute !important;
transform: translate(-50%, -50%) !important;
width: 20px !important;
height: 20px !important;
display: grid !important;
place-items: center !important;
border-radius: 999px !important;
background: #0f141a !important;
border: 1px solid #334155 !important;
color: transparent !important; /* hide default "x" text to avoid double symbol */
box-shadow: 0 2px 6px rgba(0,0,0,.35) !important;
cursor: pointer !important;
z-index: 1000 !important;
transition: transform .12s ease, box-shadow .12s ease, background-color .12s ease, border-color .12s ease !important;
}
#drawflow .drawflow-delete::before,
.drawflow-delete::before {
content: "×";
font-family: Inter, system-ui, Arial, sans-serif;
font-size: 13px;
font-weight: 700;
line-height: 1;
color: #e5e7eb;
}
#drawflow .drawflow-delete:hover,
.drawflow-delete:hover {
transform: translate(-50%, -50%) scale(1.06) !important;
background: #1f2937 !important;
border-color: var(--accent-2) !important;
box-shadow: 0 0 0 3px rgba(96,165,250,.22), 0 4px 10px rgba(0,0,0,.35) !important;
}
#drawflow .drawflow-delete:active,
.drawflow-delete:active {
transform: translate(-50%, -50%) scale(0.97) !important;
}

981
static/editor.html Normal file
View File

@@ -0,0 +1,981 @@
<!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" />
</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>
<a href="/" style="text-decoration:none"><button>Домой</button></a>
</div>
</header>
<div id="container">
<aside id="sidebar">
<div class="group-title">Ноды</div>
<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>
<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="Температура выборки (01)">[[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>,
<code title="Вторичный ключ или секрет, если задан">[[incoming.api_keys.secret]]</code>
</div>
<div class="hint"><strong>Быстрые макросы:</strong>
<code title="Единый JSONфрагмент из Prompt Blocks (подставляется провайдер‑специфично)">[[PROMPT]]</code>,
<code title="Текст из выхода ноды n1 (besteffort, вытаскивает content/text из JSON ответа)">[[OUT1]]</code>,
<code title="Текст из выхода ноды n2">[[OUT2]]</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>
// Типы портов и их имена в нашем контракте
const NODE_IO = {
// depends: используется только для порядка выполнения (зависимости), данные не читаются
ProviderCall: { inputs: ['depends'], outputs: ['result','response_text'] },
RawForward: { inputs: [], outputs: ['result'] }
};
const editor = new Drawflow(document.getElementById('drawflow'));
editor.reroute = true;
editor.start();
// Провайдерные пресеты для 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] || {};
}
// Нормализуем/заполняем дефолты конфигов нод, чтобы ключи попадали в сериализацию
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 = '{}';
}
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="${provider}" readonly />
<label>base_url</label>
<input type="text" value="${base_url.replace(/"/g,'&quot;')}" readonly />
<label>endpoint</label>
<input type="text" value="${endpoint.replace(/"/g,'&quot;')}" readonly />
<label>headers (preview JSON)</label>
<textarea readonly>${headers.replace(/</g,'&lt;')}</textarea>
<label>template (preview JSON)</label>
<textarea readonly>${template.replace(/</g,'&lt;')}</textarea>
</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="${base_url.replace(/"/g,'&quot;')}" readonly />
<label>override_path</label>
<input type="text" value="${override_path.replace(/"/g,'&quot;')}" readonly />
<label><input type="checkbox" ${passthrough_headers} disabled/> passthrough_headers</label>
<label>extra_headers (preview JSON)</label>
<textarea readonly>${extra_headers.replace(/</g,'&lt;')}</textarea>
</div>`;
}
return `<div class="box"></div>`;
}
function addNode(type, pos = {x: 100, y: 100}, data = {}) {
const io = NODE_IO[type];
const dataWithDefaults = applyNodeDefaults(type, data);
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="${(cfg.base_url||'').replace(/"/g,'"')}" placeholder="https://api.openai.com">
<label>endpoint</label><input id="f-endpoint" type="text" value="${(cfg.endpoint||'').replace(/"/g,'"')}" placeholder="/v1/chat/completions">
<label>headers (JSON)</label><textarea id="f-headers">${(cfg.headers||'{}').replace(/</g,'<')}</textarea>
<label>template (JSON)</label>
<textarea id="f-template">${(cfg.template||'{}').replace(/</g,'<')}</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 === 'RawForward') {
html += `
<label>base_url</label><input id="f-baseurl" type="text" value="${(data.base_url||'').replace(/"/g,'&quot;')}" placeholder="https://api.openai.com">
<label>override_path</label><input id="f-override" type="text" value="${(data.override_path||'').replace(/"/g,'&quot;')}" 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">${(data.extra_headers||'{}').replace(/</g,'&lt;')}</textarea>
<div class="hint">Берёт path, query, headers, json из incoming.*</div>
`;
}
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}`);
if (el) el.__data = node.data; // синхронизация
document.querySelectorAll('#inspector textarea, #inspector input').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
} else {
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;
});
});
// Поддержка 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) {}
});
}
// Кнопка сохранить параметры
const saveBtnNode = document.getElementById('btn-save-node');
if (saveBtnNode) {
saveBtnNode.addEventListener('click', () => {
const n = editor.getNodeFromId(id);
if (!n) return;
try { editor.updateNodeDataFromId(id, n.data || {}); } catch (e) {}
const el = document.querySelector(`#node-${id}`);
if (el) el.__data = JSON.parse(JSON.stringify(n.data || {}));
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];
}
// Prompt Manager UI for ProviderCall
if (type === 'ProviderCall') {
const n2 = editor.getNodeFromId(id);
const d2 = n2.data;
if (!Array.isArray(d2.blocks)) d2.blocks = [];
// Ensure node.data and DOM __data always reflect latest blocks
function syncNodeDataBlocks() {
try {
const n = editor.getNodeFromId(id);
if (!n) return;
// Готовим новые данные с глубокой копией blocks
const newData = { ...(n.data || {}) , blocks: Array.isArray(d2.blocks) ? d2.blocks.map(b => ({...b})) : [] };
// 1) Обновляем внутреннее состояние Drawflow, чтобы export() возвращал актуальные данные
try { editor.updateNodeDataFromId(id, newData); } catch (e) {}
// 2) Обновляем DOM-отражение (источник правды для toPipelineJSON)
const el2 = document.querySelector(`#node-${id}`);
if (el2) el2.__data = JSON.parse(JSON.stringify(newData));
} catch (e) {}
}
// Initial sync to attach blocks into __data for toPipelineJSON
syncNodeDataBlocks();
const listEl = document.getElementById('pm-list');
const addBtn = document.getElementById('pm-add');
const editorBox = document.getElementById('pm-editor');
const nameInp = document.getElementById('pm-name');
const roleSel = document.getElementById('pm-role');
const promptTxt = document.getElementById('pm-prompt');
const saveBtn = document.getElementById('pm-save');
const cancelBtn = document.getElementById('pm-cancel');
let editingId = null;
// Изменения блока применяются только по кнопке «Сохранить» внутри редактора блока.
// --- FIX: Drag&Drop через SortableJS ---
if (window.Sortable && listEl && !listEl.__sortable) {
listEl.__sortable = new Sortable(listEl, {
animation: 150,
handle: '.pm-handle',
onEnd(evt) {
const oldIndex = evt.oldIndex;
const newIndex = evt.newIndex;
if (oldIndex === newIndex) return;
const moved = d2.blocks.splice(oldIndex, 1)[0];
d2.blocks.splice(newIndex, 0, moved);
d2.blocks.forEach((b,i)=> b.order = i);
syncNodeDataBlocks();
}
});
}
function sortAndReindex() {
d2.blocks.sort((a,b)=> (a.order ?? 0) - (b.order ?? 0));
d2.blocks.forEach((b,i)=> b.order = i);
}
function findBlockByDomId(domId) {
return d2.blocks.find(b => (b.id || '') === domId);
}
function renderList() {
sortAndReindex();
listEl.innerHTML = '';
d2.blocks.forEach((b,i)=>{
const domId = b.id || ('b'+i);
const li = document.createElement('li');
li.draggable = true;
li.dataset.id = domId;
li.style.display = 'flex';
li.style.alignItems = 'center';
li.style.gap = '6px';
li.style.padding = '4px 0';
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>
<button class="pm-edit" title="Редактировать">✎</button>
<button class="pm-del" title="Удалить">🗑</button>
`;
// DnD
li.addEventListener('dragstart', e => { e.dataTransfer.setData('text/plain', domId); });
li.addEventListener('dragover', e => { e.preventDefault(); });
li.addEventListener('drop', e => {
e.preventDefault();
const srcId = e.dataTransfer.getData('text/plain');
const tgtId = domId;
if (!srcId || srcId === tgtId) return;
const srcIdx = d2.blocks.findIndex(x => (x.id||'') === srcId);
const tgtIdx = d2.blocks.findIndex(x => (x.id||'') === tgtId);
if (srcIdx < 0 || tgtIdx < 0) return;
const [moved] = d2.blocks.splice(srcIdx, 1);
d2.blocks.splice(tgtIdx, 0, moved);
sortAndReindex();
renderList();
syncNodeDataBlocks();
});
// toggle
li.querySelector('.pm-enabled').addEventListener('change', ev => {
b.enabled = ev.target.checked;
syncNodeDataBlocks();
});
// edit
li.querySelector('.pm-edit').addEventListener('click', () => {
openEditor(b);
});
// delete
li.querySelector('.pm-del').addEventListener('click', () => {
const idx = d2.blocks.indexOf(b);
if (idx >= 0) d2.blocks.splice(idx, 1);
sortAndReindex();
renderList();
syncNodeDataBlocks();
if (editingId && editingId === (b.id || null)) {
editorBox.style.display = 'none';
editingId = null;
}
});
listEl.appendChild(li);
});
}
function openEditor(b) {
// Гарантируем наличие id у редактируемого блока
if (!b.id) {
b.id = 'b' + Date.now().toString(36);
syncNodeDataBlocks();
}
editingId = b.id;
editorBox.style.display = '';
nameInp.value = b.name || '';
roleSel.value = (b.role || 'user');
promptTxt.value = b.prompt || '';
}
addBtn?.addEventListener('click', () => {
const idv = 'b' + Date.now().toString(36);
const nb = { id: idv, name: 'New Block', role: 'system', prompt: '', enabled: true, order: d2.blocks.length };
d2.blocks.push(nb);
sortAndReindex();
renderList();
syncNodeDataBlocks();
openEditor(nb);
});
saveBtn?.addEventListener('click', () => {
if (!editingId) { editorBox.style.display = 'none'; return; }
const b = d2.blocks.find(x => (x.id || null) === editingId);
if (b) {
b.name = nameInp.value;
b.role = roleSel.value;
b.prompt = promptTxt.value;
// Пересоберём массив, чтобы избежать проблем с мутацией по ссылке
d2.blocks = d2.blocks.map(x => (x.id === b.id ? ({...b}) : x));
}
editorBox.style.display = 'none';
editingId = null;
renderList();
syncNodeDataBlocks();
try { savePipeline(); } catch (e) {}
try { status('Блок сохранён в pipeline.json'); } catch (e) {}
});
cancelBtn?.addEventListener('click', () => {
editorBox.style.display = 'none';
editingId = null;
});
renderList();
}
}
// Добавление нод из сайдбара
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});
});
});
// Сериализация: Drawflow -> наш pipeline JSON
function toPipelineJSON() {
const data = editor.export();
const nodes = [];
const idMap = {}; // drawflow id -> generated id like n1, n2
const dfNodes = (data && data.drawflow && data.drawflow.Home && data.drawflow.Home.data) ? data.drawflow.Home.data : {};
// 1) Собираем ноды
let idx = 1;
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 = applyNodeDefaults(df.name, JSON.parse(JSON.stringify(datacopySrc)));
nodes.push({
id: genId,
type: df.name,
pos_x: df.pos_x,
pos_y: df.pos_y,
config: datacopy,
in: {}
});
}
// 2) Восстанавливаем связи по входам (inputs)
// В Drawflow v0.0.55 inputs/outputs — это объекты вида input_1/output_1
for (const id in dfNodes) {
const df = dfNodes[id];
const targetNode = nodes.find(n => n.id === idMap[id]);
if (!targetNode) continue;
const io = NODE_IO[targetNode.type] || { inputs: [], outputs: [] };
for (let i = 0; i < io.inputs.length; i++) {
const inputKey = `input_${i + 1}`;
const input = df.inputs && df.inputs[inputKey];
if (!input || !Array.isArray(input.connections) || input.connections.length === 0) continue;
// Один вход — одна связь
const conn = input.connections[0];
const sourceDfId = String(conn.node);
const outKey = String(conn.output ?? '');
// conn.output может быть "output_1", "1" (строкой), либо числом 1
let sourceOutIdx = -1;
let m = outKey.match(/output_(\d+)/);
if (m) {
sourceOutIdx = parseInt(m[1], 10) - 1;
} else if (/^\d+$/.test(outKey)) {
sourceOutIdx = parseInt(outKey, 10) - 1;
} else if (typeof conn.output === 'number') {
sourceOutIdx = conn.output - 1;
}
if (!(sourceOutIdx >= 0)) sourceOutIdx = 0; // safety to avoid -1
const sourceNode = nodes.find(n => n.id === idMap[sourceDfId]);
if (!sourceNode) continue;
const sourceIo = NODE_IO[sourceNode.type] || { outputs: [] };
// Каноничное имя выхода: по NODE_IO, иначе out{0-based}
const sourceOutName = (sourceIo.outputs && sourceIo.outputs[sourceOutIdx] != null)
? sourceIo.outputs[sourceOutIdx]
: `out${sourceOutIdx}`;
// Каноничное имя входа: по 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] = `${sourceNode.id}.${sourceOutName}`;
}
}
return { id: 'pipeline_editor', name: 'Edited Pipeline', nodes };
}
// Десериализация: pipeline JSON -> Drawflow
async function fromPipelineJSON(p) {
editor.clear();
let x = 100; let y = 120; // Fallback
const idMap = {}; // pipeline id -> drawflow id
const logs = [];
const $ = (sel) => document.querySelector(sel);
const resolveOutIdx = (type, outName) => {
const outs = (NODE_IO[type]?.outputs) || [];
let idx = outs.indexOf(outName);
if (idx < 0 && typeof outName === 'string') {
// поддержка: out-1, out_1, output_1, out1, out0
const s = String(outName);
let m = s.match(/^out(?:put)?[_-]?(\d+)$/);
if (m) {
const n = parseInt(m[1], 10);
idx = n > 0 ? n - 1 : 0;
} else {
m = s.match(/^out(\d+)$/); // совместимость со старым out0
if (m) idx = parseInt(m[1], 10) | 0;
}
}
return idx;
};
const resolveInIdx = (type, inName) => {
const ins = (NODE_IO[type]?.inputs) || [];
let idx = ins.indexOf(inName);
if (idx < 0 && typeof inName === 'string') {
// поддержка: in-1, in_1, in1, in0
const s = String(inName);
let m = s.match(/^in[_-]?(\d+)$/);
if (m) {
const n = parseInt(m[1], 10);
idx = n > 0 ? n - 1 : 0;
} else {
m = s.match(/^in(\d+)$/); // совместимость со старым in0
if (m) idx = parseInt(m[1], 10) | 0;
}
}
return idx;
};
// Ожидание появления порта в DOM (устранение гонки рендера)
async function waitForPort(dfid, kind, idx, tries = 60, delay = 16) {
// Drawflow создаёт DOM-узел с id="node-${dfid}"
const sel = `#node-${dfid} .${kind}_${idx}`;
for (let i = 0; i < tries; i++) {
if ($(sel)) return true;
await new Promise(r => setTimeout(r, delay));
}
logs.push(`port missing: #${dfid} ${kind}_${idx}`);
return false;
}
// Повторные попытки соединить порты, пока DOM не готов
async function connectWithRetry(srcDfId, tgtDfId, outNum, inNum, tries = 120, delay = 25) {
const outClass = `output_${outNum}`;
const inClass = `input_${inNum}`;
for (let i = 0; i < tries; i++) {
const okOut = await waitForPort(srcDfId, 'output', outNum, 1, delay);
const okIn = await waitForPort(tgtDfId, 'input', inNum, 1, delay);
if (okOut && okIn) {
try {
editor.addConnection(srcDfId, tgtDfId, outClass, inClass);
return true;
} catch (e) {
// retry on next loop
}
}
await new Promise(r => setTimeout(r, delay));
}
return false;
}
// 1) Создаём ноды
for (const n of p.nodes) {
const pos = { x: n.pos_x || x, y: n.pos_y || y };
const dfid = addNode(n.type, pos, { ...(n.config || {}), _origId: n.id });
idMap[n.id] = dfid;
if (!n.pos_x) x += 260; // раскладываем по горизонтали, если нет сохраненной позиции
}
// 2) Дождёмся полного рендера DOM
await new Promise(r => setTimeout(r, 0));
if (typeof requestAnimationFrame === 'function') {
await new Promise(r => requestAnimationFrame(() => r()));
await new Promise(r => requestAnimationFrame(() => r())); // двойной rAF для надежности
} else {
await new Promise(r => setTimeout(r, 32));
}
// 3) Проставляем связи из in
for (const n of p.nodes) {
if (!n.in) continue;
const targetDfId = idMap[n.id];
const targetIo = NODE_IO[n.type] || { inputs: [] };
for (const [inName, ref] of Object.entries(n.in)) {
if (!ref || typeof ref !== 'string' || !ref.includes('.')) continue;
const [srcId, outName] = ref.split('.');
const sourceDfId = idMap[srcId];
if (!sourceDfId) { logs.push(`skip: src ${srcId} not found`); continue; }
const srcType = p.nodes.find(nn=>nn.id===srcId)?.type;
let outIdx = resolveOutIdx(srcType, outName);
let inIdx = resolveInIdx(n.type, inName);
// Fallback на первый порт, если неизвестные имена, но порт существует
if (outIdx < 0) outIdx = 0;
if (inIdx < 0) inIdx = 0;
const outClass = `output_${outIdx + 1}`;
const inClass = `input_${inIdx + 1}`;
const ok = await connectWithRetry(sourceDfId, targetDfId, outIdx + 1, inIdx + 1, 200, 25);
if (ok) {
logs.push(`connect: ${srcId}.${outName} (#${sourceDfId}.${outClass}) -> ${n.id}.${inName} (#${targetDfId}.${inClass})`);
} else {
logs.push(`skip connect (ports not ready after retries): ${srcId}.${outName} -> ${n.id}.${inName}`);
}
}
}
// 4) Обновим линии и выведем лог
try {
Object.values(idMap).forEach((dfid) => {
editor.updateConnectionNodes?.(`node-${dfid}`);
});
} catch {}
if (logs.length) {
try { status('Загружено (links):\n' + logs.join('\n')); } catch {}
try { console.debug('[fromPipelineJSON]', logs); } catch {}
}
}
// Загрузка/сохранение
async function loadPipeline() {
const res = await fetch('/admin/pipeline');
const p = await res.json();
await fromPipelineJSON(p);
// Не затираем логи, которые вывел fromPipelineJSON
const st = document.getElementById('status').textContent;
if (!st) status('Загружено');
}
async function savePipeline() {
try {
const p = 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 = 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 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);
}
}
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;
loadPipeline();
refreshPresets();
</script>
</body>
</html>

60
static/index.html Normal file
View File

@@ -0,0 +1,60 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>НадTavern</title>
<style>
body { font-family: Arial, sans-serif; margin: 24px; }
textarea { width: 100%; height: 200px; }
pre { background: #111; color: #eee; padding: 12px; border-radius: 6px; overflow: auto; }
.row { display: flex; gap: 16px; }
.col { flex: 1; }
</style>
</head>
<body>
<h1>НадTavern — Мини UI</h1>
<p>Тестовый интерфейс для запроса к <code>/v1/chat/completions</code> без стриминга.</p>
<p><a href="/ui/pipeline.html">Открыть редактор пайплайна (JSON)</a></p>
<p><a href="/ui/editor.html">Открыть визуальный редактор нод</a></p>
<div class="row">
<div class="col">
<h3>Ввод (OpenAI-формат)</h3>
<textarea id="payload"></textarea>
<button id="send">Отправить</button>
</div>
<div class="col">
<h3>Ответ</h3>
<pre id="out"></pre>
</div>
</div>
<script>
const sample = {
model: "gpt-4o-mini",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Привет! Расскажи коротко о проекте НадTavern." }
],
temperature: 0.2,
max_tokens: 128
};
document.getElementById('payload').value = JSON.stringify(sample, null, 2);
document.getElementById('send').onclick = async () => {
const raw = document.getElementById('payload').value;
try {
const res = await fetch('/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: raw
});
const json = await res.json();
document.getElementById('out').textContent = JSON.stringify(json, null, 2);
} catch (e) {
document.getElementById('out').textContent = String(e);
}
};
</script>
</body>
</html>

53
static/pipeline.html Normal file
View File

@@ -0,0 +1,53 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>НадTavern — Pipeline Editor (JSON)</title>
<style>
body { font-family: Arial, sans-serif; margin: 24px; }
textarea { width: 100%; height: 70vh; }
pre { background: #111; color: #eee; padding: 12px; border-radius: 6px; }
.row { display: flex; gap: 16px; }
.col { flex: 1; }
</style>
</head>
<body>
<h1>Pipeline Editor (JSON)</h1>
<p>
Редактируйте JSON пайплайна. Нажмите "Сохранить" — используется немедленно.
<a href="/">Домой</a>
</p>
<div>
<button id="btn-load">Загрузить</button>
<button id="btn-save">Сохранить</button>
</div>
<textarea id="editor"></textarea>
<pre id="status"></pre>
<script>
async function loadPipeline() {
const res = await fetch('/admin/pipeline');
const json = await res.json();
document.getElementById('editor').value = JSON.stringify(json, null, 2);
setStatus('Загружено');
}
async function savePipeline() {
try {
const body = document.getElementById('editor').value;
JSON.parse(body);
const res = await fetch('/admin/pipeline', { method: 'POST', headers: {'Content-Type': 'application/json'}, body });
const out = await res.json();
setStatus('Сохранено: ' + JSON.stringify(out));
} catch (e) {
setStatus('Ошибка: ' + e.message);
}
}
function setStatus(t) { document.getElementById('status').textContent = t; }
document.getElementById('btn-load').onclick = loadPipeline;
document.getElementById('btn-save').onclick = savePipeline;
loadPipeline();
</script>
</body>
</html>