Merge pull request #20 from RT15548/test

Ena-planner
This commit is contained in:
RT15548
2026-02-28 21:47:06 +08:00
committed by GitHub
15 changed files with 3918 additions and 287 deletions

View File

@@ -182,5 +182,6 @@ export const TasksStorage = new StorageFile('LittleWhiteBox_Tasks.json');
export const StoryOutlineStorage = new StorageFile('LittleWhiteBox_StoryOutline.json'); export const StoryOutlineStorage = new StorageFile('LittleWhiteBox_StoryOutline.json');
export const NovelDrawStorage = new StorageFile('LittleWhiteBox_NovelDraw.json', { debounceMs: 800 }); export const NovelDrawStorage = new StorageFile('LittleWhiteBox_NovelDraw.json', { debounceMs: 800 });
export const TtsStorage = new StorageFile('LittleWhiteBox_TTS.json', { debounceMs: 800 }); export const TtsStorage = new StorageFile('LittleWhiteBox_TTS.json', { debounceMs: 800 });
export const EnaPlannerStorage = new StorageFile('LittleWhiteBox_EnaPlanner.json', { debounceMs: 800 });
export const CommonSettingStorage = new StorageFile('LittleWhiteBox_CommonSettings.json', { debounceMs: 1000 }); export const CommonSettingStorage = new StorageFile('LittleWhiteBox_CommonSettings.json', { debounceMs: 1000 });
export const VectorStorage = new StorageFile('LittleWhiteBox_Vectors.json', { debounceMs: 3000 }); export const VectorStorage = new StorageFile('LittleWhiteBox_Vectors.json', { debounceMs: 3000 });

View File

@@ -27,6 +27,7 @@ import { initNovelDraw, cleanupNovelDraw } from "./modules/novel-draw/novel-draw
import "./modules/story-summary/story-summary.js"; import "./modules/story-summary/story-summary.js";
import "./modules/story-outline/story-outline.js"; import "./modules/story-outline/story-outline.js";
import { initTts, cleanupTts } from "./modules/tts/tts.js"; import { initTts, cleanupTts } from "./modules/tts/tts.js";
import { initEnaPlanner, cleanupEnaPlanner } from "./modules/ena-planner/ena-planner.js";
extension_settings[EXT_ID] = extension_settings[EXT_ID] || { extension_settings[EXT_ID] = extension_settings[EXT_ID] || {
enabled: true, enabled: true,
@@ -44,6 +45,7 @@ extension_settings[EXT_ID] = extension_settings[EXT_ID] || {
storyOutline: { enabled: false }, storyOutline: { enabled: false },
novelDraw: { enabled: false }, novelDraw: { enabled: false },
tts: { enabled: false }, tts: { enabled: false },
enaPlanner: { enabled: false },
useBlob: false, useBlob: false,
wrapperIframe: true, wrapperIframe: true,
renderEnabled: true, renderEnabled: true,
@@ -276,7 +278,8 @@ function toggleSettingsControls(enabled) {
'xiaobaix_use_blob', 'xiaobaix_variables_core_enabled', 'xiaobaix_variables_mode', 'Wrapperiframe', 'xiaobaix_render_enabled', 'xiaobaix_use_blob', 'xiaobaix_variables_core_enabled', 'xiaobaix_variables_mode', 'Wrapperiframe', 'xiaobaix_render_enabled',
'xiaobaix_max_rendered', 'xiaobaix_story_outline_enabled', 'xiaobaix_story_summary_enabled', 'xiaobaix_max_rendered', 'xiaobaix_story_outline_enabled', 'xiaobaix_story_summary_enabled',
'xiaobaix_novel_draw_enabled', 'xiaobaix_novel_draw_open_settings', 'xiaobaix_novel_draw_enabled', 'xiaobaix_novel_draw_open_settings',
'xiaobaix_tts_enabled', 'xiaobaix_tts_open_settings' 'xiaobaix_tts_enabled', 'xiaobaix_tts_open_settings',
'xiaobaix_ena_planner_enabled', 'xiaobaix_ena_planner_open_settings'
]; ];
controls.forEach(id => { controls.forEach(id => {
$(`#${id}`).prop('disabled', !enabled).closest('.flex-container').toggleClass('disabled-control', !enabled); $(`#${id}`).prop('disabled', !enabled).closest('.flex-container').toggleClass('disabled-control', !enabled);
@@ -311,6 +314,7 @@ async function toggleAllFeatures(enabled) {
{ condition: extension_settings[EXT_ID].variablesCore?.enabled, init: initVariablesCore }, { condition: extension_settings[EXT_ID].variablesCore?.enabled, init: initVariablesCore },
{ condition: extension_settings[EXT_ID].novelDraw?.enabled, init: initNovelDraw }, { condition: extension_settings[EXT_ID].novelDraw?.enabled, init: initNovelDraw },
{ condition: extension_settings[EXT_ID].tts?.enabled, init: initTts }, { condition: extension_settings[EXT_ID].tts?.enabled, init: initTts },
{ condition: extension_settings[EXT_ID].enaPlanner?.enabled, init: initEnaPlanner },
{ condition: true, init: initStreamingGeneration }, { condition: true, init: initStreamingGeneration },
{ condition: true, init: initButtonCollapse } { condition: true, init: initButtonCollapse }
]; ];
@@ -346,6 +350,7 @@ async function toggleAllFeatures(enabled) {
try { cleanupVareventEditor(); } catch (e) { } try { cleanupVareventEditor(); } catch (e) { }
try { cleanupNovelDraw(); } catch (e) { } try { cleanupNovelDraw(); } catch (e) { }
try { cleanupTts(); } catch (e) { } try { cleanupTts(); } catch (e) { }
try { cleanupEnaPlanner(); } catch (e) { }
try { clearBlobCaches(); } catch (e) { } try { clearBlobCaches(); } catch (e) { }
toggleSettingsControls(false); toggleSettingsControls(false);
try { window.cleanupWorldbookHostBridge && window.cleanupWorldbookHostBridge(); document.getElementById('xb-worldbook')?.remove(); } catch (e) { } try { window.cleanupWorldbookHostBridge && window.cleanupWorldbookHostBridge(); document.getElementById('xb-worldbook')?.remove(); } catch (e) { }
@@ -390,7 +395,8 @@ async function setupSettings() {
{ id: 'xiaobaix_story_summary_enabled', key: 'storySummary' }, { id: 'xiaobaix_story_summary_enabled', key: 'storySummary' },
{ id: 'xiaobaix_story_outline_enabled', key: 'storyOutline' }, { id: 'xiaobaix_story_outline_enabled', key: 'storyOutline' },
{ id: 'xiaobaix_novel_draw_enabled', key: 'novelDraw', init: initNovelDraw }, { id: 'xiaobaix_novel_draw_enabled', key: 'novelDraw', init: initNovelDraw },
{ id: 'xiaobaix_tts_enabled', key: 'tts', init: initTts } { id: 'xiaobaix_tts_enabled', key: 'tts', init: initTts },
{ id: 'xiaobaix_ena_planner_enabled', key: 'enaPlanner', init: initEnaPlanner }
]; ];
moduleConfigs.forEach(({ id, key, init }) => { moduleConfigs.forEach(({ id, key, init }) => {
@@ -406,6 +412,9 @@ async function setupSettings() {
if (!enabled && key === 'tts') { if (!enabled && key === 'tts') {
try { cleanupTts(); } catch (e) { } try { cleanupTts(); } catch (e) { }
} }
if (!enabled && key === 'enaPlanner') {
try { cleanupEnaPlanner(); } catch (e) { }
}
settings[key] = extension_settings[EXT_ID][key] || {}; settings[key] = extension_settings[EXT_ID][key] || {};
settings[key].enabled = enabled; settings[key].enabled = enabled;
extension_settings[EXT_ID][key] = settings[key]; extension_settings[EXT_ID][key] = settings[key];
@@ -449,6 +458,15 @@ async function setupSettings() {
} }
}); });
$("#xiaobaix_ena_planner_open_settings").on("click", function () {
if (!isXiaobaixEnabled) return;
if (settings.enaPlanner?.enabled && window.xiaobaixEnaPlanner?.openSettings) {
window.xiaobaixEnaPlanner.openSettings();
} else {
toastr.warning('请先启用剧情规划模块');
}
});
$("#xiaobaix_use_blob").prop("checked", !!settings.useBlob).on("change", async function () { $("#xiaobaix_use_blob").prop("checked", !!settings.useBlob).on("change", async function () {
if (!isXiaobaixEnabled) return; if (!isXiaobaixEnabled) return;
settings.useBlob = $(this).prop("checked"); settings.useBlob = $(this).prop("checked");
@@ -511,10 +529,11 @@ async function setupSettings() {
variablesPanel: 'xiaobaix_variables_panel_enabled', variablesPanel: 'xiaobaix_variables_panel_enabled',
variablesCore: 'xiaobaix_variables_core_enabled', variablesCore: 'xiaobaix_variables_core_enabled',
novelDraw: 'xiaobaix_novel_draw_enabled', novelDraw: 'xiaobaix_novel_draw_enabled',
tts: 'xiaobaix_tts_enabled' tts: 'xiaobaix_tts_enabled',
enaPlanner: 'xiaobaix_ena_planner_enabled'
}; };
const ON = ['templateEditor', 'tasks', 'variablesCore', 'audio', 'storySummary', 'recorded']; const ON = ['templateEditor', 'tasks', 'variablesCore', 'audio', 'storySummary', 'recorded'];
const OFF = ['preview', 'immersive', 'variablesPanel', 'fourthWall', 'storyOutline', 'novelDraw', 'tts']; const OFF = ['preview', 'immersive', 'variablesPanel', 'fourthWall', 'storyOutline', 'novelDraw', 'tts', 'enaPlanner'];
function setChecked(id, val) { function setChecked(id, val) {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) { if (el) {
@@ -649,6 +668,7 @@ jQuery(async () => {
{ condition: settings.variablesCore?.enabled, init: initVariablesCore }, { condition: settings.variablesCore?.enabled, init: initVariablesCore },
{ condition: settings.novelDraw?.enabled, init: initNovelDraw }, { condition: settings.novelDraw?.enabled, init: initNovelDraw },
{ condition: settings.tts?.enabled, init: initTts }, { condition: settings.tts?.enabled, init: initTts },
{ condition: settings.enaPlanner?.enabled, init: initEnaPlanner },
{ condition: true, init: initStreamingGeneration }, { condition: true, init: initStreamingGeneration },
{ condition: true, init: initButtonCollapse } { condition: true, init: initButtonCollapse }
]; ];

View File

@@ -0,0 +1,59 @@
export const DEFAULT_PROMPT_BLOCKS = [
{
id: 'ena-default-system-001',
role: 'system',
name: 'Ena Planner System',
content: `你是一位剧情规划师Story Planner。你的工作是在幕后为互动叙事提供方向指引而不是直接扮演角色或撰写正文。
## 你会收到的信息
- 角色卡:当前角色的设定(描述、性格、场景)
- 世界书:世界观设定和规则
- 剧情摘要:此前发生过的重要事件
- 聊天历史:最近的 AI 回复片段
- 向量召回:与当前情境相关的记忆片段
- 历史规划:之前生成的 <plot> 块
- 玩家输入:玩家刚刚发出的指令或行动
## 你的任务
根据以上所有信息,为下一轮 AI 回复规划剧情走向。
## 输出格式(严格遵守)
只输出以下两个标签,不要输出任何其他内容:
<plot>
剧情走向指引接下来应该发生什么。包括场景推进、NPC 反应、事件触发、伏笔推进等。
写给 AI 看的导演指令,不是给玩家看的正文。简洁、具体、可执行。)
</plot>
<note>
(写作注意事项:这一轮回复应该怎么写。包括叙事节奏、情绪基调、应避免的问题、需要保持的连贯性等。
同样是给 AI 的元指令,不是正文。)
</note>
## 规划原则
1. 尊重玩家意图:玩家的输入是最高优先级,规划应围绕玩家的行动展开
2. 保持连贯:与历史 plot 和剧情摘要保持因果一致,不要凭空引入矛盾设定
3. 推进而非重复:每次规划应让故事向前推进,避免原地踏步
4. 留有空间:给出方向但不要过度规定细节,让主 AI 有创作余地
5. 遵守世界观:世界书中的规则和设定是硬约束,不可违反
如有思考过程,请放在 <thinking> 中(会被自动剔除)。`,
},
{
id: 'ena-default-assistant-001',
role: 'assistant',
name: 'Assistant Seed',
content: `<think>
让我分析当前情境,梳理玩家意图、已有伏笔和世界观约束,然后规划下一步走向...
规划结果输出在<plot>...</plot>和<note>...</note>两个块中
</think>`,
},
];
export const BUILTIN_TEMPLATES = {
'默认模板': DEFAULT_PROMPT_BLOCKS,
};

View File

@@ -0,0 +1,844 @@
/* ═══════════════════════════════════════════════════════════════════════════
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;
}
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;
}
}

View File

@@ -0,0 +1,948 @@
<!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>点击发送 → 拦截 → 收集上下文(角色卡、世界书、摘要、历史 plot、最近 AI 回复)→ 发给规划 LLM → 提取 &lt;plot&gt;
&lt;note&gt; → 追加到你的输入 → 放行发送
</div>
</div>
<section class="card">
<div class="card-title">基本设置</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">启用规划器</label>
<select id="ep_enabled" class="input">
<option value="true">开启</option>
<option value="false">关闭</option>
</select>
</div>
<div class="form-group">
<label class="form-label">跳过已有规划的输入</label>
<select id="ep_skip_plot" class="input">
<option value="true"></option>
<option value="false"></option>
</select>
</div>
</div>
<p class="form-hint">输入中已有 &lt;plot&gt; 标签时跳过自动规划。</p>
</section>
<section class="card">
<div class="card-title">快速测试</div>
<div class="form-group">
<label class="form-label">测试输入(留空使用默认)</label>
<textarea id="ep_test_input" class="input" rows="3" placeholder="输入一段剧情描述,测试规划器输出..."></textarea>
</div>
<div class="btn-group">
<button id="ep_run_test" class="btn btn-p">运行规划测试</button>
</div>
<div id="ep_test_status" class="status-text"></div>
</section>
</div>
<!-- ── API 配置 ── -->
<div id="view-api" class="view">
<section class="card">
<div class="card-title">连接设置</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">渠道类型</label>
<select id="ep_api_channel" class="input">
<option value="openai">OpenAI 兼容</option>
<option value="gemini">Gemini 兼容</option>
<option value="claude">Claude 兼容</option>
</select>
</div>
<div class="form-group">
<label class="form-label">路径前缀</label>
<select id="ep_prefix_mode" class="input">
<option value="auto">自动 (如 /v1)</option>
<option value="custom">自定义</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">API 地址</label>
<input id="ep_api_base" type="text" class="input" placeholder="https://api.openai.com">
</div>
<div class="form-group hidden" id="ep_custom_prefix_group">
<label class="form-label">自定义前缀</label>
<input id="ep_prefix_custom" type="text" class="input" placeholder="/v1">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">API Key</label>
<div class="input-row">
<input id="ep_api_key" type="password" class="input" placeholder="sk-...">
<button id="ep_toggle_key" class="btn">显示</button>
</div>
</div>
<div class="form-group">
<label class="form-label">模型</label>
<input id="ep_model" type="text" class="input" placeholder="gpt-4o, claude-3-5-sonnet...">
</div>
</div>
<div id="ep_model_selector" class="hidden" style="margin-top:12px;">
<label class="form-label">选择模型</label>
<select id="ep_model_select" class="input">
<option value="">-- 从列表选择 --</option>
</select>
</div>
<div class="btn-group" style="margin-top:16px;">
<button id="ep_fetch_models" class="btn">拉取模型列表</button>
<button id="ep_test_conn" class="btn">测试连接</button>
</div>
<div id="ep_api_status" class="status-text"></div>
</section>
<section class="card">
<div class="card-title">生成参数</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">流式输出</label>
<select id="ep_stream" class="input">
<option value="true">开启</option>
<option value="false">关闭</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Temperature</label>
<input id="ep_temp" type="number" class="input" step="0.1" min="0" max="2">
</div>
<div class="form-group">
<label class="form-label">Top P</label>
<input id="ep_top_p" type="number" class="input" step="0.05" min="0" max="1">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Top K</label>
<input id="ep_top_k" type="number" class="input" step="1" min="0">
</div>
<div class="form-group">
<label class="form-label">Presence penalty</label>
<input id="ep_pp" type="text" class="input" placeholder="-2 ~ 2">
</div>
<div class="form-group">
<label class="form-label">Frequency penalty</label>
<input id="ep_fp" type="text" class="input" placeholder="-2 ~ 2">
</div>
</div>
<div class="form-group">
<label class="form-label">最大 Token 数</label>
<input id="ep_mt" type="text" class="input" placeholder="留空则不限制">
</div>
</section>
</div>
<!-- ── 提示词 ── -->
<div id="view-prompt" class="view">
<div class="tip-box">
<div class="tip-icon">💡</div>
<div class="tip-text">
系统会自动在提示词之后注入:角色卡、世界书、剧情摘要、聊天历史、向量召回等上下文。你只需专注编写"规划指令"。
</div>
</div>
<section class="card">
<div class="card-title">模板管理</div>
<div class="form-row">
<div class="form-group" style="flex:2;">
<select id="ep_tpl_select" class="input">
<option value="">-- 选择模板 --</option>
</select>
</div>
<div class="form-group" style="flex:3;">
<div class="btn-group">
<button id="ep_tpl_save" class="btn btn-p">保存</button>
<button id="ep_tpl_saveas" class="btn">另存为</button>
<button id="ep_tpl_delete" class="btn btn-del">删除</button>
</div>
</div>
</div>
<div id="ep_tpl_undo" class="undo-bar hidden">
<span>模板 <strong id="ep_tpl_undo_name"></strong> 已删除</span>
<button id="ep_tpl_undo_btn" class="btn btn-p btn-sm">撤销</button>
</div>
</section>
<section class="card">
<div class="card-title">提示词块</div>
<div id="ep_prompt_list"></div>
<div class="prompt-empty" id="ep_prompt_empty" style="display:none;">暂无提示词块</div>
<div class="btn-group" style="margin-top:16px;">
<button id="ep_add_prompt" class="btn">添加区块</button>
<button id="ep_reset_prompt" class="btn btn-del">恢复默认</button>
</div>
</section>
</div>
<!-- ── 上下文 ── -->
<div id="view-context" class="view">
<section class="card">
<div class="card-title">世界书</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">读取全局世界书</label>
<select id="ep_include_global_wb" class="input">
<option value="false"></option>
<option value="true"></option>
</select>
</div>
<div class="form-group">
<label class="form-label">排除 position=4 的条目</label>
<select id="ep_wb_pos4" class="input">
<option value="true"></option>
<option value="false"></option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">排除的条目名称关键词(逗号分隔)</label>
<input id="ep_wb_exclude_names" type="text" class="input" placeholder="mvu_update, system, ...">
</div>
</section>
<section class="card">
<div class="card-title">聊天与历史</div>
<div class="form-group">
<label class="form-label">保留的规划输出标签(逗号分隔)</label>
<input id="ep_keep_tags" type="text" class="input" placeholder="plot, note, plot-log, state">
<p class="form-hint">仅支持英文标签(如 plot, note, memory。留空表示不按标签过滤仅去除 think。无效标签会自动忽略。</p>
</div>
<div class="form-group">
<label class="form-label">清理 AI 回复中的干扰标签(逗号分隔)</label>
<input id="ep_exclude_tags" type="text" class="input"
placeholder="行动选项, UpdateVariable, StatusPlaceHolderImpl">
</div>
<div class="form-group">
<label class="form-label">携带最近 N 条历史 plot</label>
<input id="ep_plot_n" type="number" class="input" min="0" max="10" step="1">
</div>
</section>
</div>
<!-- ── 调试 ── -->
<div id="view-debug" class="view">
<section class="card">
<div class="card-title">诊断工具</div>
<div class="btn-group">
<button id="ep_debug_worldbook" class="btn">诊断世界书</button>
<button id="ep_debug_char" class="btn">诊断角色卡</button>
<button id="ep_test_planner" class="btn btn-p">运行规划测试</button>
</div>
<pre id="ep_debug_output" class="debug-output"></pre>
</section>
<section class="card">
<div class="card-title">日志</div>
<div class="form-row" style="margin-bottom:16px;">
<div class="form-group">
<label class="form-label">持久化日志</label>
<select id="ep_logs_persist" class="input">
<option value="true"></option>
<option value="false"></option>
</select>
</div>
<div class="form-group">
<label class="form-label">最大日志条数</label>
<input id="ep_logs_max" type="number" class="input" min="1" max="200" step="1">
</div>
</div>
<div class="btn-group" style="margin-bottom:16px;">
<button id="ep_open_logs" class="btn">刷新</button>
<button id="ep_log_export" class="btn">导出 JSON</button>
<button id="ep_log_clear" class="btn btn-del">清空日志</button>
</div>
<div id="ep_log_body" class="log-list">
<div class="log-empty">暂无日志</div>
</div>
</section>
</div>
</main>
<!-- Mobile bottom nav -->
<nav class="mobile-nav">
<div class="mobile-nav-inner">
<div class="mobile-nav-item active" data-view="quickstart">
<div class="nav-dot"></div><span>开始</span>
</div>
<div class="mobile-nav-item" data-view="api">
<div class="nav-dot"></div><span>API</span>
</div>
<div class="mobile-nav-item" data-view="prompt">
<div class="nav-dot"></div><span>提示词</span>
</div>
<div class="mobile-nav-item" data-view="context">
<div class="nav-dot"></div><span>上下文</span>
</div>
<div class="mobile-nav-item" data-view="debug">
<div class="nav-dot"></div><span>调试</span>
</div>
</div>
</nav>
</div>
<script>
const PARENT_ORIGIN = (() => {
try { return new URL(document.referrer).origin; } catch { return window.location.origin; }
})();
const post = (type, payload) => parent.postMessage({ type, payload }, PARENT_ORIGIN);
const $ = id => document.getElementById(id);
const $$ = sel => document.querySelectorAll(sel);
function genId() {
try { return crypto.randomUUID(); } catch { return `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; }
}
let cfg = null;
let logs = [];
let pendingSave = null;
let undoState = null;
let undoPending = false;
let fetchedModels = [];
/* ── Save indicator ── */
function setSaveIndicator(state, text) {
const el = $('ep_save_status');
if (!el) return;
if (state === 'saving') {
el.innerHTML = `<span style="color:var(--warn)">${text || '保存中…'}</span>`;
} else if (state === 'saved') {
el.innerHTML = `<span style="color:var(--success)">${text || '已保存'}</span>`;
} else if (state === 'error') {
el.innerHTML = `<span style="color:var(--error)">${text || '保存失败'}</span>`;
} else {
el.textContent = '就绪';
}
}
function startPendingSave(requestId) {
pendingSave = {
requestId,
timer: setTimeout(() => {
if (!pendingSave || pendingSave.requestId !== requestId) return;
pendingSave = null;
setSaveIndicator('error', '保存超时');
}, 5000)
};
setSaveIndicator('saving');
}
function resolvePendingSave(requestId) {
if (!pendingSave || pendingSave.requestId !== requestId) return;
clearTimeout(pendingSave.timer);
pendingSave = null;
setSaveIndicator('saved');
setTimeout(() => setSaveIndicator(''), 2000);
}
function rejectPendingSave(requestId, msg) {
if (!pendingSave || pendingSave.requestId !== requestId) return;
clearTimeout(pendingSave.timer);
pendingSave = null;
setSaveIndicator('error', msg || '保存失败');
}
/* ── Auto-save ── */
let autoSaveTimer = null;
function scheduleSave() {
if (undoPending) return;
if (autoSaveTimer) clearTimeout(autoSaveTimer);
autoSaveTimer = setTimeout(doSave, 600);
}
function doSave() {
if (pendingSave) return;
const requestId = `ena_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const patch = collectPatch();
startPendingSave(requestId);
post('xb-ena:save-config', { requestId, patch });
}
/* ── UI helpers ── */
function setLocalStatus(elId, text, type) {
const el = $(elId);
if (!el) return;
el.textContent = text || '';
el.className = 'status-text' + (type ? ' ' + type : '');
}
function setBadge(enabled) {
const badge = $('ep_badge');
badge.innerHTML = enabled
? '<span class="hl">已启用</span>'
: '<span style="color:var(--txt3)">未启用</span>';
}
function activateTab(viewId) {
$$('.nav-item, .mobile-nav-item').forEach(n => {
n.classList.toggle('active', n.dataset.view === viewId);
});
$$('.view').forEach(v => {
v.classList.toggle('active', v.id === `view-${viewId}`);
});
if (viewId === 'debug') post('xb-ena:logs-request');
}
function updatePrefixModeUI() {
$('ep_custom_prefix_group').classList.toggle('hidden', $('ep_prefix_mode').value !== 'custom');
}
/* ── Type conversion ── */
function toBool(v, fallback = false) {
if (v === true || v === false) return v;
if (v === 'true') return true;
if (v === 'false') return false;
return fallback;
}
function toNum(v, fallback = 0) {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
function arrToCsv(arr) { return Array.isArray(arr) ? arr.join(', ') : ''; }
function csvToArr(text) {
return String(text || '').split(/[,]/).map(x => x.trim()).filter(Boolean);
}
function normalizeKeepTagsInput(text) {
const src = csvToArr(text);
const out = [];
src.forEach(item => {
const tag = String(item || '').replace(/^<+|>+$/g, '').toLowerCase();
if (!/^[a-z][a-z0-9_-]*$/.test(tag)) return;
if (!out.includes(tag)) out.push(tag);
});
return out;
}
function escapeHtml(str) {
return String(str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/* ── Prompt blocks ── */
function createPromptBlockElement(block, idx, total) {
const wrap = document.createElement('div');
wrap.className = 'prompt-block';
const head = document.createElement('div');
head.className = 'prompt-head';
const left = document.createElement('div');
left.className = 'prompt-head-left';
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.className = 'input';
nameInput.placeholder = '块名称';
nameInput.value = block.name || '';
nameInput.addEventListener('change', () => { block.name = nameInput.value; scheduleSave(); });
const roleSelect = document.createElement('select');
roleSelect.className = 'input';
['system', 'user', 'assistant'].forEach(r => {
const opt = document.createElement('option');
opt.value = r;
opt.textContent = r;
opt.selected = (block.role || 'system') === r;
roleSelect.appendChild(opt);
});
roleSelect.addEventListener('change', () => { block.role = roleSelect.value; scheduleSave(); });
left.append(nameInput, roleSelect);
const right = document.createElement('div');
right.className = 'prompt-head-right';
const upBtn = document.createElement('button');
upBtn.className = 'btn btn-sm';
upBtn.textContent = '↑';
upBtn.disabled = idx === 0;
upBtn.addEventListener('click', () => {
if (idx === 0) return;
[cfg.promptBlocks[idx - 1], cfg.promptBlocks[idx]] = [cfg.promptBlocks[idx], cfg.promptBlocks[idx - 1]];
renderPromptList(); scheduleSave();
});
const downBtn = document.createElement('button');
downBtn.className = 'btn btn-sm';
downBtn.textContent = '↓';
downBtn.disabled = idx === total - 1;
downBtn.addEventListener('click', () => {
if (idx >= total - 1) return;
[cfg.promptBlocks[idx], cfg.promptBlocks[idx + 1]] = [cfg.promptBlocks[idx + 1], cfg.promptBlocks[idx]];
renderPromptList(); scheduleSave();
});
const delBtn = document.createElement('button');
delBtn.className = 'btn btn-sm btn-del';
delBtn.textContent = '删除';
delBtn.addEventListener('click', () => {
cfg.promptBlocks.splice(idx, 1);
renderPromptList(); scheduleSave();
});
right.append(upBtn, downBtn, delBtn);
const content = document.createElement('textarea');
content.className = 'input';
content.placeholder = '提示词内容...';
content.value = block.content || '';
content.addEventListener('change', () => { block.content = content.value; scheduleSave(); });
head.append(left, right);
wrap.append(head, content);
return wrap;
}
function renderPromptList() {
const list = $('ep_prompt_list');
const empty = $('ep_prompt_empty');
const blocks = cfg?.promptBlocks || [];
list.innerHTML = '';
if (!blocks.length) { empty.style.display = ''; return; }
empty.style.display = 'none';
blocks.forEach((block, idx) => {
list.appendChild(createPromptBlockElement(block, idx, blocks.length));
});
}
function renderTemplateSelect(selected = '') {
const sel = $('ep_tpl_select');
sel.innerHTML = '<option value="">-- 选择模板 --</option>';
const names = Object.keys(cfg?.promptTemplates || {});
const selectedName = names.includes(selected) ? selected : '';
names.forEach(name => {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
opt.selected = name === selectedName;
sel.appendChild(opt);
});
}
/* ── Undo ── */
function showUndoBar(name, blocks) {
clearUndo();
undoPending = true;
undoState = {
name, blocks,
timer: setTimeout(() => {
hideUndoBar(); undoPending = false; scheduleSave();
}, 5000)
};
$('ep_tpl_undo_name').textContent = name;
$('ep_tpl_undo').classList.remove('hidden');
}
function hideUndoBar() {
$('ep_tpl_undo').classList.add('hidden');
undoState = null;
}
function clearUndo() {
if (undoState?.timer) clearTimeout(undoState.timer);
hideUndoBar();
undoPending = false;
}
/* ── Model selector ── */
function showModelSelector(models) {
fetchedModels = models;
const sel = $('ep_model_select');
const cur = $('ep_model').value.trim();
sel.innerHTML = '<option value="">-- 从列表选择 --</option>';
models.forEach(m => {
const opt = document.createElement('option');
opt.value = m; opt.textContent = m; opt.selected = m === cur;
sel.appendChild(opt);
});
$('ep_model_selector').classList.remove('hidden');
}
/* ── Logs ── */
function renderLogs() {
const body = $('ep_log_body');
if (!Array.isArray(logs) || !logs.length) {
body.innerHTML = '<div class="log-empty">暂无日志</div>';
return;
}
body.innerHTML = logs.map(item => {
const time = item.time ? new Date(item.time).toLocaleString() : '-';
const cls = item.ok ? 'success' : 'error';
const label = item.ok ? '成功' : '失败';
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>请求消息</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 ── */
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>

File diff suppressed because it is too large Load Diff

View File

@@ -679,6 +679,9 @@ async function executeTaskJS(jsCode, taskName = 'AnonymousTask') {
const listeners = new Set(); const listeners = new Set();
const createdNodes = new Set(); const createdNodes = new Set();
const waiters = new Set(); const waiters = new Set();
let suppressTimerTracking = false;
const originalToastrFns = {};
const toastrMethods = ['info', 'success', 'warning', 'error'];
const notifyActivityChange = () => { const notifyActivityChange = () => {
if (waiters.size === 0) return; if (waiters.size === 0) return;
@@ -689,16 +692,36 @@ async function executeTaskJS(jsCode, taskName = 'AnonymousTask') {
window.setTimeout = function(fn, t, ...args) { window.setTimeout = function(fn, t, ...args) {
const id = originals.setTimeout(function(...inner) { const id = originals.setTimeout(function(...inner) {
try { fn?.(...inner); } finally { timeouts.delete(id); notifyActivityChange(); } try { fn?.(...inner); }
finally {
if (timeouts.delete(id)) notifyActivityChange();
}
}, t, ...args); }, t, ...args);
if (!suppressTimerTracking) {
timeouts.add(id); timeouts.add(id);
notifyActivityChange(); notifyActivityChange();
}
return id; return id;
}; };
window.clearTimeout = function(id) { originals.clearTimeout(id); timeouts.delete(id); notifyActivityChange(); }; window.clearTimeout = function(id) {
originals.clearTimeout(id);
if (timeouts.delete(id)) notifyActivityChange();
};
window.setInterval = function(fn, t, ...args) { const id = originals.setInterval(fn, t, ...args); intervals.add(id); notifyActivityChange(); return id; }; window.setInterval = function(fn, t, ...args) { const id = originals.setInterval(fn, t, ...args); intervals.add(id); notifyActivityChange(); return id; };
window.clearInterval = function(id) { originals.clearInterval(id); intervals.delete(id); notifyActivityChange(); }; window.clearInterval = function(id) { originals.clearInterval(id); intervals.delete(id); notifyActivityChange(); };
if (window.toastr) {
for (const method of toastrMethods) {
if (typeof window.toastr[method] !== 'function') continue;
originalToastrFns[method] = window.toastr[method];
window.toastr[method] = function(...fnArgs) {
suppressTimerTracking = true;
try { return originalToastrFns[method].apply(window.toastr, fnArgs); }
finally { suppressTimerTracking = false; }
};
}
}
const addListenerEntry = (entry) => { listeners.add(entry); notifyActivityChange(); }; const addListenerEntry = (entry) => { listeners.add(entry); notifyActivityChange(); };
const removeListenerEntry = (target, type, listener, options) => { const removeListenerEntry = (target, type, listener, options) => {
let removed = false; let removed = false;
@@ -736,6 +759,13 @@ async function executeTaskJS(jsCode, taskName = 'AnonymousTask') {
Node.prototype.appendChild = originals.appendChild; Node.prototype.appendChild = originals.appendChild;
Node.prototype.insertBefore = originals.insertBefore; Node.prototype.insertBefore = originals.insertBefore;
Node.prototype.replaceChild = originals.replaceChild; Node.prototype.replaceChild = originals.replaceChild;
if (window.toastr) {
for (const method of toastrMethods) {
if (typeof originalToastrFns[method] === 'function') {
window.toastr[method] = originalToastrFns[method];
}
}
}
}; };
const hardCleanup = () => { const hardCleanup = () => {

View File

@@ -116,6 +116,7 @@ let events = null;
let activeChatId = null; let activeChatId = null;
let vectorCancelled = false; let vectorCancelled = false;
let vectorAbortController = null; let vectorAbortController = null;
let _lastBuiltPromptText = "";
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// TaskGuard — 互斥任务管理summary / vector / anchor // TaskGuard — 互斥任务管理summary / vector / anchor
@@ -1028,6 +1029,12 @@ function buildFramePayload(store) {
}; };
} }
// Compatibility export for ena-planner.
// Returns a compact plain-text snapshot of story-summary memory.
export function getStorySummaryForEna() {
return _lastBuiltPromptText;
}
function parseRelationTargetFromPredicate(predicate) { function parseRelationTargetFromPredicate(predicate) {
const text = String(predicate || "").trim(); const text = String(predicate || "").trim();
if (!text.startsWith("对")) return null; if (!text.startsWith("对")) return null;
@@ -1092,6 +1099,57 @@ function mergeCharacterRelationshipsIntoFacts(existingFacts, relationships, floo
return [...nonRelationFacts, ...newRelationFacts]; return [...nonRelationFacts, ...newRelationFacts];
} }
function getCurrentFloorHint() {
const { chat } = getContext();
const lastFloor = (Array.isArray(chat) ? chat.length : 0) - 1;
return Math.max(0, lastFloor);
}
function factKeyBySubjectPredicate(fact) {
const s = String(fact?.s || "").trim();
const p = String(fact?.p || "").trim();
return `${s}::${p}`;
}
function mergeEditedFactsWithTimestamps(existingFacts, editedFacts, floorHint = 0) {
const currentFacts = Array.isArray(existingFacts) ? existingFacts : [];
const incomingFacts = Array.isArray(editedFacts) ? editedFacts : [];
const oldMap = new Map(currentFacts.map((f) => [factKeyBySubjectPredicate(f), f]));
let nextFactId = getNextFactIdValue(currentFacts);
const merged = [];
for (const fact of incomingFacts) {
const s = String(fact?.s || "").trim();
const p = String(fact?.p || "").trim();
const o = String(fact?.o || "").trim();
if (!s || !p || !o) continue;
const key = `${s}::${p}`;
const oldFact = oldMap.get(key);
const since = oldFact?.since ?? fact?.since ?? floorHint;
const addedAt = oldFact?._addedAt ?? fact?._addedAt ?? floorHint;
const out = {
id: oldFact?.id || fact?.id || `f-${nextFactId++}`,
s,
p,
o,
since,
_addedAt: addedAt,
};
if (oldFact?._isState != null) out._isState = oldFact._isState;
const mergedTrend = fact?.trend ?? oldFact?.trend;
if (mergedTrend != null && String(mergedTrend).trim()) {
out.trend = String(mergedTrend).trim();
}
merged.push(out);
}
return merged;
}
function openPanelForMessage(mesId) { function openPanelForMessage(mesId) {
createOverlay(); createOverlay();
showOverlay(); showOverlay();
@@ -1428,13 +1486,17 @@ async function handleFrameMessage(event) {
// 如果是 events先记录旧数据用于同步向量 // 如果是 events先记录旧数据用于同步向量
const oldEvents = data.section === "events" ? [...(store.json.events || [])] : null; const oldEvents = data.section === "events" ? [...(store.json.events || [])] : null;
const oldFacts = data.section === "facts" ? [...(store.json.facts || [])] : null;
if (VALID_SECTIONS.includes(data.section)) { if (VALID_SECTIONS.includes(data.section)) {
store.json[data.section] = data.data; store.json[data.section] = data.data;
} }
if (data.section === "facts") {
store.json.facts = mergeEditedFactsWithTimestamps(oldFacts, data.data, getCurrentFloorHint());
}
if (data.section === "characters") { if (data.section === "characters") {
const rels = data?.data?.relationships || []; const rels = data?.data?.relationships || [];
const floorHint = Math.max(0, Number(store.lastSummarizedMesId) || 0); const floorHint = getCurrentFloorHint();
store.json.facts = mergeCharacterRelationshipsIntoFacts(store.json.facts, rels, floorHint); store.json.facts = mergeCharacterRelationshipsIntoFacts(store.json.facts, rels, floorHint);
} }
store.updatedAt = Date.now(); store.updatedAt = Date.now();
@@ -1756,6 +1818,7 @@ async function handleGenerationStarted(type, _params, isDryRun) {
} else { } else {
text = buildNonVectorPromptText() || ""; text = buildNonVectorPromptText() || "";
} }
_lastBuiltPromptText = text;
if (!text.trim()) return; if (!text.trim()) return;
// 获取用户配置的 role // 获取用户配置的 role

View File

@@ -1,23 +1,33 @@
/** /**
* 火山引擎 TTS API 封装 * 火山引擎 TTS API 封装
* V3 单向流式 + V1试用 * V3 单向流式 + V1试用
*/ */
const V3_URL = 'https://openspeech.bytedance.com/api/v3/tts/unidirectional'; const V3_URL = 'https://openspeech.bytedance.com/api/v3/tts/unidirectional';
const FREE_V1_URL = 'https://hstts.velure.codes'; const FREE_V1_URL = 'https://edgetts.velure.codes';
export const FREE_VOICES = [ export const FREE_VOICES = [
{ key: 'female_1', name: '桃夭', tag: '甜蜜仙子', gender: 'female' }, { key: 'female_1', name: '晓晓', tag: '温暖百变', gender: 'female' },
{ key: 'female_2', name: '霜华', tag: '清冷仙子', gender: 'female' }, { key: 'female_2', name: '晓伊', tag: '清冷知性', gender: 'female' },
{ key: 'female_3', name: '顾姐', tag: '御姐烟嗓', gender: 'female' }, { key: 'female_3', name: '小北', tag: '东北甜妹', gender: 'female' },
{ key: 'female_4', name: '苏菲', tag: '优雅知性', gender: 'female' }, { key: 'female_4', name: '小妮', tag: '陕西姑娘', gender: 'female' },
{ key: 'female_5', name: '嘉欣', tag: '港风甜心', gender: 'female' }, { key: 'hk_female_1', name: '曉佳', tag: '粤语女声', gender: 'female' },
{ key: 'female_6', name: '青梅', tag: '清秀少年音', gender: 'female' }, { key: 'hk_female_2', name: '曉曼', tag: '粤语温柔', gender: 'female' },
{ key: 'female_7', name: '可莉', tag: '奶音萝莉', gender: 'female' }, { key: 'hk_male_1', name: '雲龍', tag: '粤语男声', gender: 'male' },
{ key: 'male_1', name: '夜枭', tag: '磁性低音', gender: 'male' }, { key: 'tw_female_1', name: '曉臻', tag: '台灣女聲', gender: 'female' },
{ key: 'male_2', name: '君泽', tag: '温润公子', gender: 'male' }, { key: 'tw_female_2', name: '曉雨', tag: '台灣温柔', gender: 'female' },
{ key: 'male_3', name: '沐阳', tag: '沉稳暖男', gender: 'male' }, { key: 'tw_male_1', name: '雲哲', tag: '台灣男聲', gender: 'male' },
{ key: 'male_4', name: '梓辛', tag: '青春少年', gender: 'male' }, { key: 'male_1', name: '云希', tag: '少年温暖', gender: 'male' },
{ key: 'male_2', name: '云健', tag: '阳刚有力', gender: 'male' },
{ key: 'male_3', name: '云扬', tag: '专业播报', gender: 'male' },
{ key: 'male_4', name: '云夏', tag: '少年活力', gender: 'male' },
{ key: 'en_female_1', name: 'Jenny', tag: '美式甜美', gender: 'female' },
{ key: 'en_female_2', name: 'Aria', tag: '美式知性', gender: 'female' },
{ key: 'en_female_3', name: 'Sonia', tag: '英式优雅', gender: 'female' },
{ key: 'en_male_1', name: 'Guy', tag: '美式磁性', gender: 'male' },
{ key: 'en_male_2', name: 'Ryan', tag: '英式绅士', gender: 'male' },
{ key: 'ja_female_1', name: '七海', tag: '日式温柔', gender: 'female' },
{ key: 'ja_male_1', name: '圭太', tag: '日式少年', gender: 'male' },
]; ];
export const FREE_DEFAULT_VOICE = 'female_1'; export const FREE_DEFAULT_VOICE = 'female_1';
@@ -333,3 +343,4 @@ export async function synthesizeFreeV1(params, options = {}) {
return { audioBase64: data.data }; return { audioBase64: data.data };
} }

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
@@ -1275,7 +1275,7 @@ select.input { cursor: pointer; }
<div class="tip-box" style="margin-bottom: 16px;"> <div class="tip-box" style="margin-bottom: 16px;">
<i class="fa-solid fa-info-circle"></i> <i class="fa-solid fa-info-circle"></i>
<div> <div>
<strong>试用音色</strong> — 无需配置,使用插件服务器(11个音色<br> <strong>试用音色</strong> — 无需配置,使用插件服务器(21个音色<br>
<strong>鉴权音色</strong> — 需配置火山引擎 API200+ 音色 + 复刻) <strong>鉴权音色</strong> — 需配置火山引擎 API200+ 音色 + 复刻)
</div> </div>
</div> </div>
@@ -1719,19 +1719,30 @@ let selectedTrialVoiceValue = '';
let selectedAuthVoiceValue = ''; let selectedAuthVoiceValue = '';
let editingVoiceValue = null; let editingVoiceValue = null;
let activeSaveBtn = null; let activeSaveBtn = null;
let pendingSaveRequest = null;
const TRIAL_VOICES = [ const TRIAL_VOICES = [
{ key: 'female_1', name: '桃夭', tag: '甜蜜仙子', gender: 'female' }, { key: 'female_1', name: '晓晓', tag: '温暖百变', gender: 'female' },
{ key: 'female_2', name: '霜华', tag: '清冷仙子', gender: 'female' }, { key: 'female_2', name: '晓伊', tag: '清冷知性', gender: 'female' },
{ key: 'female_3', name: '顾姐', tag: '御姐烟嗓', gender: 'female' }, { key: 'female_3', name: '小北', tag: '东北甜妹', gender: 'female' },
{ key: 'female_4', name: '苏菲', tag: '优雅知性', gender: 'female' }, { key: 'female_4', name: '小妮', tag: '陕西姑娘', gender: 'female' },
{ key: 'female_5', name: '嘉欣', tag: '港风甜心', gender: 'female' }, { key: 'hk_female_1', name: '曉佳', tag: '粤语女声', gender: 'female' },
{ key: 'female_6', name: '青梅', tag: '清秀少年音', gender: 'female' }, { key: 'hk_female_2', name: '曉曼', tag: '粤语温柔', gender: 'female' },
{ key: 'female_7', name: '可莉', tag: '奶音萝莉', gender: 'female' }, { key: 'hk_male_1', name: '雲龍', tag: '粤语男声', gender: 'male' },
{ key: 'male_1', name: '夜枭', tag: '磁性低音', gender: 'male' }, { key: 'tw_female_1', name: '曉臻', tag: '台灣女聲', gender: 'female' },
{ key: 'male_2', name: '君泽', tag: '温润公子', gender: 'male' }, { key: 'tw_female_2', name: '曉雨', tag: '台灣温柔', gender: 'female' },
{ key: 'male_3', name: '沐阳', tag: '沉稳暖男', gender: 'male' }, { key: 'tw_male_1', name: '雲哲', tag: '台灣男聲', gender: 'male' },
{ key: 'male_4', name: '梓辛', tag: '青春少年', gender: 'male' }, { key: 'male_1', name: '云希', tag: '少年温暖', gender: 'male' },
{ key: 'male_2', name: '云健', tag: '阳刚有力', gender: 'male' },
{ key: 'male_3', name: '云扬', tag: '专业播报', gender: 'male' },
{ key: 'male_4', name: '云夏', tag: '少年活力', gender: 'male' },
{ key: 'en_female_1', name: 'Jenny', tag: '美式甜美', gender: 'female' },
{ key: 'en_female_2', name: 'Aria', tag: '美式知性', gender: 'female' },
{ key: 'en_female_3', name: 'Sonia', tag: '英式优雅', gender: 'female' },
{ key: 'en_male_1', name: 'Guy', tag: '美式磁性', gender: 'male' },
{ key: 'en_male_2', name: 'Ryan', tag: '英式绅士', gender: 'male' },
{ key: 'ja_female_1', name: '七海', tag: '日式温柔', gender: 'female' },
{ key: 'ja_male_1', name: '圭太', tag: '日式少年', gender: 'male' },
]; ];
const TRIAL_VOICE_KEYS = new Set(TRIAL_VOICES.map(v => v.key)); const TRIAL_VOICE_KEYS = new Set(TRIAL_VOICES.map(v => v.key));
@@ -1781,6 +1792,25 @@ function handleSaveResult(success) {
} }
} }
function requestSaveConfig(form, btn = null) {
const requestId = `tts_save_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
if (pendingSaveRequest?.timer) clearTimeout(pendingSaveRequest.timer);
if (btn) setSavingState(btn);
pendingSaveRequest = {
requestId,
timer: setTimeout(() => {
if (!pendingSaveRequest || pendingSaveRequest.requestId !== requestId) return;
pendingSaveRequest = null;
handleSaveResult(false);
post('xb-tts:toast', { type: 'error', message: '保存超时3秒' });
}, 3000),
};
post('xb-tts:save-config', { requestId, patch: form });
}
function setTestStatus(elId, status, text) { function setTestStatus(elId, status, text) {
const el = $(elId); const el = $(elId);
if (!el) return; if (!el) return;
@@ -2050,7 +2080,7 @@ function bindMyVoiceEvents(listEl) {
const input = btn.closest('.voice-item').querySelector('.voice-edit-input'); const input = btn.closest('.voice-item').querySelector('.voice-edit-input');
if (item && input?.value?.trim()) { if (item && input?.value?.trim()) {
item.name = input.value.trim(); item.name = input.value.trim();
post('xb-tts:save-config', collectForm()); requestSaveConfig(collectForm());
} }
editingVoiceValue = null; editingVoiceValue = null;
renderMyVoiceList(); renderMyVoiceList();
@@ -2080,7 +2110,7 @@ function bindMyVoiceEvents(listEl) {
renderTrialVoiceList(); renderTrialVoiceList();
renderAuthVoiceList(); renderAuthVoiceList();
updateCurrentVoiceDisplay(); updateCurrentVoiceDisplay();
post('xb-tts:save-config', collectForm()); requestSaveConfig(collectForm());
} }
}); });
}); });
@@ -2163,7 +2193,12 @@ function normalizeMySpeakers(list) {
value: String(item?.value || '').trim(), value: String(item?.value || '').trim(),
source: item?.source || getVoiceSource(item?.value || ''), source: item?.source || getVoiceSource(item?.value || ''),
resourceId: item?.resourceId || null, resourceId: item?.resourceId || null,
})).filter(item => item.value); })).filter(item => {
if (!item.value) return false;
// Keep UI behavior aligned with runtime: remove unsupported legacy free voices.
if (item.source === 'free' && !TRIAL_VOICE_KEYS.has(item.value)) return false;
return true;
});
} }
function applyCacheStats(stats = {}) { function applyCacheStats(stats = {}) {
@@ -2298,11 +2333,17 @@ window.addEventListener('message', ev => {
fillForm(payload); fillForm(payload);
break; break;
case 'xb-tts:config-saved': case 'xb-tts:config-saved':
if (pendingSaveRequest?.requestId && payload?.requestId && pendingSaveRequest.requestId !== payload.requestId) break;
if (pendingSaveRequest?.timer) clearTimeout(pendingSaveRequest.timer);
pendingSaveRequest = null;
fillForm(payload); fillForm(payload);
handleSaveResult(true); handleSaveResult(true);
post('xb-tts:toast', { type: 'success', message: '配置已保存' }); post('xb-tts:toast', { type: 'success', message: '配置已保存' });
break; break;
case 'xb-tts:config-save-error': case 'xb-tts:config-save-error':
if (pendingSaveRequest?.requestId && payload?.requestId && pendingSaveRequest.requestId !== payload.requestId) break;
if (pendingSaveRequest?.timer) clearTimeout(pendingSaveRequest.timer);
pendingSaveRequest = null;
handleSaveResult(false); handleSaveResult(false);
post('xb-tts:toast', { type: 'error', message: payload?.message || '保存失败' }); post('xb-tts:toast', { type: 'error', message: payload?.message || '保存失败' });
break; break;
@@ -2417,7 +2458,7 @@ document.addEventListener('DOMContentLoaded', () => {
$$('.voice-tab')[0].classList.add('active'); $$('.voice-tab')[0].classList.add('active');
$('panel-myVoice').classList.add('active'); $('panel-myVoice').classList.add('active');
post('xb-tts:save-config', collectForm()); requestSaveConfig(collectForm());
post('xb-tts:toast', { type: 'success', message: `已添加:${name}` }); post('xb-tts:toast', { type: 'success', message: `已添加:${name}` });
}); });
@@ -2441,7 +2482,7 @@ document.addEventListener('DOMContentLoaded', () => {
$$('.voice-tab')[0].classList.add('active'); $$('.voice-tab')[0].classList.add('active');
$('panel-myVoice').classList.add('active'); $('panel-myVoice').classList.add('active');
post('xb-tts:save-config', collectForm()); requestSaveConfig(collectForm());
post('xb-tts:toast', { type: 'success', message: `已添加:${name}` }); post('xb-tts:toast', { type: 'success', message: `已添加:${name}` });
}); });
@@ -2460,12 +2501,12 @@ document.addEventListener('DOMContentLoaded', () => {
renderMyVoiceList(); renderMyVoiceList();
updateCurrentVoiceDisplay(); updateCurrentVoiceDisplay();
post('xb-tts:save-config', collectForm()); requestSaveConfig(collectForm());
post('xb-tts:toast', { type: 'success', message: `已添加:${name || id}` }); post('xb-tts:toast', { type: 'success', message: `已添加:${name || id}` });
}); });
['saveConfigBtn', 'saveVoiceBtn', 'saveAdvancedBtn', 'saveCacheBtn'].forEach(id => { ['saveConfigBtn', 'saveVoiceBtn', 'saveAdvancedBtn', 'saveCacheBtn'].forEach(id => {
$(id)?.addEventListener('click', () => { setSavingState($(id)); post('xb-tts:save-config', collectForm()); }); $(id)?.addEventListener('click', () => { requestSaveConfig(collectForm(), $(id)); });
}); });
$('cacheRefreshBtn').addEventListener('click', () => post('xb-tts:cache-refresh')); $('cacheRefreshBtn').addEventListener('click', () => post('xb-tts:cache-refresh'));
@@ -2477,3 +2518,4 @@ document.addEventListener('DOMContentLoaded', () => {
</script> </script>
</body> </body>
</html> </html>

View File

@@ -169,7 +169,7 @@ export function parseTtsSegments(text) {
// ============ 非鉴权分段切割 ============ // ============ 非鉴权分段切割 ============
const FREE_MAX_TEXT = 200; const FREE_MAX_TEXT = 1000;
const FREE_MIN_TEXT = 50; const FREE_MIN_TEXT = 50;
const FREE_SENTENCE_DELIMS = new Set(['。', '', '', '!', '?', ';', '', '…', '.', '', ',', '、', ':', '']); const FREE_SENTENCE_DELIMS = new Set(['。', '', '', '!', '?', ';', '', '…', '.', '', ',', '、', ':', '']);
@@ -218,20 +218,98 @@ function splitTextForFree(text, maxLength = FREE_MAX_TEXT) {
const chunks = []; const chunks = [];
const paragraphs = String(text || '').split(/\n\s*\n/).map(s => s.replace(/\n+/g, '\n').trim()).filter(Boolean); const paragraphs = String(text || '').split(/\n\s*\n/).map(s => s.replace(/\n+/g, '\n').trim()).filter(Boolean);
let current = '';
const pushCurrent = () => {
if (!current) return;
chunks.push(current);
current = '';
};
for (const para of paragraphs) { for (const para of paragraphs) {
if (para.length <= maxLength) { if (!para) continue;
chunks.push(para);
if (para.length > maxLength) {
// Flush buffered short paragraphs before handling a long paragraph.
pushCurrent();
const longParts = splitLongTextBySentence(para, maxLength);
for (const part of longParts) {
const t = String(part || '').trim();
if (!t) continue;
if (!current) {
current = t;
continue; continue;
} }
chunks.push(...splitLongTextBySentence(para, maxLength)); if (current.length + t.length + 2 <= maxLength) {
current += `\n\n${t}`;
continue;
} }
pushCurrent();
current = t;
}
continue;
}
if (!current) {
current = para;
continue;
}
// Cross-paragraph merge: keep fewer requests while preserving paragraph boundary.
if (current.length + para.length + 2 <= maxLength) {
current += `\n\n${para}`;
continue;
}
pushCurrent();
current = para;
}
pushCurrent();
return chunks; return chunks;
} }
export function splitTtsSegmentsForFree(segments, maxLength = FREE_MAX_TEXT) { export function splitTtsSegmentsForFree(segments, maxLength = FREE_MAX_TEXT) {
if (!Array.isArray(segments) || !segments.length) return []; if (!Array.isArray(segments) || !segments.length) return [];
const out = []; const normalizedSegments = [];
// In free mode, only explicit speaker directives are semantic split points.
// Adjacent segments without speaker= are merged to reduce request count.
let mergeBuffer = null;
const flushMergeBuffer = () => {
if (!mergeBuffer) return;
normalizedSegments.push(mergeBuffer);
mergeBuffer = null;
};
for (const seg of segments) { for (const seg of segments) {
const hasExplicitSpeaker = !!String(seg?.speaker || '').trim();
const text = String(seg?.text || '').trim();
if (!text) continue;
if (hasExplicitSpeaker) {
flushMergeBuffer();
normalizedSegments.push({
...seg,
text,
});
continue;
}
if (!mergeBuffer) {
mergeBuffer = {
...seg,
text,
speaker: '',
};
continue;
}
mergeBuffer.text += `\n${text}`;
}
flushMergeBuffer();
const out = [];
for (const seg of normalizedSegments) {
const parts = splitTextForFree(seg.text, maxLength); const parts = splitTextForFree(seg.text, maxLength);
if (!parts.length) continue; if (!parts.length) continue;
let buffer = ''; let buffer = '';

View File

@@ -1,4 +1,4 @@
// ============ 导入 ============ // ============ 导入 ============
import { event_types } from "../../../../../../script.js"; import { event_types } from "../../../../../../script.js";
import { extension_settings, getContext } from "../../../../../extensions.js"; import { extension_settings, getContext } from "../../../../../extensions.js";
@@ -42,8 +42,12 @@ const HTML_PATH = `${extensionFolderPath}/modules/tts/tts-overlay.html`;
const TTS_DIRECTIVE_REGEX = /\[tts:([^\]]*)\]/gi; const TTS_DIRECTIVE_REGEX = /\[tts:([^\]]*)\]/gi;
const FREE_VOICE_KEYS = new Set([ const FREE_VOICE_KEYS = new Set([
'female_1', 'female_2', 'female_3', 'female_4', 'female_5', 'female_6', 'female_7', 'female_1', 'female_2', 'female_3', 'female_4',
'male_1', 'male_2', 'male_3', 'male_4' 'hk_female_1', 'hk_female_2', 'hk_male_1',
'tw_female_1', 'tw_female_2', 'tw_male_1',
'male_1', 'male_2', 'male_3', 'male_4',
'en_female_1', 'en_female_2', 'en_female_3', 'en_male_1', 'en_male_2',
'ja_female_1', 'ja_male_1',
]); ]);
// ============ NovelDraw 兼容 ============ // ============ NovelDraw 兼容 ============
@@ -913,11 +917,26 @@ async function loadConfig() {
config = await TtsStorage.load(); config = await TtsStorage.load();
config.volc = config.volc || {}; config.volc = config.volc || {};
let legacyPurged = false;
if (Array.isArray(config.volc.mySpeakers)) { if (Array.isArray(config.volc.mySpeakers)) {
config.volc.mySpeakers = config.volc.mySpeakers.map(s => ({ const normalized = config.volc.mySpeakers.map(s => ({
...s, ...s,
source: s.source || getVoiceSource(s.value) source: s.source || getVoiceSource(s.value)
})); }));
const filtered = normalized.filter(s => {
// Purge legacy free voices that are no longer supported by the current free voice map.
if (s.source === 'free' && !FREE_VOICE_KEYS.has(s.value)) {
legacyPurged = true;
return false;
}
return true;
});
config.volc.mySpeakers = filtered;
}
if (config.volc.defaultSpeaker && getVoiceSource(config.volc.defaultSpeaker) === 'free' && !FREE_VOICE_KEYS.has(config.volc.defaultSpeaker)) {
config.volc.defaultSpeaker = FREE_DEFAULT_VOICE;
legacyPurged = true;
} }
config.volc.disableMarkdownFilter = config.volc.disableMarkdownFilter !== false; config.volc.disableMarkdownFilter = config.volc.disableMarkdownFilter !== false;
@@ -943,6 +962,12 @@ async function loadConfig() {
config.showFloorButton = config.showFloorButton !== false; config.showFloorButton = config.showFloorButton !== false;
config.showFloatingButton = config.showFloatingButton === true; config.showFloatingButton = config.showFloatingButton === true;
if (legacyPurged) {
await TtsStorage.set('volc', config.volc);
await TtsStorage.saveNow({ silent: true });
console.info('[TTS] Purged legacy free voices from mySpeakers.');
}
return config; return config;
} }
@@ -1054,15 +1079,17 @@ async function handleIframeMessage(ev) {
closeSettings(); closeSettings();
break; break;
case 'xb-tts:save-config': { case 'xb-tts:save-config': {
const ok = await saveConfig(payload); const requestId = payload?.requestId || '';
const patch = (payload && typeof payload.patch === 'object') ? payload.patch : payload;
const ok = await saveConfig(patch);
if (ok) { if (ok) {
const cacheStats = await getCacheStatsSafe(); const cacheStats = await getCacheStatsSafe();
postToIframe(iframe, { type: 'xb-tts:config-saved', payload: { ...config, cacheStats } }); postToIframe(iframe, { type: 'xb-tts:config-saved', payload: { ...config, cacheStats, requestId } });
updateAutoSpeakAll(); updateAutoSpeakAll();
updateSpeedAll(); updateSpeedAll();
updateVoiceAll(); updateVoiceAll();
} else { } else {
postToIframe(iframe, { type: 'xb-tts:config-save-error', payload: { message: '保存失败' } }); postToIframe(iframe, { type: 'xb-tts:config-save-error', payload: { message: '保存失败', requestId } });
} }
break; break;
} }
@@ -1472,3 +1499,4 @@ export function cleanupTts() {
cacheCounters.misses = 0; cacheCounters.misses = 0;
delete window.xiaobaixTts; delete window.xiaobaixTts;
} }

View File

@@ -206,6 +206,14 @@
<input type="checkbox" id="xiaobaix_story_outline_enabled" /> <input type="checkbox" id="xiaobaix_story_outline_enabled" />
<label for="xiaobaix_story_outline_enabled" class="has-tooltip" data-tooltip="在X按钮区域添加地图图标点击可打开可视化剧情地图编辑器">小白板</label> <label for="xiaobaix_story_outline_enabled" class="has-tooltip" data-tooltip="在X按钮区域添加地图图标点击可打开可视化剧情地图编辑器">小白板</label>
</div> </div>
<div class="flex-container">
<input type="checkbox" id="xiaobaix_ena_planner_enabled" />
<label for="xiaobaix_ena_planner_enabled" class="has-tooltip" data-tooltip="发送前剧情规划,自动注入 plot/note">剧情规划</label>
<button id="xiaobaix_ena_planner_open_settings" class="menu_button menu_button_icon" type="button" style="margin-left:auto;" title="打开剧情规划设置">
<i class="fa-solid fa-compass-drafting"></i>
<small>规划设置</small>
</button>
</div>
<br> <br>
<div class="section-divider">变量控制</div> <div class="section-divider">变量控制</div>
<hr class="sysHR" /> <hr class="sysHR" />
@@ -519,14 +527,15 @@
audio: 'xiaobaix_audio_enabled', audio: 'xiaobaix_audio_enabled',
storySummary: 'xiaobaix_story_summary_enabled', storySummary: 'xiaobaix_story_summary_enabled',
tts: 'xiaobaix_tts_enabled', tts: 'xiaobaix_tts_enabled',
enaPlanner: 'xiaobaix_ena_planner_enabled',
storyOutline: 'xiaobaix_story_outline_enabled', storyOutline: 'xiaobaix_story_outline_enabled',
useBlob: 'xiaobaix_use_blob', useBlob: 'xiaobaix_use_blob',
wrapperIframe: 'Wrapperiframe', wrapperIframe: 'Wrapperiframe',
renderEnabled: 'xiaobaix_render_enabled', renderEnabled: 'xiaobaix_render_enabled',
}; };
const DEFAULTS_ON = ['templateEditor', 'tasks', 'variablesCore', 'audio', 'storySummary', 'recorded']; const DEFAULTS_ON = ['templateEditor', 'tasks', 'variablesCore', 'audio', 'storySummary', 'recorded'];
const DEFAULTS_OFF = ['preview', 'scriptAssistant', 'immersive', 'variablesPanel', 'fourthWall', 'storyOutline', 'novelDraw', 'tts']; const DEFAULTS_OFF = ['preview', 'scriptAssistant', 'immersive', 'variablesPanel', 'fourthWall', 'storyOutline', 'novelDraw', 'tts', 'enaPlanner'];
const MODULE_KEYS = ['templateEditor', 'tasks', 'fourthWall', 'variablesCore', 'recorded', 'preview', 'scriptAssistant', 'immersive', 'variablesPanel', 'audio', 'storySummary', 'storyOutline', 'novelDraw', 'tts']; const MODULE_KEYS = ['templateEditor', 'tasks', 'fourthWall', 'variablesCore', 'recorded', 'preview', 'scriptAssistant', 'immersive', 'variablesPanel', 'audio', 'storySummary', 'storyOutline', 'novelDraw', 'tts', 'enaPlanner'];
function setModuleEnabled(key, enabled) { function setModuleEnabled(key, enabled) {
try { try {
if (!extension_settings[EXT_ID][key]) extension_settings[EXT_ID][key] = {}; if (!extension_settings[EXT_ID][key]) extension_settings[EXT_ID][key] = {};