mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
Merge pull request #1 from Hao19911125/codex/ena-planner-bme
Add Ena Planner integration for ST-BME
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
// 可被 index.js 及其他模块安全导入。
|
||||
|
||||
import { clampInt } from "./ui-status.js";
|
||||
import { sanitizePlannerMessageText } from "./planner-tag-utils.js";
|
||||
import { rollbackBatch } from "./runtime-state.js";
|
||||
|
||||
export function isAssistantChatMessage(message) {
|
||||
@@ -40,7 +41,7 @@ export function buildExtractionMessages(chat, startIdx, endIdx, settings) {
|
||||
messages.push({
|
||||
seq: index,
|
||||
role: msg.is_user ? "user" : "assistant",
|
||||
content: msg.mes || "",
|
||||
content: sanitizePlannerMessageText(msg),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
53
ena-planner/ena-planner-presets.js
Normal file
53
ena-planner/ena-planner-presets.js
Normal file
@@ -0,0 +1,53 @@
|
||||
export const DEFAULT_PROMPT_BLOCKS = [
|
||||
{
|
||||
id: "ena-default-system-001",
|
||||
role: "system",
|
||||
name: "Ena Planner System",
|
||||
content: `你是一位剧情规划师(Story Planner)。你的工作是在幕后为互动叙事提供方向指引,而不是直接扮演角色或撰写正文。
|
||||
|
||||
## 你会收到的信息
|
||||
- 角色卡:当前角色的设定(描述、性格、场景)
|
||||
- 世界书:世界观设定和规则
|
||||
- 结构化记忆(BME):由记忆图谱整理出的长期记忆
|
||||
- [Memory - Core]:规则、摘要、长期约束
|
||||
- [Memory - Recalled]:与当前情境相关的人物状态、事件、地点、剧情线
|
||||
- 聊天历史:最近的 AI 回复片段
|
||||
- 历史规划:之前生成的 <plot> 块
|
||||
- 玩家输入:玩家刚刚发出的指令或行动
|
||||
|
||||
## 你的任务
|
||||
根据以上信息,为下一轮 AI 回复规划剧情走向。
|
||||
|
||||
## 输出格式(严格遵守)
|
||||
只输出以下两个标签,不要输出任何其他内容:
|
||||
|
||||
<plot>
|
||||
(剧情走向指引:接下来应该发生什么。包括场景推进、NPC 反应、事件触发、伏笔推进等。写给 AI 看的导演指令,不是给玩家看的正文。简洁、具体、可执行。)
|
||||
</plot>
|
||||
|
||||
<note>
|
||||
(写作注意事项:这一轮回复应该怎么写。包括叙事节奏、情绪基调、应避免的问题、需要保持的连贯性等。同样是给 AI 的元指令,不是正文。)
|
||||
</note>
|
||||
|
||||
## 规划原则
|
||||
1. 尊重玩家意图:玩家输入是最高优先级。
|
||||
2. 保持连贯:与 BME 记忆、历史规划和世界规则一致。
|
||||
3. 推进而非重复:每轮规划都应推动剧情前进。
|
||||
4. 留有余地:给方向,不要把正文细节写死。
|
||||
5. 遵守世界观:世界书中的规则和设定属于硬约束。
|
||||
|
||||
如有思考过程,请放在 <thinking> 中(会被自动剔除)。`,
|
||||
},
|
||||
{
|
||||
id: "ena-default-assistant-001",
|
||||
role: "assistant",
|
||||
name: "Assistant Seed",
|
||||
content: `<think>
|
||||
先梳理玩家意图、当前局势、BME 记忆里的关键约束和最近的剧情推进,再给出下一步 plot 与 note。
|
||||
</think>`,
|
||||
},
|
||||
];
|
||||
|
||||
export const BUILTIN_TEMPLATES = {
|
||||
默认模板: DEFAULT_PROMPT_BLOCKS,
|
||||
};
|
||||
168
ena-planner/ena-planner-storage.js
Normal file
168
ena-planner/ena-planner-storage.js
Normal file
@@ -0,0 +1,168 @@
|
||||
import { getRequestHeaders } from "../../../../../script.js";
|
||||
|
||||
function debounce(fn, ms) {
|
||||
let timer = null;
|
||||
return (...args) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), ms);
|
||||
};
|
||||
}
|
||||
|
||||
const toBase64 = (text) => btoa(unescape(encodeURIComponent(text)));
|
||||
|
||||
class StorageFile {
|
||||
constructor(filename, opts = {}) {
|
||||
this.filename = filename;
|
||||
this.cache = null;
|
||||
this._loading = null;
|
||||
this._dirtyVersion = 0;
|
||||
this._savedVersion = 0;
|
||||
this._saving = false;
|
||||
this._pendingSave = false;
|
||||
this._retryCount = 0;
|
||||
this._retryTimer = null;
|
||||
this._maxRetries = Number.isFinite(opts.maxRetries) ? opts.maxRetries : 5;
|
||||
const debounceMs = Number.isFinite(opts.debounceMs) ? opts.debounceMs : 2000;
|
||||
this._saveDebounced = debounce(() => this.saveNow({ silent: true }), debounceMs);
|
||||
}
|
||||
|
||||
async load() {
|
||||
if (this.cache !== null) return this.cache;
|
||||
if (this._loading) return this._loading;
|
||||
|
||||
this._loading = (async () => {
|
||||
try {
|
||||
const res = await fetch(`/user/files/${this.filename}`, {
|
||||
headers: getRequestHeaders(),
|
||||
cache: "no-cache",
|
||||
});
|
||||
if (!res.ok) {
|
||||
this.cache = {};
|
||||
return this.cache;
|
||||
}
|
||||
const text = await res.text();
|
||||
this.cache = text ? JSON.parse(text) || {} : {};
|
||||
} catch {
|
||||
this.cache = {};
|
||||
} finally {
|
||||
this._loading = null;
|
||||
}
|
||||
return this.cache;
|
||||
})();
|
||||
|
||||
return this._loading;
|
||||
}
|
||||
|
||||
async get(key, defaultValue = null) {
|
||||
const data = await this.load();
|
||||
return data[key] ?? defaultValue;
|
||||
}
|
||||
|
||||
async set(key, value) {
|
||||
const data = await this.load();
|
||||
data[key] = value;
|
||||
this._dirtyVersion++;
|
||||
this._saveDebounced();
|
||||
}
|
||||
|
||||
async saveNow({ silent = true } = {}) {
|
||||
if (this._saving) {
|
||||
this._pendingSave = true;
|
||||
if (!silent) {
|
||||
await this._waitForSaveComplete();
|
||||
if (this._dirtyVersion > this._savedVersion) {
|
||||
return this.saveNow({ silent });
|
||||
}
|
||||
return this._dirtyVersion === this._savedVersion;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!this.cache || this._dirtyVersion === this._savedVersion) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this._saving = true;
|
||||
this._pendingSave = false;
|
||||
const versionToSave = this._dirtyVersion;
|
||||
|
||||
try {
|
||||
const json = JSON.stringify(this.cache);
|
||||
const base64 = toBase64(json);
|
||||
const res = await fetch("/api/files/upload", {
|
||||
method: "POST",
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ name: this.filename, data: base64 }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Server returned ${res.status}`);
|
||||
}
|
||||
|
||||
this._savedVersion = Math.max(this._savedVersion, versionToSave);
|
||||
this._retryCount = 0;
|
||||
if (this._retryTimer) {
|
||||
clearTimeout(this._retryTimer);
|
||||
this._retryTimer = null;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("[EnaPlannerStorage] Save failed:", error);
|
||||
this._retryCount++;
|
||||
const delay = Math.min(30000, 2000 * (2 ** Math.max(0, this._retryCount - 1)));
|
||||
if (!this._retryTimer && this._retryCount <= this._maxRetries) {
|
||||
this._retryTimer = setTimeout(() => {
|
||||
this._retryTimer = null;
|
||||
this.saveNow({ silent: true });
|
||||
}, delay);
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
throw error;
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
this._saving = false;
|
||||
if (this._pendingSave || this._dirtyVersion > this._savedVersion) {
|
||||
this._saveDebounced();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_waitForSaveComplete() {
|
||||
return new Promise((resolve) => {
|
||||
const check = () => {
|
||||
if (!this._saving) resolve();
|
||||
else setTimeout(check, 50);
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const EnaPlannerStorage = new StorageFile("STBME_EnaPlanner.json", {
|
||||
debounceMs: 800,
|
||||
});
|
||||
|
||||
export async function migrateFromLWBIfNeeded() {
|
||||
const existing = await EnaPlannerStorage.get("config", null);
|
||||
if (existing) return false;
|
||||
|
||||
try {
|
||||
const res = await fetch("/user/files/LittleWhiteBox_EnaPlanner.json", {
|
||||
headers: getRequestHeaders(),
|
||||
cache: "no-cache",
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const text = await res.text();
|
||||
const old = text ? JSON.parse(text) : null;
|
||||
if (!old?.config) return false;
|
||||
|
||||
await EnaPlannerStorage.set("config", old.config);
|
||||
await EnaPlannerStorage.set("logs", Array.isArray(old.logs) ? old.logs : []);
|
||||
await EnaPlannerStorage.saveNow({ silent: false });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn("[Ena] LWB config migration failed:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
888
ena-planner/ena-planner.css
Normal file
888
ena-planner/ena-planner.css
Normal file
@@ -0,0 +1,888 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Ena Planner — Settings UI
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #121212;
|
||||
--bg2: #1e1e1e;
|
||||
--bg3: #2a2a2a;
|
||||
--txt: #e0e0e0;
|
||||
--txt2: #b0b0b0;
|
||||
--txt3: #808080;
|
||||
--bdr: #3a3a3a;
|
||||
--bdr2: #333;
|
||||
--acc: #e0e0e0;
|
||||
--hl: #e8928a;
|
||||
--hl2: #d87a7a;
|
||||
--hl-soft: rgba(232, 146, 138, .1);
|
||||
--inv: #1e1e1e;
|
||||
--success: #4caf50;
|
||||
--warn: #ffb74d;
|
||||
--error: #ef5350;
|
||||
--code-bg: #0d0d0d;
|
||||
--code-txt: #d4d4d4;
|
||||
--radius: 4px;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: auto;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--txt);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Layout
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
padding: 24px 40px;
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Header
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid var(--bdr);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header-left h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 300;
|
||||
letter-spacing: -.02em;
|
||||
margin-bottom: 4px;
|
||||
color: var(--txt);
|
||||
}
|
||||
|
||||
.header-left h1 span {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: .75rem;
|
||||
color: var(--txt3);
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
align-items: center;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.stat-val {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
color: var(--txt);
|
||||
}
|
||||
|
||||
.stat-val .hl {
|
||||
color: var(--hl);
|
||||
}
|
||||
|
||||
.stat-lbl {
|
||||
font-size: .6875rem;
|
||||
color: var(--txt3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .1em;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--bdr);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: border-color .2s;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
border-color: var(--txt2);
|
||||
}
|
||||
|
||||
.modal-close svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--txt2);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Nav Tabs (desktop)
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
border-bottom: 1px solid var(--bdr);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
font-size: .8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--txt3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
cursor: pointer;
|
||||
transition: color .2s, border-color .2s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
color: var(--txt2);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: var(--hl);
|
||||
border-bottom-color: var(--hl);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Mobile Nav (bottom)
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.mobile-nav {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 56px;
|
||||
background: var(--bg2);
|
||||
border-top: 1px solid var(--bdr);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.mobile-nav-inner {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mobile-nav-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
color: var(--txt3);
|
||||
font-size: .625rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .05em;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: color .2s;
|
||||
}
|
||||
|
||||
.mobile-nav-item span {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mobile-nav-item .nav-dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
transition: background .2s;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.mobile-nav-item.active {
|
||||
color: var(--hl);
|
||||
}
|
||||
|
||||
.mobile-nav-item.active .nav-dot {
|
||||
background: var(--hl);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Views
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.view {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.view.active {
|
||||
display: block;
|
||||
animation: fadeIn .25s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Cards
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.card {
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--bdr);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: .75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .12em;
|
||||
color: var(--txt2);
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px dashed var(--bdr2);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Forms
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-row .form-group {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-row+.form-row {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: .6875rem;
|
||||
color: var(--txt3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: .75rem;
|
||||
color: var(--txt3);
|
||||
line-height: 1.5;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--bdr);
|
||||
border-radius: var(--radius);
|
||||
font-size: .8125rem;
|
||||
color: var(--txt);
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color .2s;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--txt2);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--txt3);
|
||||
}
|
||||
|
||||
select.input {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' fill='none' stroke='%23808080' stroke-width='2'%3E%3Cpolyline points='2 3.5 5 6.5 8 3.5'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
padding-right: 28px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
textarea.input {
|
||||
min-height: 80px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-row .input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Buttons
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 9px 18px;
|
||||
background: var(--bg2);
|
||||
color: var(--txt);
|
||||
border: 1px solid var(--bdr);
|
||||
border-radius: var(--radius);
|
||||
font-size: .8125rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: border-color .2s, background .2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
border-color: var(--txt3);
|
||||
background: var(--bg3);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: .35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-p {
|
||||
background: var(--acc);
|
||||
color: var(--inv);
|
||||
border-color: var(--acc);
|
||||
}
|
||||
|
||||
.btn-p:hover {
|
||||
background: var(--txt2);
|
||||
border-color: var(--txt2);
|
||||
}
|
||||
|
||||
.btn-del {
|
||||
color: var(--hl);
|
||||
border-color: rgba(232, 146, 138, .3);
|
||||
}
|
||||
|
||||
.btn-del:hover {
|
||||
background: var(--hl-soft);
|
||||
border-color: var(--hl);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 5px 12px;
|
||||
font-size: .75rem;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Tip Box
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.tip-box {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 14px 16px;
|
||||
background: var(--hl-soft);
|
||||
border: 1px solid var(--bdr);
|
||||
border-left: 3px solid var(--hl);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tip-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: .875rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.tip-text {
|
||||
font-size: .8125rem;
|
||||
color: var(--txt2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Prompt Blocks
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.prompt-block {
|
||||
background: var(--bg3);
|
||||
border: 1px solid var(--bdr);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.prompt-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.prompt-head-left {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.prompt-head-right {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.prompt-block textarea.input {
|
||||
min-height: 120px;
|
||||
font-family: 'SF Mono', Monaco, Consolas, 'Liberation Mono', monospace;
|
||||
font-size: .75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.prompt-empty {
|
||||
text-align: center;
|
||||
padding: 36px 20px;
|
||||
color: var(--txt3);
|
||||
font-size: .8125rem;
|
||||
border: 1px dashed var(--bdr);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Undo Bar
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.undo-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
margin-top: 12px;
|
||||
background: var(--hl-soft);
|
||||
border: 1px solid var(--bdr);
|
||||
border-radius: var(--radius);
|
||||
font-size: .8125rem;
|
||||
color: var(--txt2);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Status Text
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
.status-text {
|
||||
font-size: .75rem;
|
||||
color: var(--txt3);
|
||||
margin-top: 10px;
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
.status-text.success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-text.error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.status-text.loading {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
Logs
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.log-list {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--bdr);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg3);
|
||||
}
|
||||
|
||||
.log-item {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--bdr2);
|
||||
}
|
||||
|
||||
.log-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.log-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: .6875rem;
|
||||
color: var(--txt3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.log-meta .success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.log-meta .error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.log-error {
|
||||
color: var(--error);
|
||||
font-size: .8125rem;
|
||||
margin-bottom: 8px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.log-pre {
|
||||
background: var(--code-bg);
|
||||
color: var(--code-txt);
|
||||
padding: 12px;
|
||||
border-radius: var(--radius);
|
||||
font-family: 'SF Mono', Monaco, Consolas, 'Liberation Mono', monospace;
|
||||
font-size: .6875rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.log-empty {
|
||||
text-align: center;
|
||||
padding: 36px 20px;
|
||||
color: var(--txt3);
|
||||
font-size: .8125rem;
|
||||
}
|
||||
|
||||
/* Message cards inside log */
|
||||
.msg-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.msg-card {
|
||||
border-radius: var(--radius);
|
||||
border-left: 3px solid var(--bdr);
|
||||
background: var(--code-bg);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.msg-card.msg-system { border-left-color: #6b8afd; }
|
||||
.msg-card.msg-user { border-left-color: #4ecdc4; }
|
||||
.msg-card.msg-assistant { border-left-color: #f7a046; }
|
||||
|
||||
.msg-role {
|
||||
font-size: .6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
margin-bottom: 4px;
|
||||
color: var(--txt3);
|
||||
}
|
||||
|
||||
.msg-system .msg-role { color: #6b8afd; }
|
||||
.msg-user .msg-role { color: #4ecdc4; }
|
||||
.msg-assistant .msg-role { color: #f7a046; }
|
||||
|
||||
.msg-content {
|
||||
font-family: 'SF Mono', Monaco, Consolas, 'Liberation Mono', monospace;
|
||||
font-size: .6875rem;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--code-txt);
|
||||
margin: 0;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
details {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
details:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
details summary {
|
||||
cursor: pointer;
|
||||
font-size: .75rem;
|
||||
font-weight: 500;
|
||||
color: var(--txt3);
|
||||
user-select: none;
|
||||
padding: 4px 0;
|
||||
transition: color .15s;
|
||||
}
|
||||
|
||||
details summary:hover {
|
||||
color: var(--txt);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Debug Output
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.debug-output {
|
||||
background: var(--code-bg);
|
||||
color: var(--code-txt);
|
||||
padding: 14px;
|
||||
border-radius: var(--radius);
|
||||
font-family: 'SF Mono', Monaco, Consolas, 'Liberation Mono', monospace;
|
||||
font-size: .6875rem;
|
||||
line-height: 1.6;
|
||||
margin-top: 16px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.debug-output.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
Utilities
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--bdr);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--txt3);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
Responsive — Tablet
|
||||
═══════════════════════════════════════════ */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-left h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.stats {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-nav {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding-bottom: 72px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.prompt-head {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.prompt-head-left {
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
Responsive — Small phone
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
padding-bottom: 68px;
|
||||
}
|
||||
|
||||
header {
|
||||
gap: 12px;
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.header-left h1 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: .625rem;
|
||||
}
|
||||
|
||||
.stats {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-val {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn-group .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mobile-nav {
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.mobile-nav-item {
|
||||
font-size: .5625rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
Touch devices — 44px minimum target
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.btn {
|
||||
min-height: 44px;
|
||||
padding: 10px 18px;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.input {
|
||||
min-height: 44px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
.mobile-nav-item {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
details summary {
|
||||
padding: 8px 0;
|
||||
}
|
||||
}
|
||||
965
ena-planner/ena-planner.html
Normal file
965
ena-planner/ena-planner.html
Normal file
@@ -0,0 +1,965 @@
|
||||
<!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>
|
||||
<div class="subtitle">Story Planning · LLM Integration —— Created by Hao19911125</div>
|
||||
</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>点击发送 → 拦截 → 收集上下文(角色卡、世界书、BME 记忆、历史 plot、最近 AI 回复)→ 发给规划 LLM → 提取 <plot> 和
|
||||
<note> → 追加到你的输入 → 放行发送
|
||||
</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">输入中已有 <plot> 标签时跳过自动规划。</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">
|
||||
系统会自动在提示词之后注入:角色卡、世界书、BME 结构化记忆、聊天历史和历史 plot 等上下文。你只需专注编写"规划指令"。
|
||||
</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, '&').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.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>';
|
||||
}
|
||||
|
||||
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>
|
||||
</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>
|
||||
1517
ena-planner/ena-planner.js
Normal file
1517
ena-planner/ena-planner.js
Normal file
File diff suppressed because it is too large
Load Diff
125
index.js
125
index.js
@@ -7583,6 +7583,120 @@ function buildRecallRetrieveOptions(settings, context) {
|
||||
};
|
||||
}
|
||||
|
||||
async function runPlannerRecallForEna({
|
||||
rawUserInput,
|
||||
signal = undefined,
|
||||
disableLlmRecall = true,
|
||||
} = {}) {
|
||||
const userMessage = normalizeRecallInputText(rawUserInput || "");
|
||||
if (!userMessage) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "empty-user-input",
|
||||
memoryBlock: "",
|
||||
recentMessages: [],
|
||||
result: null,
|
||||
};
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
if (!settings.enabled || !settings.recallEnabled) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "recall-disabled",
|
||||
memoryBlock: "",
|
||||
recentMessages: [],
|
||||
result: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: createAbortError("Ena Planner recall aborted");
|
||||
}
|
||||
|
||||
if (!currentGraph || !isGraphReadableForRecall()) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "graph-not-readable",
|
||||
memoryBlock: "",
|
||||
recentMessages: [],
|
||||
result: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
!Array.isArray(currentGraph.nodes) ||
|
||||
currentGraph.nodes.length === 0
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "graph-empty",
|
||||
memoryBlock: "",
|
||||
recentMessages: [],
|
||||
result: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (isGraphMetadataWriteAllowed()) {
|
||||
const recovered = await recoverHistoryIfNeeded("pre-ena-planner-recall");
|
||||
if (!recovered) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "history-recovery-not-ready",
|
||||
memoryBlock: "",
|
||||
recentMessages: [],
|
||||
result: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: createAbortError("Ena Planner recall aborted");
|
||||
}
|
||||
|
||||
await ensureVectorReadyIfNeeded("pre-ena-planner-recall", signal);
|
||||
|
||||
const context = getContext();
|
||||
const chat = context?.chat ?? [];
|
||||
const recentMessages = buildRecallRecentMessages(
|
||||
chat,
|
||||
clampInt(settings.recallLlmContextMessages, 4, 0, 20),
|
||||
userMessage,
|
||||
);
|
||||
const schema = getSchema();
|
||||
const baseOptions = buildRecallRetrieveOptions(settings, context);
|
||||
const options = {
|
||||
...baseOptions,
|
||||
enableLLMRecall: disableLlmRecall
|
||||
? false
|
||||
: baseOptions.enableLLMRecall,
|
||||
};
|
||||
|
||||
const result = await retrieve({
|
||||
graph: currentGraph,
|
||||
userMessage,
|
||||
recentMessages,
|
||||
embeddingConfig: getEmbeddingConfig(),
|
||||
schema,
|
||||
settings,
|
||||
signal,
|
||||
options,
|
||||
});
|
||||
const memoryBlock = formatInjection(result, schema).trim();
|
||||
|
||||
return {
|
||||
ok: Boolean(memoryBlock),
|
||||
reason: memoryBlock ? "completed" : "empty-memory-block",
|
||||
memoryBlock,
|
||||
recentMessages,
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 召回管线:检索并注入记忆
|
||||
*/
|
||||
@@ -8230,5 +8344,16 @@ async function onReembedDirect() {
|
||||
});
|
||||
|
||||
schedulePersistedRecallMessageUiRefresh(120);
|
||||
try {
|
||||
const { initEnaPlanner } = await import("./ena-planner/ena-planner.js");
|
||||
await initEnaPlanner({
|
||||
getContext,
|
||||
getExtensionPath: () => `scripts/extensions/third-party/${MODULE_NAME}`,
|
||||
runPlannerRecallForEna,
|
||||
});
|
||||
console.log("[ST-BME] Ena Planner module loaded");
|
||||
} catch (error) {
|
||||
console.warn("[ST-BME] Ena Planner module load failed:", error);
|
||||
}
|
||||
console.log("[ST-BME] 初始化完成");
|
||||
})();
|
||||
|
||||
25
planner-tag-utils.js
Normal file
25
planner-tag-utils.js
Normal file
@@ -0,0 +1,25 @@
|
||||
const DEFAULT_PLANNER_TAGS = ["plot", "note", "plot-log", "state"];
|
||||
|
||||
export function stripPlannerTags(text, tags = DEFAULT_PLANNER_TAGS) {
|
||||
let output = String(text ?? "");
|
||||
|
||||
for (const rawTag of Array.isArray(tags) ? tags : DEFAULT_PLANNER_TAGS) {
|
||||
const tag = String(rawTag || "").trim().toLowerCase();
|
||||
if (!tag) continue;
|
||||
const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
output = output.replace(
|
||||
new RegExp(`<${escaped}\\b[^>]*>[\\s\\S]*?<\\/${escaped}>`, "gi"),
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
return output.trim();
|
||||
}
|
||||
|
||||
export function sanitizePlannerMessageText(message, tags = DEFAULT_PLANNER_TAGS) {
|
||||
if (!message) return "";
|
||||
const text = String(message.mes ?? "");
|
||||
return message.is_user ? stripPlannerTags(text, tags) : text;
|
||||
}
|
||||
|
||||
export { DEFAULT_PLANNER_TAGS };
|
||||
@@ -1,6 +1,7 @@
|
||||
// ST-BME: UI 状态工厂、纯工具函数
|
||||
// 此模块中的函数均不依赖 index.js 模块级可变状态,
|
||||
// 可被 index.js 及其他模块安全导入。
|
||||
import { sanitizePlannerMessageText } from "./planner-tag-utils.js";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 常量
|
||||
@@ -309,7 +310,7 @@ export function shouldRunRecallForTransaction(transaction, hookName) {
|
||||
}
|
||||
|
||||
export function formatRecallContextLine(message) {
|
||||
return `[${message.is_user ? "user" : "assistant"}]: ${message.mes || ""}`;
|
||||
return `[${message.is_user ? "user" : "assistant"}]: ${sanitizePlannerMessageText(message)}`;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
3851
vendor/js-yaml.mjs
vendored
Normal file
3851
vendor/js-yaml.mjs
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user