2026-02-25 11:33:20 +08:00
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
|
<html lang="zh-CN">
|
|
|
|
|
|
|
2026-02-25 10:26:01 +08:00
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="UTF-8">
|
2026-02-25 11:33:20 +08:00
|
|
|
|
<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">
|
2026-02-25 10:26:01 +08:00
|
|
|
|
<title>Ena Planner</title>
|
2026-02-25 11:33:20 +08:00
|
|
|
|
<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">
|
2026-02-25 10:26:01 +08:00
|
|
|
|
</head>
|
|
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
<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>快速开始
|
2026-02-25 10:26:01 +08:00
|
|
|
|
</div>
|
2026-02-25 11:33:20 +08:00
|
|
|
|
<div class="nav-item" data-view="api">
|
|
|
|
|
|
<i class="fa-solid fa-key"></i>API 配置
|
2026-02-25 10:26:01 +08:00
|
|
|
|
</div>
|
2026-02-25 11:33:20 +08:00
|
|
|
|
<div class="nav-item" data-view="prompt">
|
|
|
|
|
|
<i class="fa-solid fa-pen-to-square"></i>提示词
|
2026-02-25 10:26:01 +08:00
|
|
|
|
</div>
|
2026-02-25 11:33:20 +08:00
|
|
|
|
<div class="nav-item" data-view="context">
|
|
|
|
|
|
<i class="fa-solid fa-book-open"></i>上下文收集
|
2026-02-25 10:26:01 +08:00
|
|
|
|
</div>
|
2026-02-25 11:33:20 +08:00
|
|
|
|
<div class="nav-divider"></div>
|
|
|
|
|
|
<div class="nav-item" data-view="debug">
|
|
|
|
|
|
<i class="fa-solid fa-screwdriver-wrench"></i>调试
|
2026-02-25 10:26:01 +08:00
|
|
|
|
</div>
|
2026-02-25 11:33:20 +08:00
|
|
|
|
</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 → 提取 <plot> 和 <note> → 追加到你的输入 → 放行发送
|
|
|
|
|
|
</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,将 <plot> 和 <note> 追加到你的输入中</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">如果你的输入中已经手写了 <plot> 标签,则跳过自动规划</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>
|
2026-02-25 10:26:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
<!-- ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
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 用 /v1,Gemini 用 /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>
|
2026-02-25 10:26:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
<!-- ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
提示词
|
|
|
|
|
|
═══════════════════════════════════════════════════════════════ -->
|
|
|
|
|
|
<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 如何输出 <plot> 和 <note>。
|
|
|
|
|
|
</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>
|
2026-02-25 10:26:01 +08:00
|
|
|
|
</div>
|
2026-02-25 11:33:20 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
上下文收集
|
|
|
|
|
|
═══════════════════════════════════════════════════════════════ -->
|
|
|
|
|
|
<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">从聊天中提取最近的 <plot> 块,让规划器了解前情走向</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-02-25 10:26:01 +08:00
|
|
|
|
</div>
|
2026-02-25 11:33:20 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
调试
|
|
|
|
|
|
═══════════════════════════════════════════════════════════════ -->
|
|
|
|
|
|
<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>
|
2026-02-25 10:26:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
<!-- ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
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>
|
2026-02-25 10:26:01 +08:00
|
|
|
|
</div>
|
2026-02-25 11:33:20 +08:00
|
|
|
|
<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>
|
2026-02-25 10:26:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-02-25 11:33:20 +08:00
|
|
|
|
</nav>
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
</div>
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
<script>
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Ena Planner Settings UI — JavaScript (Fixed)
|
|
|
|
|
|
// ═══════════════════════════════════════════
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
const PARENT_ORIGIN = (() => {
|
|
|
|
|
|
try { return new URL(document.referrer).origin; } catch { return window.location.origin; }
|
|
|
|
|
|
})();
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
const post = (type, payload) => parent.postMessage({ type, payload }, PARENT_ORIGIN);
|
|
|
|
|
|
const $ = id => document.getElementById(id);
|
|
|
|
|
|
const $$ = sel => document.querySelectorAll(sel);
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
function genId() {
|
|
|
|
|
|
try { return crypto.randomUUID(); } catch { return `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; }
|
|
|
|
|
|
}
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// 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';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
function startPendingSave(requestId) {
|
|
|
|
|
|
pendingSave = {
|
|
|
|
|
|
requestId,
|
|
|
|
|
|
timer: setTimeout(() => {
|
|
|
|
|
|
if (!pendingSave || pendingSave.requestId !== requestId) return;
|
|
|
|
|
|
pendingSave = null;
|
|
|
|
|
|
setSaveIndicator('error', '保存超时');
|
|
|
|
|
|
}, 5000)
|
|
|
|
|
|
};
|
|
|
|
|
|
setSaveIndicator('saving');
|
|
|
|
|
|
}
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
function resolvePendingSave(requestId) {
|
|
|
|
|
|
if (!pendingSave || pendingSave.requestId !== requestId) return;
|
|
|
|
|
|
clearTimeout(pendingSave.timer);
|
|
|
|
|
|
pendingSave = null;
|
|
|
|
|
|
setSaveIndicator('saved');
|
|
|
|
|
|
setTimeout(() => setSaveIndicator(''), 2000);
|
|
|
|
|
|
}
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
function rejectPendingSave(requestId, msg) {
|
|
|
|
|
|
if (!pendingSave || pendingSave.requestId !== requestId) return;
|
|
|
|
|
|
clearTimeout(pendingSave.timer);
|
|
|
|
|
|
pendingSave = null;
|
|
|
|
|
|
setSaveIndicator('error', msg || '保存失败');
|
|
|
|
|
|
}
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Auto-save: debounced, incremental patch
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
let autoSaveTimer = null;
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
function scheduleSave() {
|
|
|
|
|
|
if (undoPending) return; // 模板删除等待撤销中,不保存
|
|
|
|
|
|
if (autoSaveTimer) clearTimeout(autoSaveTimer);
|
|
|
|
|
|
autoSaveTimer = setTimeout(doSave, 600);
|
|
|
|
|
|
}
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
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 });
|
2026-02-25 10:26:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// UI Helpers
|
|
|
|
|
|
// ═══════════════════════════════════════════
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
function setLocalStatus(elId, text, type) {
|
|
|
|
|
|
const el = $(elId);
|
|
|
|
|
|
if (!el) return;
|
|
|
|
|
|
el.textContent = text || '';
|
|
|
|
|
|
el.className = 'status-text' + (type ? ' ' + type : '');
|
|
|
|
|
|
}
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
function setBadge(enabled) {
|
|
|
|
|
|
const badge = $('ep_badge');
|
|
|
|
|
|
badge.className = 'header-badge' + (enabled ? ' on' : '');
|
|
|
|
|
|
badge.querySelector('span').textContent = enabled ? '已启用' : '未启用';
|
|
|
|
|
|
}
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
function updatePrefixModeUI() {
|
|
|
|
|
|
$('ep_custom_prefix_group').classList.toggle('hidden', $('ep_prefix_mode').value !== 'custom');
|
|
|
|
|
|
}
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Type conversion
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
function toBool(v, fallback = false) {
|
|
|
|
|
|
if (v === true || v === false) return v;
|
|
|
|
|
|
if (v === 'true') return true;
|
|
|
|
|
|
if (v === 'false') return false;
|
|
|
|
|
|
return fallback;
|
|
|
|
|
|
}
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
function toNum(v, fallback = 0) {
|
|
|
|
|
|
const n = Number(v);
|
|
|
|
|
|
return Number.isFinite(n) ? n : fallback;
|
|
|
|
|
|
}
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
function arrToCsv(arr) {
|
|
|
|
|
|
return Array.isArray(arr) ? arr.join(', ') : '';
|
2026-02-25 10:26:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
function csvToArr(text) {
|
|
|
|
|
|
return String(text || '').split(/[,,]/).map(x => x.trim()).filter(Boolean);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function escapeHtml(str) {
|
|
|
|
|
|
return String(str || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// 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;
|
2026-02-25 10:26:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
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;
|
2026-02-25 10:26:01 +08:00
|
|
|
|
}
|
2026-02-25 11:33:20 +08:00
|
|
|
|
empty.style.display = 'none';
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
function hideUndoBar() {
|
|
|
|
|
|
$('ep_tpl_undo').classList.add('hidden');
|
|
|
|
|
|
undoState = null;
|
|
|
|
|
|
}
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
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);
|
2026-02-25 10:26:01 +08:00
|
|
|
|
});
|
2026-02-25 11:33:20 +08:00
|
|
|
|
$('ep_model_selector').classList.remove('hidden');
|
|
|
|
|
|
}
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Logs
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
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();
|
2026-02-25 10:26:01 +08:00
|
|
|
|
renderPromptList();
|
2026-02-25 11:33:20 +08:00
|
|
|
|
renderLogs();
|
|
|
|
|
|
}
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
// Event bindings
|
|
|
|
|
|
// ═══════════════════════════════════════════
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
function bindEvents() {
|
|
|
|
|
|
// Navigation
|
|
|
|
|
|
$$('.nav-item, .mobile-nav-item').forEach(item => {
|
|
|
|
|
|
item.addEventListener('click', () => activateTab(item.dataset.view));
|
|
|
|
|
|
});
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
// Header
|
|
|
|
|
|
$('ep_close').addEventListener('click', () => post('xb-ena:close'));
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
// 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');
|
|
|
|
|
|
});
|
2026-02-25 10:26:01 +08:00
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
// 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 = '显示';
|
2026-02-25 10:26:01 +08:00
|
|
|
|
}
|
2026-02-25 11:33:20 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
});
|
2026-02-25 10:26:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
// ═══════════════════════════════════════════
|
|
|
|
|
|
// 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>
|
2026-02-25 10:26:01 +08:00
|
|
|
|
</body>
|
|
|
|
|
|
|
2026-02-25 11:33:20 +08:00
|
|
|
|
</html>
|