Files
LittleWhiteBox/modules/ena-planner/ena-planner.html

654 lines
23 KiB
HTML
Raw Normal View History

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ena Planner</title>
<link rel="stylesheet" href="./ena-planner.css">
</head>
<body>
<div class="wrap">
<div class="top">
<strong>Ena Planner</strong>
<span id="stateBadge" class="badge"><span class="dot"></span><span>Disabled</span></span>
</div>
<div class="tabs">
<div class="tab active" data-tab="general">General</div>
<div class="tab" data-tab="api">API</div>
<div class="tab" data-tab="prompt">Prompt</div>
<div class="tab" data-tab="debug">Debug</div>
<div class="tab" data-tab="logs">Logs</div>
</div>
<div class="panel active" data-panel="general">
<div class="card">
<div class="row">
<div class="col">
<label>Enabled</label>
<select id="ep_enabled"><option value="true">Enabled</option><option value="false">Disabled</option></select>
</div>
<div class="col">
<label>Skip if input already contains &lt;plot&gt;</label>
<select id="ep_skip_plot"><option value="true">Yes</option><option value="false">No</option></select>
</div>
</div>
<div class="row">
<div class="col">
<label>Include global worldbooks</label>
<select id="ep_include_global_wb"><option value="false">No</option><option value="true">Yes</option></select>
</div>
<div class="col">
<label>Exclude worldbook entries with position=4</label>
<select id="ep_wb_pos4"><option value="true">Yes</option><option value="false">No</option></select>
</div>
</div>
<div class="row">
<div class="col">
<label>Worldbook name excludes (comma separated)</label>
<input id="ep_wb_exclude_names" type="text" placeholder="mvu_update">
</div>
<div class="col">
<label>Recent plot count</label>
<input id="ep_plot_n" type="number" min="0" step="1">
</div>
</div>
<div class="row">
<div class="col">
<label>Chat exclude tags (comma separated)</label>
<input id="ep_exclude_tags" type="text" placeholder="ActionOptions, UpdateVariable, StatusPlaceHolderImpl">
</div>
</div>
<div class="row">
<div class="col">
<label>Persist logs</label>
<select id="ep_logs_persist"><option value="true">Yes</option><option value="false">No</option></select>
</div>
<div class="col">
<label>Log max count</label>
<input id="ep_logs_max" type="number" min="1" max="200" step="1">
</div>
</div>
</div>
</div>
<div class="panel" data-panel="api">
<div class="card">
<div class="row">
<div class="col">
<label>Channel</label>
<select id="ep_api_channel">
<option value="openai">OpenAI compatible</option>
<option value="gemini">Gemini compatible</option>
<option value="claude">Claude compatible</option>
</select>
</div>
<div class="col">
<label>Prefix mode</label>
<select id="ep_prefix_mode"><option value="auto">Auto</option><option value="custom">Custom</option></select>
</div>
</div>
<div class="row">
<div class="col"><label>API base URL</label><input id="ep_api_base" type="text" placeholder="https://..."></div>
<div class="col"><label>Custom prefix</label><input id="ep_prefix_custom" type="text" placeholder="/v1"></div>
</div>
<div class="row">
<div class="col"><label>API key</label><input id="ep_api_key" type="password" placeholder="sk-..."></div>
<div class="col"><label>Model</label><input id="ep_model" type="text" placeholder="model-name"></div>
</div>
<div class="row">
<div class="col"><label>Stream</label><select id="ep_stream"><option value="true">Enabled</option><option value="false">Disabled</option></select></div>
<div class="col"><label>Temperature</label><input id="ep_temp" type="number" step="0.1"></div>
</div>
<div class="row">
<div class="col"><label>Top P</label><input id="ep_top_p" type="number" step="0.05"></div>
<div class="col"><label>Top K</label><input id="ep_top_k" type="number" step="1"></div>
</div>
<div class="row">
<div class="col"><label>Presence penalty</label><input id="ep_pp" type="text"></div>
<div class="col"><label>Frequency penalty</label><input id="ep_fp" type="text"></div>
</div>
<div class="row">
<div class="col"><label>Max tokens</label><input id="ep_mt" type="text"></div>
</div>
<div class="actions">
<button id="ep_fetch_models" class="btn">Fetch models</button>
<button id="ep_test_conn" class="btn">Test connection</button>
</div>
<div class="hint">Models preview: <span id="ep_models_preview">Not loaded</span></div>
</div>
</div>
<div class="panel" data-panel="prompt">
<div class="card">
<div class="row">
<div class="col">
<label>Template</label>
<select id="ep_tpl_select"><option value="">-- Select template --</option></select>
</div>
</div>
<div class="actions">
<button id="ep_tpl_save" class="btn">Save</button>
<button id="ep_tpl_saveas" class="btn">Save as</button>
<button id="ep_tpl_delete" class="btn">Delete</button>
</div>
<div id="ep_prompt_list"></div>
<div class="actions">
<button id="ep_add_prompt" class="btn">Add block</button>
<button id="ep_reset_prompt" class="btn">Reset default</button>
</div>
</div>
</div>
<div class="panel" data-panel="debug">
<div class="card">
<div class="actions">
<button id="ep_debug_worldbook" class="btn">Debug worldbook</button>
<button id="ep_debug_char" class="btn">Debug character</button>
<button id="ep_test_planner" class="btn">Run planner test</button>
</div>
<pre id="ep_debug_output" class="log-pre" style="display:none;"></pre>
</div>
</div>
<div class="panel" data-panel="logs">
<div class="card">
<div class="actions">
<button id="ep_open_logs" class="btn">Refresh logs</button>
<button id="ep_log_export" class="btn">Export JSON</button>
<button id="ep_log_clear" class="btn">Clear</button>
</div>
<div id="ep_log_body" class="log-list"></div>
</div>
</div>
<div class="actions">
<button id="ep_save" class="btn primary">Save</button>
<button id="ep_close" class="btn">Close</button>
</div>
<div id="ep_status" class="status"></div>
</div>
<script>
const PARENT_ORIGIN = (() => {
try { return new URL(document.referrer).origin; } catch { return window.location.origin; }
})();
const post = (type, payload) => parent.postMessage({ type, payload }, PARENT_ORIGIN);
const $ = (id) => document.getElementById(id);
const $$ = (sel) => document.querySelectorAll(sel);
let cfg = null;
let logs = [];
let pendingSave = null;
function setStatus(text, isError = false) {
const el = $('ep_status');
el.textContent = text || '';
el.className = `status${isError ? ' error' : ''}`;
}
function setSaveButtonBusy(busy) {
const btn = $('ep_save');
if (!btn) return;
if (busy) {
btn.disabled = true;
btn.dataset.originalText = btn.textContent;
btn.textContent = 'Saving...';
} else {
btn.disabled = false;
btn.textContent = btn.dataset.originalText || 'Save';
delete btn.dataset.originalText;
}
}
function startPendingSave(requestId, pendingText = 'Saving...') {
pendingSave = {
requestId,
timer: setTimeout(() => {
if (!pendingSave || pendingSave.requestId !== requestId) return;
pendingSave = null;
setSaveButtonBusy(false);
setStatus('Save timeout after 3s', true);
}, 3000)
};
setSaveButtonBusy(true);
setStatus(pendingText);
}
function setBadge(enabled) {
const badge = $('stateBadge');
badge.innerHTML = `<span class="dot${enabled ? ' ok' : ''}"></span><span>${enabled ? 'Enabled' : 'Disabled'}</span>`;
}
function activateTab(tabId) {
$$('.tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === tabId));
$$('.panel').forEach((p) => p.classList.toggle('active', p.dataset.panel === tabId));
if (tabId === 'logs') post('xb-ena:logs-request');
}
function toBool(v, fallback = false) {
if (v === true || v === false) return v;
if (v === 'true') return true;
if (v === 'false') return false;
return fallback;
}
function toNum(v, fallback = 0) {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
function arrToCsv(arr) {
return Array.isArray(arr) ? arr.join(', ') : '';
}
function csvToArr(text) {
return String(text || '').split(',').map((x) => x.trim()).filter(Boolean);
}
function createPromptBlockElement(block, idx, total) {
const wrap = document.createElement('div');
wrap.className = 'prompt-block';
const head = document.createElement('div');
head.className = 'prompt-head';
const left = document.createElement('div');
left.className = 'prompt-head-left';
const name = document.createElement('input');
name.type = 'text';
name.placeholder = 'Block name';
name.value = block.name || '';
name.style.minWidth = '180px';
name.addEventListener('change', () => {
block.name = name.value || '';
});
const role = document.createElement('select');
['system', 'user', 'assistant'].forEach((r) => {
const opt = document.createElement('option');
opt.value = r;
opt.textContent = r;
opt.selected = (block.role || 'system') === r;
role.appendChild(opt);
});
role.addEventListener('change', () => {
block.role = role.value;
});
left.append(name, role);
const right = document.createElement('div');
right.style.display = 'flex';
right.style.gap = '6px';
const up = document.createElement('button');
up.className = 'btn';
up.textContent = 'Up';
up.disabled = idx === 0;
up.addEventListener('click', () => {
if (idx === 0) return;
const list = cfg.promptBlocks;
[list[idx - 1], list[idx]] = [list[idx], list[idx - 1]];
renderPromptList();
});
const down = document.createElement('button');
down.className = 'btn';
down.textContent = 'Down';
down.disabled = idx === total - 1;
down.addEventListener('click', () => {
if (idx >= total - 1) return;
const list = cfg.promptBlocks;
[list[idx], list[idx + 1]] = [list[idx + 1], list[idx]];
renderPromptList();
});
const del = document.createElement('button');
del.className = 'btn';
del.textContent = 'Delete';
del.addEventListener('click', () => {
cfg.promptBlocks.splice(idx, 1);
renderPromptList();
});
right.append(up, down, del);
const content = document.createElement('textarea');
content.value = block.content || '';
content.placeholder = 'Prompt block content';
content.addEventListener('change', () => {
block.content = content.value || '';
});
head.append(left, right);
wrap.append(head, content);
return wrap;
}
function renderTemplateSelect(selected = '') {
const sel = $('ep_tpl_select');
sel.textContent = '';
const first = document.createElement('option');
first.value = '';
first.textContent = '-- Select template --';
sel.appendChild(first);
const names = Object.keys(cfg?.promptTemplates || {});
names.forEach((name) => {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
opt.selected = name === selected;
sel.appendChild(opt);
});
}
function renderPromptList() {
const list = $('ep_prompt_list');
list.textContent = '';
const blocks = cfg?.promptBlocks || [];
if (!blocks.length) {
const empty = document.createElement('div');
empty.className = 'hint';
empty.textContent = 'No prompt blocks';
list.appendChild(empty);
return;
}
blocks.forEach((block, idx) => {
list.appendChild(createPromptBlockElement(block, idx, blocks.length));
});
}
function renderLogs() {
const body = $('ep_log_body');
body.textContent = '';
if (!Array.isArray(logs) || logs.length === 0) {
const empty = document.createElement('div');
empty.className = 'hint';
empty.textContent = 'No logs';
body.appendChild(empty);
return;
}
logs.forEach((item) => {
const row = document.createElement('div');
row.className = 'log-item';
const meta = document.createElement('div');
meta.className = 'log-meta';
const left = document.createElement('span');
left.textContent = `${item.time || '-'} | ${item.ok ? 'OK' : 'FAIL'}`;
const right = document.createElement('span');
right.textContent = item.model || '-';
meta.append(left, right);
row.appendChild(meta);
if (item.error) {
const err = document.createElement('div');
err.className = 'log-error';
err.textContent = String(item.error);
row.appendChild(err);
}
const req = document.createElement('details');
const reqSm = document.createElement('summary');
reqSm.textContent = 'Request messages';
const reqPre = document.createElement('pre');
reqPre.className = 'log-pre';
reqPre.textContent = JSON.stringify(item.requestMessages || [], null, 2);
req.append(reqSm, reqPre);
row.appendChild(req);
const raw = document.createElement('details');
const rawSm = document.createElement('summary');
rawSm.textContent = 'Raw reply';
const rawPre = document.createElement('pre');
rawPre.className = 'log-pre';
rawPre.textContent = String(item.rawReply || '');
raw.append(rawSm, rawPre);
row.appendChild(raw);
const filtered = document.createElement('details');
filtered.open = true;
const filteredSm = document.createElement('summary');
filteredSm.textContent = 'Filtered reply';
const filteredPre = document.createElement('pre');
filteredPre.className = 'log-pre';
filteredPre.textContent = String(item.filteredReply || '');
filtered.append(filteredSm, filteredPre);
row.appendChild(filtered);
body.appendChild(row);
});
}
function applyConfig(nextCfg) {
cfg = nextCfg || {};
logs = Array.isArray(cfg.logs) ? cfg.logs : [];
$('ep_enabled').value = String(toBool(cfg.enabled, true));
$('ep_skip_plot').value = String(toBool(cfg.skipIfPlotPresent, true));
$('ep_include_global_wb').value = String(toBool(cfg.includeGlobalWorldbooks, false));
$('ep_wb_pos4').value = String(toBool(cfg.excludeWorldbookPosition4, true));
$('ep_wb_exclude_names').value = arrToCsv(cfg.worldbookExcludeNames);
$('ep_plot_n').value = String(toNum(cfg.plotCount, 2));
$('ep_exclude_tags').value = arrToCsv(cfg.chatExcludeTags);
$('ep_logs_persist').value = String(toBool(cfg.logsPersist, true));
$('ep_logs_max').value = String(toNum(cfg.logsMax, 20));
const api = cfg.api || {};
$('ep_api_channel').value = api.channel || 'openai';
$('ep_prefix_mode').value = api.prefixMode || 'auto';
$('ep_api_base').value = api.baseUrl || '';
$('ep_prefix_custom').value = api.customPrefix || '';
$('ep_api_key').value = api.apiKey || '';
$('ep_model').value = api.model || '';
$('ep_stream').value = String(toBool(api.stream, false));
$('ep_temp').value = String(toNum(api.temperature, 1));
$('ep_top_p').value = String(toNum(api.top_p, 1));
$('ep_top_k').value = String(toNum(api.top_k, 0));
$('ep_pp').value = api.presence_penalty ?? '';
$('ep_fp').value = api.frequency_penalty ?? '';
$('ep_mt').value = api.max_tokens ?? '';
setBadge(toBool(cfg.enabled, true));
renderTemplateSelect();
renderPromptList();
renderLogs();
}
function collectPatch() {
const next = structuredClone(cfg || {});
next.enabled = toBool($('ep_enabled').value, true);
next.skipIfPlotPresent = toBool($('ep_skip_plot').value, true);
next.includeGlobalWorldbooks = toBool($('ep_include_global_wb').value, false);
next.excludeWorldbookPosition4 = toBool($('ep_wb_pos4').value, true);
next.worldbookExcludeNames = csvToArr($('ep_wb_exclude_names').value);
next.plotCount = Math.max(0, Math.floor(toNum($('ep_plot_n').value, 2)));
next.chatExcludeTags = csvToArr($('ep_exclude_tags').value);
next.logsPersist = toBool($('ep_logs_persist').value, true);
next.logsMax = Math.max(1, Math.min(200, Math.floor(toNum($('ep_logs_max').value, 20))));
next.api = next.api || {};
next.api.channel = $('ep_api_channel').value;
next.api.prefixMode = $('ep_prefix_mode').value;
next.api.baseUrl = $('ep_api_base').value.trim();
next.api.customPrefix = $('ep_prefix_custom').value.trim();
next.api.apiKey = $('ep_api_key').value;
next.api.model = $('ep_model').value.trim();
next.api.stream = toBool($('ep_stream').value, false);
next.api.temperature = toNum($('ep_temp').value, 1);
next.api.top_p = toNum($('ep_top_p').value, 1);
next.api.top_k = Math.floor(toNum($('ep_top_k').value, 0));
next.api.presence_penalty = $('ep_pp').value.trim();
next.api.frequency_penalty = $('ep_fp').value.trim();
next.api.max_tokens = $('ep_mt').value.trim();
next.promptBlocks = Array.isArray(cfg?.promptBlocks) ? cfg.promptBlocks : [];
next.promptTemplates = cfg?.promptTemplates || {};
return next;
}
function bindUiEvents() {
$$('.tab').forEach((tab) => {
tab.addEventListener('click', () => activateTab(tab.dataset.tab));
});
$('ep_enabled').addEventListener('change', () => setBadge(toBool($('ep_enabled').value, true)));
$('ep_add_prompt').addEventListener('click', () => {
cfg.promptBlocks = cfg.promptBlocks || [];
cfg.promptBlocks.push({
id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
role: 'system',
name: 'New block',
content: ''
});
renderPromptList();
});
$('ep_reset_prompt').addEventListener('click', () => {
if (!confirm('Reset prompt blocks to default?')) return;
if (pendingSave) return;
const requestId = `ena_reset_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
startPendingSave(requestId, 'Resetting prompt blocks...');
post('xb-ena:reset-prompt-default', { requestId });
});
$('ep_tpl_select').addEventListener('change', () => {
const name = $('ep_tpl_select').value;
if (!name) return;
const blocks = cfg?.promptTemplates?.[name];
if (!Array.isArray(blocks)) return;
cfg.promptBlocks = structuredClone(blocks);
renderPromptList();
setStatus(`Template loaded: ${name}`);
});
$('ep_tpl_save').addEventListener('click', () => {
const name = $('ep_tpl_select').value;
if (!name) return setStatus('Select a template first', true);
cfg.promptTemplates = cfg.promptTemplates || {};
cfg.promptTemplates[name] = structuredClone(cfg.promptBlocks || []);
renderTemplateSelect(name);
setStatus(`Template saved: ${name}`);
});
$('ep_tpl_saveas').addEventListener('click', () => {
const name = prompt('Template name');
if (!name) return;
cfg.promptTemplates = cfg.promptTemplates || {};
cfg.promptTemplates[name] = structuredClone(cfg.promptBlocks || []);
renderTemplateSelect(name);
setStatus(`Template saved as: ${name}`);
});
$('ep_tpl_delete').addEventListener('click', () => {
const name = $('ep_tpl_select').value;
if (!name) return;
if (!confirm(`Delete template \"${name}\"?`)) return;
delete cfg.promptTemplates[name];
renderTemplateSelect('');
setStatus(`Template deleted: ${name}`);
});
$('ep_open_logs').addEventListener('click', () => post('xb-ena:logs-request'));
$('ep_log_clear').addEventListener('click', () => post('xb-ena:logs-clear'));
$('ep_log_export').addEventListener('click', () => {
const blob = new Blob([JSON.stringify(logs || [], null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ena-planner-logs-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
});
$('ep_fetch_models').addEventListener('click', () => post('xb-ena:fetch-models'));
$('ep_test_conn').addEventListener('click', () => post('xb-ena:fetch-models'));
$('ep_debug_worldbook').addEventListener('click', () => post('xb-ena:debug-worldbook'));
$('ep_debug_char').addEventListener('click', () => post('xb-ena:debug-char'));
$('ep_test_planner').addEventListener('click', () => post('xb-ena:run-test', { text: '(test input) Please plan the next story step.' }));
$('ep_save').addEventListener('click', () => {
if (pendingSave) return;
const requestId = `ena_save_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const patch = collectPatch();
startPendingSave(requestId, 'Saving...');
post('xb-ena:save-config', { requestId, patch });
});
$('ep_close').addEventListener('click', () => post('xb-ena:close'));
}
window.addEventListener('message', (ev) => {
if (ev.origin !== PARENT_ORIGIN) return;
const { type, payload } = ev.data || {};
if (type === 'xb-ena:config' || type === 'xb-ena:config-saved') {
applyConfig(payload || {});
if (type === 'xb-ena:config-saved') {
const requestId = payload?.requestId || '';
if (pendingSave && pendingSave.requestId === requestId) {
clearTimeout(pendingSave.timer);
pendingSave = null;
setSaveButtonBusy(false);
setStatus('Saved');
}
}
} else if (type === 'xb-ena:config-save-error') {
const requestId = payload?.requestId || '';
if (pendingSave && pendingSave.requestId === requestId) {
clearTimeout(pendingSave.timer);
pendingSave = null;
setSaveButtonBusy(false);
}
setStatus(payload?.message || 'Save failed', true);
} else if (type === 'xb-ena:test-done') {
setStatus('Planner test completed');
} else if (type === 'xb-ena:test-error') {
setStatus(payload?.message || 'Planner test failed', true);
} else if (type === 'xb-ena:logs') {
logs = Array.isArray(payload?.logs) ? payload.logs : [];
renderLogs();
} else if (type === 'xb-ena:models') {
const models = Array.isArray(payload?.models) ? payload.models : [];
const preview = models.slice(0, 8).join(', ');
$('ep_models_preview').textContent = models.length ? `${models.length} models: ${preview}` : 'No models';
setStatus(`Fetched ${models.length} models`);
} else if (type === 'xb-ena:models-error') {
setStatus(payload?.message || 'Model fetch failed', true);
} else if (type === 'xb-ena:debug-output') {
const out = $('ep_debug_output');
out.style.display = '';
out.textContent = String(payload?.output || '');
}
});
bindUiEvents();
post('xb-ena:ready');
</script>
</body>
</html>