Files
HadTavern/static/js/serialization.js

450 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* global window, document, requestAnimationFrame */
// AgentUI serialization helpers extracted from editor.html
// Exposes window.AgentUISer.{toPipelineJSON, fromPipelineJSON}
// Depends on globals defined by editor.html: editor, NODE_IO, addNode, applyNodeDefaults, status
(function (w) {
'use strict';
function ensureDeps() {
if (!w || !w.editor) throw new Error('AgentUISer: global editor is not available');
if (!w.NODE_IO) throw new Error('AgentUISer: global NODE_IO is not available');
}
// Top-level pipeline meta kept in memory and included into JSON on save.
// Allows UI to edit loop parameters without manual JSON edits.
let _pipelineMeta = {
id: 'pipeline_editor',
name: 'Edited Pipeline',
parallel_limit: 8,
loop_mode: 'dag',
loop_max_iters: 1000,
loop_time_budget_ms: 10000,
clear_var_store: true,
// New: default HTTP timeout for upstream requests (seconds)
http_timeout_sec: 60,
// New (v1): стратегия извлечения текста для [[OUTx]] (глобальная по умолчанию)
// auto | deep | openai | gemini | claude | jsonpath
text_extract_strategy: 'auto',
// Используется при стратегии jsonpath (dot-нотация, поддержка индексов: a.b.0.c)
text_extract_json_path: '',
// Разделитель при объединении массива результатов
text_join_sep: '\n',
// v2: коллекция пресетов извлечения текста, управляется в "Запуск"
// [{ id, name, strategy, json_path, join_sep }]
text_extract_presets: [],
};
function getPipelineMeta() {
return { ..._pipelineMeta };
}
function updatePipelineMeta(p) {
if (!p || typeof p !== 'object') return;
const keys = [
'id','name','parallel_limit','loop_mode','loop_max_iters','loop_time_budget_ms','clear_var_store','http_timeout_sec',
'text_extract_strategy','text_extract_json_path','text_join_sep','text_join_sep','text_join_SEP',
// v2 presets collection
'text_extract_presets'
];
for (const k of keys) {
if (Object.prototype.hasOwnProperty.call(p, k) && p[k] !== undefined && p[k] !== null && (k === 'clear_var_store' ? true : p[k] !== '')) {
if (k === 'parallel_limit' || k === 'loop_max_iters' || k === 'loop_time_budget_ms') {
const v = parseInt(p[k], 10);
if (!Number.isNaN(v) && v > 0) _pipelineMeta[k] = v;
} else if (k === 'http_timeout_sec') {
const fv = parseFloat(p[k]);
if (!Number.isNaN(fv) && fv > 0) _pipelineMeta[k] = fv;
} else if (k === 'clear_var_store') {
_pipelineMeta[k] = !!p[k];
} else {
// спец-обработка коллекции пресетов
if (k === 'text_extract_presets') {
try {
const arr = Array.isArray(p[k]) ? p[k] : [];
_pipelineMeta[k] = arr
.filter(it => it && typeof it === 'object')
.map(it => ({
id: String((it.id ?? '') || ('p' + Date.now().toString(36) + Math.random().toString(36).slice(2))),
name: String(it.name ?? 'Preset'),
strategy: String(it.strategy ?? 'auto'),
json_path: String(it.json_path ?? ''),
join_sep: String(it.join_sep ?? '\n'),
}));
} catch (_) {
_pipelineMeta[k] = [];
}
} else if (k.toLowerCase() === 'text_join_sep') {
// нормализация ключа join separator (допускаем разные написания)
_pipelineMeta['text_join_sep'] = String(p[k]);
} else {
_pipelineMeta[k] = String(p[k]);
}
}
}
}
}
// Drawflow -> pipeline JSON
function toPipelineJSON() {
ensureDeps();
const editor = w.editor;
const NODE_IO = w.NODE_IO;
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) Собираем ноды с устойчивыми id на основе _origId (если валиден/уникален)
const usedIds = new Set();
const wantIds = {}; // drawflow id -> желаемый/финальный nX
const isValidNid = (s) => typeof s === 'string' && /^n\d+$/i.test(s.trim());
// Helper: вернуть исключительно «живые» данные ноды из редактора (если доступны).
// Это исключает расхождения между DOM.__data и editor.getNodeFromId(..).data.
function mergedNodeData(df, el, dfid) {
try {
const nid = parseInt(dfid, 10);
const n = (w.editor && typeof w.editor.getNodeFromId === 'function') ? w.editor.getNodeFromId(nid) : null;
if (n && n.data) return n.data;
} catch (_) {}
if (df && df.data) return df.data;
// как последний fallback — DOM.__data (почти не используется после этого изменения)
return (el && el.__data) ? el.__data : {};
}
// Первый проход: резервируем существующие валидные _origId
for (const id in dfNodes) {
const df = dfNodes[id];
const el = document.querySelector(`#node-${id}`);
const datacopySrc = mergedNodeData(df, el, id);
const tmp = typeof w.applyNodeDefaults === 'function'
? w.applyNodeDefaults(df.name, JSON.parse(JSON.stringify(datacopySrc)))
: (JSON.parse(JSON.stringify(datacopySrc)));
let desired = (tmp && typeof tmp._origId === 'string') ? String(tmp._origId).trim() : '';
if (isValidNid(desired) && !usedIds.has(desired)) {
wantIds[id] = desired;
usedIds.add(desired);
} else {
wantIds[id] = null; // назначим позже
}
}
// Поиск ближайшего свободного nX
function nextFreeId() {
let x = 1;
while (usedIds.has('n' + x)) x += 1;
return 'n' + x;
}
// Второй проход: назначаем конфликты/пустые
for (const id in dfNodes) {
if (!wantIds[id]) {
const nid = nextFreeId();
wantIds[id] = nid;
usedIds.add(nid);
}
idMap[id] = wantIds[id];
}
// Финальный проход: формируем массив нод, синхронизируя _origId
for (const id in dfNodes) {
const df = dfNodes[id];
const el = document.querySelector(`#node-${id}`);
const datacopySrc = mergedNodeData(df, el, id);
const datacopy = typeof w.applyNodeDefaults === 'function'
? w.applyNodeDefaults(df.name, JSON.parse(JSON.stringify(datacopySrc)))
: (JSON.parse(JSON.stringify(datacopySrc)));
try { datacopy._origId = idMap[id]; } catch (e) {}
// Спец-обработка SetVars: гарантированно берём свежие variables из живых данных редактора
try {
if (String(df.name) === 'SetVars') {
const nid = parseInt(id, 10);
const nLive = (w.editor && typeof w.editor.getNodeFromId === 'function') ? w.editor.getNodeFromId(nid) : null;
const v = nLive && nLive.data && Array.isArray(nLive.data.variables) ? nLive.data.variables : (Array.isArray(datacopy.variables) ? datacopy.variables : []);
datacopy.variables = v.map(x => ({ ...(x || {}) })); // глубокая копия
}
} catch (_) {}
nodes.push({
id: idMap[id],
type: df.name,
pos_x: df.pos_x,
pos_y: df.pos_y,
config: datacopy,
in: {}
});
}
try { console.debug('[AgentUISer.toPipelineJSON] idMap drawflowId->nX', idMap); } catch (e) {}
// 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;
// Собираем все связи этого входа и сохраняем строку либо массив строк (для depends поддерживаем мульти-коннекты)
const refs = [];
for (const conn of (input.connections || [])) {
if (!conn) continue;
const sourceDfId = String(conn.node);
const outKey = String(conn.output ?? '');
// 1) Попробуем определить индекс выхода из conn.output
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;
}
const sourceNode = nodes.find(n => n.id === idMap[sourceDfId]);
if (!sourceNode) continue;
const sourceIo = NODE_IO[sourceNode.type] || { outputs: [] };
// 2) Fallback: если индекс не распознан или вне диапазона — проверим dfNodes[source].outputs
if (!(sourceOutIdx >= 0) || !(Array.isArray(sourceIo.outputs) && sourceIo.outputs[sourceOutIdx] != null)) {
try {
const srcDf = dfNodes[sourceDfId];
const outsObj = (srcDf && srcDf.outputs) ? srcDf.outputs : {};
let found = -1;
// Текущая целевая drawflow-нода — это id (внешняя переменная цикла по dfNodes)
const tgtDfId = id;
for (const k of Object.keys(outsObj || {})) {
const conns = (outsObj[k] && Array.isArray(outsObj[k].connections)) ? outsObj[k].connections : [];
if (conns.some(c => String(c && c.node) === String(tgtDfId))) {
const m2 = String(k).match(/output_(\d+)/);
if (m2) { found = parseInt(m2[1], 10) - 1; break; }
}
}
if (found >= 0) sourceOutIdx = found;
} catch (e) {
// ignore
}
}
// 3) Ещё один safety: если до сих пор индекс невалидный — зажмём в границы
if (!(sourceOutIdx >= 0)) sourceOutIdx = 0;
if (Array.isArray(sourceIo.outputs) && sourceIo.outputs.length > 0) {
if (sourceOutIdx >= sourceIo.outputs.length) sourceOutIdx = sourceIo.outputs.length - 1;
}
// 4) Вычислим каноническое имя выхода по NODE_IO
let sourceOutName;
if (Array.isArray(sourceIo.outputs) && sourceIo.outputs[sourceOutIdx] != null) {
sourceOutName = sourceIo.outputs[sourceOutIdx];
} else {
// Fallback на технические имена (совместимость со старыми out0/out1)
sourceOutName = `out${sourceOutIdx}`;
}
refs.push(`${sourceNode.id}.${sourceOutName}`);
}
// Каноничное имя входа: по 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] = (refs.length <= 1 ? refs[0] : refs);
}
}
// 3) Собираем итоговый pipeline JSON с метаданными
const meta = getPipelineMeta();
return {
id: meta.id || 'pipeline_editor',
name: meta.name || 'Edited Pipeline',
parallel_limit: (typeof meta.parallel_limit === 'number' ? meta.parallel_limit : 8),
loop_mode: (meta.loop_mode || 'dag'),
loop_max_iters: (typeof meta.loop_max_iters === 'number' ? meta.loop_max_iters : 1000),
loop_time_budget_ms: (typeof meta.loop_time_budget_ms === 'number' ? meta.loop_time_budget_ms : 10000),
clear_var_store: (typeof meta.clear_var_store === 'boolean' ? meta.clear_var_store : true),
http_timeout_sec: (typeof meta.http_timeout_sec === 'number' ? meta.http_timeout_sec : 60),
text_extract_strategy: (meta.text_extract_strategy || 'auto'),
text_extract_json_path: (meta.text_extract_json_path || ''),
text_join_sep: (meta.text_join_sep || '\n'),
// v2: persist presets
text_extract_presets: (Array.isArray(meta.text_extract_presets) ? meta.text_extract_presets : []),
nodes
};
}
// pipeline JSON -> Drawflow
async function fromPipelineJSON(p) {
ensureDeps();
const editor = w.editor;
const NODE_IO = w.NODE_IO;
// Сохраняем метаданные пайплайна для UI
try {
updatePipelineMeta({
id: p && p.id ? p.id : 'pipeline_editor',
name: p && p.name ? p.name : 'Edited Pipeline',
parallel_limit: (p && typeof p.parallel_limit === 'number') ? p.parallel_limit : 8,
loop_mode: p && p.loop_mode ? p.loop_mode : 'dag',
loop_max_iters: (p && typeof p.loop_max_iters === 'number') ? p.loop_max_iters : 1000,
loop_time_budget_ms: (p && typeof p.loop_time_budget_ms === 'number') ? p.loop_time_budget_ms : 10000,
clear_var_store: (p && typeof p.clear_var_store === 'boolean') ? p.clear_var_store : true,
http_timeout_sec: (p && typeof p.http_timeout_sec === 'number') ? p.http_timeout_sec : 60,
text_extract_strategy: (p && typeof p.text_extract_strategy === 'string') ? p.text_extract_strategy : 'auto',
text_extract_json_path: (p && typeof p.text_extract_json_path === 'string') ? p.text_extract_json_path : '',
text_join_sep: (p && typeof p.text_join_sep === 'string') ? p.text_join_sep : '\n',
// v2: presets from pipeline.json
text_extract_presets: (p && Array.isArray(p.text_extract_presets)) ? p.text_extract_presets : [],
});
} catch (e) {}
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] && 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] && 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 data = { ...(n.config || {}), _origId: n.id };
const dfid = typeof w.addNode === 'function'
? w.addNode(n.type, pos, data)
: (function () { throw new Error('AgentUISer: addNode() is not defined'); })();
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)) {
const refs = Array.isArray(ref) ? ref : [ref];
for (const oneRef of refs) {
if (!oneRef || typeof oneRef !== 'string' || !oneRef.includes('.')) continue;
const [srcId, outName] = oneRef.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 ok = await connectWithRetry(sourceDfId, targetDfId, outIdx + 1, inIdx + 1, 200, 25);
if (ok) {
logs.push(`connect: ${srcId}.${outName} (#${sourceDfId}.output_${outIdx + 1}) -> ${n.id}.${inName} (#${targetDfId}.input_${inIdx + 1})`);
} else {
logs.push(`skip connect (ports not ready after retries): ${srcId}.${outName} -> ${n.id}.${inName}`);
}
}
}
}
// 4) Обновим линии и выведем лог
try {
Object.values(idMap).forEach((dfid) => {
if (editor.updateConnectionNodes) {
editor.updateConnectionNodes(`node-${dfid}`);
}
});
} catch {}
if (logs.length) {
try { (typeof w.status === 'function') && w.status('Загружено (links):\n' + logs.join('\n')); } catch {}
try { console.debug('[fromPipelineJSON]', logs); } catch {}
}
}
w.AgentUISer = {
toPipelineJSON,
fromPipelineJSON,
getPipelineMeta,
updatePipelineMeta,
};
})(window);