/* 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. // DRY: единый источник дефолтов и нормализации meta const MetaDefaults = Object.freeze({ 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, http_timeout_sec: 60, text_extract_strategy: 'auto', text_extract_json_path: '', text_join_sep: '\n', // v2: коллекция пресетов извлечения текста, управляется в "Запуск" // [{ id, name, strategy, json_path, join_sep }] text_extract_presets: [], }); let _pipelineMeta = { ...MetaDefaults }; // Нормализатор meta: приводит типы, поддерживает синонимы ключей, заполняет дефолты function ensureMeta(p) { const src = (p && typeof p === 'object') ? p : {}; const out = { ...MetaDefaults }; // helpers const toInt = (v, def) => { try { const n = parseInt(v, 10); return Number.isFinite(n) && n > 0 ? n : def; } catch { return def; } }; const toNum = (v, def) => { try { const n = parseFloat(v); return !Number.isNaN(n) && n > 0 ? n : def; } catch { return def; } }; // базовые поля try { out.id = String((src.id ?? out.id) || out.id); } catch {} try { out.name = String((src.name ?? out.name) || out.name); } catch {} out.parallel_limit = toInt(src.parallel_limit, out.parallel_limit); out.loop_mode = String((src.loop_mode ?? out.loop_mode) || out.loop_mode); out.loop_max_iters = toInt(src.loop_max_iters, out.loop_max_iters); out.loop_time_budget_ms = toInt(src.loop_time_budget_ms, out.loop_time_budget_ms); out.clear_var_store = (typeof src.clear_var_store === 'boolean') ? !!src.clear_var_store : out.clear_var_store; out.http_timeout_sec = toNum(src.http_timeout_sec, out.http_timeout_sec); out.text_extract_strategy = String((src.text_extract_strategy ?? out.text_extract_strategy) || out.text_extract_strategy); out.text_extract_json_path = String((src.text_extract_json_path ?? out.text_extract_json_path) || out.text_extract_json_path); // поддержка синонимов text_join_sep (регистр и вариации) let joinSep = out.text_join_sep; try { for (const k of Object.keys(src)) { if (String(k).toLowerCase() === 'text_join_sep') { joinSep = src[k]; break; } } } catch {} out.text_join_sep = String((joinSep ?? src.text_join_sep ?? out.text_join_sep) || out.text_join_sep); // коллекция пресетов try { const arr = Array.isArray(src.text_extract_presets) ? src.text_extract_presets : []; out.text_extract_presets = arr .filter(it => it && typeof it === 'object') .map((it, idx) => ({ id: String((it.id ?? '') || ('p' + Date.now().toString(36) + Math.random().toString(36).slice(2) + idx)), name: String(it.name ?? (it.json_path || 'Preset')), strategy: String(it.strategy ?? 'auto'), json_path: String(it.json_path ?? ''), join_sep: String(it.join_sep ?? '\n'), })); } catch { out.text_extract_presets = []; } return out; } function getPipelineMeta() { return { ..._pipelineMeta }; } function updatePipelineMeta(p) { if (!p || typeof p !== 'object') return; // DRY: единая точка нормализации _pipelineMeta = ensureMeta({ ..._pipelineMeta, ...p }); } // 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 = ensureMeta(getPipelineMeta()); try { console.debug('[AgentUISer.toPipelineJSON] meta_keys', Object.keys(meta || {})); } catch (e) {} return { ...meta, nodes }; } // pipeline JSON -> Drawflow async function fromPipelineJSON(p) { ensureDeps(); const editor = w.editor; const NODE_IO = w.NODE_IO; // Сохраняем метаданные пайплайна для UI (сквозная нормализация) try { updatePipelineMeta(p || {}); // Диагностический лог состава meta для подтверждения DRY-рефакторинга try { const metaKeys = ["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_extract_presets"]; const incomingKeys = metaKeys.filter(k => (p && Object.prototype.hasOwnProperty.call(p, k))); const currentMeta = (typeof getPipelineMeta === 'function') ? getPipelineMeta() : {}; console.debug('[AgentUISer.fromPipelineJSON] meta_keys', { incomingKeys, resultKeys: Object.keys(currentMeta || {}), metaPreview: { id: currentMeta && currentMeta.id, loop_mode: currentMeta && currentMeta.loop_mode, http_timeout_sec: currentMeta && currentMeta.http_timeout_sec } }); } catch (_) {} } 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);