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

1119 lines
50 KiB
HTML
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.
<!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="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="./ena-planner.css">
</head>
<body>
<div class="app-container">
<!-- ═══════════════════════════════════════════════════════════════════════
Header
═══════════════════════════════════════════════════════════════════════ -->
<header class="app-header">
<div class="header-logo">
<i class="fa-solid fa-compass"></i>
<span>Ena Planner</span>
</div>
<div id="ep_badge" class="header-badge">
<i class="fa-solid fa-circle"></i>
<span>未启用</span>
</div>
<div id="ep_save_status" class="save-status">
<i class="fa-solid fa-check"></i>
<span>就绪</span>
</div>
<div class="header-spacer"></div>
<button id="ep_close" class="header-close"></button>
</header>
<div class="app-body">
<!-- ═══════════════════════════════════════════════════════════════════
Sidebar
═══════════════════════════════════════════════════════════════════ -->
<nav class="app-sidebar">
<div class="nav-item active" data-view="quickstart">
<i class="fa-solid fa-bolt"></i>快速开始
</div>
<div class="nav-item" data-view="api">
<i class="fa-solid fa-key"></i>API 配置
</div>
<div class="nav-item" data-view="prompt">
<i class="fa-solid fa-pen-to-square"></i>提示词
</div>
<div class="nav-item" data-view="context">
<i class="fa-solid fa-book-open"></i>上下文收集
</div>
<div class="nav-divider"></div>
<div class="nav-item" data-view="debug">
<i class="fa-solid fa-screwdriver-wrench"></i>调试
</div>
</nav>
<!-- ═══════════════════════════════════════════════════════════════════
Main content
═══════════════════════════════════════════════════════════════════ -->
<main class="app-main">
<!-- ═══════════════════════════════════════════════════════════════
快速开始
═══════════════════════════════════════════════════════════════ -->
<div id="view-quickstart" class="view active">
<div class="view-header">
<h2 class="view-title">快速开始</h2>
<p class="view-desc">Ena Planner 在你发送消息前,自动调用独立 LLM 规划剧情走向</p>
</div>
<div class="tip-box">
<i class="fa-solid fa-circle-info"></i>
<div>
<strong>工作流程:</strong>你点击发送 → 拦截 → 收集上下文(角色卡、世界书、摘要、历史 plot、最近 AI 回复)
→ 发给规划 LLM → 提取 &lt;plot&gt;&lt;note&gt; → 追加到你的输入 → 放行发送
</div>
</div>
<div 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>
<p class="form-hint">开启后,每次发送消息前会先调用规划 LLM&lt;plot&gt;&lt;note&gt; 追加到你的输入中</p>
</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>
<p class="form-hint">如果你的输入中已经手写了 &lt;plot&gt; 标签,则跳过自动规划</p>
</div>
</div>
</div>
<div 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-primary">
<i class="fa-solid fa-play"></i> 运行规划测试
</button>
</div>
<div id="ep_test_status" class="status-text"></div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════
API 配置
═══════════════════════════════════════════════════════════════ -->
<div id="view-api" class="view">
<div class="view-header">
<h2 class="view-title">API 配置</h2>
<p class="view-desc">规划器使用独立的 LLM 渠道,与酒馆主 API 分开</p>
</div>
<div 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">自动</option>
<option value="custom">自定义</option>
</select>
<p class="form-hint">自动模式下 OpenAI 用 /v1Gemini 用 /v1beta</p>
</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 btn-toggle">
<i class="fa-solid fa-eye"></i> <span>显示</span>
</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="model-selector hidden">
<label class="form-label">选择模型</label>
<select id="ep_model_select" class="input">
<option value="">-- 从列表选择 --</option>
</select>
</div>
<div class="btn-group">
<button id="ep_fetch_models" class="btn"><i class="fa-solid fa-plug"></i> 拉取模型列表</button>
<button id="ep_test_conn" class="btn"><i class="fa-solid fa-check-double"></i> 测试连接</button>
</div>
<div id="ep_api_status" class="status-text"></div>
</div>
<div 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>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════
提示词
═══════════════════════════════════════════════════════════════ -->
<div id="view-prompt" class="view">
<div class="view-header">
<h2 class="view-title">提示词设计</h2>
<p class="view-desc">配置发给规划 LLM 的提示词块,控制它如何规划剧情</p>
</div>
<div class="tip-box">
<i class="fa-solid fa-lightbulb"></i>
<div>
提示词块按顺序组装成消息发给规划 LLM。系统会自动在提示词之后注入角色卡、世界书、剧情摘要、聊天历史、向量召回、历史 plot、用户输入。
你只需要在这里写"规划指令",告诉 LLM 如何输出 &lt;plot&gt;&lt;note&gt;
</div>
</div>
<div class="card">
<div class="card-title">模板管理</div>
<div class="form-row">
<div class="form-group" style="flex: 1;">
<label class="form-label">选择模板</label>
<select id="ep_tpl_select" class="input">
<option value="">-- 选择模板 --</option>
</select>
</div>
<div class="form-group" style="display: flex; align-items: flex-end;">
<div class="btn-group">
<button id="ep_tpl_save" class="btn btn-primary"><i class="fa-solid fa-floppy-disk"></i> 保存</button>
<button id="ep_tpl_saveas" class="btn"><i class="fa-solid fa-plus"></i> 另存为</button>
<button id="ep_tpl_delete" class="btn btn-danger"><i class="fa-solid fa-trash"></i> 删除</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-sm btn-primary">撤销</button>
</div>
</div>
<div 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: 12px;">
<button id="ep_add_prompt" class="btn"><i class="fa-solid fa-plus"></i> 添加块</button>
<button id="ep_reset_prompt" class="btn btn-danger"><i class="fa-solid fa-rotate-left"></i> 恢复默认</button>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════
上下文收集
═══════════════════════════════════════════════════════════════ -->
<div id="view-context" class="view">
<div class="view-header">
<h2 class="view-title">上下文收集</h2>
<p class="view-desc">控制规划器能"看到"哪些信息,影响规划质量</p>
</div>
<div 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>
<p class="form-hint">默认只读角色卡绑定的世界书。开启后额外读取全局世界书</p>
</div>
<div class="form-group">
<label class="form-label">排除"聊天深度注入"类条目</label>
<select id="ep_wb_pos4" class="input">
<option value="true"></option>
<option value="false"></option>
</select>
<p class="form-hint">世界书中 position=4按深度插入聊天的条目通常是运行时机制对规划意义不大</p>
</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, ...">
<p class="form-hint">条目标题包含这些关键词的会被跳过</p>
</div>
</div>
<div class="card">
<div class="card-title">聊天历史</div>
<div class="form-group">
<label class="form-label">清理 AI 回复中的标签(逗号分隔)</label>
<input id="ep_exclude_tags" type="text" class="input"
placeholder="行动选项, UpdateVariable, StatusPlaceHolderImpl">
<p class="form-hint">读取 AI 回复时,这些 XML 标签及其内容会被剥离,避免游戏机制标签干扰规划</p>
</div>
</div>
<div class="card">
<div class="card-title">历史规划</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">
<p class="form-hint">从聊天中提取最近的 &lt;plot&gt; 块,让规划器了解前情走向</p>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════
调试
═══════════════════════════════════════════════════════════════ -->
<div id="view-debug" class="view">
<div class="view-header">
<h2 class="view-title">调试与日志</h2>
<p class="view-desc">诊断问题和查看规划历史</p>
</div>
<div class="card">
<div class="card-title">诊断工具</div>
<div class="btn-group">
<button id="ep_debug_worldbook" class="btn"><i class="fa-solid fa-book"></i> 诊断世界书</button>
<button id="ep_debug_char" class="btn"><i class="fa-solid fa-user"></i> 诊断角色卡</button>
<button id="ep_test_planner" class="btn btn-primary"><i class="fa-solid fa-play"></i> 运行规划测试</button>
</div>
<pre id="ep_debug_output" class="debug-output"></pre>
</div>
<div class="card">
<div class="card-title">日志设置</div>
<div class="form-row">
<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>
<div class="card">
<div class="card-title">日志列表</div>
<div class="btn-group" style="margin-bottom: 12px;">
<button id="ep_open_logs" class="btn"><i class="fa-solid fa-rotate"></i> 刷新</button>
<button id="ep_log_export" class="btn"><i class="fa-solid fa-download"></i> 导出 JSON</button>
<button id="ep_log_clear" class="btn btn-danger"><i class="fa-solid fa-trash"></i> 清空</button>
</div>
<div id="ep_log_body" class="log-list">
<div class="log-empty">暂无日志</div>
</div>
</div>
</div>
</main>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
Mobile nav
═══════════════════════════════════════════════════════════════════════ -->
<nav class="mobile-nav">
<div class="mobile-nav-inner">
<div class="mobile-nav-item active" data-view="quickstart">
<i class="fa-solid fa-bolt"></i><span>开始</span>
</div>
<div class="mobile-nav-item" data-view="api">
<i class="fa-solid fa-key"></i><span>API</span>
</div>
<div class="mobile-nav-item" data-view="prompt">
<i class="fa-solid fa-pen-to-square"></i><span>提示词</span>
</div>
<div class="mobile-nav-item" data-view="context">
<i class="fa-solid fa-book-open"></i><span>上下文</span>
</div>
<div class="mobile-nav-item" data-view="debug">
<i class="fa-solid fa-screwdriver-wrench"></i><span>调试</span>
</div>
</div>
</nav>
</div>
<script>
// ═══════════════════════════════════════════════════════════════════════════
// Ena Planner Settings UI — JavaScript (Fixed)
// ═══════════════════════════════════════════
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)}`; }
}
// ═══════════════════════════════════════════════════════════════════════════
// State
// ═══════════════════════════════════════════════════════════════════════════
let cfg = null;
let logs = [];
let pendingSave = null;
let undoState = null; // { name, blocks, timer }
let undoPending = false; // true = 模板删除等待撤销中,冻结自动保存
let fetchedModels = []; // cached model list from last fetch
// ═══════════════════════════════════════════════════════════════════════════
// Save status indicator (header)
// ═══════════════════════════════════════════
function setSaveIndicator(state, text) {
const el = $('ep_save_status');
if (!el) return;
el.className = 'save-status ' + state;
const icon = el.querySelector('i');
const span = el.querySelector('span');
if (state === 'saving') {
icon.className = 'fa-solid fa-spinner fa-spin';
span.textContent = text || '保存中…';
} else if (state === 'saved') {
icon.className = 'fa-solid fa-check';
span.textContent = text || '已保存';
} else if (state === 'error') {
icon.className = 'fa-solid fa-xmark';
span.textContent = text || '保存失败';
} else {
icon.className = 'fa-solid fa-check';
span.textContent = '就绪';
el.className = 'save-status';
}
}
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: debounced, incremental patch
// ═══════════════════════════════════════════════════════════════════════════
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.className = 'header-badge' + (enabled ? ' on' : '');
badge.querySelector('span').textContent = enabled ? '已启用' : '未启用';
}
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 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.innerHTML = '<i class="fa-solid fa-arrow-up"></i>';
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.innerHTML = '<i class="fa-solid fa-arrow-down"></i>';
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-danger';
delBtn.innerHTML = '<i class="fa-solid fa-trash"></i>';
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 || {});
names.forEach(name => {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
opt.selected = name === selected;
sel.appendChild(opt);
});
}
// ═══════════════════════════════════════════════════════════════════════════
// Template undo
// ═══════════════════════════════════════════════════════════════════════════
function showUndoBar(name, blocks) {
clearUndo();
// 冻结自动保存:删除期间其他字段变更不会触发保存
undoPending = true;
undoState = {
name,
blocks,
timer: setTimeout(() => {
hideUndoBar();
undoPending = false;
scheduleSave(); // finalize deletion
}, 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 currentModel = $('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 === currentModel;
sel.appendChild(opt);
});
$('ep_model_selector').classList.remove('hidden');
}
// ═══════════════════════════════════════════════════════════════════════════
// Logs
// ═══════════════════════════════════════════════════════════════════════════
function renderLogs() {
const body = $('ep_log_body');
if (!Array.isArray(logs) || logs.length === 0) {
body.innerHTML = '<div class="log-empty">暂无日志</div>';
return;
}
body.innerHTML = logs.map(item => {
const time = item.time ? new Date(item.time).toLocaleString() : '-';
const okStyle = item.ok ? 'color: var(--success);' : 'color: var(--danger);';
return `
<div class="log-item">
<div class="log-meta">
<span>${escapeHtml(time)} · <span style="${okStyle}">${item.ok ? '成功' : '失败'}</span></span>
<span>${escapeHtml(item.model || '-')}</span>
</div>
${item.error ? `<div class="log-error">${escapeHtml(item.error)}</div>` : ''}
<details>
<summary>请求消息</summary>
<pre class="log-pre">${escapeHtml(JSON.stringify(item.requestMessages || [], null, 2))}</pre>
</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 config
// ═══════════════════════════════════════════════════════════════════════════
function applyConfig(nextCfg) {
cfg = nextCfg || {};
logs = Array.isArray(cfg.logs) ? cfg.logs : [];
// Quickstart
$('ep_enabled').value = String(toBool(cfg.enabled, true));
$('ep_skip_plot').value = String(toBool(cfg.skipIfPlotPresent, true));
// API
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 ?? '';
// Context
$('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);
// Debug
$('ep_logs_persist').value = String(toBool(cfg.logsPersist, true));
$('ep_logs_max').value = String(toNum(cfg.logsMax, 20));
// UI state
setBadge(toBool(cfg.enabled, true));
updatePrefixModeUI();
renderTemplateSelect();
renderPromptList();
renderLogs();
}
function collectPatch() {
const p = {};
// Quickstart
p.enabled = toBool($('ep_enabled').value, true);
p.skipIfPlotPresent = toBool($('ep_skip_plot').value, true);
// API
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()
};
// Context
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.chatExcludeTags = csvToArr($('ep_exclude_tags').value);
// Debug — clamp logsMax
p.logsPersist = toBool($('ep_logs_persist').value, true);
p.logsMax = Math.max(1, Math.min(200, Math.floor(toNum($('ep_logs_max').value, 20))));
// Prompt blocks & templates from live cfg (mutated in-place by block editors)
p.promptBlocks = cfg?.promptBlocks || [];
p.promptTemplates = cfg?.promptTemplates || {};
return p;
}
// ═══════════════════════════════════════════════════════════════════════════
// Event bindings
// ═══════════════════════════════════════════
function bindEvents() {
// Navigation
$$('.nav-item, .mobile-nav-item').forEach(item => {
item.addEventListener('click', () => activateTab(item.dataset.view));
});
// Header
$('ep_close').addEventListener('click', () => post('xb-ena:close'));
// Quickstart
$('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');
});
// API — key toggle
$('ep_toggle_key').addEventListener('click', () => {
const input = $('ep_api_key');
const icon = $('ep_toggle_key').querySelector('i');
const label = $('ep_toggle_key').querySelector('span');
if (input.type === 'password') {
input.type = 'text';
icon.className = 'fa-solid fa-eye-slash';
label.textContent = '隐藏';
} else {
input.type = 'password';
icon.className = 'fa-solid fa-eye';
label.textContent = '显示';
}
});
// API — prefix mode
$('ep_prefix_mode').addEventListener('change', updatePrefixModeUI);
// API — fetch models
$('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');
});
// API — model selector
$('ep_model_select').addEventListener('change', () => {
const val = $('ep_model_select').value;
if (val) {
$('ep_model').value = val;
scheduleSave();
}
});
// Prompt — add block
$('ep_add_prompt').addEventListener('click', () => {
cfg.promptBlocks = cfg.promptBlocks || [];
cfg.promptBlocks.push({
id: genId(),
role: 'system',
name: '新块',
content: ''
});
renderPromptList();
scheduleSave();
});
// Prompt — reset default
$('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 });
});
// Prompt — template load
$('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();
scheduleSave();
});
// Prompt — template save
$('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 || []);
renderTemplateSelect(name);
scheduleSave();
});
// Prompt — template save as
$('ep_tpl_saveas').addEventListener('click', () => {
const name = prompt('新模板名称');
if (!name) return;
cfg.promptTemplates = cfg.promptTemplates || {};
cfg.promptTemplates[name] = structuredClone(cfg.promptBlocks || []);
renderTemplateSelect(name);
scheduleSave();
});
// Prompt — template delete (with undo)
$('ep_tpl_delete').addEventListener('click', () => {
const name = $('ep_tpl_select').value;
if (!name) return;
const backup = structuredClone(cfg.promptTemplates[name]);
delete cfg.promptTemplates[name];
renderTemplateSelect('');
showUndoBar(name, backup);
// Don't save yet — wait for undo timeout
});
// Prompt — undo delete
$('ep_tpl_undo_btn').addEventListener('click', () => {
if (!undoState) return;
cfg.promptTemplates = cfg.promptTemplates || {};
cfg.promptTemplates[undoState.name] = undoState.blocks;
renderTemplateSelect(undoState.name);
clearUndo();
scheduleSave(); // 持久化恢复状态
});
// Debug
$('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 = '规划测试中…';
});
// Logs
$('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);
});
// ═══════════════════════════════════════════════════════════════════════
// Auto-save on any .input change (except prompt blocks, handled above)
// ═══════════════════════════════════════
document.querySelectorAll('.card .input').forEach(el => {
// Skip prompt block inputs — they have their own scheduleSave
if (el.closest('.prompt-block')) return;
// Skip test input — not a config field
if (el.id === 'ep_test_input') return;
el.addEventListener('change', scheduleSave);
});
}
// ═══════════════════════════════════════════
// Message handler from parent
// ═══════════════════════════════════════════════════════════════════════════
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 debugOut = $('ep_debug_output');
if (debugOut.classList.contains('visible') && debugOut.textContent.includes('测试中')) {
debugOut.textContent = '测试完成,请查看下方日志';
}
break;
}
case 'xb-ena:test-error': {
const msg = payload?.message || '规划测试失败';
setLocalStatus('ep_test_status', msg, 'error');
const debugOut = $('ep_debug_output');
if (debugOut.classList.contains('visible')) {
debugOut.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>