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

966 lines
37 KiB
HTML
Raw Normal View History

2026-02-25 23:58:05 +08:00
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<title>Ena Planner</title>
<link rel="stylesheet" href="./ena-planner.css">
</head>
<body>
<div class="container">
<header>
<div class="header-left">
<h1>Ena<span>Planner</span></h1>
2026-02-26 16:15:53 +08:00
<div class="subtitle">Story Planning · LLM Integration —— Created by Hao19911125</div>
2026-02-25 23:58:05 +08:00
</div>
<div class="stats">
<div class="stat">
<div class="stat-val" id="ep_badge"><span class="hl">未启用</span></div>
<div class="stat-lbl">状态</div>
</div>
<div class="stat">
<div class="stat-val" id="ep_save_status">就绪</div>
<div class="stat-lbl">保存</div>
</div>
<button class="modal-close" id="ep_close" title="关闭">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</header>
<!-- Desktop tabs -->
<div class="nav-tabs">
<div class="nav-item active" data-view="quickstart">快速开始</div>
<div class="nav-item" data-view="api">API 配置</div>
<div class="nav-item" data-view="prompt">提示词</div>
<div class="nav-item" data-view="context">上下文</div>
<div class="nav-item" data-view="debug">调试</div>
</div>
<main class="app-main">
<!-- ── 快速开始 ── -->
<div id="view-quickstart" class="view active">
<div class="tip-box">
<div class="tip-icon"></div>
<div class="tip-text">
<strong>工作流程:</strong>点击发送 → 拦截 → 收集上下文(角色卡、世界书、摘要、历史 plot、最近 AI 回复)→ 发给规划 LLM → 提取 &lt;plot&gt;
&lt;note&gt; → 追加到你的输入 → 放行发送
</div>
</div>
<section class="card">
<div class="card-title">基本设置</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">启用规划器</label>
<select id="ep_enabled" class="input">
<option value="true">开启</option>
<option value="false">关闭</option>
</select>
</div>
<div class="form-group">
<label class="form-label">跳过已有规划的输入</label>
<select id="ep_skip_plot" class="input">
<option value="true"></option>
<option value="false"></option>
</select>
</div>
</div>
<p class="form-hint">输入中已有 &lt;plot&gt; 标签时跳过自动规划。</p>
</section>
<section class="card">
<div class="card-title">快速测试</div>
<div class="form-group">
<label class="form-label">测试输入(留空使用默认)</label>
<textarea id="ep_test_input" class="input" rows="3" placeholder="输入一段剧情描述,测试规划器输出..."></textarea>
</div>
<div class="btn-group">
<button id="ep_run_test" class="btn btn-p">运行规划测试</button>
</div>
<div id="ep_test_status" class="status-text"></div>
</section>
</div>
<!-- ── API 配置 ── -->
<div id="view-api" class="view">
<section class="card">
<div class="card-title">连接设置</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">渠道类型</label>
<select id="ep_api_channel" class="input">
<option value="openai">OpenAI 兼容</option>
<option value="gemini">Gemini 兼容</option>
<option value="claude">Claude 兼容</option>
</select>
</div>
<div class="form-group">
<label class="form-label">路径前缀</label>
<select id="ep_prefix_mode" class="input">
<option value="auto">自动 (如 /v1)</option>
<option value="custom">自定义</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">API 地址</label>
<input id="ep_api_base" type="text" class="input" placeholder="https://api.openai.com">
</div>
<div class="form-group hidden" id="ep_custom_prefix_group">
<label class="form-label">自定义前缀</label>
<input id="ep_prefix_custom" type="text" class="input" placeholder="/v1">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">API Key</label>
<div class="input-row">
<input id="ep_api_key" type="password" class="input" placeholder="sk-...">
<button id="ep_toggle_key" class="btn">显示</button>
</div>
</div>
<div class="form-group">
<label class="form-label">模型</label>
<input id="ep_model" type="text" class="input" placeholder="gpt-4o, claude-3-5-sonnet...">
</div>
</div>
<div id="ep_model_selector" class="hidden" style="margin-top:12px;">
<label class="form-label">选择模型</label>
<select id="ep_model_select" class="input">
<option value="">-- 从列表选择 --</option>
</select>
</div>
<div class="btn-group" style="margin-top:16px;">
<button id="ep_fetch_models" class="btn">拉取模型列表</button>
<button id="ep_test_conn" class="btn">测试连接</button>
</div>
<div id="ep_api_status" class="status-text"></div>
</section>
<section class="card">
<div class="card-title">生成参数</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">流式输出</label>
<select id="ep_stream" class="input">
<option value="true">开启</option>
<option value="false">关闭</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Temperature</label>
<input id="ep_temp" type="number" class="input" step="0.1" min="0" max="2">
</div>
<div class="form-group">
<label class="form-label">Top P</label>
<input id="ep_top_p" type="number" class="input" step="0.05" min="0" max="1">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Top K</label>
<input id="ep_top_k" type="number" class="input" step="1" min="0">
</div>
<div class="form-group">
<label class="form-label">Presence penalty</label>
<input id="ep_pp" type="text" class="input" placeholder="-2 ~ 2">
</div>
<div class="form-group">
<label class="form-label">Frequency penalty</label>
<input id="ep_fp" type="text" class="input" placeholder="-2 ~ 2">
</div>
</div>
<div class="form-group">
<label class="form-label">最大 Token 数</label>
<input id="ep_mt" type="text" class="input" placeholder="留空则不限制">
</div>
</section>
</div>
<!-- ── 提示词 ── -->
<div id="view-prompt" class="view">
<div class="tip-box">
<div class="tip-icon">💡</div>
<div class="tip-text">
系统会自动在提示词之后注入:角色卡、世界书、剧情摘要、聊天历史、向量召回等上下文。你只需专注编写"规划指令"。
</div>
</div>
<section class="card">
<div class="card-title">模板管理</div>
<div class="form-row">
<div class="form-group" style="flex:2;">
<select id="ep_tpl_select" class="input">
<option value="">-- 选择模板 --</option>
</select>
</div>
<div class="form-group" style="flex:3;">
<div class="btn-group">
<button id="ep_tpl_save" class="btn btn-p">保存</button>
<button id="ep_tpl_saveas" class="btn">另存为</button>
<button id="ep_tpl_delete" class="btn btn-del">删除</button>
</div>
</div>
</div>
<div id="ep_tpl_undo" class="undo-bar hidden">
<span>模板 <strong id="ep_tpl_undo_name"></strong> 已删除</span>
<button id="ep_tpl_undo_btn" class="btn btn-p btn-sm">撤销</button>
</div>
</section>
<section class="card">
<div class="card-title">提示词块</div>
<div id="ep_prompt_list"></div>
<div class="prompt-empty" id="ep_prompt_empty" style="display:none;">暂无提示词块</div>
<div class="btn-group" style="margin-top:16px;">
<button id="ep_add_prompt" class="btn">添加区块</button>
<button id="ep_reset_prompt" class="btn btn-del">恢复默认</button>
</div>
</section>
</div>
<!-- ── 上下文 ── -->
<div id="view-context" class="view">
<section class="card">
<div class="card-title">世界书</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">读取全局世界书</label>
<select id="ep_include_global_wb" class="input">
<option value="false"></option>
<option value="true"></option>
</select>
</div>
<div class="form-group">
<label class="form-label">排除 position=4 的条目</label>
<select id="ep_wb_pos4" class="input">
<option value="true"></option>
<option value="false"></option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">排除的条目名称关键词(逗号分隔)</label>
<input id="ep_wb_exclude_names" type="text" class="input" placeholder="mvu_update, system, ...">
</div>
</section>
<section class="card">
<div class="card-title">聊天与历史</div>
<div class="form-group">
<label class="form-label">保留的规划输出标签(逗号分隔)</label>
<input id="ep_keep_tags" type="text" class="input" placeholder="plot, note, plot-log, state">
<p class="form-hint">仅支持英文标签(如 plot, note, memory。留空表示不按标签过滤仅去除 think。无效标签会自动忽略。</p>
</div>
<div class="form-group">
<label class="form-label">清理 AI 回复中的干扰标签(逗号分隔)</label>
<input id="ep_exclude_tags" type="text" class="input"
placeholder="行动选项, UpdateVariable, StatusPlaceHolderImpl">
</div>
<div class="form-group">
<label class="form-label">携带最近 N 条历史 plot</label>
<input id="ep_plot_n" type="number" class="input" min="0" max="10" step="1">
</div>
</section>
</div>
<!-- ── 调试 ── -->
<div id="view-debug" class="view">
<section class="card">
<div class="card-title">诊断工具</div>
<div class="btn-group">
<button id="ep_debug_worldbook" class="btn">诊断世界书</button>
<button id="ep_debug_char" class="btn">诊断角色卡</button>
<button id="ep_test_planner" class="btn btn-p">运行规划测试</button>
</div>
<pre id="ep_debug_output" class="debug-output"></pre>
</section>
<section class="card">
<div class="card-title">日志</div>
<div class="form-row" style="margin-bottom:16px;">
<div class="form-group">
<label class="form-label">持久化日志</label>
<select id="ep_logs_persist" class="input">
<option value="true"></option>
<option value="false"></option>
</select>
</div>
<div class="form-group">
<label class="form-label">最大日志条数</label>
<input id="ep_logs_max" type="number" class="input" min="1" max="200" step="1">
</div>
</div>
<div class="btn-group" style="margin-bottom:16px;">
<button id="ep_open_logs" class="btn">刷新</button>
<button id="ep_log_export" class="btn">导出 JSON</button>
<button id="ep_log_clear" class="btn btn-del">清空日志</button>
</div>
<div id="ep_log_body" class="log-list">
<div class="log-empty">暂无日志</div>
</div>
</section>
</div>
</main>
<!-- Mobile bottom nav -->
<nav class="mobile-nav">
<div class="mobile-nav-inner">
<div class="mobile-nav-item active" data-view="quickstart">
<div class="nav-dot"></div><span>开始</span>
</div>
<div class="mobile-nav-item" data-view="api">
<div class="nav-dot"></div><span>API</span>
</div>
<div class="mobile-nav-item" data-view="prompt">
<div class="nav-dot"></div><span>提示词</span>
</div>
<div class="mobile-nav-item" data-view="context">
<div class="nav-dot"></div><span>上下文</span>
</div>
<div class="mobile-nav-item" data-view="debug">
<div class="nav-dot"></div><span>调试</span>
</div>
</div>
</nav>
</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);
function genId() {
try { return crypto.randomUUID(); } catch { return `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; }
}
let cfg = null;
let logs = [];
let pendingSave = null;
let undoState = null;
let undoPending = false;
let fetchedModels = [];
/* ── Save indicator ── */
function setSaveIndicator(state, text) {
const el = $('ep_save_status');
if (!el) return;
if (state === 'saving') {
el.innerHTML = `<span style="color:var(--warn)">${text || '保存中…'}</span>`;
} else if (state === 'saved') {
el.innerHTML = `<span style="color:var(--success)">${text || '已保存'}</span>`;
} else if (state === 'error') {
el.innerHTML = `<span style="color:var(--error)">${text || '保存失败'}</span>`;
} else {
el.textContent = '就绪';
}
}
function startPendingSave(requestId) {
pendingSave = {
requestId,
timer: setTimeout(() => {
if (!pendingSave || pendingSave.requestId !== requestId) return;
pendingSave = null;
setSaveIndicator('error', '保存超时');
}, 5000)
};
setSaveIndicator('saving');
}
function resolvePendingSave(requestId) {
if (!pendingSave || pendingSave.requestId !== requestId) return;
clearTimeout(pendingSave.timer);
pendingSave = null;
setSaveIndicator('saved');
setTimeout(() => setSaveIndicator(''), 2000);
}
function rejectPendingSave(requestId, msg) {
if (!pendingSave || pendingSave.requestId !== requestId) return;
clearTimeout(pendingSave.timer);
pendingSave = null;
setSaveIndicator('error', msg || '保存失败');
}
/* ── Auto-save ── */
let autoSaveTimer = null;
function scheduleSave() {
if (undoPending) return;
if (autoSaveTimer) clearTimeout(autoSaveTimer);
autoSaveTimer = setTimeout(doSave, 600);
}
function doSave() {
if (pendingSave) return;
const requestId = `ena_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const patch = collectPatch();
startPendingSave(requestId);
post('xb-ena:save-config', { requestId, patch });
}
/* ── UI helpers ── */
function setLocalStatus(elId, text, type) {
const el = $(elId);
if (!el) return;
el.textContent = text || '';
el.className = 'status-text' + (type ? ' ' + type : '');
}
function setBadge(enabled) {
const badge = $('ep_badge');
badge.innerHTML = enabled
? '<span class="hl">已启用</span>'
: '<span style="color:var(--txt3)">未启用</span>';
}
function activateTab(viewId) {
$$('.nav-item, .mobile-nav-item').forEach(n => {
n.classList.toggle('active', n.dataset.view === viewId);
});
$$('.view').forEach(v => {
v.classList.toggle('active', v.id === `view-${viewId}`);
});
if (viewId === 'debug') post('xb-ena:logs-request');
}
function updatePrefixModeUI() {
$('ep_custom_prefix_group').classList.toggle('hidden', $('ep_prefix_mode').value !== 'custom');
}
/* ── Type conversion ── */
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 normalizeKeepTagsInput(text) {
const src = csvToArr(text);
const out = [];
src.forEach(item => {
const tag = String(item || '').replace(/^<+|>+$/g, '').toLowerCase();
if (!/^[a-z][a-z0-9_-]*$/.test(tag)) return;
if (!out.includes(tag)) out.push(tag);
});
return out;
}
function escapeHtml(str) {
return String(str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/* ── Prompt blocks ── */
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 nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.className = 'input';
nameInput.placeholder = '块名称';
nameInput.value = block.name || '';
nameInput.addEventListener('change', () => { block.name = nameInput.value; scheduleSave(); });
const roleSelect = document.createElement('select');
roleSelect.className = 'input';
['system', 'user', 'assistant'].forEach(r => {
const opt = document.createElement('option');
opt.value = r;
opt.textContent = r;
opt.selected = (block.role || 'system') === r;
roleSelect.appendChild(opt);
});
roleSelect.addEventListener('change', () => { block.role = roleSelect.value; scheduleSave(); });
left.append(nameInput, roleSelect);
const right = document.createElement('div');
right.className = 'prompt-head-right';
const upBtn = document.createElement('button');
upBtn.className = 'btn btn-sm';
upBtn.textContent = '↑';
upBtn.disabled = idx === 0;
upBtn.addEventListener('click', () => {
if (idx === 0) return;
[cfg.promptBlocks[idx - 1], cfg.promptBlocks[idx]] = [cfg.promptBlocks[idx], cfg.promptBlocks[idx - 1]];
renderPromptList(); scheduleSave();
});
const downBtn = document.createElement('button');
downBtn.className = 'btn btn-sm';
downBtn.textContent = '↓';
downBtn.disabled = idx === total - 1;
downBtn.addEventListener('click', () => {
if (idx >= total - 1) return;
[cfg.promptBlocks[idx], cfg.promptBlocks[idx + 1]] = [cfg.promptBlocks[idx + 1], cfg.promptBlocks[idx]];
renderPromptList(); scheduleSave();
});
const delBtn = document.createElement('button');
delBtn.className = 'btn btn-sm btn-del';
delBtn.textContent = '删除';
delBtn.addEventListener('click', () => {
cfg.promptBlocks.splice(idx, 1);
renderPromptList(); scheduleSave();
});
right.append(upBtn, downBtn, delBtn);
const content = document.createElement('textarea');
content.className = 'input';
content.placeholder = '提示词内容...';
content.value = block.content || '';
content.addEventListener('change', () => { block.content = content.value; scheduleSave(); });
head.append(left, right);
wrap.append(head, content);
return wrap;
}
function renderPromptList() {
const list = $('ep_prompt_list');
const empty = $('ep_prompt_empty');
const blocks = cfg?.promptBlocks || [];
list.innerHTML = '';
if (!blocks.length) { empty.style.display = ''; return; }
empty.style.display = 'none';
blocks.forEach((block, idx) => {
list.appendChild(createPromptBlockElement(block, idx, blocks.length));
});
}
function renderTemplateSelect(selected = '') {
const sel = $('ep_tpl_select');
sel.innerHTML = '<option value="">-- 选择模板 --</option>';
const names = Object.keys(cfg?.promptTemplates || {});
const selectedName = names.includes(selected) ? selected : '';
names.forEach(name => {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
opt.selected = name === selectedName;
sel.appendChild(opt);
});
}
/* ── Undo ── */
function showUndoBar(name, blocks) {
clearUndo();
undoPending = true;
undoState = {
name, blocks,
timer: setTimeout(() => {
hideUndoBar(); undoPending = false; scheduleSave();
}, 5000)
};
$('ep_tpl_undo_name').textContent = name;
$('ep_tpl_undo').classList.remove('hidden');
}
function hideUndoBar() {
$('ep_tpl_undo').classList.add('hidden');
undoState = null;
}
function clearUndo() {
if (undoState?.timer) clearTimeout(undoState.timer);
hideUndoBar();
undoPending = false;
}
/* ── Model selector ── */
function showModelSelector(models) {
fetchedModels = models;
const sel = $('ep_model_select');
const cur = $('ep_model').value.trim();
sel.innerHTML = '<option value="">-- 从列表选择 --</option>';
models.forEach(m => {
const opt = document.createElement('option');
opt.value = m; opt.textContent = m; opt.selected = m === cur;
sel.appendChild(opt);
});
$('ep_model_selector').classList.remove('hidden');
}
/* ── Logs ── */
function renderLogs() {
const body = $('ep_log_body');
if (!Array.isArray(logs) || !logs.length) {
body.innerHTML = '<div class="log-empty">暂无日志</div>';
return;
}
body.innerHTML = logs.map(item => {
const time = item.time ? new Date(item.time).toLocaleString() : '-';
const cls = item.ok ? 'success' : 'error';
const label = item.ok ? '成功' : '失败';
// Format request messages: one card per message with role label
let msgHtml = '';
if (Array.isArray(item.requestMessages) && item.requestMessages.length) {
msgHtml = item.requestMessages.map((m, i) => {
const role = escapeHtml(m.role || 'unknown');
const roleClass = role === 'system' ? 'msg-system' : role === 'user' ? 'msg-user' : 'msg-assistant';
const content = escapeHtml(m.content || '');
return `<div class="msg-card ${roleClass}">
<div class="msg-role">[${i + 1}] ${role}</div>
<pre class="msg-content">${content}</pre>
</div>`;
}).join('');
} else {
msgHtml = '<div class="log-empty">无消息</div>';
}
2026-02-25 23:58:05 +08:00
return `
<div class="log-item">
<div class="log-meta">
<span>${escapeHtml(time)} · <span class="${cls}">${label}</span></span>
<span>${escapeHtml(item.model || '-')}</span>
</div>
${item.error ? `<div class="log-error">${escapeHtml(item.error)}</div>` : ''}
<details><summary>请求消息 (${(item.requestMessages || []).length} 条)</summary>
<div class="msg-list">${msgHtml}</div>
2026-02-25 23:58:05 +08:00
</details>
<details><summary>原始回复</summary>
<pre class="log-pre">${escapeHtml(item.rawReply || '')}</pre>
</details>
<details open><summary>过滤后回复</summary>
<pre class="log-pre">${escapeHtml(item.filteredReply || '')}</pre>
</details>
</div>`;
}).join('');
}
/* ── Apply / Collect ── */
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));
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 ?? '';
$('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_keep_tags').value = arrToCsv(cfg.responseKeepTags || ['plot', 'note', 'plot-log', 'state']);
$('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));
setBadge(toBool(cfg.enabled, true));
updatePrefixModeUI();
const keepSelectedTemplate = cfg?.activePromptTemplate || $('ep_tpl_select')?.value || '';
renderTemplateSelect(keepSelectedTemplate);
renderPromptList();
renderLogs();
}
function collectPatch() {
const p = {};
p.enabled = toBool($('ep_enabled').value, true);
p.skipIfPlotPresent = toBool($('ep_skip_plot').value, true);
p.api = {
channel: $('ep_api_channel').value,
prefixMode: $('ep_prefix_mode').value,
baseUrl: $('ep_api_base').value.trim(),
customPrefix: $('ep_prefix_custom').value.trim(),
apiKey: $('ep_api_key').value,
model: $('ep_model').value.trim(),
stream: toBool($('ep_stream').value, false),
temperature: toNum($('ep_temp').value, 1),
top_p: toNum($('ep_top_p').value, 1),
top_k: Math.floor(toNum($('ep_top_k').value, 0)),
presence_penalty: $('ep_pp').value.trim(),
frequency_penalty: $('ep_fp').value.trim(),
max_tokens: $('ep_mt').value.trim()
};
p.includeGlobalWorldbooks = toBool($('ep_include_global_wb').value, false);
p.excludeWorldbookPosition4 = toBool($('ep_wb_pos4').value, true);
p.worldbookExcludeNames = csvToArr($('ep_wb_exclude_names').value);
p.plotCount = Math.max(0, Math.floor(toNum($('ep_plot_n').value, 2)));
p.responseKeepTags = normalizeKeepTagsInput($('ep_keep_tags').value);
p.chatExcludeTags = csvToArr($('ep_exclude_tags').value);
p.logsPersist = toBool($('ep_logs_persist').value, true);
p.logsMax = Math.max(1, Math.min(200, Math.floor(toNum($('ep_logs_max').value, 20))));
p.promptBlocks = cfg?.promptBlocks || [];
p.promptTemplates = cfg?.promptTemplates || {};
p.activePromptTemplate = $('ep_tpl_select')?.value || '';
return p;
}
/* ── Event bindings ── */
function bindEvents() {
$$('.nav-item, .mobile-nav-item').forEach(item => {
item.addEventListener('click', () => activateTab(item.dataset.view));
});
$('ep_close').addEventListener('click', () => post('xb-ena:close'));
$('ep_enabled').addEventListener('change', () => setBadge(toBool($('ep_enabled').value, true)));
$('ep_run_test').addEventListener('click', () => {
const text = $('ep_test_input').value.trim() || '(测试输入)我想让你帮我规划下一步剧情。';
post('xb-ena:run-test', { text });
setLocalStatus('ep_test_status', '测试中…', 'loading');
});
$('ep_toggle_key').addEventListener('click', () => {
const input = $('ep_api_key');
const btn = $('ep_toggle_key');
if (input.type === 'password') {
input.type = 'text'; btn.textContent = '隐藏';
} else {
input.type = 'password'; btn.textContent = '显示';
}
});
$('ep_prefix_mode').addEventListener('change', updatePrefixModeUI);
$('ep_fetch_models').addEventListener('click', () => {
post('xb-ena:fetch-models');
setLocalStatus('ep_api_status', '拉取中…', 'loading');
});
$('ep_test_conn').addEventListener('click', () => {
post('xb-ena:fetch-models');
setLocalStatus('ep_api_status', '测试中…', 'loading');
});
$('ep_model_select').addEventListener('change', () => {
const val = $('ep_model_select').value;
if (val) { $('ep_model').value = val; scheduleSave(); }
});
$('ep_keep_tags').addEventListener('change', () => {
const normalized = normalizeKeepTagsInput($('ep_keep_tags').value);
$('ep_keep_tags').value = normalized.join(', ');
});
$('ep_add_prompt').addEventListener('click', () => {
cfg.promptBlocks = cfg.promptBlocks || [];
cfg.promptBlocks.push({ id: genId(), role: 'system', name: '新块', content: '' });
renderPromptList(); scheduleSave();
});
$('ep_reset_prompt').addEventListener('click', () => {
if (!confirm('确定恢复默认提示词块?当前提示词块将被覆盖。')) return;
if (pendingSave) return;
const requestId = `ena_reset_${Date.now()}`;
startPendingSave(requestId);
post('xb-ena:reset-prompt-default', { requestId });
});
$('ep_tpl_select').addEventListener('change', () => {
const name = $('ep_tpl_select').value;
cfg.activePromptTemplate = name;
if (!name) return;
const blocks = cfg?.promptTemplates?.[name];
if (!Array.isArray(blocks)) return;
cfg.promptBlocks = structuredClone(blocks);
renderPromptList(); scheduleSave();
});
$('ep_tpl_save').addEventListener('click', () => {
const name = $('ep_tpl_select').value;
if (!name) { setSaveIndicator('error', '请先选择或创建模板'); return; }
cfg.promptTemplates = cfg.promptTemplates || {};
cfg.promptTemplates[name] = structuredClone(cfg.promptBlocks || []);
cfg.activePromptTemplate = name;
renderTemplateSelect(name); scheduleSave();
});
$('ep_tpl_saveas').addEventListener('click', () => {
const name = prompt('新模板名称');
if (!name) return;
cfg.promptTemplates = cfg.promptTemplates || {};
cfg.promptTemplates[name] = structuredClone(cfg.promptBlocks || []);
cfg.activePromptTemplate = name;
renderTemplateSelect(name); scheduleSave();
});
$('ep_tpl_delete').addEventListener('click', () => {
const name = $('ep_tpl_select').value;
if (!name) return;
const backup = structuredClone(cfg.promptTemplates[name]);
delete cfg.promptTemplates[name];
cfg.activePromptTemplate = '';
renderTemplateSelect('');
showUndoBar(name, backup);
});
$('ep_tpl_undo_btn').addEventListener('click', () => {
if (!undoState) return;
cfg.promptTemplates = cfg.promptTemplates || {};
cfg.promptTemplates[undoState.name] = undoState.blocks;
cfg.activePromptTemplate = undoState.name;
renderTemplateSelect(undoState.name);
clearUndo(); scheduleSave();
});
$('ep_debug_worldbook').addEventListener('click', () => {
$('ep_debug_output').classList.add('visible');
$('ep_debug_output').textContent = '诊断中…';
post('xb-ena:debug-worldbook');
});
$('ep_debug_char').addEventListener('click', () => {
$('ep_debug_output').classList.add('visible');
$('ep_debug_output').textContent = '诊断中…';
post('xb-ena:debug-char');
});
$('ep_test_planner').addEventListener('click', () => {
post('xb-ena:run-test', { text: '(测试输入)请规划下一步剧情走向。' });
$('ep_debug_output').classList.add('visible');
$('ep_debug_output').textContent = '规划测试中…';
});
$('ep_open_logs').addEventListener('click', () => post('xb-ena:logs-request'));
$('ep_log_clear').addEventListener('click', () => {
if (!confirm('确定清空所有日志?')) return;
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);
});
document.querySelectorAll('.card .input').forEach(el => {
if (el.closest('.prompt-block')) return;
if (el.id === 'ep_test_input') return;
el.addEventListener('change', scheduleSave);
});
}
/* ── Message handler ── */
window.addEventListener('message', ev => {
if (ev.origin !== PARENT_ORIGIN) return;
const { type, payload } = ev.data || {};
switch (type) {
case 'xb-ena:config':
applyConfig(payload || {});
break;
case 'xb-ena:config-saved':
applyConfig(payload || {});
resolvePendingSave(payload?.requestId || '');
break;
case 'xb-ena:config-save-error':
rejectPendingSave(payload?.requestId || '', payload?.message);
break;
case 'xb-ena:test-done': {
setLocalStatus('ep_test_status', '规划测试完成', 'success');
const d = $('ep_debug_output');
if (d.classList.contains('visible') && d.textContent.includes('测试中'))
d.textContent = '测试完成,请查看下方日志';
break;
}
case 'xb-ena:test-error': {
const msg = payload?.message || '规划测试失败';
setLocalStatus('ep_test_status', msg, 'error');
const d = $('ep_debug_output');
if (d.classList.contains('visible')) d.textContent = '测试失败: ' + msg;
break;
}
case 'xb-ena:logs':
logs = Array.isArray(payload?.logs) ? payload.logs : [];
renderLogs();
break;
case 'xb-ena:models': {
const models = Array.isArray(payload?.models) ? payload.models : [];
if (models.length) {
showModelSelector(models);
setLocalStatus('ep_api_status', `获取到 ${models.length} 个模型`, 'success');
} else {
setLocalStatus('ep_api_status', '未获取到模型', 'error');
}
break;
}
case 'xb-ena:models-error':
setLocalStatus('ep_api_status', payload?.message || '拉取模型失败', 'error');
break;
case 'xb-ena:debug-output': {
const out = $('ep_debug_output');
out.classList.add('visible');
out.textContent = String(payload?.output || '');
break;
}
}
});
/* ── Init ── */
bindEvents();
post('xb-ena:ready');
</script>
</body>
</html>