Files
ST-Amily2-Chat-Optimisation/ui/plot-opt-bindings.js
Jenkins CI 0d7e3b799e release: v2.2.6 [2026-06-13 01:02:05]
### 新功能
- **翰林院向量化质量升级**:
  - **边界感知切块**:替换四个来源(聊天记录/小说/世界书/手动)的纯字符硬切——优先在段落边界断开,其次句末标点(含中文引号闭合),极端长串才硬切;句子/对话不再被拦腰截断,embedding 质量同步受益。仅影响新录入,已有向量无需重建
  - **注入时序重排**:检索结果注入提示词前按时序重排(聊天记录按楼层、小说按卷/章/节——中文数字章节号可解析),rerank 只决定"选哪些块",不再决定呈现顺序;修复"不打不相识的剧情之后紧跟关系亲密"这类因按相关度排序导致的认知时间错乱
  - **断层提示**:聊天记录相邻块楼层跳跃时自动插入"与上文相隔约 N 楼,并非连续发生"提示行,消除中间剧情缺失造成的割裂感
  - **时间标识**:新录入的聊天记录块在来源标识中带上消息发送时间(ST 向量存储不持久化元数据,时间必须写入块文本才能在检索后取回;旧格式块兼容解析)
- **记忆块工作流(memory-blocks)**:剧情优化新增"自定义记忆块"体系——占位符驱动的并发工作流框架
  - 在剧情优化面板「匹配替换 (sulv)」下方可增删自定义块:每个块定义一个占位符,执行剧情优化时主/拦截提示词中的占位符会被块的产出替换
  - **静态块**:直接输出固定内容;**AI 调用块**:用所选 API 功能槽独立请求一次,把回复(或其中指定 `<标签>` 的内容)作为替换值
  - 原有 sulv1-4 速率占位符迁入同一框架,行为与旧版逐字节一致
  - 块定义为纯 JSON、随设置持久化,为后续导入导出与战斗系统接入预留扩展点
  - 框架层新增**顺序拼接式 Chain**(`composeChain`):与占位符替换并列的第二种组合范式——同链的块并发执行后按 `order` 排序、以 `separator` 拼接并可选 `header/footer` 包裹,产出一个完整注入块;为记忆注入合成块与战斗系统"底部战报块"预留的承载结构,本版本暂无 UI 入口
- **API 连接配置**:
  - 角色世界书(cwb)与一键生卡(autoCharCard)纳入旧配置自动迁移:老用户首次加载会把旧 URL / Key / 模型自动迁移为连接配置并分配槽位(一键生卡仅在规划者与执行者配置一致或规划者为空时迁移,避免悄悄改变行为)
  - **profile 已分配时参数控件 informational 化**:主面板 / 并发剧情优化 / 角色世界书 / 术语表的温度、maxTokens 控件在槽位分配 profile 后自动禁用并显示"由连接配置控制"提示,消除"改了没效果"的用户陷阱
  - **profile 状态卡新增"本设备无 Key"警示**:API Key 仅保存在最初填写它的设备/浏览器上(安全设计,不随云端设置同步),换设备后状态卡会直接亮出警示徽标,不必等到调用报错才发现
### 修复
- **独立聊天记忆从摆设变真功能(原作遗留坑)**:此前向量数据"随卡不随聊天"——开启"独立聊天记忆"后录入仍存进角色库、查询却去查一个从未被写入过的聊天集合、计数恒为 0,整体静默失效。现已重构为聊天级分桶:
  - 独立模式下,聊天记录类向量按当前聊天隔离存储与检索,同一张卡开多个聊天(不同剧情线)的记忆互不污染
  - 小说 / 世界书 / 手动录入属于"知识",仍随角色卡跨聊天共享;全局库不受影响
  - 知识管理列表为聊天专属库显示"聊天级"徽标;聊天级库禁止移动到全局
  - 统一模式(默认关闭独立记忆)的存量数据与行为完全不变
  - 已知限制:聊天专属记忆跟随聊天文件,重命名聊天文件会使其失联(与 ST 官方向量扩展同等限制)
- **超级排序截断顺序修正**:开启"超级排序"时,时序重排发生在 top_n 截断之前,导致保留的是"时序最早"而非"最相关"的块,检索结果长期偏向最旧的聊天记录。现改为先按相关度截取 top_n、再做时序排序
- **翰林院向量化失败("向量化块数量不识别"反馈)**:
  - 一次性清洗 profile-sync 历史污染:`retrieval/rerank.apiKey` 中的掩码占位符在持久层根治(此前仅读取侧防御);`apiEndpoint` / `rerank.apiMode` 的非法值(如被旧版写入的空字符串)归一化为 `custom`
  - 修复 `apiEndpoint` 为空/非法时请求被硬定向到 `api.openai.com`、无视用户自定义 URL 的问题(CSP 拦截 / 401 的元凶)
  - 修复**本地代理(LM Studio/Ollama)模式**自始就缺少 URL 分支、同样被错误定向到 openai.com 的问题
  - API 模式下拉补全 `OpenAI 官方` / `Azure` 选项;默认 API 模式改为 `custom`(与默认 URL 配套),新用户不再因选项缺失导致首次保存写入空值
  - profile-sync 给下拉框赋不存在选项值的污染源头修复(影响所有模块面板,不止翰林院)
- **Rerank "API Key 未提供"报错升级**:当原因是"连接配置在本设备没有可用 Key"时,报错会直接说明 Key 的设备本地性并指引到 API 连接配置重新填写(向量化 Google 直连、获取模型列表同步处理)
- **旧配置迁移**:一键生卡迁移时排除掩码占位符,避免把历史污染的假 Key 迁入新连接配置
- **超级记忆稳定性专项**(针对"工作不大稳定"反馈,4 处根因一次修复):
  - **切聊天竞态污染**:CHAT_CHANGED 时超级记忆立即全量同步,而表格系统延迟 100ms 才加载新聊天的表格,导致【旧聊天】的表格内容被写进【新角色】的记忆世界书;两边表名不同时旧表条目无 GC 兜底会**永久残留**("记忆串台"元凶)。现 CHAT_CHANGED 只确保世界书存在,新状态同步交由 `loadTables()` 完成后的自动推送,单次且时序正确
  - **死代码双轨存储拆除**:`saveStateToMetadata` / `tryRestoreStateFromMetadata` 把表格状态写到 `msg.metadata`——该字段非 ST 持久化位(同 v2.2.5 二次填表修过的坑),写入即蒸发、恢复永远为空,且每次同步还白调一次 `saveChat()`。整条链路删除,表格状态唯一信源为表格系统的 `msg.extra.amily2_tables_data`
  - **`awaitSync()` 穿透**:同步队列正忙时 `pushUpdate` 会用一个立即 resolve 的空 Promise 覆盖 `_syncPromise`,Pipeline Stage 4 等待形同虚设、后续阶段在同步未完成时被放行。现忙时不覆盖,正在运行的 drain 循环自然吃掉新入队项
  - **开关打开不生效**:启动时若总开关为关,初始化早退且不注册监听器;此后在 UI 勾选开关只写设置,超级记忆直到刷新页面前都是死的。现勾选即触发初始化(幂等)
  - 附带:`forceSyncAll` 的表格角色推断改为复用 `events-schema.inferTableRole`,消除两处重复逻辑漂移风险;每次切聊天的双倍全量同步(restore 路径一次 + 显式一次)随死代码移除归一
### 重构
- 表格核心 `manager.js` 瘦身(约 1050 → 600 行):19 个 UI 突变操作拆分至 `actions/ui-mutations.js`,SuperMemory 事件分发拆分至 `events-dispatch.js`;全部经 re-export 保持兼容,外部调用路径零改动
- 角色世界书最后 2 处散乱的厂商 URL 判断迁移至 `detectVendor` 统一入口,业务路径上不再有硬编码的 URL substring 判断
2026-06-13 01:02:05 +08:00

1487 lines
64 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* plot-opt-bindings.js — 剧情优化 + JQYH 面板的所有 UI 事件绑定
*
* 从 bindings.js 中拆分而来,由 PlotOptModule.mount() 调用入口函数
* initializePlotOptimizationBindings()。
*/
import { extension_settings, getContext } from "/scripts/extensions.js";
import { characters, this_chid, getRequestHeaders, saveSettingsDebounced, eventSource, event_types } from "/script.js";
import { defaultSettings, extensionName } from "../utils/settings.js";
import { testConcurrentApiConnection, fetchConcurrentModels } from '../core/api/ConcurrentApi.js';
import { safeLorebooks, safeCharLorebooks, safeLorebookEntries } from "../core/tavernhelper-compatibility.js";
import { createDrawer } from '../ui/drawer.js';
import { pluginAuthStatus } from "../utils/auth.js";
import { configManager } from '../utils/config/ConfigManager.js';
import { SENSITIVE_KEYS } from '../utils/config/sensitive-keys.js';
import { showHtmlModal } from './page-window.js';
import { escapeHTML } from '../utils/utils.js';
import { SLOTS } from '../utils/config/ApiProfileManager.js';
import {
listCustomBlocks,
getCustomBlock,
addCustomBlock,
updateCustomBlock,
deleteCustomBlock,
syncCustomBlocksFromSettings,
} from '../core/memory-blocks/index.js';
import { watchProfileSliderGuard } from './profile-slider-guard.js';
// ========== Prompt Cache (module-level state) ==========
const promptCache = {
main: '',
system: '',
final_system: ''
};
// ========== 导出函数 ==========
export function opt_saveAllSettings() {
const panel = $('#amily2_plot_optimization_panel');
if (panel.length === 0) return;
console.log(`[${extensionName}] 手动触发所有剧情优化设置的保存...`);
panel.find('input[type="checkbox"], input[type="radio"], input[type="text"], input[type="password"], textarea, select').trigger('change.amily2_opt');
panel.find('input[type="range"]').trigger('change.amily2_opt');
opt_saveEnabledEntries();
toastr.info('剧情优化设置已自动保存。');
}
function opt_toCamelCase(str) {
return str.replace(/[-_]([a-z])/g, (g) => g[1].toUpperCase());
}
function opt_updateWorldbookSourceVisibility(panel, source) {
const manualSelectionWrapper = panel.find('#amily2_opt_worldbook_select_wrapper');
if (source === 'manual') {
manualSelectionWrapper.show();
const selectBox = manualSelectionWrapper.find('#amily2_opt_selected_worldbooks');
selectBox.css({
'height': 'auto',
'background-color': 'var(--bg1)',
'appearance': 'none',
'-webkit-appearance': 'none'
});
} else {
manualSelectionWrapper.hide();
}
}
const opt_characterSpecificSettings = [
'plotOpt_worldbookSource',
'plotOpt_selectedWorldbooks',
'plotOpt_autoSelectWorldbooks',
'plotOpt_enabledWorldbookEntries'
];
async function opt_saveSetting(key, value) {
if (opt_characterSpecificSettings.includes(key)) {
const character = characters[this_chid];
if (!character) return;
if (!character.data.extensions) character.data.extensions = {};
if (!character.data.extensions[extensionName]) character.data.extensions[extensionName] = {};
character.data.extensions[extensionName][key] = value;
try {
const response = await fetch('/api/characters/merge-attributes', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
avatar: character.avatar,
data: { extensions: { [extensionName]: character.data.extensions[extensionName] } }
})
});
if (!response.ok) throw new Error(`API call failed with status: ${response.status}`);
console.log(`[${extensionName}] 角色卡设置已更新: ${key} ->`, value);
} catch (error) {
console.error(`[${extensionName}] 保存角色数据失败:`, error);
toastr.error('无法保存角色卡设置,请检查控制台。');
}
} else if (SENSITIVE_KEYS.has(key)) {
// 敏感字段API Key经 configManager 写入 localStorage
configManager.set(key, value);
} else {
if (!extension_settings[extensionName]) {
extension_settings[extensionName] = {};
}
extension_settings[extensionName][key] = value;
saveSettingsDebounced();
}
}
function opt_getMergedSettings() {
const character = characters[this_chid];
const globalSettings = extension_settings[extensionName] || defaultSettings;
const characterSettings = character?.data?.extensions?.[extensionName] || {};
return { ...globalSettings, ...characterSettings };
}
function bindInputLikeSave(element, handler) {
if (!element) return;
element.oninput = handler;
element.onchange = handler;
}
function syncModelMirror(inputElement, selectElement) {
if (!inputElement || !selectElement) return;
const value = inputElement.value || '';
if (!value) return;
let option = Array.from(selectElement.options || []).find(item => item.value === value);
if (!option) {
option = new Option(value, value, true, true);
selectElement.add(option);
}
selectElement.value = value;
}
function opt_bindSlider(panel, sliderId, displayId) {
const slider = panel.find(sliderId);
const display = panel.find(displayId);
display.text(slider.val());
slider.on('input', function() {
display.text($(this).val());
});
}
async function opt_loadWorldbooks(panel) {
const container = panel.find('#amily2_opt_worldbook_checkbox_list');
const settings = opt_getMergedSettings();
const currentSelection = settings.plotOpt_selectedWorldbooks || [];
container.empty();
// 移除旧的搜索框以防重复
panel.find('#amily2_opt_worldbook_search').remove();
const searchBox = $(`<input type="text" id="amily2_opt_worldbook_search" class="text_pole" placeholder="搜索世界书..." style="width: 100%; margin-bottom: 10px;">`);
container.before(searchBox);
searchBox.on('input', function() {
const searchTerm = $(this).val().toLowerCase();
container.find('.amily2_opt_worldbook_list_item').each(function() {
const bookName = $(this).find('label').text().toLowerCase();
if (bookName.includes(searchTerm)) {
$(this).show();
} else {
$(this).hide();
}
});
});
try {
const lorebooks = await safeLorebooks();
if (!lorebooks || lorebooks.length === 0) {
container.html('<p class="notes">未找到世界书。</p>');
return;
}
lorebooks.forEach(name => {
const bookId = `amily2-opt-wb-check-${name.replace(/[^a-zA-Z0-9]/g, '-')}`;
const isChecked = currentSelection.includes(name);
// Auto Select Logic
const autoId = `amily2-opt-wb-auto-${name.replace(/[^a-zA-Z0-9]/g, '-')}`;
const isAuto = (settings.plotOpt_autoSelectWorldbooks || []).includes(name);
const item = $(`
<div class="amily2_opt_worldbook_list_item" style="display: flex; align-items: center; justify-content: space-between; padding-right: 5px;">
<div style="display: flex; align-items: center;">
<input type="checkbox" id="${bookId}" value="${name}" ${isChecked ? 'checked' : ''} style="margin-right: 5px;">
<label for="${bookId}" style="margin-bottom: 0;">${name}</label>
</div>
<div style="display: flex; align-items: center;" title="开启后自动加载该世界书所有条目(包括新增)">
<input type="checkbox" class="amily2_opt_wb_auto_check" id="${autoId}" data-book="${name}" ${isAuto ? 'checked' : ''} style="margin-right: 5px;">
<label for="${autoId}" style="margin-bottom: 0; font-size: 0.9em; opacity: 0.8; cursor: pointer;">全选</label>
</div>
</div>
`);
container.append(item);
});
} catch (error) {
console.error(`[${extensionName}] 加载世界书失败:`, error);
container.html('<p class="notes" style="color:red;">加载世界书列表失败。</p>');
toastr.error('无法加载世界书列表,请查看控制台。');
}
}
async function opt_loadWorldbookEntries(panel) {
const container = panel.find('#amily2_opt_worldbook_entry_list_container');
const countDisplay = panel.find('#amily2_opt_worldbook_entry_count');
container.html('<p>加载条目中...</p>');
countDisplay.text('');
// 移除旧的搜索框以防重复
panel.find('#amily2_opt_worldbook_entry_search').remove();
const searchBox = $(`<input type="text" id="amily2_opt_worldbook_entry_search" class="text_pole" placeholder="搜索条目..." style="width: 100%; margin-bottom: 10px;">`);
container.before(searchBox);
searchBox.on('input', function() {
const searchTerm = $(this).val().toLowerCase();
let visibleCount = 0;
container.find('.amily2_opt_worldbook_entry_item').each(function() {
const entryName = $(this).find('label').text().toLowerCase();
if (entryName.includes(searchTerm)) {
$(this).show();
visibleCount++;
} else {
$(this).hide();
}
});
const totalEntries = container.find('.amily2_opt_worldbook_entry_item').length;
countDisplay.text(`显示 ${visibleCount} / ${totalEntries} 条目.`);
});
const settings = opt_getMergedSettings();
const currentSource = settings.plotOpt_worldbookSource || 'character';
let bookNames = [];
if (currentSource === 'manual') {
bookNames = settings.plotOpt_selectedWorldbooks || [];
} else {
if (this_chid === -1 || !characters[this_chid]) {
container.html('<p class="notes">未选择角色。</p>');
countDisplay.text('');
return;
}
try {
const charLorebooks = await safeCharLorebooks({ type: 'all' });
if (charLorebooks.primary) bookNames.push(charLorebooks.primary);
if (charLorebooks.additional?.length) bookNames.push(...charLorebooks.additional);
} catch (error) {
console.error(`[${extensionName}] 获取角色世界书失败:`, error);
toastr.error('获取角色世界书失败。');
container.html('<p class="notes" style="color:red;">获取角色世界书失败。</p>');
return;
}
}
const selectedBooks = bookNames;
let enabledEntries = settings.plotOpt_enabledWorldbookEntries || {};
let totalEntries = 0;
let visibleEntries = 0;
if (selectedBooks.length === 0) {
container.html('<p class="notes">请选择一个或多个世界书以查看其条目。</p>');
return;
}
try {
const allEntries = [];
for (const bookName of selectedBooks) {
const entries = await safeLorebookEntries(bookName);
entries.forEach(entry => {
allEntries.push({ ...entry, bookName });
});
}
// 根据用户要求,只显示默认启用的条目
const enabledOnlyEntries = allEntries.filter(entry => entry.enabled);
container.empty();
totalEntries = enabledOnlyEntries.length;
if (totalEntries === 0) {
container.html('<p class="notes">所选世界书没有(已启用的)条目。</p>');
countDisplay.text('0 条目.');
return;
}
enabledOnlyEntries.sort((a, b) => (a.comment || '').localeCompare(b.comment || '')).forEach(entry => {
const entryId = `amily2-opt-entry-${entry.bookName.replace(/[^a-zA-Z0-9]/g, '-')}-${entry.uid}`;
const isAuto = (settings.plotOpt_autoSelectWorldbooks || []).includes(entry.bookName);
// If auto is enabled, the entry is forced enabled in logic, so show checked and disabled
const isChecked = isAuto || (enabledEntries[entry.bookName]?.includes(entry.uid) ?? true);
const isDisabled = isAuto;
const item = $(`
<div class="amily2_opt_worldbook_entry_item" style="display: flex; align-items: center;">
<input type="checkbox" id="${entryId}" data-book="${entry.bookName}" data-uid="${entry.uid}" ${isChecked ? 'checked' : ''} ${isDisabled ? 'disabled' : ''} style="margin-right: 5px;">
<label for="${entryId}" title="世界书: ${entry.bookName}\nUID: ${entry.uid}" style="margin-bottom: 0; ${isDisabled ? 'opacity:0.7;' : ''}">${entry.comment || '无标题条目'} ${isAuto ? '<span style="font-size:0.8em; opacity:0.6;">(全选生效中)</span>' : ''}</label>
</div>
`);
container.append(item);
});
visibleEntries = container.children().length;
countDisplay.text(`显示 ${visibleEntries} / ${totalEntries} 条目.`);
} catch (error) {
console.error(`[${extensionName}] 加载世界书条目失败:`, error);
container.html('<p class="notes" style="color:red;">加载条目失败。</p>');
}
}
function opt_saveEnabledEntries() {
const panel = $('#amily2_plot_optimization_panel');
let enabledEntries = {};
panel.find('#amily2_opt_worldbook_entry_list_container input[type="checkbox"]').each(function() {
const bookName = $(this).data('book');
const uid = parseInt($(this).data('uid'));
if (!enabledEntries[bookName]) {
enabledEntries[bookName] = [];
}
if ($(this).is(':checked')) {
enabledEntries[bookName].push(uid);
}
});
const settings = opt_getMergedSettings();
if (settings.plotOpt_worldbookSource === 'manual') {
const selectedBooks = settings.plotOpt_selectedWorldbooks || [];
Object.keys(enabledEntries).forEach(bookName => {
if (!selectedBooks.includes(bookName)) {
delete enabledEntries[bookName];
}
});
}
opt_saveSetting('plotOpt_enabledWorldbookEntries', enabledEntries);
}
function opt_loadPromptPresets(panel) {
const presets = extension_settings[extensionName]?.promptPresets || [];
const select = panel.find('#amily2_opt_prompt_preset_select');
const settings = opt_getMergedSettings();
const lastUsedPresetName = settings.plotOpt_lastUsedPresetName;
select.empty().append(new Option('-- 选择一个预设 --', ''));
presets.forEach(preset => {
const option = new Option(preset.name, preset.name);
if (preset.name === lastUsedPresetName) {
option.selected = true;
}
select.append(option);
});
}
function opt_saveCurrentPromptsAsPreset(panel) {
const selectedPresetName = panel.find('#amily2_opt_prompt_preset_select').val();
let presetName;
let isOverwriting = false;
if (selectedPresetName) {
if (confirm(`您确定要用当前编辑的提示词覆盖预设 "${selectedPresetName}" 吗?`)) {
presetName = selectedPresetName;
isOverwriting = true;
} else {
toastr.info('保存操作已取消。');
return;
}
} else {
presetName = prompt("您正在创建一个新的预设,请输入预设名称:");
if (!presetName) {
toastr.info('保存操作已取消。');
return;
}
}
const presets = extension_settings[extensionName]?.promptPresets || [];
const existingPresetIndex = presets.findIndex(p => p.name === presetName);
// Ensure the cache is up-to-date before saving
const currentEditorPromptKey = panel.find('#amily2_opt_prompt_selector').val();
promptCache[currentEditorPromptKey] = panel.find('#amily2_opt_prompt_editor').val();
const currentSettings = extension_settings[extensionName] || {};
const newPresetData = {
name: presetName,
mainPrompt: promptCache.main,
systemPrompt: promptCache.system,
finalSystemDirective: promptCache.final_system,
concurrentMainPrompt: currentSettings.plotOpt_concurrentMainPrompt || '',
concurrentSystemPrompt: currentSettings.plotOpt_concurrentSystemPrompt || '',
rateMain: parseFloat(panel.find('#amily2_opt_rate_main').val()),
ratePersonal: parseFloat(panel.find('#amily2_opt_rate_personal').val()),
rateErotic: parseFloat(panel.find('#amily2_opt_rate_erotic').val()),
rateCuckold: parseFloat(panel.find('#amily2_opt_rate_cuckold').val())
};
if (existingPresetIndex !== -1) {
presets[existingPresetIndex] = newPresetData;
toastr.success(`预设 "${presetName}" 已成功覆盖。`);
} else {
presets.push(newPresetData);
toastr.success(`新预设 "${presetName}" 已成功创建。`);
}
opt_saveSetting('promptPresets', presets);
opt_loadPromptPresets(panel);
setTimeout(() => {
panel.find('#amily2_opt_prompt_preset_select').val(presetName).trigger('change', { isAutomatic: false });
}, 0);
}
function opt_deleteSelectedPreset(panel) {
const select = panel.find('#amily2_opt_prompt_preset_select');
const selectedName = select.val();
if (!selectedName) {
toastr.warning('没有选择任何预设。');
return;
}
if (!confirm(`确定要删除预设 "${selectedName}" 吗?`)) {
return;
}
const presets = extension_settings[extensionName]?.promptPresets || [];
const indexToDelete = presets.findIndex(p => p.name === selectedName);
if (indexToDelete > -1) {
presets.splice(indexToDelete, 1);
opt_saveSetting('promptPresets', presets);
toastr.success(`预设 "${selectedName}" 已被删除。`);
} else {
toastr.error('找不到要删除的预设,操作可能已过期。');
}
opt_loadPromptPresets(panel);
select.trigger('change');
}
function opt_exportPromptPresets() {
const select = $('#amily2_opt_prompt_preset_select');
const selectedName = select.val();
if (!selectedName) {
toastr.info('请先从下拉菜单中选择一个要导出的预设。');
return;
}
const presets = extension_settings[extensionName]?.promptPresets || [];
const selectedPreset = presets.find(p => p.name === selectedName);
if (!selectedPreset) {
toastr.error('找不到选中的预设,请刷新页面后重试。');
return;
}
const dataToExport = [selectedPreset];
const dataStr = JSON.stringify(dataToExport, null, 2);
const blob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `amily2_opt_preset_${selectedName.replace(/[^a-z0-9]/gi, '_')}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toastr.success(`预设 "${selectedName}" 已成功导出。`);
}
function opt_importPromptPresets(file, panel) {
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
try {
const importedPresets = JSON.parse(e.target.result);
if (!Array.isArray(importedPresets)) {
throw new Error('JSON文件格式不正确根节点必须是一个数组。');
}
let currentPresets = extension_settings[extensionName]?.promptPresets || [];
let importedCount = 0;
let overwrittenCount = 0;
importedPresets.forEach(preset => {
if (preset && typeof preset.name === 'string' && preset.name.length > 0) {
const presetData = {
name: preset.name,
mainPrompt: preset.mainPrompt || '',
systemPrompt: preset.systemPrompt || '',
finalSystemDirective: preset.finalSystemDirective || '',
concurrentMainPrompt: preset.concurrentMainPrompt || '',
concurrentSystemPrompt: preset.concurrentSystemPrompt || '',
rateMain: preset.rateMain ?? 1.0,
ratePersonal: preset.ratePersonal ?? 1.0,
rateErotic: preset.rateErotic ?? 1.0,
rateCuckold: preset.rateCuckold ?? 1.0
};
const existingIndex = currentPresets.findIndex(p => p.name === preset.name);
if (existingIndex !== -1) {
currentPresets[existingIndex] = presetData;
overwrittenCount++;
} else {
currentPresets.push(presetData);
importedCount++;
}
}
});
if (importedCount > 0 || overwrittenCount > 0) {
const selectedPresetBeforeImport = panel.find('#amily2_opt_prompt_preset_select').val();
opt_saveSetting('promptPresets', currentPresets);
opt_loadPromptPresets(panel);
panel.find('#amily2_opt_prompt_preset_select').val(selectedPresetBeforeImport);
panel.find('#amily2_opt_prompt_preset_select').trigger('change');
let messages = [];
if (importedCount > 0) messages.push(`成功导入 ${importedCount} 个新预设。`);
if (overwrittenCount > 0) messages.push(`成功覆盖 ${overwrittenCount} 个同名预设。`);
toastr.success(messages.join(' '));
} else {
toastr.warning('未找到可导入的有效预设。');
}
} catch (error) {
console.error(`[${extensionName}] 导入预设失败:`, error);
toastr.error(`导入失败: ${error.message}`, '错误');
} finally {
panel.find('#amily2_opt_preset_file_input').val('');
}
};
reader.readAsText(file);
}
function opt_loadSettings(panel) {
const settings = opt_getMergedSettings();
panel.find('#amily2_opt_enabled').prop('checked', settings.plotOpt_enabled);
// Handle table enabled setting which can be boolean (legacy) or string
let tableEnabledValue = settings.plotOpt_tableEnabled;
if (tableEnabledValue === true) {
tableEnabledValue = 'main';
} else if (tableEnabledValue === false || tableEnabledValue === undefined) {
tableEnabledValue = 'disabled';
}
panel.find('#amily2_opt_table_enabled').val(tableEnabledValue);
panel.find('#amily2_opt_ejs_enabled').prop('checked', settings.plotOpt_ejsEnabled);
panel.find(`input[name="amily2_opt_worldbook_source"][value="${settings.plotOpt_worldbookSource || 'character'}"]`).prop('checked', true);
panel.find('#amily2_opt_worldbook_enabled').prop('checked', settings.plotOpt_worldbookEnabled);
panel.find('#amily2_opt_new_memory_logic_enabled').prop('checked', settings.plotOpt_newMemoryLogicEnabled);
panel.find('#amily2_opt_top_p').val(settings.plotOpt_top_p);
panel.find('#amily2_opt_presence_penalty').val(settings.plotOpt_presence_penalty);
panel.find('#amily2_opt_frequency_penalty').val(settings.plotOpt_frequency_penalty);
const contextLimit = settings.plotOpt_contextLimit ?? settings.plotOpt_contextTurnCount ?? defaultSettings.plotOpt_contextLimit;
panel.find('#amily2_opt_worldbook_char_limit').val(settings.plotOpt_worldbookCharLimit);
panel.find('#amily2_opt_context_limit').val(contextLimit);
panel.find('#amily2_opt_rate_main').val(settings.plotOpt_rateMain);
panel.find('#amily2_opt_rate_personal').val(settings.plotOpt_ratePersonal);
panel.find('#amily2_opt_rate_erotic').val(settings.plotOpt_rateErotic);
panel.find('#amily2_opt_rate_cuckold').val(settings.plotOpt_rateCuckold);
opt_loadPromptPresets(panel);
const lastUsedPresetName = settings.plotOpt_lastUsedPresetName;
const initFunc = panel.data('initAmily2PromptEditor');
if (initFunc) {
initFunc();
}
// After loading presets and initializing the editor, trigger a "light" change event
// to update UI elements like the delete button, without reloading all the data.
if (lastUsedPresetName && panel.find('#amily2_opt_prompt_preset_select').val() === lastUsedPresetName) {
setTimeout(() => {
panel.find('#amily2_opt_prompt_preset_select').trigger('change', { isAutomatic: true, noLoad: true });
}, 0);
}
opt_updateWorldbookSourceVisibility(panel, settings.plotOpt_worldbookSource || 'character');
opt_bindSlider(panel, '#amily2_opt_top_p', '#amily2_opt_top_p_value');
opt_bindSlider(panel, '#amily2_opt_presence_penalty', '#amily2_opt_presence_penalty_value');
opt_bindSlider(panel, '#amily2_opt_frequency_penalty', '#amily2_opt_frequency_penalty_value');
opt_bindSlider(panel, '#amily2_opt_worldbook_char_limit', '#amily2_opt_worldbook_char_limit_value');
opt_bindSlider(panel, '#amily2_opt_context_limit', '#amily2_opt_context_limit_value');
opt_loadWorldbooks(panel).then(() => {
opt_loadWorldbookEntries(panel);
});
}
function bindConcurrentApiEvents() {
const concurrentToggle = document.getElementById('amily2_plotOpt_concurrentEnabled');
const concurrentContent = document.getElementById('amily2_concurrent_content');
if (!concurrentToggle || !concurrentContent) return;
// plotOptConc 槽分配 profile 后maxTokens 由 profile 权威控制T-006 informational 化)
watchProfileSliderGuard('plotOptConc', ['#amily2_plotOpt_concurrentMaxTokens']);
const settings = extension_settings[extensionName] || {};
// Initial Load
concurrentToggle.checked = settings.plotOpt_concurrentEnabled ?? false;
concurrentContent.style.display = concurrentToggle.checked ? 'grid' : 'none';
const fields = [
{ id: 'amily2_plotOpt_concurrentApiProvider', key: 'plotOpt_concurrentApiProvider' },
{ id: 'amily2_plotOpt_concurrentApiUrl', key: 'plotOpt_concurrentApiUrl' },
{ id: 'amily2_plotOpt_concurrentApiKey', key: 'plotOpt_concurrentApiKey', sensitive: true },
{ id: 'amily2_plotOpt_concurrentModel', key: 'plotOpt_concurrentModel' }
];
fields.forEach(field => {
const element = document.getElementById(field.id);
if (element) {
// 敏感字段API Key从 configManagerlocalStorage读取
element.value = field.sensitive
? (configManager.get(field.key) || '')
: (settings[field.key] || '');
}
});
// Button Listeners
const testButton = document.getElementById('amily2_plotOpt_concurrent_test_connection');
if (testButton) {
testButton.addEventListener('click', async () => {
const button = $(testButton);
const originalHtml = button.html();
button.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> 测试中');
try {
await testConcurrentApiConnection();
} finally {
button.prop('disabled', false).html(originalHtml);
}
});
}
const fetchButton = document.getElementById('amily2_plotOpt_concurrent_fetch_models');
const modelInput = document.getElementById('amily2_plotOpt_concurrentModel');
const modelSelect = document.getElementById('amily2_plotOpt_concurrentModel_select');
if (fetchButton && modelInput && modelSelect) {
fetchButton.addEventListener('click', async () => {
const button = $(fetchButton);
const originalHtml = button.html();
button.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> 获取中');
try {
const models = await fetchConcurrentModels();
if (models && models.length > 0) {
modelSelect.innerHTML = '<option value="">-- 选择一个模型 --</option>';
models.forEach(model => {
const option = document.createElement('option');
option.value = model.id;
option.textContent = model.name;
if (model.id === modelInput.value) {
option.selected = true;
}
modelSelect.appendChild(option);
});
modelSelect.style.display = 'block';
modelInput.style.display = 'none';
toastr.success(`成功获取 ${models.length} 个并发模型`, '获取模型成功');
} else {
toastr.warning('未获取到任何并发模型。', '获取模型');
}
} catch (error) {
toastr.error(`获取并发模型失败: ${error.message}`, '获取模型失败');
} finally {
button.prop('disabled', false).html(originalHtml);
}
});
modelSelect.addEventListener('change', function() {
const selectedModel = this.value;
if (selectedModel) {
modelInput.value = selectedModel;
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
extension_settings[extensionName].plotOpt_concurrentModel = selectedModel;
saveSettingsDebounced();
}
});
}
// Event Listeners
concurrentToggle.addEventListener('change', function() {
const isEnabled = this.checked;
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
extension_settings[extensionName].plotOpt_concurrentEnabled = isEnabled;
saveSettingsDebounced();
concurrentContent.style.display = isEnabled ? 'grid' : 'none';
});
fields.forEach(field => {
const element = document.getElementById(field.id);
if (element) {
const saveField = function() {
if (field.sensitive) {
configManager.set(field.key, this.value);
} else {
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
extension_settings[extensionName][field.key] = this.value;
saveSettingsDebounced();
if (field.key === 'plotOpt_concurrentModel') {
syncModelMirror(
document.getElementById('amily2_plotOpt_concurrentModel'),
document.getElementById('amily2_plotOpt_concurrentModel_select')
);
}
}
};
bindInputLikeSave(element, saveField);
}
});
// Slider Bindings
const sliderFields = [
{ id: 'amily2_plotOpt_concurrentMaxTokens', key: 'plotOpt_concurrentMaxTokens', defaultValue: 8100 }
];
sliderFields.forEach(field => {
const slider = document.getElementById(field.id);
const display = document.getElementById(field.id + '_value');
if (slider && display) {
const value = settings[field.key] || field.defaultValue;
slider.value = value;
display.textContent = value;
slider.addEventListener('input', function() {
const newValue = parseInt(this.value, 10);
display.textContent = newValue;
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
extension_settings[extensionName][field.key] = newValue;
saveSettingsDebounced();
});
}
});
}
function bindConcurrentPromptEvents() {
const panel = $('#sinan-prompt-settings-tab');
if (panel.length === 0) return;
const selector = panel.find('#amily2_concurrent_prompt_selector');
const editor = panel.find('#amily2_concurrent_prompt_editor');
const resetButton = panel.find('#amily2_opt_reset_concurrent_prompt');
const promptMap = {
main: 'plotOpt_concurrentMainPrompt',
system: 'plotOpt_concurrentSystemPrompt'
};
function updateConcurrentEditor() {
const settings = extension_settings[extensionName] || {};
const selectedKey = selector.val();
const settingKey = promptMap[selectedKey];
editor.val(settings[settingKey] || '');
}
// Initial load
updateConcurrentEditor();
// Event Listeners
selector.on('change', updateConcurrentEditor);
editor.on('input', function() {
const selectedKey = selector.val();
const settingKey = promptMap[selectedKey];
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
extension_settings[extensionName][settingKey] = $(this).val();
saveSettingsDebounced();
});
resetButton.on('click', function() {
const selectedKey = selector.val();
const settingKey = promptMap[selectedKey];
const defaultValue = defaultSettings[settingKey] || '';
if (confirm(`您确定要将 "${selector.find('option:selected').text()}" 恢复为默认值吗?`)) {
editor.val(defaultValue);
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
extension_settings[extensionName][settingKey] = defaultValue;
saveSettingsDebounced();
toastr.success('并发提示词已成功恢复为默认值。');
}
});
}
function opt_loadConcurrentWorldbookSettings() {
const panel = $('#amily2_plot_optimization_panel');
if (panel.length === 0) return;
const settings = extension_settings[extensionName] || {};
const enabledCheckbox = panel.find('#amily2_plotOpt_concurrentWorldbookEnabled');
const sourceRadios = panel.find('input[name="amily2_plotOpt_concurrentWorldbook_source"]');
const charLimitSlider = panel.find('#amily2_plotOpt_concurrentWorldbookCharLimit');
const charLimitValue = panel.find('#amily2_plotOpt_concurrentWorldbookCharLimit_value');
enabledCheckbox.prop('checked', settings.plotOpt_concurrentWorldbookEnabled ?? true);
const currentSource = settings.plotOpt_concurrentWorldbookSource || 'character';
panel.find(`input[name="amily2_plotOpt_concurrentWorldbook_source"][value="${currentSource}"]`).prop('checked', true);
charLimitSlider.val(settings.plotOpt_concurrentWorldbookCharLimit || 60000);
charLimitValue.text(charLimitSlider.val());
// This will also trigger the visibility update
enabledCheckbox.trigger('change');
}
function bindConcurrentWorldbookEvents() {
const panel = $('#amily2_plot_optimization_panel');
if (panel.length === 0) return;
const settings = extension_settings[extensionName] || {};
const enabledCheckbox = panel.find('#amily2_plotOpt_concurrentWorldbookEnabled');
const contentDiv = panel.find('#amily2_concurrent_worldbook_content');
const sourceRadios = panel.find('input[name="amily2_plotOpt_concurrentWorldbook_source"]');
const manualSelectWrapper = panel.find('#amily2_plotOpt_concurrent_worldbook_select_wrapper');
const refreshButton = panel.find('#amily2_plotOpt_concurrent_refresh_worldbooks');
const bookListContainer = panel.find('#amily2_plotOpt_concurrent_worldbook_checkbox_list');
const charLimitSlider = panel.find('#amily2_plotOpt_concurrentWorldbookCharLimit');
const charLimitValue = panel.find('#amily2_plotOpt_concurrentWorldbookCharLimit_value');
function updateVisibility() {
const isEnabled = enabledCheckbox.is(':checked');
contentDiv.css('display', isEnabled ? 'block' : 'none');
if (isEnabled) {
const source = panel.find('input[name="amily2_plotOpt_concurrentWorldbook_source"]:checked').val();
manualSelectWrapper.css('display', source === 'manual' ? 'block' : 'none');
}
}
async function loadConcurrentWorldbooks() {
bookListContainer.html('<p class="notes">加载中...</p>');
try {
const lorebooks = await safeLorebooks();
bookListContainer.empty();
if (!lorebooks || lorebooks.length === 0) {
bookListContainer.html('<p class="notes">未找到世界书。</p>');
return;
}
const selectedBooks = settings.plotOpt_concurrentSelectedWorldbooks || [];
const autoSelectedBooks = settings.plotOpt_concurrentAutoSelectWorldbooks || [];
lorebooks.forEach(name => {
const bookId = `amily2-opt-concurrent-wb-check-${name.replace(/[^a-zA-Z0-9]/g, '-')}`;
const autoId = `amily2-opt-concurrent-wb-auto-${name.replace(/[^a-zA-Z0-9]/g, '-')}`;
const isChecked = selectedBooks.includes(name);
const isAuto = autoSelectedBooks.includes(name);
const item = $(`
<div class="amily2_opt_worldbook_list_item" style="display: flex; align-items: center; justify-content: space-between; padding-right: 5px;">
<div style="display: flex; align-items: center;">
<input type="checkbox" id="${bookId}" value="${name}" ${isChecked ? 'checked' : ''} style="margin-right: 5px;">
<label for="${bookId}" style="margin-bottom: 0;">${name}</label>
</div>
<div style="display: flex; align-items: center;" title="开启后自动加载该世界书所有条目(包括新增)">
<input type="checkbox" class="amily2_opt_concurrent_wb_auto_check" id="${autoId}" data-book="${name}" ${isAuto ? 'checked' : ''} style="margin-right: 5px;">
<label for="${autoId}" style="margin-bottom: 0; font-size: 0.9em; opacity: 0.8; cursor: pointer;">全选</label>
</div>
</div>
`);
bookListContainer.append(item);
});
} catch (error) {
console.error(`[${extensionName}] 加载并发世界书失败:`, error);
bookListContainer.html('<p class="notes" style="color:red;">加载世界书列表失败。</p>');
}
}
// Initial State is now handled by opt_loadConcurrentWorldbookSettings
updateVisibility();
if (panel.find('input[name="amily2_plotOpt_concurrentWorldbook_source"]:checked').val() === 'manual') {
loadConcurrentWorldbooks();
}
// Event Listeners
enabledCheckbox.on('change', function() {
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
extension_settings[extensionName].plotOpt_concurrentWorldbookEnabled = this.checked;
saveSettingsDebounced();
updateVisibility();
});
sourceRadios.on('change', function() {
if (this.checked) {
const source = $(this).val();
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
extension_settings[extensionName].plotOpt_concurrentWorldbookSource = source;
saveSettingsDebounced();
updateVisibility();
if (source === 'manual') {
loadConcurrentWorldbooks();
}
}
});
refreshButton.on('click', loadConcurrentWorldbooks);
bookListContainer.on('change', 'input[type="checkbox"]:not(.amily2_opt_concurrent_wb_auto_check)', function() {
const selected = [];
bookListContainer.find('input[type="checkbox"]:not(.amily2_opt_concurrent_wb_auto_check):checked').each(function() {
selected.push($(this).val());
});
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
extension_settings[extensionName].plotOpt_concurrentSelectedWorldbooks = selected;
saveSettingsDebounced();
});
bookListContainer.on('change', '.amily2_opt_concurrent_wb_auto_check', function() {
const autoSelected = [];
bookListContainer.find('.amily2_opt_concurrent_wb_auto_check:checked').each(function() {
autoSelected.push($(this).data('book'));
});
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
extension_settings[extensionName].plotOpt_concurrentAutoSelectWorldbooks = autoSelected;
saveSettingsDebounced();
});
charLimitSlider.on('input', function() {
const value = $(this).val();
charLimitValue.text(value);
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
extension_settings[extensionName].plotOpt_concurrentWorldbookCharLimit = parseInt(value, 10);
saveSettingsDebounced();
});
}
function opt_purgeGarbageKeys() {
const store = extension_settings[extensionName];
if (!store) return;
let removed = 0;
for (const key of Object.keys(store)) {
// 历史 bug 造成的污染 keyhandleSettingChange 误把世界书/条目复选框当作设置项,
// 生成形如 plotOpt_amily2-opt-wb-*、plotOpt_amily2-opt-entry-*、plotOpt_amily2-opt-concurrent-wb-* 的键
if (/^plotOpt_amily2-opt-/.test(key)) {
delete store[key];
removed++;
}
}
if (removed > 0) {
console.log(`[${extensionName}] 清理残留的 ${removed} 条无效 plotOpt_* 设置键。`);
saveSettingsDebounced();
}
}
// ========== 自定义记忆块memory-blocks Phase 2 ==========
// 该面板管理的块固定挂在剧情优化流水线;其他 context如战斗系统由各自模块注册
const MEMORY_BLOCK_CONTEXT = 'plotOptimization';
function opt_renderCustomBlocks(panel) {
const list = panel.find('#amily2_opt_custom_blocks_list');
if (list.length === 0) return;
const blocks = listCustomBlocks(MEMORY_BLOCK_CONTEXT);
if (blocks.length === 0) {
list.html('<div style="opacity: 0.6; font-style: italic; padding: 4px 0;">尚无自定义块。</div>');
return;
}
const rows = blocks.map(b => {
const typeBadge = b.generator?.type === 'ai_call'
? '<span style="font-size: 0.8em; padding: 1px 6px; border-radius: 3px; background: rgba(88,166,255,0.25);">AI 调用</span>'
: '<span style="font-size: 0.8em; padding: 1px 6px; border-radius: 3px; background: rgba(120,200,120,0.25);">静态</span>';
return `
<div class="amily2-custom-block-row" data-id="${escapeHTML(b.id)}" style="display: flex; align-items: center; gap: 8px; padding: 6px 8px; margin-bottom: 4px; background: rgba(0,0,0,0.15); border-radius: 4px;">
<input type="checkbox" class="mb-enabled" ${b.enabled !== false ? 'checked' : ''} title="启用/停用此块">
<span style="flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
<b>${escapeHTML(b.name || '(未命名)')}</b>
<code style="margin-left: 6px;">${escapeHTML(b.placeholder)}</code>
</span>
${typeBadge}
<button class="menu_button mb-edit" title="编辑" style="padding: 2px 8px;"><i class="fa-solid fa-pen"></i></button>
<button class="menu_button mb-delete" title="删除" style="padding: 2px 8px;"><i class="fa-solid fa-trash-alt"></i></button>
</div>`;
});
list.html(rows.join(''));
}
function opt_showCustomBlockModal(panel, blockId) {
const existing = blockId ? getCustomBlock(blockId) : null;
const gen = existing?.generator || {};
const isAiCall = gen.type === 'ai_call';
// API 槽下拉:仅列出 chat 类型功能槽
const slotOptions = Object.entries(SLOTS)
.filter(([, def]) => def.type === 'chat')
.map(([key, def]) => `<option value="${key}" ${key === (gen.apiSlot || 'main') ? 'selected' : ''}>${escapeHTML(def.label)} (${key})</option>`)
.join('');
const formHtml = `
<div class="amily2-mb-form" style="display: flex; flex-direction: column; gap: 12px;">
<label>显示名称
<input id="mb_name" class="text_pole" type="text" value="${escapeHTML(existing?.name || '')}" placeholder="例如:战况摘要">
</label>
<label>占位符(在主/拦截提示词中按字面量匹配替换)
<input id="mb_placeholder" class="text_pole" type="text" value="${escapeHTML(existing?.placeholder || '')}" placeholder="例如:{{combat_state}} 或 myBlock1">
</label>
<label>生成方式
<select id="mb_type" class="text_pole">
<option value="static" ${!isAiCall ? 'selected' : ''}>静态内容</option>
<option value="ai_call" ${isAiCall ? 'selected' : ''}>AI 调用</option>
</select>
</label>
<div id="mb_static_fields" style="${isAiCall ? 'display: none;' : ''}">
<label>静态内容
<textarea id="mb_static_value" class="text_pole" rows="4">${escapeHTML(gen.value !== undefined ? String(gen.value) : '')}</textarea>
</label>
</div>
<div id="mb_ai_fields" style="display: ${isAiCall ? 'flex' : 'none'}; flex-direction: column; gap: 12px;">
<label>API 槽(使用该功能槽的连接配置独立请求一次)
<select id="mb_api_slot" class="text_pole">${slotOptions}</select>
</label>
<label>系统提示词(可选)
<textarea id="mb_system_prompt" class="text_pole" rows="3">${escapeHTML(gen.systemPrompt || '')}</textarea>
</label>
<label>用户提示词(必填)
<textarea id="mb_prompt_template" class="text_pole" rows="5">${escapeHTML(gen.promptTemplate || '')}</textarea>
</label>
<label>提取标签(可选,只取回复中 &lt;标签&gt;...&lt;/标签&gt; 的内容;标签缺失时回退完整回复)
<input id="mb_extract_tag" class="text_pole" type="text" value="${escapeHTML(gen.extractTag || '')}" placeholder="例如result">
</label>
</div>
</div>`;
showHtmlModal(existing ? '编辑记忆块' : '新增记忆块', formHtml, {
okText: '保存',
onShow: (dialog) => {
dialog.find('#mb_type').on('change', function () {
const aiMode = $(this).val() === 'ai_call';
dialog.find('#mb_static_fields').toggle(!aiMode);
dialog.find('#mb_ai_fields').css('display', aiMode ? 'flex' : 'none');
});
},
onOk: (dialog) => {
const placeholder = String(dialog.find('#mb_placeholder').val() || '').trim();
if (!placeholder) {
toastr.warning('占位符不能为空。');
return false;
}
const conflict = listCustomBlocks(MEMORY_BLOCK_CONTEXT)
.find(b => b.placeholder === placeholder && b.id !== blockId);
if (conflict) {
toastr.warning(`占位符 "${placeholder}" 已被块 "${conflict.name || conflict.id}" 占用。`);
return false;
}
const type = dialog.find('#mb_type').val();
let generator;
if (type === 'ai_call') {
const promptTemplate = String(dialog.find('#mb_prompt_template').val() || '');
if (!promptTemplate.trim()) {
toastr.warning('AI 调用块的用户提示词不能为空。');
return false;
}
generator = {
type: 'ai_call',
apiSlot: dialog.find('#mb_api_slot').val() || 'main',
promptTemplate,
};
const systemPrompt = String(dialog.find('#mb_system_prompt').val() || '');
if (systemPrompt.trim()) generator.systemPrompt = systemPrompt;
const extractTag = String(dialog.find('#mb_extract_tag').val() || '').trim();
if (extractTag) generator.extractTag = extractTag;
} else {
generator = { type: 'static', value: String(dialog.find('#mb_static_value').val() || '') };
}
const patch = {
name: String(dialog.find('#mb_name').val() || '').trim(),
placeholder,
context: MEMORY_BLOCK_CONTEXT,
generator,
};
try {
if (existing) {
updateCustomBlock(blockId, patch);
toastr.success('记忆块已更新。');
} else {
addCustomBlock(patch);
toastr.success('记忆块已创建。');
}
} catch (error) {
toastr.error(`保存失败: ${error.message}`);
return false;
}
opt_renderCustomBlocks(panel);
},
});
}
function bindCustomBlockEvents(panel) {
// settings → registry 重放一次,确保面板与执行器看到同一份块清单
syncCustomBlocksFromSettings();
opt_renderCustomBlocks(panel);
panel.on('click', '#amily2_opt_add_custom_block', () => opt_showCustomBlockModal(panel, null));
panel.on('click', '.amily2-custom-block-row .mb-edit', function () {
opt_showCustomBlockModal(panel, $(this).closest('[data-id]').attr('data-id'));
});
panel.on('click', '.amily2-custom-block-row .mb-delete', function () {
const row = $(this).closest('[data-id]');
const id = row.attr('data-id');
const block = getCustomBlock(id);
if (!confirm(`确定删除记忆块 "${block?.name || id}"`)) return;
deleteCustomBlock(id);
opt_renderCustomBlocks(panel);
toastr.success('记忆块已删除。');
});
panel.on('change', '.amily2-custom-block-row .mb-enabled', function () {
const id = $(this).closest('[data-id]').attr('data-id');
updateCustomBlock(id, { enabled: this.checked });
});
}
export function initializePlotOptimizationBindings() {
const panel = $('#amily2_plot_optimization_panel');
if (panel.length === 0 || panel.data('events-bound')) {
return;
}
opt_purgeGarbageKeys();
// Tab switching logic
panel.find('.sinan-navigation-deck').on('click', '.sinan-nav-item', function() {
const tabButton = $(this);
const tabName = tabButton.data('tab');
const contentWrapper = panel.find('.sinan-content-wrapper');
// Deactivate all tabs and panes
panel.find('.sinan-nav-item').removeClass('active');
contentWrapper.find('.sinan-tab-pane').removeClass('active');
// Activate the clicked tab and corresponding pane
tabButton.addClass('active');
contentWrapper.find(`#sinan-${tabName}-tab`).addClass('active');
});
// Unified prompt editor logic
function updateEditorFromCache() {
const selectedPrompt = panel.find('#amily2_opt_prompt_selector').val();
if (selectedPrompt) {
panel.find('#amily2_opt_prompt_editor').val(promptCache[selectedPrompt]);
}
}
// Make it available for opt_loadSettings
panel.data('initAmily2PromptEditor', function() {
const settings = opt_getMergedSettings();
const lastUsedPresetName = settings.plotOpt_lastUsedPresetName;
const presets = settings.promptPresets || [];
const lastUsedPreset = presets.find(p => p.name === lastUsedPresetName);
if (lastUsedPreset) {
// If a valid preset was last used, load its data into the cache
promptCache.main = lastUsedPreset.mainPrompt || defaultSettings.plotOpt_mainPrompt;
promptCache.system = lastUsedPreset.systemPrompt || defaultSettings.plotOpt_systemPrompt;
promptCache.final_system = lastUsedPreset.finalSystemDirective || defaultSettings.plotOpt_finalSystemDirective;
} else {
// Otherwise, load from the base settings (non-preset values)
promptCache.main = settings.plotOpt_mainPrompt || defaultSettings.plotOpt_mainPrompt;
promptCache.system = settings.plotOpt_systemPrompt || defaultSettings.plotOpt_systemPrompt;
promptCache.final_system = settings.plotOpt_finalSystemDirective || defaultSettings.plotOpt_finalSystemDirective;
}
updateEditorFromCache();
panel.find('#amily2_opt_prompt_editor').data('current-prompt', panel.find('#amily2_opt_prompt_selector').val());
});
panel.on('change', '#amily2_opt_prompt_selector', function() {
const previousPromptKey = panel.find('#amily2_opt_prompt_editor').data('current-prompt');
if (previousPromptKey) {
const previousValue = panel.find('#amily2_opt_prompt_editor').val();
promptCache[previousPromptKey] = previousValue;
const keyMap = {
main: 'plotOpt_mainPrompt',
system: 'plotOpt_systemPrompt',
final_system: 'plotOpt_finalSystemDirective'
};
opt_saveSetting(keyMap[previousPromptKey], previousValue);
}
const selectedPrompt = $(this).val();
panel.find('#amily2_opt_prompt_editor').val(promptCache[selectedPrompt]);
panel.find('#amily2_opt_prompt_editor').data('current-prompt', selectedPrompt);
});
panel.on('input', '#amily2_opt_prompt_editor', function() {
const currentPrompt = panel.find('#amily2_opt_prompt_selector').val();
const currentValue = $(this).val();
promptCache[currentPrompt] = currentValue;
const keyMap = {
main: 'plotOpt_mainPrompt',
system: 'plotOpt_systemPrompt',
final_system: 'plotOpt_finalSystemDirective'
};
opt_saveSetting(keyMap[currentPrompt], currentValue);
});
panel.on('click', '#amily2_opt_reset_main_prompt', function() {
const defaultValue = defaultSettings.plotOpt_mainPrompt;
promptCache.main = defaultValue;
updateEditorFromCache();
opt_saveSetting('plotOpt_mainPrompt', defaultValue);
toastr.info('主提示词已恢复为默认值。');
});
panel.on('click', '#amily2_opt_reset_system_prompt', function() {
const defaultValue = defaultSettings.plotOpt_systemPrompt;
promptCache.system = defaultValue;
updateEditorFromCache();
opt_saveSetting('plotOpt_systemPrompt', defaultValue);
toastr.info('拦截任务指令已恢复为默认值。');
});
panel.on('click', '#amily2_opt_reset_final_system_directive', function() {
const defaultValue = defaultSettings.plotOpt_finalSystemDirective;
promptCache.final_system = defaultValue;
updateEditorFromCache();
opt_saveSetting('plotOpt_finalSystemDirective', defaultValue);
toastr.info('最终注入指令已恢复为默认值。');
});
opt_loadSettings(panel);
bindCustomBlockEvents(panel);
bindJqyhApiEvents();
bindConcurrentApiEvents();
bindConcurrentPromptEvents();
opt_loadConcurrentWorldbookSettings(); // Load settings
bindConcurrentWorldbookEvents(); // Then bind events
eventSource.on(event_types.CHAT_CHANGED, () => {
console.log(`[${extensionName}] 检测到角色/聊天切换正在刷新剧情优化设置UI...`);
opt_loadSettings(panel);
});
const refreshWorldbookUI = () => {
if (panel.is(':visible')) {
console.log(`[${extensionName}] 检测到世界书变更,正在刷新列表...`);
opt_loadWorldbooks(panel).then(() => {
opt_loadWorldbookEntries(panel);
});
}
};
eventSource.on(event_types.WORLDINFO_UPDATED, refreshWorldbookUI);
// 尝试监听更多可能的世界书事件,确保第一时间更新
if (event_types.WORLDINFO_ENTRY_UPDATED) eventSource.on(event_types.WORLDINFO_ENTRY_UPDATED, refreshWorldbookUI);
if (event_types.WORLDINFO_ENTRY_CREATED) eventSource.on(event_types.WORLDINFO_ENTRY_CREATED, refreshWorldbookUI);
if (event_types.WORLDINFO_ENTRY_DELETED) eventSource.on(event_types.WORLDINFO_ENTRY_DELETED, refreshWorldbookUI);
const handleSettingChange = function(element) {
const el = $(element);
const rawName = element.name || element.id || '';
// 仅处理下划线前缀的真实设置项;动态生成的世界书/条目复选框用连字符命名amily2-opt-wb-*、amily2-opt-entry-*
// 它们有自己的专属 handler若被此处捕获会生成 plotOpt_amily2-opt-... 的垃圾 key 污染 settings
if (!rawName.startsWith('amily2_opt_')) return;
const key_part = rawName.replace('amily2_opt_', '');
const key = 'plotOpt_' + key_part.replace(/_([a-z])/g, (g) => g[1].toUpperCase());
let value = element.type === 'checkbox' ? element.checked : el.val();
if (key === 'plotOpt_selected_worldbooks' && !Array.isArray(value)) {
value = el.val() || [];
}
const floatKeys = ['plotOpt_temperature', 'plotOpt_top_p', 'plotOpt_presence_penalty', 'plotOpt_frequency_penalty', 'plotOpt_rateMain', 'plotOpt_ratePersonal', 'plotOpt_rateErotic', 'plotOpt_rateCuckold'];
if (floatKeys.includes(key) && value !== '') {
value = parseFloat(value);
} else if (element.type === 'range' || element.type === 'number') {
if (value !== '') value = parseInt(value, 10);
}
if (value !== '' || element.type === 'checkbox') {
opt_saveSetting(key, value);
}
if (element.name === 'amily2_opt_worldbook_source') {
opt_updateWorldbookSourceVisibility(panel, value);
opt_loadWorldbookEntries(panel);
}
};
const allInputSelectors = [
'input[type="checkbox"]', 'input[type="radio"]', 'select',
'input[type="text"]', 'input[type="password"]', 'textarea',
'input[type="range"]', 'input[type="number"]'
].join(', ');
panel.on('input.amily2_opt change.amily2_opt', allInputSelectors, function() {
handleSettingChange(this);
});
panel.find('#amily2_opt_import_prompt_presets').on('click', () => panel.find('#amily2_opt_preset_file_input').click());
panel.find('#amily2_opt_export_prompt_presets').on('click', () => opt_exportPromptPresets());
panel.find('#amily2_opt_save_prompt_preset').on('click', () => opt_saveCurrentPromptsAsPreset(panel));
panel.find('#amily2_opt_delete_prompt_preset').on('click', () => opt_deleteSelectedPreset(panel));
panel.on('change.amily2_opt', '#amily2_opt_preset_file_input', function(e) {
opt_importPromptPresets(e.target.files[0], panel);
});
panel.on('change.amily2_opt', '#amily2_opt_prompt_preset_select', function(event, data) {
const selectedName = $(this).val();
const deleteBtn = panel.find('#amily2_opt_delete_prompt_preset');
const isAutomatic = data && data.isAutomatic;
const noLoad = data && data.noLoad;
console.log('[Amily2-Debug] Preset select changed:', selectedName, 'isAutomatic:', isAutomatic, 'noLoad:', noLoad);
opt_saveSetting('plotOpt_lastUsedPresetName', selectedName);
console.log('[Amily2-Debug] After saving, extension_settings contains:', extension_settings[extensionName]?.plotOpt_lastUsedPresetName);
// On initial load, we might not need to reload all the data, just update the UI state.
if (noLoad) {
if (selectedName) deleteBtn.show();
else deleteBtn.hide();
return;
}
if (!selectedName) {
deleteBtn.hide();
opt_saveSetting('lastUsedPresetName', '');
return;
}
const presets = extension_settings[extensionName]?.promptPresets || [];
const selectedPreset = presets.find(p => p.name === selectedName);
if (selectedPreset) {
// Update cache with preset values
promptCache.main = selectedPreset.mainPrompt || defaultSettings.plotOpt_mainPrompt;
promptCache.system = selectedPreset.systemPrompt || defaultSettings.plotOpt_systemPrompt;
promptCache.final_system = selectedPreset.finalSystemDirective || defaultSettings.plotOpt_finalSystemDirective;
// Update the editor to show the content of the currently selected prompt type
const initFunc = panel.data('initAmily2PromptEditor');
if (initFunc) {
initFunc();
}
// Save the new prompt values to the main settings
opt_saveSetting('plotOpt_mainPrompt', promptCache.main);
opt_saveSetting('plotOpt_systemPrompt', promptCache.system);
opt_saveSetting('plotOpt_finalSystemDirective', promptCache.final_system);
// Also load and save concurrent prompts
const concurrentMain = selectedPreset.concurrentMainPrompt || defaultSettings.plotOpt_concurrentMainPrompt;
const concurrentSystem = selectedPreset.concurrentSystemPrompt || defaultSettings.plotOpt_concurrentSystemPrompt;
opt_saveSetting('plotOpt_concurrentMainPrompt', concurrentMain);
opt_saveSetting('plotOpt_concurrentSystemPrompt', concurrentSystem);
// Trigger UI update for concurrent editor
const concurrentEditor = panel.find('#amily2_concurrent_prompt_editor');
const concurrentSelector = panel.find('#amily2_concurrent_prompt_selector');
if (concurrentSelector.val() === 'main') {
concurrentEditor.val(concurrentMain);
} else {
concurrentEditor.val(concurrentSystem);
}
panel.find('#amily2_opt_rate_main').val(selectedPreset.rateMain ?? 1.0).trigger('change');
panel.find('#amily2_opt_rate_personal').val(selectedPreset.ratePersonal ?? 1.0).trigger('change');
panel.find('#amily2_opt_rate_erotic').val(selectedPreset.rateErotic ?? 1.0).trigger('change');
panel.find('#amily2_opt_rate_cuckold').val(selectedPreset.rateCuckold ?? 1.0).trigger('change');
if (!isAutomatic) {
toastr.success(`已加载预设 "${selectedName}"。`);
}
deleteBtn.show();
} else {
deleteBtn.hide();
}
});
panel.data('events-bound', true);
console.log(`[${extensionName}] 剧情优化UI事件已成功绑定自动保存已激活。`);
panel.on('click.amily2_opt', '#amily2_opt_refresh_worldbooks', () => {
opt_loadWorldbooks(panel).then(() => {
opt_loadWorldbookEntries(panel);
});
});
// Manual Selection Change
panel.on('change.amily2_opt', '#amily2_opt_worldbook_checkbox_list input[type="checkbox"]:not(.amily2_opt_wb_auto_check)', async function() {
const selected = [];
panel.find('#amily2_opt_worldbook_checkbox_list input[type="checkbox"]:not(.amily2_opt_wb_auto_check):checked').each(function() {
selected.push($(this).val());
});
await opt_saveSetting('plotOpt_selectedWorldbooks', selected);
await opt_loadWorldbookEntries(panel);
});
// Auto Selection Change
panel.on('change.amily2_opt', '#amily2_opt_worldbook_checkbox_list input.amily2_opt_wb_auto_check', async function() {
const autoSelected = [];
panel.find('#amily2_opt_worldbook_checkbox_list input.amily2_opt_wb_auto_check:checked').each(function() {
autoSelected.push($(this).data('book'));
});
await opt_saveSetting('plotOpt_autoSelectWorldbooks', autoSelected);
await opt_loadWorldbookEntries(panel);
});
panel.on('change.amily2_opt', '#amily2_opt_worldbook_entry_list_container input[type="checkbox"]', () => {
opt_saveEnabledEntries();
});
panel.on('click.amily2_opt', '#amily2_opt_worldbook_entry_select_all', () => {
panel.find('#amily2_opt_worldbook_entry_list_container input[type="checkbox"]').prop('checked', true);
opt_saveEnabledEntries();
});
panel.on('click.amily2_opt', '#amily2_opt_worldbook_entry_deselect_all', () => {
panel.find('#amily2_opt_worldbook_entry_list_container input[type="checkbox"]').prop('checked', false);
opt_saveEnabledEntries();
});
}
// ========== Jqyh API 事件绑定函数(已迁移至 plotOpt 槽位,此处仅保留空壳) ==========
function bindJqyhApiEvents() {
// Jqyh 直连配置已移除,剧情优化统一走 ApiProfile plotOpt 槽位
}
// ========== 图标位置切换(跨模块通用事件) ==========
$(document).on('change', 'input[name="amily2_icon_location"]', function() {
if (!pluginAuthStatus.authorized) return;
const newLocation = $(this).val();
extension_settings[extensionName]['iconLocation'] = newLocation;
saveSettingsDebounced();
console.log(`[Amily-禁卫军] 收到迁都指令 -> ${newLocation}。圣意已存档。`);
toastr.info(`正在将帝国徽记迁往 [${newLocation === 'topbar' ? '顶栏' : '扩展区'}]...`, "迁都令", { timeOut: 2000 });
$('#amily2_main_drawer').remove();
$(document).off("mousedown.amily2Drawer");
$('#amily2_extension_frame').remove();
setTimeout(createDrawer, 50);
});