@@ -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 : 10 px 16 px ; border-bottom : 1 px solid var ( - - border ) ; background : var ( - - panel ) ; }
header . actions { display : flex ; gap : 8 px ; }
button { background : #1f2937 ; border : 1 px solid #334155 ; color : #e5e7eb ; padding : 6 px 10 px ; border-radius : 8 px ; cursor : pointer ; }
button : hover { background : #273246 ; }
# container { display : grid ; grid-template-columns : 260 px 1 fr 360 px ; height : calc ( 100 vh - 52 px ) ; }
# sidebar { border-right : 1 px solid var ( - - border ) ; padding : 12 px ; background : var ( - - panel ) ; overflow : auto ; }
# canvas { position : relative ; }
# inspector { border-left : 1 px solid var ( - - border ) ; padding : 12 px ; overflow : auto ; background : var ( - - panel ) ; }
# drawflow { width : 100 % ; height : 100 % ; }
. group-title { font-size : 12 px ; text-transform : uppercase ; color : var ( - - muted ) ; margin : 12 px 0 6 px ; letter-spacing : .08 em ; }
. node-btn { width : 100 % ; text-align : left ; margin-bottom : 6 px ; border-left : 3 px solid transparent ; }
. node-btn : hover { border-left-color : var ( - - accent -2 ) ; }
. hint { color : var ( - - muted ) ; font-size : 12 px ; margin-top : 4 px ; }
. df-node . title-box { background : var ( - - node ) ; border : 1 px solid var ( - - node - border ) ; color : #e5e7eb ; border-radius : 10 px 10 px 0 0 ; }
. df-node . box { background : var ( - - node ) ; border : 1 px solid var ( - - node - border ) ; border-top : 0 ; color : #e5e7eb ; border-radius : 0 0 10 px 10 px ; }
/* 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 1 px 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 : 1 px solid #2b3646 ; border-radius : 8 px ; padding : 8 px ; }
label { font-size : 12 px ; color : var ( - - muted ) ; display : block ; margin : 8 px 0 4 px ; }
pre { background : #0f141a ; border : 1 px solid #2b3646 ; padding : 10 px ; border-radius : 8 px ; overflow : auto ; }
. preview { pointer-events : none ; opacity : .85 ; }
/* Help popups */
details . help { margin : 6 px 0 ; }
details . help summary { list-style : none ; cursor : pointer ; display : inline-block ; width : 20 px ; height : 20 px ; border-radius : 50 % ; background : #334155 ; color : #e5e7eb ; text-align : center ; line-height : 20 px ; font-weight : 700 ; border : 1 px solid #2b3646 ; }
details . help summary :: -webkit-details-marker { display : none ; }
details . help . panel { margin-top : 8 px ; background : #0f141a ; border : 1 px solid #2b3646 ; padding : 10 px ; border-radius : 8 px ; }
< / 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 = "Температура выборки (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 > ,
< code title = "Вторичный ключ или секрет, если задан" > [[incoming.api_keys.secret]]< / 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 >
< 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 , '"' ) } " readonly />
<label>endpoint</label>
<input type="text" value=" ${ endpoint . replace ( /"/g , '"' ) } " readonly />
<label>headers (preview JSON)</label>
<textarea readonly> ${ headers . replace ( /</g , '<' ) } </textarea>
<label>template (preview JSON)</label>
<textarea readonly> ${ template . replace ( /</g , '<' ) } </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 , '"' ) } " readonly />
<label>override_path</label>
<input type="text" value=" ${ override _path . replace ( /"/g , '"' ) } " readonly />
<label><input type="checkbox" ${ passthrough _headers } disabled/> passthrough_headers</label>
<label>extra_headers (preview JSON)</label>
<textarea readonly> ${ extra _headers . replace ( /</g , '<' ) } </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 , '"' ) } " placeholder="https://api.openai.com">
<label>override_path</label><input id="f-override" type="text" value=" ${ ( data . override _path || '' ) . replace ( /"/g , '"' ) } " 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 , '<' ) } </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 >