HadTavern 0.01: Gemini/Claude fixes; UI _origId reuse; docs; .bat open
This commit is contained in:
191
static/js/pm-ui.js
Normal file
191
static/js/pm-ui.js
Normal file
@@ -0,0 +1,191 @@
|
||||
/* global window, document */
|
||||
// AgentUI Prompt Manager UI extracted from editor.html
|
||||
// Exposes window.PM.setupProviderCallPMUI(editor, id)
|
||||
// Depends on DOM elements rendered by editor.html inspector:
|
||||
// #pm-list, #pm-editor, #pm-name, #pm-role, #pm-prompt, #pm-save, #pm-cancel
|
||||
(function (w) {
|
||||
'use strict';
|
||||
|
||||
function setupProviderCallPMUI(editor, id) {
|
||||
try {
|
||||
const n2 = editor.getNodeFromId(id);
|
||||
if (!n2) return;
|
||||
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;
|
||||
|
||||
// Изменения блока применяются только по кнопке «Сохранить» внутри редактора блока.
|
||||
|
||||
// Drag&Drop через SortableJS (если доступен)
|
||||
if (w.Sortable && listEl && !listEl.__sortable) {
|
||||
listEl.__sortable = new w.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 renderList() {
|
||||
sortAndReindex();
|
||||
if (!listEl) return;
|
||||
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 via HTML5 fallback as well (kept for compatibility)
|
||||
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)) {
|
||||
if (editorBox) 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;
|
||||
if (editorBox) editorBox.style.display = '';
|
||||
if (nameInp) nameInp.value = b.name || '';
|
||||
if (roleSel) roleSel.value = (b.role || 'user');
|
||||
if (promptTxt) promptTxt.value = b.prompt || '';
|
||||
}
|
||||
|
||||
if (addBtn) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', () => {
|
||||
if (!editingId) { if (editorBox) editorBox.style.display = 'none'; return; }
|
||||
const b = d2.blocks.find(x => (x.id || null) === editingId);
|
||||
if (b) {
|
||||
b.name = nameInp ? nameInp.value : b.name;
|
||||
b.role = roleSel ? roleSel.value : b.role || 'user';
|
||||
b.prompt = promptTxt ? promptTxt.value : b.prompt;
|
||||
// Пересоберём массив, чтобы избежать проблем с мутацией по ссылке
|
||||
d2.blocks = d2.blocks.map(x => (x.id === b.id ? ({ ...b }) : x));
|
||||
}
|
||||
if (editorBox) editorBox.style.display = 'none';
|
||||
editingId = null;
|
||||
renderList();
|
||||
syncNodeDataBlocks();
|
||||
// попытка автосохранения пайплайна, если доступна глобальная функция
|
||||
try { (typeof w.savePipeline === 'function') && w.savePipeline(); } catch (e) {}
|
||||
try { (typeof w.status === 'function') && w.status('Блок сохранён в pipeline.json'); } catch (e) {}
|
||||
});
|
||||
}
|
||||
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
if (editorBox) editorBox.style.display = 'none';
|
||||
editingId = null;
|
||||
});
|
||||
}
|
||||
|
||||
// Первичная отрисовка
|
||||
renderList();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
w.PM = {
|
||||
setupProviderCallPMUI
|
||||
};
|
||||
})(window);
|
||||
247
static/js/serialization.js
Normal file
247
static/js/serialization.js
Normal file
@@ -0,0 +1,247 @@
|
||||
/* 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');
|
||||
}
|
||||
|
||||
// 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) Собираем ноды
|
||||
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 = typeof w.applyNodeDefaults === 'function'
|
||||
? w.applyNodeDefaults(df.name, JSON.parse(JSON.stringify(datacopySrc)))
|
||||
: (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;
|
||||
|
||||
// Собираем все связи этого входа и сохраняем строку либо массив строк (для depends поддерживаем мульти-коннекты)
|
||||
const refs = [];
|
||||
for (const conn of (input.connections || [])) {
|
||||
if (!conn) continue;
|
||||
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
|
||||
|
||||
const sourceNode = nodes.find(n => n.id === idMap[sourceDfId]);
|
||||
if (!sourceNode) continue;
|
||||
const sourceIo = NODE_IO[sourceNode.type] || { outputs: [] };
|
||||
const sourceOutName = (sourceIo.outputs && sourceIo.outputs[sourceOutIdx] != null)
|
||||
? sourceIo.outputs[sourceOutIdx]
|
||||
: `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);
|
||||
}
|
||||
}
|
||||
|
||||
return { id: 'pipeline_editor', name: 'Edited Pipeline', nodes };
|
||||
}
|
||||
|
||||
// pipeline JSON -> Drawflow
|
||||
async function fromPipelineJSON(p) {
|
||||
ensureDeps();
|
||||
const editor = w.editor;
|
||||
const NODE_IO = w.NODE_IO;
|
||||
|
||||
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,
|
||||
};
|
||||
})(window);
|
||||
Reference in New Issue
Block a user