feat: v0.5.0 - 总结世界书拆分优化、Part调试面板、Amily表格并发等

主要更新:
- 总结世界书并发拆分功能(自动检测约5万字拆分为Part)
- Part调试面板
- Amily表格并发填充模块(src/table-filler/)
- 合并去重开关
- 内置默认独立模板
- 多主题支持优化
- 添加.gitignore排除不必要文件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-27 01:46:18 +08:00
parent e78cd230d9
commit 6078f85d06
46 changed files with 10778 additions and 842 deletions

View File

@@ -372,10 +372,10 @@ export async function callOpenAIWithMessages(
headers["Authorization"] = `Bearer ${apiKey}`;
}
const fullMessages = [
{ role: "system", content: systemPrompt },
...messages,
];
// 构建消息列表,如果 systemPrompt 为空则不添加
const fullMessages = systemPrompt
? [{ role: "system", content: systemPrompt }, ...messages]
: [...messages];
const response = await fetch(apiUrl, {
method: "POST",

View File

@@ -190,9 +190,12 @@ export function clearOldData(maxAgeMs = OLD_DATA_MAX_AGE_MS) {
const preserved = {
memoryConfigs: structuredClone(config?.memoryConfigs || {}),
summaryConfigs: structuredClone(config?.summaryConfigs || {}),
summaryPartConfigs: structuredClone(config?.summaryPartConfigs || {}),
summaryAutoSplit: structuredClone(config?.global?.summaryAutoSplit || {}),
indexMergeConfig: structuredClone(config?.global?.indexMergeConfig || {}),
plotOptimizeConfig: structuredClone(config?.global?.plotOptimizeConfig || {}),
providers: structuredClone(config?.global?.multiAIGeneration?.providers || []),
tableFillerConfig: structuredClone(config?.global?.tableFillerConfig || {}),
};
// 保留完整的 API 配置字段(包括 enabled 等)
@@ -244,9 +247,51 @@ export function clearOldData(maxAgeMs = OLD_DATA_MAX_AGE_MS) {
const newConfig = structuredClone(defaultConfig);
newConfig.memoryConfigs = preserved.memoryConfigs;
newConfig.summaryConfigs = preserved.summaryConfigs;
newConfig.summaryPartConfigs = preserved.summaryPartConfigs;
newConfig.global.summaryAutoSplit = preserved.summaryAutoSplit;
newConfig.global.indexMergeConfig = pickApiFields(preserved.indexMergeConfig, newConfig.global.indexMergeConfig);
newConfig.global.plotOptimizeConfig = pickApiFields(preserved.plotOptimizeConfig, newConfig.global.plotOptimizeConfig);
newConfig.global.multiAIGeneration.providers = sanitizedProviders;
// 恢复表格填表并发配置(保留 API 配置)
if (preserved.tableFillerConfig) {
const tableFillerApiFields = ["apiFormat", "apiUrl", "apiKey", "model", "maxTokens", "temperature", "customTemplate", "responsePath"];
const sanitizedTableFillerConfig = {
enabled: preserved.tableFillerConfig.enabled ?? false,
callMode: preserved.tableFillerConfig.callMode ?? "auto",
promptMode: "shared", // 提示词模式重置为共享(清除预设关联)
retryCount: preserved.tableFillerConfig.retryCount ?? 2,
retryDelay: preserved.tableFillerConfig.retryDelay ?? 2000,
importedPreset: null, // 清除导入的预设
defaultApi: {},
tableApiConfigs: {},
};
// 保留默认 API 配置
if (preserved.tableFillerConfig.defaultApi) {
for (const f of tableFillerApiFields) {
if (Object.hasOwn(preserved.tableFillerConfig.defaultApi, f)) {
sanitizedTableFillerConfig.defaultApi[f] = preserved.tableFillerConfig.defaultApi[f];
}
}
}
// 保留各表格独立 API 配置
if (preserved.tableFillerConfig.tableApiConfigs) {
for (const [tableName, tableConfig] of Object.entries(preserved.tableFillerConfig.tableApiConfigs)) {
sanitizedTableFillerConfig.tableApiConfigs[tableName] = {};
for (const f of tableFillerApiFields) {
if (Object.hasOwn(tableConfig, f)) {
sanitizedTableFillerConfig.tableApiConfigs[tableName][f] = tableConfig[f];
}
}
// 保留 useDefault 标记
if (Object.hasOwn(tableConfig, "useDefault")) {
sanitizedTableFillerConfig.tableApiConfigs[tableName].useDefault = tableConfig.useDefault;
}
}
}
newConfig.global.tableFillerConfig = sanitizedTableFillerConfig;
}
saveConfig(newConfig);
// localStorage 旧数据清理(无时间戳的也视为旧)
@@ -574,3 +619,569 @@ export function setMultiAIEnabled(enabled) {
multiAI.enabled = enabled;
saveMultiAIConfig(multiAI);
}
// ============================================================================
// 表格填表并发配置管理
// ============================================================================
/**
* 获取表格填表配置
* @returns {object} 表格填表配置对象
*/
export function getTableFillerConfig() {
const config = loadConfig();
const tableFillerConfig = config?.global?.tableFillerConfig;
if (!tableFillerConfig) {
return {
enabled: false,
callMode: "auto",
promptMode: "shared",
retryCount: 2,
retryDelay: 2000,
importedPreset: null,
defaultApi: {},
tableApiConfigs: {},
independentTemplates: {},
independentTagName: "Instructions for filling out the form",
};
}
// 确保 retryCount 有默认值
if (tableFillerConfig.retryCount === undefined) {
tableFillerConfig.retryCount = 2;
}
// 确保 retryDelay 有默认值
if (tableFillerConfig.retryDelay === undefined) {
tableFillerConfig.retryDelay = 2000;
}
// 确保 independentTemplates 有默认值
if (!tableFillerConfig.independentTemplates) {
tableFillerConfig.independentTemplates = {};
}
// 确保 independentTagName 有默认值
if (!tableFillerConfig.independentTagName) {
tableFillerConfig.independentTagName = "Instructions for filling out the form";
}
return tableFillerConfig;
}
/**
* 检查表格填表功能是否启用
* @returns {boolean}
*/
export function isTableFillerEnabled() {
const tableFillerConfig = getTableFillerConfig();
return tableFillerConfig?.enabled === true;
}
/**
* 检查调试模式是否启用
* @returns {boolean}
*/
export function isDebugModeEnabled() {
const tableFillerConfig = getTableFillerConfig();
return tableFillerConfig?.debugMode === true;
}
/**
* 保存表格填表配置
* @param {object} tableFillerConfig 表格填表配置
*/
export function saveTableFillerConfig(tableFillerConfig) {
const config = loadConfig();
if (!config.global) config.global = {};
config.global.tableFillerConfig = tableFillerConfig;
saveConfig(config);
}
/**
* 更新表格填表配置的部分字段
* @param {object} updates 要更新的字段
*/
export function updateTableFillerConfig(updates) {
const tableFillerConfig = getTableFillerConfig();
const newConfig = { ...tableFillerConfig, ...updates };
saveTableFillerConfig(newConfig);
}
/**
* 设置表格填表功能启用状态
* @param {boolean} enabled 是否启用
*/
export function setTableFillerEnabled(enabled) {
updateTableFillerConfig({ enabled });
}
/**
* 获取表格的 API 配置
* @param {string} tableName 表格名称
* @returns {object} API 配置
*/
export function getTableApiConfig(tableName) {
const tableFillerConfig = getTableFillerConfig();
const tableConfig = tableFillerConfig.tableApiConfigs?.[tableName];
// 如果表格有独立配置且不是使用默认
if (tableConfig && !tableConfig.useDefault) {
return tableConfig;
}
// 使用默认 API 配置
return tableFillerConfig.defaultApi || {};
}
/**
* 设置表格的 API 配置
* @param {string} tableName 表格名称
* @param {object} apiConfig API 配置
*/
export function setTableApiConfig(tableName, apiConfig) {
const tableFillerConfig = getTableFillerConfig();
if (!tableFillerConfig.tableApiConfigs) {
tableFillerConfig.tableApiConfigs = {};
}
tableFillerConfig.tableApiConfigs[tableName] = apiConfig;
saveTableFillerConfig(tableFillerConfig);
}
/**
* 删除表格的独立 API 配置(恢复使用默认)
* @param {string} tableName 表格名称
*/
export function deleteTableApiConfig(tableName) {
const tableFillerConfig = getTableFillerConfig();
if (tableFillerConfig.tableApiConfigs?.[tableName]) {
delete tableFillerConfig.tableApiConfigs[tableName];
saveTableFillerConfig(tableFillerConfig);
}
}
/**
* 检查表格填表配置是否有效
* @returns {boolean}
*/
export function hasValidTableFillerConfig() {
const config = getTableFillerConfig();
// 必须有默认 API 配置
if (!config.defaultApi?.apiUrl || !config.defaultApi?.model) {
return false;
}
return true;
}
/**
* 获取表格的独立模板
* @param {string} tableName 表格名称
* @returns {object|null} 模板配置
*/
export function getIndependentTemplate(tableName) {
const tableFillerConfig = getTableFillerConfig();
return tableFillerConfig.independentTemplates?.[tableName] || null;
}
/**
* 默认独立模板缓存
*/
let defaultIndependentTemplatesCache = null;
/**
* 加载内置默认独立模板
* @returns {Promise<object|null>} 默认模板对象
*/
export async function loadDefaultIndependentTemplates() {
// 如果已缓存,直接返回
if (defaultIndependentTemplatesCache) {
return defaultIndependentTemplatesCache;
}
try {
const response = await fetch('/scripts/extensions/third-party/memory-manager-concurrent/prompts/table-filler/default-independent-template.json');
if (!response.ok) {
Logger.warn('[独立模板] 加载内置默认模板失败:', response.status);
return null;
}
const data = await response.json();
defaultIndependentTemplatesCache = data;
Logger.log('[独立模板] 已加载内置默认模板');
return data;
} catch (e) {
Logger.error('[独立模板] 加载内置默认模板出错:', e);
return null;
}
}
/**
* 获取表格的独立模板(带默认值回退)
* 优先从持久化配置加载,若没有则从内置默认模板加载
* @param {string} tableName 表格名称
* @returns {Promise<object|null>} 模板配置
*/
export async function getIndependentTemplateWithDefault(tableName) {
// 1. 先从持久化配置加载
const savedTemplate = getIndependentTemplate(tableName);
if (savedTemplate) {
return savedTemplate;
}
// 2. 从内置默认模板加载
const defaultTemplates = await loadDefaultIndependentTemplates();
if (defaultTemplates?.templates?.[tableName]) {
return { template: defaultTemplates.templates[tableName] };
}
return null;
}
/**
* 获取所有独立模板(合并持久化和默认模板)
* @returns {Promise<object>} 合并后的所有模板
*/
export async function getAllIndependentTemplatesWithDefault() {
const savedTemplates = getAllIndependentTemplates();
const defaultTemplates = await loadDefaultIndependentTemplates();
// 合并:持久化优先
const merged = { ...savedTemplates };
if (defaultTemplates?.templates) {
for (const [tableName, templateObj] of Object.entries(defaultTemplates.templates)) {
if (!merged[tableName]) {
// 处理嵌套结构templateObj 可能是 { template: "..." } 或直接是字符串
const templateContent = typeof templateObj === 'string' ? templateObj : templateObj?.template;
if (templateContent) {
merged[tableName] = { template: templateContent, isDefault: true };
}
}
}
}
return merged;
}
/**
* 检查是否有可用的独立模板(持久化或默认)
* @returns {Promise<boolean>}
*/
export async function hasAnyIndependentTemplates() {
const savedTemplates = getAllIndependentTemplates();
if (Object.keys(savedTemplates).length > 0) {
return true;
}
const defaultTemplates = await loadDefaultIndependentTemplates();
return defaultTemplates?.templates && Object.keys(defaultTemplates.templates).length > 0;
}
/**
* 设置表格的独立模板
* @param {string} tableName 表格名称
* @param {string} template 模板内容
*/
export function setIndependentTemplate(tableName, template) {
const tableFillerConfig = getTableFillerConfig();
if (!tableFillerConfig.independentTemplates) {
tableFillerConfig.independentTemplates = {};
}
tableFillerConfig.independentTemplates[tableName] = { template };
saveTableFillerConfig(tableFillerConfig);
}
/**
* 删除表格的独立模板
* @param {string} tableName 表格名称
*/
export function deleteIndependentTemplate(tableName) {
const tableFillerConfig = getTableFillerConfig();
if (tableFillerConfig.independentTemplates?.[tableName]) {
delete tableFillerConfig.independentTemplates[tableName];
saveTableFillerConfig(tableFillerConfig);
}
}
/**
* 获取所有独立模板
* @returns {object} 所有模板
*/
export function getAllIndependentTemplates() {
const tableFillerConfig = getTableFillerConfig();
return tableFillerConfig.independentTemplates || {};
}
/**
* 设置独立模式的标签名称
* @param {string} tagName 标签名称
*/
export function setIndependentTagName(tagName) {
updateTableFillerConfig({ independentTagName: tagName });
}
/**
* 获取独立模式的标签名称
* @returns {string} 标签名称
*/
export function getIndependentTagName() {
const tableFillerConfig = getTableFillerConfig();
return tableFillerConfig.independentTagName || "Instructions for filling out the form";
}
// ============================================================================
// 总结世界书拆分配置管理
// ============================================================================
/**
* 获取总结世界书拆分配置
* @returns {object} 拆分配置
*/
export function getSummaryAutoSplitConfig() {
const config = loadConfig();
const splitConfig = config?.global?.summaryAutoSplit;
if (!splitConfig) {
return {
enabled: false,
targetChars: 50000,
minChars: 40000,
maxChars: 60000,
};
}
return splitConfig;
}
/**
* 检查总结世界书拆分功能是否启用
* @returns {boolean}
*/
export function isSummaryAutoSplitEnabled() {
const splitConfig = getSummaryAutoSplitConfig();
return splitConfig?.enabled === true;
}
/**
* 检查总结世界书合并去重是否启用
* @returns {boolean}
*/
export function isSummaryMergeDeduplicateEnabled() {
const splitConfig = getSummaryAutoSplitConfig();
return splitConfig?.deduplicateOnMerge === true;
}
/**
* 设置总结世界书合并去重启用状态
* @param {boolean} enabled 是否启用
*/
export function setSummaryMergeDeduplicateEnabled(enabled) {
const config = loadConfig();
if (!config.global) config.global = {};
if (!config.global.summaryAutoSplit) {
config.global.summaryAutoSplit = {
enabled: false,
targetChars: 50000,
minChars: 40000,
maxChars: 60000,
deduplicateOnMerge: false,
};
}
config.global.summaryAutoSplit.deduplicateOnMerge = enabled;
saveConfig(config);
}
/**
* 设置总结世界书拆分功能启用状态
* @param {boolean} enabled 是否启用
*/
export function setSummaryAutoSplitEnabled(enabled) {
const config = loadConfig();
if (!config.global) config.global = {};
if (!config.global.summaryAutoSplit) {
config.global.summaryAutoSplit = {
enabled: false,
targetChars: 50000,
minChars: 40000,
maxChars: 60000,
};
}
config.global.summaryAutoSplit.enabled = enabled;
saveConfig(config);
}
/**
* 更新总结世界书拆分配置
* @param {object} updates 要更新的字段
*/
export function updateSummaryAutoSplitConfig(updates) {
const config = loadConfig();
if (!config.global) config.global = {};
if (!config.global.summaryAutoSplit) {
config.global.summaryAutoSplit = {
enabled: false,
targetChars: 50000,
minChars: 40000,
maxChars: 60000,
};
}
config.global.summaryAutoSplit = { ...config.global.summaryAutoSplit, ...updates };
saveConfig(config);
}
/**
* 获取指定世界书的Part配置
* @param {string} bookName 世界书名称
* @returns {object|null} Part配置
*/
export function getSummaryPartConfigs(bookName) {
const config = loadConfig();
return config?.summaryPartConfigs?.[bookName] || null;
}
/**
* 设置指定世界书的Part配置
* @param {string} bookName 世界书名称
* @param {object} partConfigs Part配置
*/
export function setSummaryPartConfigs(bookName, partConfigs) {
const config = loadConfig();
if (!config.summaryPartConfigs) {
config.summaryPartConfigs = {};
}
config.summaryPartConfigs[bookName] = partConfigs;
saveConfig(config);
}
/**
* 删除指定世界书的Part配置
* @param {string} bookName 世界书名称
*/
export function deleteSummaryPartConfigs(bookName) {
const config = loadConfig();
if (config.summaryPartConfigs?.[bookName]) {
delete config.summaryPartConfigs[bookName];
saveConfig(config);
}
}
/**
* 获取所有世界书的Part配置
* @returns {object} 所有Part配置
*/
export function getAllSummaryPartConfigs() {
const config = loadConfig();
return config?.summaryPartConfigs || {};
}
/**
* 获取指定Part的API配置
* @param {string} bookName 世界书名称
* @param {string} partId Part ID
* @returns {object|null} API配置
*/
export function getSummaryPartApiConfig(bookName, partId) {
const partConfigs = getSummaryPartConfigs(bookName);
if (!partConfigs?.parts) return null;
const part = partConfigs.parts.find(p => p.id === partId);
return part?.apiConfig || null;
}
/**
* 设置指定Part的API配置
* @param {string} bookName 世界书名称
* @param {string} partId Part ID
* @param {object} apiConfig API配置
*/
export function setSummaryPartApiConfig(bookName, partId, apiConfig) {
const config = loadConfig();
if (!config.summaryPartConfigs) {
config.summaryPartConfigs = {};
}
if (!config.summaryPartConfigs[bookName]) {
config.summaryPartConfigs[bookName] = { parts: [] };
}
const parts = config.summaryPartConfigs[bookName].parts;
const existingIndex = parts.findIndex(p => p.id === partId);
if (existingIndex >= 0) {
parts[existingIndex].apiConfig = apiConfig;
} else {
parts.push({ id: partId, apiConfig });
}
saveConfig(config);
}
/**
* 删除指定Part的API配置
* @param {string} bookName 世界书名称
* @param {string} partId Part ID
*/
export function deleteSummaryPartApiConfig(bookName, partId) {
const config = loadConfig();
if (!config.summaryPartConfigs?.[bookName]?.parts) return;
const parts = config.summaryPartConfigs[bookName].parts;
const index = parts.findIndex(p => p.id === partId);
if (index >= 0) {
parts[index].apiConfig = null;
saveConfig(config);
}
}
/**
* 检查指定世界书的所有Part是否都已配置API
* @param {string} bookName 世界书名称
* @param {Array} parts Part列表
* @returns {object} 检查结果 { allConfigured: boolean, unconfiguredParts: Array }
*/
export function checkSummaryPartsConfigured(bookName, parts) {
const partConfigs = getSummaryPartConfigs(bookName);
const unconfiguredParts = [];
for (const part of parts) {
const savedPart = partConfigs?.parts?.find(p => p.id === part.id);
if (!savedPart?.apiConfig?.apiUrl || !savedPart?.apiConfig?.model) {
unconfiguredParts.push(part);
}
}
return {
allConfigured: unconfiguredParts.length === 0,
unconfiguredParts,
};
}
/**
* 迁移原有的单API配置到Part 1
* @param {string} bookName 世界书名称
* @param {object} firstPart 第一个Part对象
* @returns {boolean} 是否进行了迁移
*/
export function migrateSummaryConfigToPart(bookName, firstPart) {
const config = loadConfig();
const existingConfig = config?.summaryConfigs?.[bookName];
if (!existingConfig?.apiUrl || !existingConfig?.model) {
return false;
}
// 检查是否已有Part配置
if (config.summaryPartConfigs?.[bookName]?.parts?.length > 0) {
return false;
}
// 迁移配置
if (!config.summaryPartConfigs) {
config.summaryPartConfigs = {};
}
config.summaryPartConfigs[bookName] = {
parts: [{
id: firstPart.id,
startFloor: firstPart.startFloor,
endFloor: firstPart.endFloor,
charCount: firstPart.charCount,
apiConfig: { ...existingConfig },
}],
};
saveConfig(config);
Logger.log(`[ConfigManager] 已将 ${bookName} 的原有API配置迁移至 Part 1`);
return true;
}

View File

@@ -78,9 +78,59 @@ export const defaultConfig = Object.freeze({
},
// 剧情优化助手开关(移到 global 内部保持一致性)
enablePlotOptimize: false,
// 表格填表并发配置
tableFillerConfig: {
enabled: false,
// 调用模式:'auto'(自动选择)、'bus_only'仅Bus、'intercept_only'(仅拦截)
callMode: "auto",
// 提示词模式:'independent'(独立)或 'shared'(共享)
promptMode: "shared",
// 重试次数(单个表格失败后重试的次数)
retryCount: 2,
// 重试延迟基数毫秒使用指数退避第N次重试等待 retryDelay * 2^(N-1)
retryDelay: 2000,
// 导入的预设 JSON包含所有表格的提示词配置
importedPreset: null,
// 默认 API未单独配置的表格使用
defaultApi: {
apiUrl: "",
apiKey: "",
model: "",
apiFormat: "openai",
maxTokens: 4096,
temperature: 0.7,
responsePath: "choices.0.message.content",
},
// 每个表格的 API 配置(可选,留空则使用 defaultApi
tableApiConfigs: {
// "角色表": { useDefault: true } 或 { apiUrl, apiKey, model, ... }
},
},
// 总结世界书自动拆分配置
summaryAutoSplit: {
enabled: false, // 全局开关
targetChars: 50000, // 目标拆分字符数
minChars: 40000, // 最小字符数(确保段落完整)
maxChars: 60000, // 最大字符数(确保段落完整)
},
},
memoryConfigs: {},
summaryConfigs: {},
// 拆分后的Part配置动态生成每个Part可独立配置API
summaryPartConfigs: {
// "Amily2-Lore-char-哥布林杀手9.6": {
// parts: [
// {
// id: "floor_1_60",
// startFloor: 1,
// endFloor: 60,
// charCount: 48000,
// apiConfig: { enabled: true, apiUrl: "...", model: "...", ... }
// },
// ...
// ]
// }
},
importedBooks: [],
importedPromptFiles: {}, // 提示词文件存储(跨浏览器同步)
});

View File

@@ -1,8 +1,8 @@
/**
* 记忆管理并发系统 - 主入口
* @version 0.4.0
* @version 0.4.9
* @author 可乐、繁华
* @license AGPLv3
* @license CC BY-NC-ND 4.0
* @see https://github.com/Cola-Echo/memory-manager-concurrent
*
* 这是模块化重构后的入口文件
@@ -96,6 +96,9 @@ import {
// 模型显示更新
updateIndexMergeModelDisplay,
updatePlotOptimizeModelDisplay,
// 总结世界书拆分配置弹窗
setSummaryPartConfigModalFunction,
showSummaryPartConfigModal,
} from "@ui";
// 世界书模块
@@ -123,8 +126,11 @@ import {
getHistoricalPromptTemplate,
} from "@memory";
// 表格填表模块
import { initTableFiller } from "@table-filler/index";
// 版本信息
const VERSION = "0.4.7";
const VERSION = "0.4.9";
// 面板状态
let isPanelVisible = false;
@@ -260,6 +266,9 @@ async function initPlugin() {
refreshAIConfigList,
);
// 设置总结世界书拆分配置弹窗函数
setSummaryPartConfigModalFunction(showSummaryPartConfigModal);
// 注入记忆处理回调
setProcessMemoryCallback(processMemoryForMessage);
@@ -343,6 +352,13 @@ async function initUI() {
// 启动世界书轮询检测
startWorldBookPolling();
// 初始化表格填表模块(延迟以确保 Amily2 加载完成)
setTimeout(() => {
initTableFiller().catch((e) => {
Logger.debug("表格填表模块初始化失败:", e);
});
}, 3000);
Logger.log("UI 初始化完成");
} catch (error) {
Logger.error("UI 初始化失败:", error);

View File

@@ -0,0 +1,203 @@
/**
* Part 结果调试弹窗模块
* @module memory/part-debug-modal
*/
import { getGlobalSettings } from "@config/config-manager";
import { enableModalDrag } from "@ui/modals/index";
// 是否启用调试模式
let debugEnabled = false;
/**
* 设置调试模式
* @param {boolean} enabled 是否启用
*/
export function setPartDebugEnabled(enabled) {
debugEnabled = enabled;
}
/**
* 获取调试模式状态
* @returns {boolean}
*/
export function isPartDebugEnabled() {
return debugEnabled;
}
/**
* 显示 Part 结果调试弹窗
* @param {Array} partResults 各 Part 的结果数组
* @param {string} bookName 世界书名称
* @param {object} mergedResult 合并后的结果
*/
export function showPartDebugModal(partResults, bookName, mergedResult) {
if (!debugEnabled) return;
// 创建弹窗容器
const modal = document.createElement("div");
modal.className = "mm-modal mm-modal-visible";
modal.style.zIndex = "999999";
// 应用当前主题
const settings = getGlobalSettings();
const theme = settings.theme || "default";
if (theme !== "default") {
modal.setAttribute("data-mm-theme", theme);
}
// 创建弹窗内容
const content = document.createElement("div");
content.className = "mm-modal-content";
content.style.maxWidth = "900px";
content.style.maxHeight = "85vh";
content.style.display = "flex";
content.style.flexDirection = "column";
// 创建弹窗头部
const header = document.createElement("div");
header.className = "mm-modal-header";
header.innerHTML = `
<h4 style="margin: 0; display: flex; align-items: center; gap: 8px;">
<i class="fa-solid fa-bug" style="color: #9b59b6;"></i>
总结世界书拆分调试 - ${bookName}
</h4>
<button class="mm-modal-close mm-btn mm-btn-icon">
<i class="fa-solid fa-times"></i>
</button>
`;
// 创建弹窗主体
const body = document.createElement("div");
body.className = "mm-modal-body";
body.style.padding = "16px";
body.style.overflow = "auto";
body.style.flex = "1";
// 统计信息
const validResults = partResults.filter(r => r !== null && r.rawMemory);
const statsHtml = `
<div style="background: var(--mm-bg-secondary); border-radius: 8px; padding: 12px; margin-bottom: 16px;">
<div style="display: flex; gap: 24px; flex-wrap: wrap;">
<div><strong>总 Part 数:</strong>${partResults.length}</div>
<div><strong>有效返回:</strong><span style="color: #27ae60;">${validResults.length}</span></div>
<div><strong>无返回/失败:</strong><span style="color: ${partResults.length - validResults.length > 0 ? '#e74c3c' : '#27ae60'};">${partResults.length - validResults.length}</span></div>
<div><strong>合并后事件数:</strong>${mergedResult?.eventCount || 0}</div>
</div>
</div>
`;
// 各 Part 结果
let partsHtml = '<div style="display: flex; flex-direction: column; gap: 12px;">';
partResults.forEach((result, index) => {
const partNum = index + 1;
const hasResult = result !== null && result.rawMemory;
const statusColor = hasResult ? "#27ae60" : "#e74c3c";
const statusIcon = hasResult ? "fa-check-circle" : "fa-times-circle";
const statusText = hasResult ? "成功" : "无返回";
// 提取楼层范围
let floorRange = "";
if (result?.partId) {
const match = result.partId.match(/floor_(\d+)_(\d+)/);
if (match) {
floorRange = `${match[1]}-${match[2]}`;
}
}
partsHtml += `
<div style="border: 1px solid var(--mm-border); border-radius: 8px; overflow: hidden;">
<div style="background: var(--mm-bg-secondary); padding: 10px 14px; display: flex; justify-content: space-between; align-items: center; cursor: pointer;" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? 'block' : 'none'">
<div style="display: flex; align-items: center; gap: 10px;">
<i class="fa-solid ${statusIcon}" style="color: ${statusColor};"></i>
<strong>Part ${partNum}</strong>
${floorRange ? `<span style="color: var(--mm-text-muted); font-size: 12px;">(${floorRange})</span>` : ""}
</div>
<div style="display: flex; align-items: center; gap: 10px;">
<span style="color: ${statusColor}; font-size: 13px;">${statusText}</span>
${hasResult ? `<span style="color: var(--mm-text-muted); font-size: 12px;">${result.rawMemory.length} 字符</span>` : ""}
<i class="fa-solid fa-chevron-down" style="color: var(--mm-text-muted);"></i>
</div>
</div>
<div style="display: none; padding: 12px; background: var(--mm-bg); max-height: 300px; overflow: auto;">
${hasResult
? `<pre style="margin: 0; white-space: pre-wrap; word-break: break-all; font-size: 12px; line-height: 1.5; color: var(--mm-text);">${escapeHtml(result.rawMemory)}</pre>`
: `<div style="color: var(--mm-text-muted); font-style: italic;">该 Part 未返回内容(可能未配置 API 或请求失败)</div>`
}
</div>
</div>
`;
});
partsHtml += '</div>';
// 合并结果
let mergedHtml = '';
if (mergedResult && mergedResult.rawMemory) {
mergedHtml = `
<div style="margin-top: 16px; border: 2px solid #3498db; border-radius: 8px; overflow: hidden;">
<div style="background: rgba(52, 152, 219, 0.1); padding: 10px 14px; display: flex; justify-content: space-between; align-items: center; cursor: pointer;" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? 'block' : 'none'">
<div style="display: flex; align-items: center; gap: 10px;">
<i class="fa-solid fa-layer-group" style="color: #3498db;"></i>
<strong>合并后结果</strong>
</div>
<div style="display: flex; align-items: center; gap: 10px;">
<span style="color: var(--mm-text-muted); font-size: 12px;">${mergedResult.rawMemory.length} 字符</span>
<i class="fa-solid fa-chevron-down" style="color: var(--mm-text-muted);"></i>
</div>
</div>
<div style="display: block; padding: 12px; background: var(--mm-bg); max-height: 300px; overflow: auto;">
<pre style="margin: 0; white-space: pre-wrap; word-break: break-all; font-size: 12px; line-height: 1.5; color: var(--mm-text);">${escapeHtml(mergedResult.rawMemory)}</pre>
</div>
</div>
`;
}
body.innerHTML = statsHtml + partsHtml + mergedHtml;
// 创建弹窗底部
const footer = document.createElement("div");
footer.className = "mm-modal-footer";
footer.style.display = "flex";
footer.style.justifyContent = "flex-end";
footer.style.gap = "10px";
footer.style.padding = "12px 16px";
footer.style.borderTop = "1px solid var(--mm-border)";
const closeBtn = document.createElement("button");
closeBtn.className = "mm-btn mm-btn-primary";
closeBtn.innerHTML = `<i class="fa-solid fa-check" style="margin-right: 6px;"></i>确定`;
footer.appendChild(closeBtn);
content.appendChild(header);
content.appendChild(body);
content.appendChild(footer);
modal.appendChild(content);
document.body.appendChild(modal);
// 启用弹窗拖拽移动
enableModalDrag(modal, content, header);
const cleanup = () => {
document.body.removeChild(modal);
};
closeBtn.addEventListener("click", cleanup);
header.querySelector(".mm-modal-close").addEventListener("click", cleanup);
modal.addEventListener("click", (e) => {
if (e.target === modal) cleanup();
});
}
/**
* HTML 转义
* @param {string} str 原始字符串
* @returns {string} 转义后的字符串
*/
function escapeHtml(str) {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}

View File

@@ -11,6 +11,11 @@ import {
getSummaryConfig,
isPluginEnabled,
getEnabledProviders,
getSummaryAutoSplitConfig,
getSummaryPartConfigs,
getSummaryPartApiConfig,
isSummaryAutoSplitEnabled,
isSummaryMergeDeduplicateEnabled,
} from "@config/config-manager";
import Logger from "@core/logger";
import { getContext } from "@core/sillytavern-api";
@@ -31,6 +36,7 @@ import {
import { classifyWorldBooks, getImportedWorldBooks } from "@worldbook/api";
import { formatAsWorldBook, getSummaryContent } from "@worldbook/parser";
import { refreshWorldBookList } from "@worldbook/refresh";
import { analyzeSummaryContent } from "@worldbook/summary-splitter";
import { getJailbreakPrefix } from "./jailbreak";
import {
buildDataInjection,
@@ -40,6 +46,7 @@ import {
} from "./prompt-builder";
import { mergeResults } from "./result-merger";
import { collectAllRequestInfos } from "./request-collector";
import { showPartDebugModal, isPartDebugEnabled } from "./part-debug-modal";
// 创建模块专用日志记录器
const log = Logger.createModuleLogger("记忆处理");
@@ -205,19 +212,23 @@ export async function processCategory(
// 获取提示词模板
const template = await getPromptTemplate();
const prompt = injectDataToPrompt(template, dataInjection);
// 获取破限词前缀
const jailbreakPrefix = getJailbreakPrefix();
// 注入数据到提示词(使用流程配置顺序)
const prompt = injectDataToPrompt(template, dataInjection, {
flowType: "记忆世界书",
jailbreakPrefix: jailbreakPrefix,
});
// 替换变量
const baseSystemPrompt = replacePromptVariables(
const finalSystemPrompt = replacePromptVariables(
prompt.systemPrompt,
aiConfig,
globalConfig,
);
// 添加破限词前缀
const finalSystemPrompt =
getJailbreakPrefix() + "\n\n" + baseSystemPrompt;
// 构建用户提示词
const finalUserMessage = buildUserPrompt(userMessage);
@@ -281,19 +292,23 @@ export async function processSummaryBook(book, userMessage, context, signal) {
// 使用历史事件回忆提示词模板
const template = await getHistoricalPromptTemplate();
const prompt = injectDataToPrompt(template, dataInjection);
// 获取破限词前缀
const jailbreakPrefix = getJailbreakPrefix();
// 注入数据到提示词(使用流程配置顺序)
const prompt = injectDataToPrompt(template, dataInjection, {
flowType: "总结世界书",
jailbreakPrefix: jailbreakPrefix,
});
// 替换变量
const baseSystemPrompt = replacePromptVariables(
const finalSystemPrompt = replacePromptVariables(
prompt.systemPrompt,
aiConfig,
globalConfig,
);
// 添加破限词前缀
const finalSystemPrompt =
getJailbreakPrefix() + "\n\n" + baseSystemPrompt;
// 构建用户提示词
const finalUserMessage = buildUserPrompt(userMessage);
@@ -325,6 +340,325 @@ export async function processSummaryBook(book, userMessage, context, signal) {
}
}
/**
* 处理单个总结世界书的 Part
* @param {object} book 世界书对象
* @param {object} part Part 信息 { id, startFloor, endFloor, content, charCount }
* @param {string} userMessage 用户消息
* @param {string} context 上下文
* @param {AbortSignal} signal 中止信号
* @returns {Promise<object|null>} 处理结果
*/
export async function processSummaryPart(book, part, userMessage, context, signal) {
const progressTracker = getProgressTracker();
const taskId = `summary_${book.name}_${part.id}`;
try {
progressTracker?.startTask(taskId);
// Part 1index=0复用原总结世界书的 API 配置,其他 Part 使用各自的配置
let partConfig;
if (part.index === 0) {
partConfig = getSummaryConfig(book.name);
} else {
partConfig = getSummaryPartApiConfig(book.name, part.id);
}
if (!partConfig || !partConfig.enabled) {
log.warn(`总结世界书 "${book.name}" Part "${part.id}" 未启用,跳过`);
progressTracker?.completeTask(taskId, false, "未配置");
return null;
}
const globalConfig = getGlobalConfig();
// Part 的内容带有标识
const partContent = `=== Part ${part.id} (${part.startFloor}-${part.endFloor}楼) ===\n${part.content}`;
// 构建数据注入
const dataInjection = buildDataInjection({
worldBookContent: partContent,
context: context,
userMessage: userMessage,
});
// 使用历史事件回忆提示词模板
const template = await getHistoricalPromptTemplate();
// 获取破限词前缀
const jailbreakPrefix = getJailbreakPrefix();
// 注入数据到提示词(使用流程配置顺序,与总结世界书使用相同流程)
const prompt = injectDataToPrompt(template, dataInjection, {
flowType: "总结世界书",
jailbreakPrefix: jailbreakPrefix,
});
// 替换变量
const finalSystemPrompt = replacePromptVariables(
prompt.systemPrompt,
partConfig,
globalConfig,
);
// 构建用户提示词
const finalUserMessage = buildUserPrompt(userMessage);
// 调用 API添加 taskId 以支持流式进度更新)
const response = await APIAdapter.call(
{ ...partConfig, taskId },
finalSystemPrompt,
finalUserMessage,
signal,
);
progressTracker?.completeTask(taskId, true);
return {
source: `${book.name} (${part.startFloor}-${part.endFloor}楼)`,
category: book.name,
type: "summary_part",
rawMemory: response,
bookName: book.name,
partId: part.id,
startFloor: part.startFloor,
endFloor: part.endFloor,
};
} catch (error) {
if (error.name === "AbortError") {
progressTracker?.completeTask(taskId, false, "已取消");
throw error;
}
log.error(`处理总结世界书 "${book.name}" Part "${part.id}" 失败:`, error);
progressTracker?.completeTask(taskId, false, error.message);
return null;
}
}
/**
* 合并多个 Part 的处理结果
* @param {Array} partResults Part 处理结果数组
* @param {string} bookName 世界书名称
* @returns {object|null} 合并后的结果
*/
export function mergePartResults(partResults, bookName) {
// 计算合并结果
const mergedResult = computeMergedResult(partResults, bookName);
// 显示调试弹窗(在返回结果前)
if (isPartDebugEnabled()) {
showPartDebugModal(partResults, bookName, mergedResult);
}
return mergedResult;
}
/**
* 计算合并结果(内部函数)
* @param {Array} partResults Part 处理结果数组
* @param {string} bookName 世界书名称
* @returns {object|null} 合并后的结果
*/
function computeMergedResult(partResults, bookName) {
const validResults = partResults.filter(r => r !== null && r.rawMemory);
if (validResults.length === 0) {
return null;
}
// 获取去重配置
const deduplicateEnabled = isSummaryMergeDeduplicateEnabled();
// 提取所有历史事件(保持原始顺序,不排序)
const allEvents = [];
const eventPattern = /<Historical_Occurrences>([\s\S]*?)<\/Historical_Occurrences>/gi;
// 兼容多种楼层格式【124楼】、【124至#125】、【124至125楼】
const floorPattern = /【(\d+)(?:楼】|至#?(\d+)楼?】)/;
for (const result of validResults) {
const content = result.rawMemory;
let match;
let foundEvents = false;
// 提取所有 Historical_Occurrences 块
while ((match = eventPattern.exec(content)) !== null) {
foundEvents = true;
const eventsContent = match[1];
// 按行分割并提取每个事件
const lines = eventsContent.split('\n').filter(line => line.trim());
for (const line of lines) {
const floorMatch = line.match(floorPattern);
const floor = floorMatch ? parseInt(floorMatch[1], 10) : 0;
allEvents.push({
floor: floor,
content: line.trim(),
sourcePartId: result.partId,
});
}
}
// 重置正则的 lastIndex
eventPattern.lastIndex = 0;
// 如果没有找到标签格式,尝试直接提取楼层事件
if (!foundEvents) {
const lines = content.split('\n').filter(line => line.trim());
for (const line of lines) {
const floorMatch = line.match(floorPattern);
if (floorMatch) {
const floor = parseInt(floorMatch[1], 10);
allEvents.push({
floor: floor,
content: line.trim(),
sourcePartId: result.partId,
});
}
}
}
}
// 处理事件列表
let finalEvents;
if (deduplicateEnabled) {
// 去重模式:同一楼层只保留内容最长的
const floorBestEvent = new Map();
for (const event of allEvents) {
const existing = floorBestEvent.get(event.floor);
if (!existing || event.content.length > existing.content.length) {
floorBestEvent.set(event.floor, event);
}
}
// 按原始出现顺序输出(使用第一次出现的顺序)
const seenFloors = new Set();
finalEvents = [];
for (const event of allEvents) {
if (!seenFloors.has(event.floor)) {
seenFloors.add(event.floor);
finalEvents.push(floorBestEvent.get(event.floor));
}
}
} else {
// 不去重模式:相同楼层的内容放在一起(保持原始顺序)
// 使用 Map 按楼层分组,保持首次出现的顺序
const floorGroups = new Map();
const floorOrder = [];
for (const event of allEvents) {
if (!floorGroups.has(event.floor)) {
floorGroups.set(event.floor, []);
floorOrder.push(event.floor);
}
floorGroups.get(event.floor).push(event);
}
// 按首次出现顺序输出
finalEvents = [];
for (const floor of floorOrder) {
finalEvents.push(...floorGroups.get(floor));
}
}
// 重新构建响应
const mergedContent = finalEvents.map(e => e.content).join('\n');
const rawMemory = finalEvents.length > 0
? `<Historical_Occurrences>\n${mergedContent}\n</Historical_Occurrences>`
: validResults.map(r => r.rawMemory).join('\n\n');
const mergedResult = {
source: bookName,
category: bookName,
type: "summary",
rawMemory: rawMemory,
bookName: bookName,
partCount: validResults.length,
eventCount: finalEvents.length,
};
return mergedResult;
}
/**
* 处理总结世界书(支持自动拆分)
* @param {object} book 世界书对象
* @param {string} userMessage 用户消息
* @param {string} context 上下文
* @param {AbortSignal} signal 中止信号
* @returns {Promise<object|null>} 处理结果
*/
export async function processSummaryBookWithSplit(book, userMessage, context, signal) {
// 检查是否启用拆分
if (!isSummaryAutoSplitEnabled()) {
// 未启用拆分,使用原有逻辑
return processSummaryBook(book, userMessage, context, signal);
}
// 获取总结内容
const summaryContent = getSummaryContent(book);
// 获取拆分配置
const splitConfig = getSummaryAutoSplitConfig();
// 分析拆分方案
const parts = analyzeSummaryContent(summaryContent, splitConfig);
if (parts.length <= 1) {
// 内容不足以拆分,使用原有逻辑
log.debug(`总结世界书 "${book.name}" 内容字符数不足以拆分,使用单任务处理`);
return processSummaryBook(book, userMessage, context, signal);
}
log.log(`总结世界书 "${book.name}" 拆分为 ${parts.length} 个 Part 进行并发处理`);
// 检查每个 Part 是否都有 API 配置Part 1 复用原配置)
const partConfigs = getSummaryPartConfigs(book.name);
const originalConfig = getSummaryConfig(book.name);
const unconfiguredParts = [];
for (const part of parts) {
if (part.index === 0) {
// Part 1index=0复用原总结世界书配置
if (!originalConfig || !originalConfig.enabled) {
unconfiguredParts.push(part);
}
} else {
// 其他 Part 使用各自的配置
const partConfig = partConfigs?.parts?.find(p => p.id === part.id);
if (!partConfig || !partConfig.apiConfig || !partConfig.apiConfig.enabled) {
unconfiguredParts.push(part);
}
}
}
if (unconfiguredParts.length > 0) {
const partNames = unconfiguredParts.map(p => `Part ${p.id} (${p.startFloor}-${p.endFloor}楼)`).join(', ');
log.warn(`总结世界书 "${book.name}" 有 ${unconfiguredParts.length} 个 Part 未配置 API: ${partNames}`);
// 即使有未配置的 Part仍然处理已配置的 Part
}
// 并发处理所有已配置的 Part
const partPromises = parts.map(part => {
if (part.index === 0) {
// Part 1index=0复用原配置
if (!originalConfig || !originalConfig.enabled) {
return Promise.resolve(null);
}
} else {
// 其他 Part 使用各自的配置
const partConfig = partConfigs?.parts?.find(p => p.id === part.id);
if (!partConfig || !partConfig.apiConfig || !partConfig.apiConfig.enabled) {
return Promise.resolve(null);
}
}
return processSummaryPart(book, part, userMessage, context, signal);
});
const partResults = await Promise.all(partPromises);
// 合并结果
return mergePartResults(partResults, book.name);
}
/**
* 收集所有分类的索引内容(用于索引合并模式)
* @param {Array} memoryBooks 记忆世界书数组
@@ -394,19 +728,23 @@ export async function processIndexMerge(
// 获取提示词模板
const template = await getPromptTemplate();
const prompt = injectDataToPrompt(template, dataInjection);
// 获取破限词前缀
const jailbreakPrefix = getJailbreakPrefix();
// 注入数据到提示词(使用流程配置顺序)
const prompt = injectDataToPrompt(template, dataInjection, {
flowType: "索引合并",
jailbreakPrefix: jailbreakPrefix,
});
// 替换变量
const baseSystemPrompt = replacePromptVariables(
const finalSystemPrompt = replacePromptVariables(
prompt.systemPrompt,
config,
globalConfig,
);
// 添加破限词前缀
const finalSystemPrompt =
getJailbreakPrefix() + "\n\n" + baseSystemPrompt;
// 构建用户提示词
const finalUserMessage = buildUserPrompt(userMessage);
@@ -751,26 +1089,88 @@ export async function processMemoryForMessage(userMessage) {
continue;
}
const taskId = `summary_${book.name}`;
const taskController = new AbortController();
taskAbortControllers.set(taskId, taskController);
// 检查是否启用拆分,并分析是否需要拆分
const splitEnabled = isSummaryAutoSplitEnabled();
let parts = [];
if (splitEnabled) {
const summaryContent = getSummaryContent(book);
const splitConfig = getSummaryAutoSplitConfig();
parts = analyzeSummaryContent(summaryContent, splitConfig);
}
taskInfoList.push({
id: taskId,
name: book.name,
type: "summary",
});
if (splitEnabled && parts.length > 1) {
// 启用拆分且有多个Part注册Part任务用于进度追踪
const partConfigs = getSummaryPartConfigs(book.name);
const originalConfig = getSummaryConfig(book.name);
tasks.push({
taskId,
fn: () =>
processSummaryBook(
book,
userMessage,
context,
taskController.signal,
),
});
// 收集已配置的Part用于进度追踪显示
const configuredParts = [];
for (const part of parts) {
let isConfigured = false;
if (part.index === 0) {
isConfigured = originalConfig && originalConfig.enabled;
} else {
const partConfig = partConfigs?.parts?.find(p => p.id === part.id);
isConfigured = partConfig && partConfig.apiConfig && partConfig.apiConfig.enabled;
}
if (isConfigured) {
configuredParts.push(part);
}
}
if (configuredParts.length === 0) {
log.warn(`总结世界书 "${book.name}" 所有 Part 均未配置,跳过`);
continue;
}
// 为每个已配置的Part注册任务信息用于进度追踪显示
for (const part of configuredParts) {
const taskId = `summary_${book.name}_${part.id}`;
taskInfoList.push({
id: taskId,
name: `${book.name} Part ${part.index + 1}`,
type: "summary_part",
});
}
// 使用单个任务执行 processSummaryBookWithSplit内部会并发处理Part并合并结果
const mainTaskId = `summary_${book.name}`;
const taskController = new AbortController();
taskAbortControllers.set(mainTaskId, taskController);
tasks.push({
taskId: mainTaskId,
fn: () =>
processSummaryBookWithSplit(
book,
userMessage,
context,
taskController.signal,
),
});
} else {
// 未启用拆分或内容不足以拆分:注册单个任务
const taskId = `summary_${book.name}`;
const taskController = new AbortController();
taskAbortControllers.set(taskId, taskController);
taskInfoList.push({
id: taskId,
name: book.name,
type: "summary",
});
tasks.push({
taskId,
fn: () =>
processSummaryBook(
book,
userMessage,
context,
taskController.signal,
),
});
}
} catch (e) {
log.warn(`总结世界书 "${book.name}" 未配置,跳过`);
}
@@ -930,7 +1330,9 @@ export async function processMemoryForMessage(userMessage) {
for (const m of selectedMemories) {
const floor = m.uid || "0";
const content = m.content || "";
historicalLines.push(`${floor}楼】${content}`);
// 如果 floor 已经是完整标签格式,直接使用
const floorTag = String(floor).startsWith('【') ? floor : `${floor}楼】`;
historicalLines.push(`${floorTag}${content}`);
}
const rawMemory = `<Historical_Occurrences>\n${historicalLines.join(

View File

@@ -4,7 +4,28 @@
*/
import Logger from '@core/logger';
import { getGlobalConfig } from '@config/config-manager';
import { getGlobalConfig, getGlobalSettings } from '@config/config-manager';
// 默认流程顺序(与 flow-configs/default.json 保持一致)
const DEFAULT_FLOW_ORDER = ["jailbreak", "main", "worldbook", "context", "auxiliary", "user"];
/**
* 获取流程配置顺序
* @param {string} flowType 流程类型(记忆世界书、总结世界书、索引合并、剧情优化)
* @returns {Array<string>} 流程顺序数组
*/
function getFlowOrder(flowType) {
const settings = getGlobalSettings();
const savedOrder = settings.promptPartsOrder || {};
const sourceOrder = savedOrder[flowType];
// 如果有用户保存的顺序,使用它;否则使用默认顺序
if (sourceOrder && Array.isArray(sourceOrder) && sourceOrder.length > 0) {
return sourceOrder;
}
return DEFAULT_FLOW_ORDER;
}
/**
* 构建数据注入对象
@@ -20,22 +41,49 @@ export function buildDataInjection(data) {
}
/**
* 将数据注入到提示词模板
* 将数据注入到提示词模板(支持流程配置顺序)
* @param {object} template 提示词模板
* @param {object} dataInjection 数据注入对象
* @param {object} options 选项
* @param {string} options.flowType 流程类型,默认 "记忆世界书"
* @param {string} options.jailbreakPrefix 破限词前缀
* @returns {object} 注入后的提示词
*/
export function injectDataToPrompt(template, dataInjection) {
let mainPrompt = template.mainPrompt || template.main_prompt || "";
let systemPrompt = template.systemPrompt || template.system_prompt || "";
export function injectDataToPrompt(template, dataInjection, options = {}) {
const {
flowType = "记忆世界书",
jailbreakPrefix = "",
} = options;
// 构建数据注入内容
let injectionContent = "";
let injectionParts = [];
const mainPromptRaw = template.mainPrompt || template.main_prompt || "";
const systemPromptRaw = template.systemPrompt || template.system_prompt || "";
// 注入世界书内容
// 分离 mainPrompt 中 <数据注入区> 前后的内容
let mainPromptBefore = mainPromptRaw;
let mainPromptAfter = "";
if (mainPromptRaw.includes("<数据注入区>")) {
const parts = mainPromptRaw.split("<数据注入区>");
mainPromptBefore = parts[0] || "";
mainPromptAfter = parts.slice(1).join("<数据注入区>") || "";
}
// 构建各个来源的内容块
const sourceContents = {};
const injectionParts = [];
// jailbreak - 破限词
if (jailbreakPrefix && jailbreakPrefix.trim()) {
sourceContents.jailbreak = jailbreakPrefix.trim();
}
// main - 主提示词(<数据注入区>前的部分)
if (mainPromptBefore && mainPromptBefore.trim()) {
sourceContents.main = mainPromptBefore.trim();
}
// worldbook - 世界书内容
if (dataInjection.worldBookContent) {
injectionContent += `<世界书内容>\n${dataInjection.worldBookContent}\n</世界书内容>\n\n`;
sourceContents.worldbook = `<世界书内容>\n${dataInjection.worldBookContent}\n</世界书内容>`;
injectionParts.push({
label: "世界书内容",
content: dataInjection.worldBookContent,
@@ -43,7 +91,7 @@ export function injectDataToPrompt(template, dataInjection) {
});
} else {
const emptyWorldbook = `[当前无世界书数据,禁止编造任何历史事件回忆或关键词]`;
injectionContent += `<世界书内容>\n${emptyWorldbook}\n</世界书内容>\n\n`;
sourceContents.worldbook = `<世界书内容>\n${emptyWorldbook}\n</世界书内容>`;
injectionParts.push({
label: "世界书内容",
content: emptyWorldbook,
@@ -51,9 +99,9 @@ export function injectDataToPrompt(template, dataInjection) {
});
}
// 注入前文内容(最近对话上下文)
// context - 前文内容
if (dataInjection.context) {
injectionContent += `<前文内容>\n${dataInjection.context}\n</前文内容>\n\n`;
sourceContents.context = `<前文内容>\n${dataInjection.context}\n</前文内容>`;
injectionParts.push({
label: "前文内容",
content: dataInjection.context,
@@ -61,27 +109,55 @@ export function injectDataToPrompt(template, dataInjection) {
});
}
// 注入用户消息
if (dataInjection.userMessage) {
injectionContent += `<核心用户消息>\n${dataInjection.userMessage}\n</核心用户消息>\n`;
// auxiliary - 辅助提示词systemPrompt + mainPrompt 中 <数据注入区> 后的部分)
let auxiliaryContent = "";
if (mainPromptAfter && mainPromptAfter.trim()) {
auxiliaryContent += mainPromptAfter.trim();
}
if (systemPromptRaw && systemPromptRaw.trim()) {
if (auxiliaryContent) {
auxiliaryContent += "\n";
}
auxiliaryContent += systemPromptRaw.trim();
}
if (auxiliaryContent) {
sourceContents.auxiliary = auxiliaryContent;
}
// 将数据注入到 <数据注入区> 占位符
if (mainPrompt.includes("<数据注入区>")) {
mainPrompt = mainPrompt.replace(
"<数据注入区>",
`<数据注入区>\n${injectionContent}`
);
// user - 用户消息(作为最后的用户提示词,不在系统提示词中)
// 注意user 部分不放入 systemPrompt而是单独返回给调用方处理
// 获取流程顺序
const flowOrder = getFlowOrder(flowType);
// 按流程顺序构建系统提示词
const orderedParts = [];
for (const source of flowOrder) {
// user 来源不放入系统提示词
if (source === "user") continue;
if (sourceContents[source]) {
orderedParts.push(sourceContents[source]);
}
}
// 合并 mainPrompt 和 systemPrompt
const finalSystemPrompt = mainPrompt + "\n" + systemPrompt;
// 添加未在流程配置中的部分(保持原顺序)
for (const [source, content] of Object.entries(sourceContents)) {
if (source === "user") continue;
if (!flowOrder.includes(source) && content) {
orderedParts.push(content);
}
}
// 合并为最终的系统提示词
const finalSystemPrompt = orderedParts.join("\n\n");
return {
systemPrompt: finalSystemPrompt,
injectionParts: injectionParts,
mainPrompt: mainPrompt,
auxiliaryPrompt: systemPrompt,
mainPrompt: mainPromptBefore,
auxiliaryPrompt: auxiliaryContent,
flowOrder: flowOrder,
};
}

View File

@@ -8,9 +8,13 @@ import {
getGlobalSettings,
getMemoryConfig,
getSummaryConfig,
isSummaryAutoSplitEnabled,
getSummaryAutoSplitConfig,
getSummaryPartApiConfig,
} from "@config/config-manager";
import Logger from "@core/logger";
import { formatAsWorldBook, getSummaryContent } from "@worldbook/parser";
import { analyzeSummaryContent } from "@worldbook/summary-splitter";
import { getJailbreakPrefix } from "./jailbreak";
import {
buildDataInjection,
@@ -26,16 +30,16 @@ import {
// 来源标签映射(与 flow-config.js 保持一致)
const SOURCE_LABELS = {
jailbreak: "[条件块] 破限词",
main: "[条件块] 主提示词 (mainPrompt <数据注入区>前)",
main: "[条件块] 主提示词 (mainPrompt <数据注入点>)",
user: "[条件块] 核心用户消息 <核心用户消息>",
worldbook: "[条件块] 世界书内容 <世界书内容>",
context: "[条件块] 前文内容 <前文内容>",
auxiliary: "[条件块] 辅助提示词 (systemPrompt <数据注入区>后)",
auxiliary: "[条件块] 辅助提示词 (systemPrompt <数据注入点>)",
};
/**
* 根据流程配置对 promptParts 重新排序
* @param {Array} promptParts 原 prompt 部分列表
* @param {Array} promptParts 原<EFBFBD><EFBFBD> prompt 部分列表
* @param {string} flowType 流程类型
* @returns {Array} 排序后的 promptParts
*/
@@ -86,23 +90,27 @@ export async function collectMemoryRequestInfo(category, data, userMessage, cont
});
const template = await getPromptTemplate();
const prompt = injectDataToPrompt(template, dataInjection);
const baseSystemPrompt = replacePromptVariables(
// 获取破限词前缀
const jailbreakPrefix = getJailbreakPrefix();
// 使用与 processor.js 相同的方式构建提示词(包含流程配置顺序)
const prompt = injectDataToPrompt(template, dataInjection, {
flowType: "记忆世界书",
jailbreakPrefix: jailbreakPrefix,
});
// 替换变量得到最终系统提示词
const finalSystemPrompt = replacePromptVariables(
prompt.systemPrompt,
aiConfig,
globalConfig,
);
// 添加破限词前缀
const jailbreakPrefix = getJailbreakPrefix();
const finalSystemPrompt = jailbreakPrefix
? jailbreakPrefix + "\n\n" + baseSystemPrompt
: baseSystemPrompt;
// 构建用户提示词
const finalUserMessage = buildUserPrompt(userMessage);
// 构建详细的 prompt 部分列表
// 构建详细的 prompt 部分列表(用于预览显示)
const promptParts = [];
// 添加破限词
@@ -118,7 +126,7 @@ export async function collectMemoryRequestInfo(category, data, userMessage, cont
const mainPromptWithoutInjection =
template.mainPrompt || template.main_prompt || "";
const cleanMainPrompt = replacePromptVariables(
mainPromptWithoutInjection.split("<数据注入>")[0].trim(),
mainPromptWithoutInjection.split("<数据注入>")[0].trim(),
aiConfig,
globalConfig,
);
@@ -156,8 +164,7 @@ export async function collectMemoryRequestInfo(category, data, userMessage, cont
source: "user",
});
// 根据流程配置对 promptParts 重新排序
// 使用 "记忆世界书" 作为流程类型(与流程配置弹窗中的分类名称一致)
// 根据流程配置对 promptParts 重新排序(使用与实际发送相同的顺序)
const sortedPromptParts = sortPromptPartsByFlowConfig(promptParts, "记忆世界书");
return {
@@ -210,23 +217,27 @@ export async function collectSummaryRequestInfo(book, userMessage, context) {
// 使用历史事件回忆提示词模板
const template = await getHistoricalPromptTemplate();
const prompt = injectDataToPrompt(template, dataInjection);
const baseSystemPrompt = replacePromptVariables(
// 获取破限词前缀
const jailbreakPrefix = getJailbreakPrefix();
// 使用与 processor.js 相同的方式构建提示词(包含流程配置顺序)
const prompt = injectDataToPrompt(template, dataInjection, {
flowType: "总结世界书",
jailbreakPrefix: jailbreakPrefix,
});
// 替换变量得到最终系统提示词
const finalSystemPrompt = replacePromptVariables(
prompt.systemPrompt,
aiConfig,
globalConfig,
);
// 添加破限词前缀
const jailbreakPrefix = getJailbreakPrefix();
const finalSystemPrompt = jailbreakPrefix
? jailbreakPrefix + "\n\n" + baseSystemPrompt
: baseSystemPrompt;
// 构建用户提示词
const finalUserMessage = buildUserPrompt(userMessage);
// 构建详细的 prompt 部分列表
// 构建详细的 prompt 部分列表(用于预览显示)
const promptParts = [];
// 添加破限词
@@ -242,7 +253,7 @@ export async function collectSummaryRequestInfo(book, userMessage, context) {
const mainPromptWithoutInjection =
template.mainPrompt || template.main_prompt || "";
const cleanMainPrompt = replacePromptVariables(
mainPromptWithoutInjection.split("<数据注入>")[0].trim(),
mainPromptWithoutInjection.split("<数据注入>")[0].trim(),
aiConfig,
globalConfig,
);
@@ -280,8 +291,7 @@ export async function collectSummaryRequestInfo(book, userMessage, context) {
source: "user",
});
// 根据流程配置对 promptParts 重新排序
// 使用 "总结世界书" 作为流程类型(与流程配置弹窗中的分类名称一致)
// 根据流程配置对 promptParts 重新排序(使用与实际发送相同的顺序)
const sortedPromptParts = sortPromptPartsByFlowConfig(promptParts, "总结世界书");
return {
@@ -311,6 +321,142 @@ export async function collectSummaryRequestInfo(book, userMessage, context) {
}
}
/**
* 收集单个总结世界书Part的请求信息
* @param {object} book 世界书对象
* @param {object} part Part信息 { id, index, startFloor, endFloor, content, charCount }
* @param {string} userMessage 用户消息
* @param {string} context 上下文
* @returns {Promise<object|null>} 请求信息
*/
export async function collectSummaryPartRequestInfo(book, part, userMessage, context) {
// Part 1index=0复用原总结世界书的 API 配置,其他 Part 使用各自的配置
let aiConfig;
if (part.index === 0) {
aiConfig = getSummaryConfig(book.name);
} else {
aiConfig = getSummaryPartApiConfig(book.name, part.id);
}
if (!aiConfig || !aiConfig.enabled) {
return null;
}
const globalConfig = getGlobalConfig();
try {
// Part 的内容带有标记
const partNumber = part.index + 1;
const partContent = `=== Part ${partNumber} (${part.startFloor}-${part.endFloor}楼) ===\n${part.content}`;
const dataInjection = buildDataInjection({
worldBookContent: partContent,
context: context,
userMessage: userMessage,
});
// 使用历史事件回忆提示词模板
const template = await getHistoricalPromptTemplate();
// 获取破限词前缀
const jailbreakPrefix = getJailbreakPrefix();
// 使用与 processor.js 相同的方式构建提示词
const prompt = injectDataToPrompt(template, dataInjection, {
flowType: "总结世界书",
jailbreakPrefix: jailbreakPrefix,
});
// 替换变量得到最终系统提示词
const finalSystemPrompt = replacePromptVariables(
prompt.systemPrompt,
aiConfig,
globalConfig,
);
// 构建用户提示词
const finalUserMessage = buildUserPrompt(userMessage);
// 构建详细的 prompt 部分列表
const promptParts = [];
if (jailbreakPrefix && jailbreakPrefix.trim()) {
promptParts.push({
label: "破限词",
content: jailbreakPrefix,
source: "jailbreak",
});
}
const mainPromptWithoutInjection = template.mainPrompt || template.main_prompt || "";
const cleanMainPrompt = replacePromptVariables(
mainPromptWithoutInjection.split("<数据注入点>")[0].trim(),
aiConfig,
globalConfig,
);
if (cleanMainPrompt) {
promptParts.push({
label: "主提示词",
content: cleanMainPrompt,
source: "main",
});
}
if (prompt.injectionParts && prompt.injectionParts.length > 0) {
promptParts.push(...prompt.injectionParts);
}
if (prompt.auxiliaryPrompt && prompt.auxiliaryPrompt.trim()) {
const processedAuxiliary = replacePromptVariables(
prompt.auxiliaryPrompt,
aiConfig,
globalConfig,
);
promptParts.push({
label: "辅助提示词",
content: processedAuxiliary,
source: "auxiliary",
});
}
promptParts.push({
label: SOURCE_LABELS.user || "用户消息",
content: finalUserMessage,
source: "user",
});
const sortedPromptParts = sortPromptPartsByFlowConfig(promptParts, "总结世界书");
return {
category: `${book.name} - Part ${partNumber} (${part.startFloor}-${part.endFloor}楼)`,
source: `${book.name}_part_${part.id}`,
model: aiConfig.model || "未指定模型",
promptParts: sortedPromptParts,
prompt: `${finalSystemPrompt}\n\n${finalUserMessage}`,
aiConfig: {
apiFormat: aiConfig.apiFormat,
apiUrl: aiConfig.apiUrl,
apiKey: aiConfig.apiKey,
model: aiConfig.model,
maxTokens: aiConfig.maxTokens,
temperature: aiConfig.temperature,
responsePath: aiConfig.responsePath,
},
taskType: "summary_part",
bookName: book.name,
partId: part.id,
startFloor: part.startFloor,
endFloor: part.endFloor,
};
} catch (err) {
Logger.error(
`收集总结任务 "${book.name}" Part ${part.index + 1} 请求信息失败:`,
err.message,
);
return null;
}
}
/**
* 收集索引合并任务的请求信息
* @param {string} mergedContent 合并后的索引内容
@@ -337,23 +483,27 @@ export async function collectIndexMergeRequestInfo(
});
const template = await getPromptTemplate();
const prompt = injectDataToPrompt(template, dataInjection);
const baseSystemPrompt = replacePromptVariables(
// 获取破限词前缀
const jailbreakPrefix = getJailbreakPrefix();
// 使用与 processor.js 相同的方式构建提示词(包含流程配置顺序)
const prompt = injectDataToPrompt(template, dataInjection, {
flowType: "索引合并",
jailbreakPrefix: jailbreakPrefix,
});
// 替换变量得到最终系统提示词
const finalSystemPrompt = replacePromptVariables(
prompt.systemPrompt,
indexMergeConfig,
globalConfig,
);
// 添加破限词前缀
const jailbreakPrefix = getJailbreakPrefix();
const finalSystemPrompt = jailbreakPrefix
? jailbreakPrefix + "\n\n" + baseSystemPrompt
: baseSystemPrompt;
// 构建用户提示词
const finalUserMessage = buildUserPrompt(userMessage);
// 构建详细的 prompt 部分列表
// 构建详细的 prompt 部分列表(用于预览显示)
const promptParts = [];
// 添加破限词
@@ -369,7 +519,7 @@ export async function collectIndexMergeRequestInfo(
const mainPromptWithoutInjection =
template.mainPrompt || template.main_prompt || "";
const cleanMainPrompt = replacePromptVariables(
mainPromptWithoutInjection.split("<数据注入>")[0].trim(),
mainPromptWithoutInjection.split("<数据注入>")[0].trim(),
indexMergeConfig,
globalConfig,
);
@@ -496,6 +646,30 @@ export async function collectAllRequestInfos(
continue;
}
// 检查是否启用拆分
if (isSummaryAutoSplitEnabled()) {
const summaryContent = getSummaryContent(book);
const splitConfig = getSummaryAutoSplitConfig();
const parts = analyzeSummaryContent(summaryContent, splitConfig);
if (parts.length > 1) {
// 拆分模式为每个Part收集请求信息
for (const part of parts) {
const partInfo = await collectSummaryPartRequestInfo(
book,
part,
userMessage,
context,
);
if (partInfo) {
requestInfos.push(partInfo);
}
}
continue; // 跳过整本书的收集
}
}
// 未启用拆分或内容不足以拆分:收集整本书的请求信息
const summaryInfo = await collectSummaryRequestInfo(
book,
userMessage,

View File

@@ -109,7 +109,8 @@ export function mergeResults(results, latestContext = "") {
) {
events.split("\n").forEach((line) => {
const trimmed = line.trim();
if (trimmed && /^【\d+楼】/.test(trimmed)) {
// 兼容多种楼层格式【124楼】、【124至#125】、【124至125楼】
if (trimmed && /^【\d+(?:楼】|至#?\d+楼?】)/.test(trimmed)) {
historicalEvents.add(trimmed);
}
});

View File

@@ -0,0 +1,157 @@
/**
* Amily2Bus 联动集成模块
* 通过 Amily2Bus 暴露并发填表能力
* @module table-filler/bus-integration
*/
import Logger from "@core/logger";
import { getTableFillerConfig, isTableFillerEnabled } from "@config/config-manager";
import { setBusRegistered } from "./mode-manager";
import { splitTablesFromMessages, mergeResults } from "./table-splitter";
import { ParallelExecutor } from "./parallel-executor";
let busContext = null;
/**
* 检查是否有有效配置
* @returns {boolean}
*/
function hasValidConfig() {
const config = getTableFillerConfig();
if (!config) return false;
// 检查默认 API 配置
const defaultApi = config.defaultApi;
if (defaultApi?.apiUrl && defaultApi?.model) {
return true;
}
// 检查是否有表格独立 API 配置
const tableApis = config.tableApiConfigs;
if (tableApis && Object.keys(tableApis).length > 0) {
return true;
}
return false;
}
/**
* 处理来自 Amily2 的 Bus 调用
* @param {Object} params 填表参数
* @returns {Promise<string>}
*/
async function handleBusCall(params) {
const { messages, options = {}, tableData } = params;
const config = getTableFillerConfig();
const executor = new ParallelExecutor(config);
try {
// 如果已提供拆分好的表格数据,直接使用
// 否则从 messages 中提取
const tables = tableData || splitTablesFromMessages(messages);
if (tables.length === 0) {
throw new Error("未能解析出表格数据");
}
Logger.log(`[TableFiller-Bus] 开始并发填表,共 ${tables.length} 个表格`);
const results = await executor.fillAllTables(tables, messages, options);
return mergeResults(results);
} catch (error) {
Logger.error("[TableFiller-Bus] 并发填表失败:", error);
throw error; // 抛出错误让 Amily2 处理回退
}
}
/**
* 通过 Amily2Bus 暴露并发填表能力
* Amily2 可通过 window.Amily2Bus.query('TableFillerProxy') 获取
* @returns {Object|null} Bus 上下文对象
*/
export function registerToBus() {
if (!window.Amily2Bus) {
Logger.warn("[TableFiller] Amily2Bus 未找到,跳过 Bus 注册");
setBusRegistered(false);
return null;
}
try {
// 检查是否已注册
const existing = window.Amily2Bus.query("TableFillerProxy");
if (existing) {
Logger.log("[TableFiller] TableFillerProxy 已存在,跳过重复注册");
setBusRegistered(true);
return existing;
}
// 注册插件身份
busContext = window.Amily2Bus.register("TableFillerProxy");
// 暴露并发填表能力
busContext.expose({
// 版本信息
version: "1.0.0",
description: "Amily2 表格模块并发填表代理",
/**
* 核心方法:并发填表
* @param {Object} params 填表参数
* @param {Array} params.messages 原始 messages 数组
* @param {Object} params.options 调用选项
* @param {Object} params.tableData 可选,已拆分的表格数据
* @returns {Promise<string>} 合并后的 <Amily2Edit> 指令
*/
fillParallel: async (params) => {
return await handleBusCall(params);
},
/**
* 检查并发模式是否可用
* @returns {boolean}
*/
isAvailable: () => {
return isTableFillerEnabled() && hasValidConfig();
},
/**
* 获取当前配置状态
* @returns {Object}
*/
getStatus: () => {
const config = getTableFillerConfig();
return {
enabled: config?.enabled || false,
promptMode: config?.promptMode || "shared",
tableCount: Object.keys(
config?.importedPreset?.tablePresets || {},
).length,
hasDefaultApi: !!(config?.defaultApi?.apiUrl),
};
},
});
busContext.log(
"Init",
"info",
"TableFillerProxy 已通过 Amily2Bus 暴露联动接口",
);
setBusRegistered(true);
Logger.log("[TableFiller] Bus 注册成功");
return busContext;
} catch (e) {
Logger.error("[TableFiller] Bus 注册失败:", e);
setBusRegistered(false);
return null;
}
}
/**
* 获取 Bus 上下文
* @returns {Object|null}
*/
export function getBusContext() {
return busContext;
}

View File

@@ -0,0 +1,761 @@
/**
* 调试弹窗模块
* 用于检查并发填表的提示词和结果
* @module table-filler/debug-modal
*/
import { getGlobalSettings } from "@config/config-manager";
/**
* 获取当前主题
* @returns {string|null}
*/
function getCurrentTheme() {
try {
const settings = getGlobalSettings();
return settings?.theme || null;
} catch {
return null;
}
}
/**
* 获取主题对应的颜色方案
* @param {string} theme
* @returns {Object}
*/
function getThemeColors(theme) {
const themes = {
'default': {
bg: '#1a1a2e',
headerBg: '#2a2a4e',
bodyBg: '#0a0a1e',
border: '#333',
text: '#fff',
textMuted: '#a0a0a0',
primary: '#4a90d9',
success: '#4CAF50',
},
'warm-brown': {
bg: '#2a2520',
headerBg: '#3a3530',
bodyBg: '#1a1510',
border: '#4a4540',
text: '#e4dcd0',
textMuted: '#a09080',
primary: '#a08070',
success: '#6a9a6a',
},
'lavender': {
bg: '#1e1a24',
headerBg: '#2e2a34',
bodyBg: '#0e0a14',
border: '#3a3644',
text: '#e4e0ea',
textMuted: '#9b8aa8',
primary: '#9b8aa8',
success: '#7aa87a',
},
'forest': {
bg: '#1a2420',
headerBg: '#2a3430',
bodyBg: '#0a1410',
border: '#3a4a40',
text: '#e0e8e4',
textMuted: '#6a9a7a',
primary: '#6a9a7a',
success: '#5a8a6a',
},
'rose': {
bg: '#241a1c',
headerBg: '#342a2c',
bodyBg: '#140a0c',
border: '#443a3c',
text: '#e8e0e2',
textMuted: '#b08a90',
primary: '#b08a90',
success: '#8aaa8a',
},
'slate': {
bg: '#1a1e22',
headerBg: '#2a2e32',
bodyBg: '#0a0e12',
border: '#3a3e42',
text: '#e4e8ec',
textMuted: '#7a8a98',
primary: '#7a8a98',
success: '#6a9a7a',
},
'starry-purple': {
bg: 'rgba(26, 21, 37, 0.95)',
headerBg: 'rgba(42, 26, 64, 0.95)',
bodyBg: 'rgba(13, 10, 20, 0.95)',
border: 'rgba(138, 100, 200, 0.3)',
text: '#e4dcea',
textMuted: '#9d7cd8',
primary: '#9d7cd8',
success: '#7ac87a',
},
'starry-blue': {
bg: 'rgba(16, 24, 40, 0.95)',
headerBg: 'rgba(16, 32, 64, 0.95)',
bodyBg: 'rgba(8, 12, 20, 0.95)',
border: 'rgba(100, 150, 220, 0.3)',
text: '#e4ecf4',
textMuted: '#5d8fca',
primary: '#5d8fca',
success: '#6aaa7a',
},
'starry-black': {
bg: 'rgba(12, 12, 16, 0.95)',
headerBg: 'rgba(26, 26, 30, 0.95)',
bodyBg: 'rgba(10, 10, 12, 0.95)',
border: 'rgba(255, 255, 255, 0.15)',
text: '#e8e8ec',
textMuted: '#707078',
primary: '#606068',
success: '#5a8a6a',
},
};
return themes[theme] || themes['default'];
}
/**
* 显示发送前的调试弹窗
* @param {Array} tablePrompts 每个表格的提示词 [{tableName, messages}]
* @returns {Promise<boolean>} 用户是否确认继续
*/
export function showPreSendDebugModal(tablePrompts) {
return new Promise((resolve) => {
const theme = getCurrentTheme() || 'default';
const colors = getThemeColors(theme);
// 创建弹窗容器
const overlay = document.createElement('div');
overlay.id = 'mm-debug-modal-overlay';
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.8);
z-index: 99999;
display: flex;
justify-content: center;
align-items: center;
`;
const modal = document.createElement('div');
modal.style.cssText = `
background: ${colors.bg};
border: 1px solid ${colors.border};
border-radius: 8px;
width: 90%;
max-width: 1200px;
max-height: 90vh;
display: flex;
flex-direction: column;
color: ${colors.text};
`;
// 应用主题属性
if (theme !== 'default') {
modal.setAttribute('data-mm-theme', theme);
}
// 标题栏
const header = document.createElement('div');
header.style.cssText = `
padding: 16px 20px;
border-bottom: 1px solid ${colors.border};
font-size: 18px;
font-weight: bold;
`;
header.textContent = `📋 发送前检查 - ${tablePrompts.length} 个表格的提示词`;
// 内容区域
const content = document.createElement('div');
content.style.cssText = `
flex: 1;
overflow-y: auto;
padding: 16px;
`;
// 为每个表格创建折叠面板
tablePrompts.forEach((item, index) => {
const panel = createCollapsiblePanel(
`${index + 1}. ${item.tableName}`,
formatMessages(item.messages),
false, // 默认折叠
colors
);
content.appendChild(panel);
});
// 按钮区域
const footer = document.createElement('div');
footer.style.cssText = `
padding: 16px 20px;
border-top: 1px solid ${colors.border};
display: flex;
justify-content: flex-end;
gap: 12px;
`;
const cancelBtn = document.createElement('button');
cancelBtn.textContent = '取消发送';
cancelBtn.style.cssText = `
padding: 10px 24px;
border: 1px solid ${colors.textMuted};
background: transparent;
color: ${colors.text};
border-radius: 4px;
cursor: pointer;
`;
cancelBtn.onclick = () => {
overlay.remove();
resolve(false);
};
const confirmBtn = document.createElement('button');
confirmBtn.textContent = '确认发送';
confirmBtn.style.cssText = `
padding: 10px 24px;
border: none;
background: ${colors.success};
color: #fff;
border-radius: 4px;
cursor: pointer;
`;
confirmBtn.onclick = () => {
overlay.remove();
resolve(true);
};
footer.appendChild(cancelBtn);
footer.appendChild(confirmBtn);
modal.appendChild(header);
modal.appendChild(content);
modal.appendChild(footer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
});
}
/**
* 显示合并结果的调试弹窗
* @param {Array} results 每个表格的结果 [{tableName, response, success}]
* @param {string} mergedContent 合并后的内容
* @returns {Promise<boolean>} 用户是否确认继续
*/
export function showPostMergeDebugModal(results, mergedContent) {
return new Promise((resolve) => {
const theme = getCurrentTheme() || 'default';
const colors = getThemeColors(theme);
const overlay = document.createElement('div');
overlay.id = 'mm-debug-modal-overlay';
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.8);
z-index: 99999;
display: flex;
justify-content: center;
align-items: center;
`;
const modal = document.createElement('div');
modal.style.cssText = `
background: ${colors.bg};
border: 1px solid ${colors.border};
border-radius: 8px;
width: 90%;
max-width: 1200px;
max-height: 90vh;
display: flex;
flex-direction: column;
color: ${colors.text};
`;
// 应用主题属性
if (theme !== 'default') {
modal.setAttribute('data-mm-theme', theme);
}
// 标题栏
const header = document.createElement('div');
header.style.cssText = `
padding: 16px 20px;
border-bottom: 1px solid ${colors.border};
font-size: 18px;
font-weight: bold;
`;
const successCount = results.filter(r => r.success).length;
header.textContent = `📥 合并结果检查 - ${successCount}/${results.length} 成功`;
// 内容区域
const content = document.createElement('div');
content.style.cssText = `
flex: 1;
overflow-y: auto;
padding: 16px;
`;
// 每个表格的原始响应
results.forEach((item, index) => {
const statusIcon = item.success ? '✅' : '❌';
const panel = createCollapsiblePanel(
`${statusIcon} ${index + 1}. ${item.tableName}`,
item.response || '(无响应)',
false,
colors
);
content.appendChild(panel);
});
// 合并后的最终内容
const mergedPanel = createCollapsiblePanel(
'📦 合并后的最终内容(将返回给 Amily',
mergedContent,
true, // 默认展开
colors
);
mergedPanel.style.marginTop = '20px';
mergedPanel.style.borderColor = colors.success;
content.appendChild(mergedPanel);
// 按钮区域
const footer = document.createElement('div');
footer.style.cssText = `
padding: 16px 20px;
border-top: 1px solid ${colors.border};
display: flex;
justify-content: flex-end;
gap: 12px;
`;
const cancelBtn = document.createElement('button');
cancelBtn.textContent = '取消(回退原始请求)';
cancelBtn.style.cssText = `
padding: 10px 24px;
border: 1px solid ${colors.textMuted};
background: transparent;
color: ${colors.text};
border-radius: 4px;
cursor: pointer;
`;
cancelBtn.onclick = () => {
overlay.remove();
resolve(false);
};
const confirmBtn = document.createElement('button');
confirmBtn.textContent = '确认返回给 Amily';
confirmBtn.style.cssText = `
padding: 10px 24px;
border: none;
background: ${colors.success};
color: #fff;
border-radius: 4px;
cursor: pointer;
`;
confirmBtn.onclick = () => {
overlay.remove();
resolve(true);
};
footer.appendChild(cancelBtn);
footer.appendChild(confirmBtn);
modal.appendChild(header);
modal.appendChild(content);
modal.appendChild(footer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
});
}
/**
* 创建可折叠面板
* @param {string} title 标题
* @param {string} content 内容
* @param {boolean} expanded 是否默认展开
* @param {Object} colors 颜色方案
*/
function createCollapsiblePanel(title, content, expanded = false, colors = null) {
// 如果没有传入颜色,使用默认主题
if (!colors) {
const theme = getCurrentTheme() || 'default';
colors = getThemeColors(theme);
}
const panel = document.createElement('div');
panel.style.cssText = `
border: 1px solid ${colors.border};
border-radius: 4px;
margin-bottom: 8px;
overflow: hidden;
`;
const header = document.createElement('div');
header.style.cssText = `
padding: 12px 16px;
background: ${colors.headerBg};
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
user-select: none;
`;
const titleSpan = document.createElement('span');
titleSpan.textContent = title;
const arrow = document.createElement('span');
arrow.textContent = expanded ? '▼' : '▶';
arrow.style.transition = 'transform 0.2s';
header.appendChild(titleSpan);
header.appendChild(arrow);
const body = document.createElement('div');
body.style.cssText = `
padding: 12px 16px;
background: ${colors.bodyBg};
white-space: pre-wrap;
word-break: break-all;
font-family: monospace;
font-size: 12px;
max-height: 400px;
overflow-y: auto;
display: ${expanded ? 'block' : 'none'};
color: ${colors.text};
`;
body.textContent = content;
header.onclick = () => {
const isExpanded = body.style.display !== 'none';
body.style.display = isExpanded ? 'none' : 'block';
arrow.textContent = isExpanded ? '▶' : '▼';
};
panel.appendChild(header);
panel.appendChild(body);
return panel;
}
/**
* 格式化 messages 数组为可读文本
*/
function formatMessages(messages) {
if (!messages || !Array.isArray(messages)) {
return '(无消息)';
}
return messages.map((msg, i) => {
const role = msg.role || 'unknown';
const content = msg.content || '(空)';
// 不再截断,显示完整内容
return `=== [${i}] ${role.toUpperCase()} ===\n${content}`;
}).join('\n\n');
}
/**
* 显示失败重试横幅右下角通知样式类似Win10通知
* @param {Array} failedTables 失败的表格列表 [{tableName, error, retryAttempts}]
* @param {Function} onRetry 重试回调
* @param {Function} onGiveUp 放弃回调
* @returns {HTMLElement} 横幅元素(用于外部控制移除)
*/
export function showRetryBanner(failedTables, onRetry, onGiveUp) {
// 移除已存在的横幅
const existingBanner = document.getElementById('mm-retry-banner');
if (existingBanner) {
existingBanner.remove();
}
// 添加动画和响应式样式
if (!document.getElementById('mm-banner-styles')) {
const style = document.createElement('style');
style.id = 'mm-banner-styles';
style.textContent = `
@keyframes mm-banner-slide-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes mm-banner-slide-out {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
@keyframes mm-twinkle {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
#mm-retry-banner {
position: fixed;
bottom: 20px;
right: 20px;
width: 300px;
max-width: calc(100vw - 40px);
background: rgba(15, 52, 96, 0.75);
border: 1px solid rgba(255, 255, 255, 0.1);
border-left: 3px solid #dc3545;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
z-index: 99998;
animation: mm-banner-slide-in 0.3s ease-out;
overflow: hidden;
}
/* 暖灰棕主题 */
#mm-retry-banner[data-mm-theme="warm-brown"] {
background: rgba(61, 53, 46, 0.75);
border-color: rgba(255, 255, 255, 0.1);
}
/* 淡紫薰衣草主题 */
#mm-retry-banner[data-mm-theme="lavender"] {
background: rgba(45, 40, 56, 0.75);
border-color: rgba(255, 255, 255, 0.1);
}
/* 森林绿主题 */
#mm-retry-banner[data-mm-theme="forest"] {
background: rgba(37, 53, 48, 0.75);
border-color: rgba(255, 255, 255, 0.1);
}
/* 玫瑰灰主题 */
#mm-retry-banner[data-mm-theme="rose"] {
background: rgba(56, 40, 48, 0.75);
border-color: rgba(255, 255, 255, 0.1);
}
/* 静谧蓝灰主题 */
#mm-retry-banner[data-mm-theme="slate"] {
background: rgba(40, 46, 53, 0.75);
border-color: rgba(255, 255, 255, 0.1);
}
/* 星空紫主题 */
#mm-retry-banner[data-mm-theme="starry-purple"] {
background:
radial-gradient(1px 1px at 20px 30px, rgba(255,255,255,0.8), transparent),
radial-gradient(1px 1px at 40px 70px, rgba(255,255,255,0.6), transparent),
radial-gradient(1px 1px at 50px 160px, rgba(255,255,255,0.7), transparent),
radial-gradient(1.5px 1.5px at 100px 40px, rgba(255,255,255,0.9), transparent),
radial-gradient(1px 1px at 130px 80px, rgba(255,255,255,0.5), transparent),
radial-gradient(1.5px 1.5px at 160px 120px, rgba(255,255,255,0.8), transparent),
radial-gradient(1px 1px at 200px 50px, rgba(255,255,255,0.6), transparent),
radial-gradient(1px 1px at 250px 90px, rgba(255,255,255,0.7), transparent),
radial-gradient(1.5px 1.5px at 280px 140px, rgba(255,255,255,0.5), transparent),
rgba(26, 21, 37, 0.7);
border-color: rgba(138, 100, 200, 0.3);
}
/* 星空蓝主题 */
#mm-retry-banner[data-mm-theme="starry-blue"] {
background:
radial-gradient(1px 1px at 15px 25px, rgba(255,255,255,0.8), transparent),
radial-gradient(1.5px 1.5px at 45px 65px, rgba(200,220,255,0.9), transparent),
radial-gradient(1px 1px at 75px 150px, rgba(255,255,255,0.6), transparent),
radial-gradient(1px 1px at 110px 35px, rgba(200,220,255,0.7), transparent),
radial-gradient(1.5px 1.5px at 140px 95px, rgba(255,255,255,0.8), transparent),
radial-gradient(1px 1px at 180px 55px, rgba(200,220,255,0.5), transparent),
radial-gradient(1px 1px at 220px 110px, rgba(255,255,255,0.7), transparent),
radial-gradient(1.5px 1.5px at 260px 70px, rgba(200,220,255,0.6), transparent),
radial-gradient(1px 1px at 290px 130px, rgba(255,255,255,0.5), transparent),
rgba(16, 24, 40, 0.7);
border-color: rgba(100, 150, 220, 0.3);
}
/* 星空黑主题 */
#mm-retry-banner[data-mm-theme="starry-black"] {
background:
radial-gradient(1px 1px at 10px 20px, rgba(255,255,255,0.9), transparent),
radial-gradient(1.5px 1.5px at 35px 75px, rgba(255,255,255,0.7), transparent),
radial-gradient(1px 1px at 60px 140px, rgba(255,255,255,0.8), transparent),
radial-gradient(1px 1px at 95px 30px, rgba(255,255,255,0.6), transparent),
radial-gradient(1.5px 1.5px at 125px 100px, rgba(255,255,255,0.9), transparent),
radial-gradient(1px 1px at 165px 60px, rgba(255,255,255,0.5), transparent),
radial-gradient(1px 1px at 195px 120px, rgba(255,255,255,0.7), transparent),
radial-gradient(1.5px 1.5px at 235px 45px, rgba(255,255,255,0.6), transparent),
radial-gradient(1px 1px at 275px 85px, rgba(255,255,255,0.8), transparent),
rgba(12, 12, 16, 0.75);
border-color: rgba(255, 255, 255, 0.15);
}
#mm-retry-banner .mm-banner-content {
padding: 12px 14px;
}
#mm-retry-banner .mm-banner-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
#mm-retry-banner .mm-banner-icon {
color: #dc3545;
font-size: 16px;
flex-shrink: 0;
}
#mm-retry-banner .mm-banner-title {
color: #e4e4e4;
font-weight: 600;
font-size: 13px;
flex: 1;
}
#mm-retry-banner .mm-banner-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
#mm-retry-banner .mm-banner-btn {
padding: 6px 14px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s ease;
}
#mm-retry-banner .mm-banner-btn-secondary {
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.08);
color: #a0a0a0;
}
#mm-retry-banner .mm-banner-btn-secondary:hover {
border-color: rgba(255, 255, 255, 0.3);
color: #e4e4e4;
background: rgba(255, 255, 255, 0.12);
}
#mm-retry-banner .mm-banner-btn-primary {
border: none;
background: #4a90d9;
color: #fff;
font-weight: 500;
}
#mm-retry-banner .mm-banner-btn-primary:hover {
background: #3a7bc8;
}
#mm-retry-banner .mm-banner-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 移动端适配 */
@media (max-width: 400px) {
#mm-retry-banner {
bottom: 10px;
right: 10px;
width: calc(100vw - 20px);
}
#mm-retry-banner .mm-banner-content {
padding: 10px 12px;
}
#mm-retry-banner .mm-banner-btn {
padding: 5px 12px;
font-size: 11px;
}
}
`;
document.head.appendChild(style);
}
const banner = document.createElement('div');
banner.id = 'mm-retry-banner';
// 应用当前主题
const theme = getCurrentTheme();
if (theme && theme !== 'default') {
banner.setAttribute('data-mm-theme', theme);
}
const content = document.createElement('div');
content.className = 'mm-banner-content';
// 标题行
const header = document.createElement('div');
header.className = 'mm-banner-header';
const icon = document.createElement('span');
icon.className = 'mm-banner-icon';
icon.innerHTML = '<i class="fa-solid fa-exclamation-triangle"></i>';
const title = document.createElement('span');
title.className = 'mm-banner-title';
title.textContent = `${failedTables.length} 个表格填充失败`;
header.appendChild(icon);
header.appendChild(title);
// 按钮行
const actions = document.createElement('div');
actions.className = 'mm-banner-actions';
const giveUpBtn = document.createElement('button');
giveUpBtn.className = 'mm-banner-btn mm-banner-btn-secondary';
giveUpBtn.textContent = '放弃';
giveUpBtn.onclick = () => {
banner.remove();
onGiveUp();
};
const retryBtn = document.createElement('button');
retryBtn.className = 'mm-banner-btn mm-banner-btn-primary';
retryBtn.textContent = '重试';
retryBtn.onclick = () => {
retryBtn.disabled = true;
retryBtn.textContent = '重试中...';
giveUpBtn.disabled = true;
onRetry();
};
actions.appendChild(giveUpBtn);
actions.appendChild(retryBtn);
content.appendChild(header);
content.appendChild(actions);
banner.appendChild(content);
document.body.appendChild(banner);
return banner;
}
/**
* 更新重试横幅状态
* @param {Array} failedTables 失败的表格列表
*/
export function updateRetryBanner(failedTables) {
const banner = document.getElementById('mm-retry-banner');
if (!banner) return;
const title = banner.querySelector('.mm-banner-title');
if (title) {
title.textContent = `${failedTables.length} 个表格填充失败`;
}
}
/**
* 移除重试横幅
*/
export function removeRetryBanner() {
const banner = document.getElementById('mm-retry-banner');
if (banner) {
banner.style.animation = 'mm-banner-slide-out 0.3s ease-out forwards';
setTimeout(() => banner.remove(), 280);
}
}
/**
* 显示重试进度提示
* @param {string} tableName 表格名称
* @param {number} attempt 当前尝试次数
* @param {number} maxRetry 最大重试次数
*/
export function showRetryProgress(tableName, attempt, maxRetry) {
// 使用 toastr 显示(如果可用)
if (window.toastr) {
window.toastr.info(
`正在重试 ${tableName}${attempt}/${maxRetry}...`,
"并发填表",
{ timeOut: 2000 }
);
}
}

View File

@@ -0,0 +1,668 @@
/**
* Fetch 拦截器
* 通过替换 window.fetch 和 XMLHttpRequest 拦截 Amily2 的 API 请求
*
* 工作原理:
* 1. 替换 window.fetch覆盖 openai、openai_test、google 模式)
* 2. 替换 XMLHttpRequest覆盖 sillytavern_backend 的 $.ajax 调用)
* 3. 拦截发往 AI 请求端点的请求
* 4. 检查请求体中是否包含表格填充特征
* 5. 如果是表格请求:拆分 → 并发调用 → 合并 → 返回伪造响应
* 6. 如果不是:透传给原始函数
*
* @module table-filler/fetch-interceptor
*/
import Logger from "@core/logger";
import { getTableFillerConfig, isTableFillerEnabled, isDebugModeEnabled } from "@config/config-manager";
import { splitTablesFromMessages, mergeResults } from "./table-splitter";
import { ParallelExecutor } from "./parallel-executor";
import { showPostMergeDebugModal, showRetryBanner, removeRetryBanner } from "./debug-modal";
// 原始函数引用
let originalFetch = null;
let originalXHROpen = null;
let originalXHRSend = null;
// 安装状态
let isFetchInstalled = false;
let isXHRInstalled = false;
// 进度回调
let progressCallback = null;
// 并发填表正在进行中的标记(防止循环拦截)
let isParallelFillInProgress = false;
// 需要拦截的 API 端点
const INTERCEPT_ENDPOINTS = [
'/api/backends/chat-completions/generate',
'/v1/chat/completions',
'/chat/completions'
];
/**
* 设置进度回调
* @param {Function} callback
*/
export function setFetchInterceptorProgressCallback(callback) {
progressCallback = callback;
}
/**
* 安装 Fetch 拦截器
* @returns {boolean} 是否成功安装
*/
export function installFetchInterceptor() {
if (isFetchInstalled) {
Logger.log("[FetchInterceptor] Fetch 拦截器已安装,跳过");
return true;
}
if (!window.fetch) {
Logger.error("[FetchInterceptor] window.fetch 不存在");
return false;
}
// 保存原始 fetch
originalFetch = window.fetch;
// 替换为拦截版本
window.fetch = interceptedFetch;
isFetchInstalled = true;
Logger.log("[FetchInterceptor] ✓ Fetch 拦截器已安装");
return true;
}
/**
* 安装 XMLHttpRequest 拦截器(覆盖 $.ajax
* @returns {boolean} 是否成功安装
*/
export function installXHRInterceptor() {
if (isXHRInstalled) {
Logger.log("[FetchInterceptor] XHR 拦截器已安装,跳过");
return true;
}
if (!window.XMLHttpRequest) {
Logger.error("[FetchInterceptor] XMLHttpRequest 不存在");
return false;
}
// 保存原始函数
originalXHROpen = XMLHttpRequest.prototype.open;
originalXHRSend = XMLHttpRequest.prototype.send;
// 拦截 open 记录 URL 和 method
XMLHttpRequest.prototype.open = function(method, url, ...args) {
this._tableFillerUrl = url;
this._tableFillerMethod = method;
return originalXHROpen.apply(this, [method, url, ...args]);
};
// 拦截 send 检查请求体
XMLHttpRequest.prototype.send = function(body) {
const xhr = this;
const url = this._tableFillerUrl;
const method = this._tableFillerMethod;
// 只检测 POST 请求到 AI 端点(仅记录日志,不拦截)
if (method === 'POST' && shouldInterceptUrl(url)) {
try {
const bodyObj = typeof body === 'string' ? JSON.parse(body) : body;
if (bodyObj && isTableFillerEnabled() && isTableFillerRequest(bodyObj.messages)) {
Logger.log("[FetchInterceptor] ✓ XHR 检测到表格填充请求,但暂时不启用并发(调试中)");
}
} catch (e) {
// 静默忽略解析错误
}
}
return originalXHRSend.apply(this, [body]);
};
isXHRInstalled = true;
Logger.log("[FetchInterceptor] ✓ XHR 拦截器已安装");
return true;
}
/**
* 卸载 Fetch 拦截器
*/
export function uninstallFetchInterceptor() {
if (isFetchInstalled && originalFetch) {
window.fetch = originalFetch;
originalFetch = null;
isFetchInstalled = false;
Logger.log("[FetchInterceptor] Fetch 拦截器已卸载");
}
if (isXHRInstalled && originalXHROpen && originalXHRSend) {
XMLHttpRequest.prototype.open = originalXHROpen;
XMLHttpRequest.prototype.send = originalXHRSend;
originalXHROpen = null;
originalXHRSend = null;
isXHRInstalled = false;
Logger.log("[FetchInterceptor] XHR 拦截器已卸载");
}
}
/**
* 获取安装状态
* @returns {boolean}
*/
export function isFetchInterceptorInstalled() {
return isFetchInstalled || isXHRInstalled;
}
/**
* 拦截后的 fetch 函数
* @param {string|Request} input URL 或 Request 对象
* @param {RequestInit} init 请求配置
* @returns {Promise<Response>}
*/
async function interceptedFetch(input, init) {
// 获取 URL
const url = typeof input === 'string' ? input : input.url;
// 只拦截 SillyTavern 的 AI 请求端点
if (shouldInterceptUrl(url)) {
// 如果并发填表正在进行中,跳过拦截(防止循环)
if (isParallelFillInProgress) {
return originalFetch.apply(window, [input, init]);
}
try {
// 先检查请求体大小,避免解析过大的请求
const bodySize = init?.body?.length || 0;
// 如果请求体超过 5MB跳过拦截可能导致内存问题
if (bodySize > 5 * 1024 * 1024) {
console.warn("[MM] 请求体过大,跳过拦截:", bodySize);
return originalFetch.apply(window, [input, init]);
}
const body = parseRequestBody(init);
if (body && isTableFillerEnabled() && isTableFillerRequest(body.messages)) {
// 检查是否有配置的表格 API
const config = getTableFillerConfig();
const hasTableConfigs = config.tableApiConfigs && Object.keys(config.tableApiConfigs).length > 0;
if (hasTableConfigs) {
// 尝试解析表格
const tables = splitTablesFromMessages(body.messages);
if (tables.length > 1) {
// 有多个表格,启用并发处理
Logger.log(`[FetchInterceptor] 检测到 ${tables.length} 个表格,启用并发模式`);
try {
// 执行并发填表
const response = await handleParallelFill(url, body, init);
return response;
} catch (parallelError) {
Logger.error("[FetchInterceptor] 并发填表失败,回退到原始请求:", parallelError);
// 回退到原始请求
return originalFetch.apply(window, [input, init]);
}
} else {
Logger.log("[FetchInterceptor] 只有单个表格,使用原始请求");
}
}
}
} catch (e) {
Logger.error("[FetchInterceptor] 拦截处理错误:", e);
}
}
// 透传给原始 fetch
return originalFetch.apply(window, [input, init]);
}
/**
* 检查是否应该拦截此 URL
* @param {string} url
* @returns {boolean}
*/
function shouldInterceptUrl(url) {
if (!url || typeof url !== 'string') return false;
// 检查是否匹配任一端点
return INTERCEPT_ENDPOINTS.some(endpoint => url.includes(endpoint));
}
/**
* 解析请求体
* @param {RequestInit} init
* @returns {Object|null}
*/
function parseRequestBody(init) {
if (!init || !init.body) return null;
try {
if (typeof init.body === 'string') {
return JSON.parse(init.body);
}
// 如果是其他类型(如 FormData无法解析
return null;
} catch {
return null;
}
}
/**
* 检测是否为 Amily2 表格填充请求
* 不检查填表模式,只检测请求特征
* @param {Array} messages 消息数组
* @returns {boolean}
*/
function isTableFillerRequest(messages) {
if (!messages || !Array.isArray(messages)) return false;
// 检测 Amily2 表格模块的特征标记
// 遍历消息而不是序列化整个数组(避免性能问题)
try {
for (const msg of messages) {
const content = msg.content;
if (!content || typeof content !== 'string') continue;
// 主要特征flowTemplate 中的标记
if (content.includes('# dataTable 说明') || content.includes('dataTable 说明')) {
Logger.log("[FetchInterceptor] ✓ 检测到 dataTable 说明特征");
return true;
}
// 检查辅助特征需要至少2个
let auxCount = 0;
// 辅助特征ruleTemplate 中的身份标识
if (content.includes('职业是小说填表AI') || content.includes('酒馆国家的臣民')) {
auxCount++;
}
// 辅助特征:输出格式标签
if (content.includes('<Amily2Edit>') || content.includes('Amily2Edit')) {
auxCount++;
}
// 辅助特征:表格操作函数
if (content.includes('insertRow(') || content.includes('updateRow(') || content.includes('deleteRow(')) {
auxCount++;
}
// 辅助特征Amily2TableData 占位符或表格结构
if (content.includes('Amily2TableData') || content.includes('rowIndex')) {
auxCount++;
}
if (auxCount >= 2) {
Logger.log(`[FetchInterceptor] ✓ 检测到 ${auxCount} 个辅助特征`);
return true;
}
}
return false;
} catch {
return false;
}
}
/**
* 处理并发填表
* @param {string} url 原始请求 URL
* @param {Object} requestBody 请求体
* @param {RequestInit} originalInit 原始请求配置
* @returns {Promise<Response>}
*/
async function handleParallelFill(url, requestBody, originalInit) {
// 设置标记,防止循环拦截
isParallelFillInProgress = true;
const config = getTableFillerConfig();
const executor = new ParallelExecutor(config);
// 设置进度回调
if (progressCallback) {
executor.setProgressCallback(progressCallback);
}
try {
// 从 messages 中提取表格数据
const tables = splitTablesFromMessages(requestBody.messages);
if (tables.length === 0) {
Logger.warn("[FetchInterceptor] 未检测到多表格,使用原始请求");
// 回退到原始请求
return originalFetch.apply(window, [url, originalInit]);
}
Logger.log(`[FetchInterceptor] 检测到 ${tables.length} 个表格,启用并发模式`);
// 显示开始通知
if (window.toastr) {
window.toastr.info(
`正在并发处理 ${tables.length} 个表格...`,
"并发填表",
{ timeOut: 3000 }
);
}
// 并发填充(发送前调试弹窗在 executor 内部处理)
let results = await executor.fillAllTables(tables, requestBody, {
originalUrl: url,
originalInit: originalInit,
originalFetch: originalFetch
});
// 检查是否用户取消
const allCancelled = results.every(r => r.error?.message === "用户取消");
if (allCancelled) {
Logger.log("[FetchInterceptor] 用户取消,回退到原始请求");
return originalFetch.apply(window, [url, originalInit]);
}
// 处理失败表格的重试交互
let failedResults = results.filter(r => !r.success);
let successResults = results.filter(r => r.success);
// 如果有失败的表格,显示重试横幅让用户选择(必须等待用户操作)
while (failedResults.length > 0) {
const userChoice = await showRetryInteraction(failedResults);
if (userChoice === 'giveup') {
// 用户选择放弃,继续处理已成功的结果
Logger.log("[FetchInterceptor] 用户放弃重试失败的表格");
break;
}
// 用户选择重试
Logger.log(`[FetchInterceptor] 用户选择重试 ${failedResults.length} 个失败的表格`);
// 找到对应的表格对象
const failedTableNames = failedResults.map(r => r.tableName);
const failedTables = tables.filter(t => failedTableNames.includes(t.name));
// 重新创建 executor 并重试
const retryExecutor = new ParallelExecutor(config);
if (progressCallback) {
retryExecutor.setProgressCallback(progressCallback);
}
const retryResults = await retryExecutor.fillAllTables(failedTables, requestBody, {
originalUrl: url,
originalInit: originalInit,
originalFetch: originalFetch
});
// 更新结果
for (const retryResult of retryResults) {
if (retryResult.success) {
// 成功了,从失败列表移到成功列表
successResults.push(retryResult);
failedResults = failedResults.filter(r => r.tableName !== retryResult.tableName);
} else {
// 仍然失败,更新错误信息
const idx = failedResults.findIndex(r => r.tableName === retryResult.tableName);
if (idx >= 0) {
failedResults[idx] = retryResult;
}
}
}
// 如果全部成功了,跳出循环
if (failedResults.length === 0) {
Logger.log("[FetchInterceptor] 重试后全部成功");
break;
}
Logger.log(`[FetchInterceptor] 重试后仍有 ${failedResults.length} 个表格失败,等待用户选择`);
}
// 移除重试横幅
removeRetryBanner();
// 合并最终结果
results = [...successResults, ...failedResults];
// 统计结果
const successCount = results.filter(r => r.success).length;
if (successCount === 0) {
// 全部失败时回退到原始请求
Logger.warn("[FetchInterceptor] 所有表格均失败,回退到原始请求");
if (window.toastr) {
window.toastr.error("所有表格填充均失败,回退到原始请求", "并发填表失败");
}
return originalFetch.apply(window, [url, originalInit]);
}
// 合并结果
const mergedContent = mergeResults(results);
// 调试模式:显示合并后的弹窗
if (isDebugModeEnabled()) {
const shouldReturn = await showPostMergeDebugModal(results, mergedContent);
if (!shouldReturn) {
Logger.log("[FetchInterceptor] 用户取消返回,回退到原始请求");
return originalFetch.apply(window, [url, originalInit]);
}
}
// 构造伪造的 Response 对象
return createFakeResponse(mergedContent);
} catch (error) {
Logger.error("[FetchInterceptor] 并发填表失败:", error);
// 显示错误通知,帮助用户了解问题
if (window.toastr) {
window.toastr.error(
`并发填表失败: ${error.message || '未知错误'},已回退到原始请求`,
"并发填表错误",
{ timeOut: 8000 }
);
}
// 失败时回退到原始请求
return originalFetch.apply(window, [url, originalInit]);
} finally {
// 清除标记,允许下一次拦截
isParallelFillInProgress = false;
// 确保横幅被移除
removeRetryBanner();
}
}
/**
* 显示重试交互横幅并等待用户选择
* @param {Array} failedResults 失败的结果
* @returns {Promise<'retry'|'giveup'>}
*/
function showRetryInteraction(failedResults) {
return new Promise((resolve) => {
showRetryBanner(
failedResults,
() => resolve('retry'), // onRetry
() => resolve('giveup') // onGiveUp
);
});
}
/**
* 创建伪造的 Response 对象
* 模拟 OpenAI 兼容格式的响应
* @param {string} content 响应内容
* @returns {Response}
*/
function createFakeResponse(content) {
const responseData = {
id: `chatcmpl-${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: "concurrent-table-filler",
choices: [{
index: 0,
message: {
role: "assistant",
content: content
},
finish_reason: "stop"
}],
usage: {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0
}
};
const responseBody = JSON.stringify(responseData);
return new Response(responseBody, {
status: 200,
statusText: "OK",
headers: {
'Content-Type': 'application/json',
'X-Concurrent-Table-Filler': 'true'
}
});
}
/**
* 导出原始 fetch 引用(供其他模块使用)
* @returns {Function|null}
*/
export function getOriginalFetch() {
return originalFetch;
}
/**
* 处理 XHR 并发填表
* @param {XMLHttpRequest} xhr 原始 XHR 对象
* @param {string} url 请求 URL
* @param {Object} requestBody 请求体对象
* @param {string} originalBody 原始请求体字符串
*/
async function handleXHRParallelFill(xhr, url, requestBody, originalBody) {
const config = getTableFillerConfig();
const executor = new ParallelExecutor(config);
if (progressCallback) {
executor.setProgressCallback(progressCallback);
}
try {
const tables = splitTablesFromMessages(requestBody.messages);
if (tables.length === 0) {
Logger.warn("[FetchInterceptor] XHR 未检测到多表格,使用原始请求");
// 回退到原始请求
return originalXHRSend.call(xhr, originalBody);
}
Logger.log(`[FetchInterceptor] XHR 检测到 ${tables.length} 个表格,启用并发模式`);
if (window.toastr) {
window.toastr.info(
`🚀 正在并发处理 ${tables.length} 个表格...`,
"并发填表已启动",
{ timeOut: 3000 }
);
}
const results = await executor.fillAllTables(tables, requestBody, {
originalUrl: url,
originalFetch: originalFetch
});
const successCount = results.filter(r => r.success).length;
const failedCount = results.length - successCount;
if (window.toastr) {
if (failedCount === 0) {
window.toastr.success(`${successCount} 个表格全部处理成功`, "并发填表完成");
} else if (successCount > 0) {
window.toastr.warning(`⚠️ ${successCount}/${results.length} 个表格成功`, "并发填表部分完成");
} else {
window.toastr.error("❌ 所有表格处理失败", "并发填表失败");
return originalXHRSend.call(xhr, originalBody);
}
}
const mergedContent = mergeResults(results);
// 模拟 XHR 响应
simulateXHRResponse(xhr, mergedContent);
} catch (error) {
Logger.error("[FetchInterceptor] XHR 并发填表失败:", error);
if (window.toastr) {
window.toastr.error(`❌ 并发填表出错: ${error.message}`, "并发填表错误");
}
// 回退到原始请求
return originalXHRSend.call(xhr, originalBody);
}
}
/**
* 模拟 XHR 响应
* @param {XMLHttpRequest} xhr
* @param {string} content 响应内容
*/
function simulateXHRResponse(xhr, content) {
const responseData = {
id: `chatcmpl-${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: "concurrent-table-filler",
choices: [{
index: 0,
message: {
role: "assistant",
content: content
},
finish_reason: "stop"
}],
usage: {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0
}
};
const responseText = JSON.stringify(responseData);
// 设置只读属性需要使用 Object.defineProperty
Object.defineProperty(xhr, 'readyState', { value: 4, writable: false });
Object.defineProperty(xhr, 'status', { value: 200, writable: false });
Object.defineProperty(xhr, 'statusText', { value: 'OK', writable: false });
Object.defineProperty(xhr, 'responseText', { value: responseText, writable: false });
Object.defineProperty(xhr, 'response', { value: responseText, writable: false });
// 触发事件
if (typeof xhr.onreadystatechange === 'function') {
xhr.onreadystatechange();
}
if (typeof xhr.onload === 'function') {
xhr.onload();
}
// 触发 load 事件
try {
xhr.dispatchEvent(new Event('load'));
xhr.dispatchEvent(new Event('loadend'));
} catch (e) {
Logger.debug("[FetchInterceptor] 触发 XHR 事件失败:", e.message);
}
}
/**
* 导出 isTableFillerRequest 供其他模块使用
*/
export { isTableFillerRequest };

155
src/table-filler/index.js Normal file
View File

@@ -0,0 +1,155 @@
/**
* 表格填表模块入口
* 支持多种拦截模式Fetch、XHR、Service、Bus
* @module table-filler/index
*/
import Logger from "@core/logger";
import { getTableFillerConfig, isTableFillerEnabled } from "@config/config-manager";
import { registerToBus, getBusContext } from "./bus-integration";
import { installInterceptor, uninstallInterceptor, setInterceptorProgressCallback, getInterceptorStatus } from "./interceptor";
import { isFetchInterceptorInstalled } from "./fetch-interceptor";
import { isServiceInterceptorInstalled } from "./service-interceptor";
import {
CallMode,
detectAvailableMode,
getModeStatus,
isBusRegistered,
isInterceptorInstalled,
isSecondaryApiMode,
getAmily2FillingModeName,
} from "./mode-manager";
// 导出子模块
export { CallMode, getModeStatus, isSecondaryApiMode, getAmily2FillingModeName } from "./mode-manager";
export { ParallelExecutor } from "./parallel-executor";
export { PromptMode, TABLE_PHASE_MAP } from "./prompt-handler";
export { splitTablesFromMessages, mergeResults } from "./table-splitter";
export { isFetchInterceptorInstalled, getOriginalFetch, isTableFillerRequest } from "./fetch-interceptor";
export { isServiceInterceptorInstalled } from "./service-interceptor";
// 初始化状态
let isInitialized = false;
/**
* 初始化表格填表模块
* @param {boolean} immediate - 是否立即安装拦截器(默认延迟)
* @returns {Promise<void>}
*/
export async function initTableFiller(immediate = false) {
if (isInitialized) {
Logger.log("[TableFiller] 模块已初始化,跳过");
return;
}
const config = getTableFillerConfig();
if (!config?.enabled) {
Logger.log("[TableFiller] 功能未启用");
return;
}
Logger.log("[TableFiller] 开始初始化...");
// 1. 始终注册 Bus 接口(供未来 Amily2 调用)
const busCtx = registerToBus();
// 2. 根据配置决定是否安装拦截器
const mode = config.callMode || CallMode.AUTO;
if (mode === CallMode.AUTO || mode === CallMode.INTERCEPT_ONLY) {
// 检测当前模式
const available = detectAvailableMode();
if (mode === CallMode.AUTO && available.bus) {
// Bus 模式可用,不安装拦截器
Logger.log("[TableFiller] Bus 模式可用,跳过拦截器安装");
} else {
// 安装拦截器
if (immediate) {
// 立即安装(用于重新初始化)
const installed = await installInterceptor();
if (installed) {
Logger.log("[TableFiller] 拦截器安装成功");
// 触发状态更新事件
dispatchStatusUpdateEvent();
}
} else {
// 延迟安装,确保 Amily2 模块已加载(首次初始化)
setTimeout(async () => {
const installed = await installInterceptor();
if (installed) {
Logger.log("[TableFiller] 拦截器安装成功");
// 触发状态更新事件
dispatchStatusUpdateEvent();
}
}, 2000);
}
}
}
isInitialized = true;
Logger.log("[TableFiller] 初始化完成", {
mode: config.callMode,
busRegistered: isBusRegistered(),
});
}
/**
* 触发状态更新事件,通知 UI 刷新
*/
function dispatchStatusUpdateEvent() {
try {
window.dispatchEvent(new CustomEvent('tableFillerStatusUpdate', {
detail: getTableFillerStatus()
}));
} catch (e) {
Logger.debug("[TableFiller] 触发状态更新事件失败:", e.message);
}
}
/**
* 禁用表格填表模块
*/
export function disableTableFiller() {
uninstallInterceptor();
isInitialized = false;
Logger.log("[TableFiller] 模块已禁用");
// 通知已由 UI 层显示,此处不再重复
}
/**
* 获取表格填表模块状态
* @returns {Object}
*/
export function getTableFillerStatus() {
const interceptorStatus = getInterceptorStatus();
return {
initialized: isInitialized,
enabled: isTableFillerEnabled(),
mode: getModeStatus(),
busContext: getBusContext() ? true : false,
interceptor: interceptorStatus,
fetchInterceptor: isFetchInterceptorInstalled(),
};
}
/**
* 设置进度回调
* @param {Function} callback
*/
export function setProgressCallback(callback) {
setInterceptorProgressCallback(callback);
}
/**
* 重新初始化(配置变更后调用)
*/
export async function reinitTableFiller() {
if (isInitialized) {
disableTableFiller();
}
// 使用立即模式安装拦截器,避免延迟导致状态不更新
await initTableFiller(true);
}

View File

@@ -0,0 +1,328 @@
/**
* API 拦截器
* 拦截 Amily2 的 API 调用,实现并发填表
* @module table-filler/interceptor
*
* 实现方式:
* 1. Fetch 拦截:替换 window.fetch覆盖 openai、openai_test、google 模式)
* 2. XHR 拦截:替换 XMLHttpRequest覆盖 sillytavern_backend 的 $.ajax
* 3. Service 拦截Hook ConnectionManagerRequestService覆盖 sillytavern_preset
* 4. 全局钩子:暴露 _tableFillerInterceptor 供 Amily2 可选调用
* 5. Bus 联动:注册 TableFillerProxy 供未来 Amily2 版本直接调用
*/
import Logger from "@core/logger";
import { getTableFillerConfig, isTableFillerEnabled } from "@config/config-manager";
import { setInterceptorInstalled } from "./mode-manager";
import { splitTablesFromMessages, mergeResults } from "./table-splitter";
import { ParallelExecutor } from "./parallel-executor";
import {
installFetchInterceptor,
installXHRInterceptor,
uninstallFetchInterceptor,
isFetchInterceptorInstalled,
setFetchInterceptorProgressCallback,
isTableFillerRequest
} from "./fetch-interceptor";
import {
installServiceInterceptor,
uninstallServiceInterceptor,
isServiceInterceptorInstalled,
setServiceInterceptorProgressCallback
} from "./service-interceptor";
// 原始函数引用(用于 Bus 钩子)
let originalNccsCall = null;
let isHooked = false;
// 进度回调
let progressCallback = null;
/**
* 设置进度回调
* @param {Function} callback
*/
export function setInterceptorProgressCallback(callback) {
progressCallback = callback;
// 同时设置给所有拦截器
setFetchInterceptorProgressCallback(callback);
setServiceInterceptorProgressCallback(callback);
}
// 重新导出 isTableFillerRequest
export { isTableFillerRequest };
/**
* 处理并发填表(核心逻辑)
* @param {Array} messages 消息数组
* @param {Object} options 选项
* @param {Function} fallbackFn 回退函数(可选)
* @returns {Promise<string>}
*/
export async function handleParallelFill(messages, options = {}, fallbackFn = null) {
const config = getTableFillerConfig();
const executor = new ParallelExecutor(config);
// 设置进度回调
if (progressCallback) {
executor.setProgressCallback(progressCallback);
}
try {
// 从 messages 中提取表格数据
const tables = splitTablesFromMessages(messages);
if (tables.length === 0) {
Logger.warn("[Interceptor] 未能解析出表格数据");
if (window.toastr) {
window.toastr.info("未检测到多表格数据,使用原始模式", "并发填表");
}
if (fallbackFn) return fallbackFn(messages, options);
throw new Error("未检测到表格数据");
}
Logger.log(
`[Interceptor] 检测到 ${tables.length} 个表格,启用并发模式`,
);
// 显示通知
if (window.toastr) {
window.toastr.info(
`🚀 正在并发处理 ${tables.length} 个表格...`,
"并发填表已启动",
{ timeOut: 3000 },
);
}
// 并发填充
const results = await executor.fillAllTables(tables, messages, options);
// 统计结果
const successCount = results.filter((r) => r.success).length;
const failedCount = results.length - successCount;
// 显示结果通知
if (window.toastr) {
if (failedCount === 0) {
window.toastr.success(
`${successCount} 个表格全部处理成功`,
"并发填表完成",
);
} else if (successCount > 0) {
window.toastr.warning(
`⚠️ ${successCount}/${results.length} 个表格成功,${failedCount} 个失败`,
"并发填表部分完成",
);
} else {
window.toastr.error(
"❌ 所有表格处理失败",
"并发填表失败",
);
if (fallbackFn) return fallbackFn(messages, options);
throw new Error("所有表格处理失败");
}
}
// 合并结果
return mergeResults(results);
} catch (error) {
Logger.error("[Interceptor] 并发填表失败:", error);
if (window.toastr) {
window.toastr.error(
`❌ 并发填表出错: ${error.message}`,
"并发填表错误",
);
}
// 失败时回退到原始调用
if (fallbackFn) return fallbackFn(messages, options);
throw error;
}
}
/**
* 创建拦截版本的调用函数
* @param {Function} originalFn 原始函数
* @returns {Function}
*/
function createInterceptedCall(originalFn) {
return async function interceptedCall(messages, options = {}) {
if (isTableFillerEnabled() && isTableFillerRequest(messages)) {
Logger.log("[Interceptor] ✓ 检测到表格填充请求,启用并发模式");
return await handleParallelFill(messages, options, originalFn);
}
return originalFn(messages, options);
};
}
/**
* 安装全局钩子
* 在 window 上暴露拦截器接口,供 Amily2 可选调用
*/
function installGlobalHook() {
// 暴露全局拦截器接口
window._tableFillerInterceptor = {
// 版本
version: "1.0.0",
// 检查是否应该拦截此请求
shouldIntercept: (messages) => {
return isTableFillerEnabled() && isTableFillerRequest(messages);
},
// 并发填表接口(供 Amily2 调用)
fillParallel: handleParallelFill,
// 检查是否启用
isEnabled: isTableFillerEnabled,
// 获取配置
getConfig: getTableFillerConfig,
};
Logger.log("[Interceptor] 全局钩子已安装 (window._tableFillerInterceptor)");
}
/**
* 安装 API 拦截器
* 安装所有拦截器以覆盖各种 API 提供商
* @returns {Promise<boolean>}
*/
export async function installInterceptor() {
if (isHooked) {
Logger.log("[Interceptor] 拦截器已安装,跳过");
return true;
}
try {
// 1. 安装 Fetch 拦截器(覆盖 openai、openai_test、google
const fetchInstalled = installFetchInterceptor();
if (fetchInstalled) {
Logger.log("[Interceptor] ✓ Fetch 拦截器安装成功");
}
// 2. 安装 XHR 拦截器(覆盖 sillytavern_backend 的 $.ajax
const xhrInstalled = installXHRInterceptor();
if (xhrInstalled) {
Logger.log("[Interceptor] ✓ XHR 拦截器安装成功");
}
// 3. 安装 Service 拦截器(覆盖 sillytavern_preset
const serviceInstalled = installServiceInterceptor();
if (serviceInstalled) {
Logger.log("[Interceptor] ✓ Service 拦截器安装成功");
}
// 4. 安装全局钩子(预留方式)
installGlobalHook();
// 5. 尝试通过 Amily2Bus 进行更深度的集成(预留方式)
let busHookSuccess = false;
if (window.Amily2Bus) {
try {
const nccsApi = window.Amily2Bus.query("NccsApi");
if (nccsApi && typeof nccsApi.call === 'function') {
originalNccsCall = nccsApi.call;
nccsApi.call = createInterceptedCall(originalNccsCall);
busHookSuccess = true;
Logger.log("[Interceptor] ✓ Bus.NccsApi.call 已替换");
}
} catch (e) {
Logger.debug("[Interceptor] Bus 钩子失败:", e.message);
}
}
isHooked = true;
setInterceptorInstalled(true);
// 统计安装结果
const installedCount = [fetchInstalled, xhrInstalled, serviceInstalled].filter(Boolean).length;
// 显示状态通知
if (window.toastr) {
if (installedCount > 0) {
window.toastr.success(
`已安装 ${installedCount} 个拦截器`,
"Amily表格并发"
);
} else {
window.toastr.warning(
"拦截器安装失败",
"Amily表格并发"
);
}
}
Logger.log("============================================");
Logger.log("[Interceptor] 拦截器安装完成");
Logger.log("[Interceptor] Fetch 拦截: " + (fetchInstalled ? "✓" : "✗"));
Logger.log("[Interceptor] XHR 拦截: " + (xhrInstalled ? "✓" : "✗"));
Logger.log("[Interceptor] Service 拦截: " + (serviceInstalled ? "✓" : "✗"));
Logger.log("[Interceptor] Bus 钩子: " + (busHookSuccess ? "✓" : "✗"));
Logger.log("[Interceptor] 全局钩子: ✓");
Logger.log("============================================");
return true;
} catch (e) {
Logger.error("[Interceptor] 安装拦截器失败:", e);
setInterceptorInstalled(false);
return false;
}
}
/**
* 卸载 API 拦截器
*/
export function uninstallInterceptor() {
if (!isHooked) {
return;
}
// 1. 卸载 Fetch 和 XHR 拦截器
uninstallFetchInterceptor();
// 2. 卸载 Service 拦截器
uninstallServiceInterceptor();
// 3. 恢复 Bus 上的原始函数
if (window.Amily2Bus && originalNccsCall) {
try {
const nccsApi = window.Amily2Bus.query("NccsApi");
if (nccsApi) {
nccsApi.call = originalNccsCall;
Logger.log("[Interceptor] 已恢复 Bus.NccsApi.call");
}
} catch (e) {
Logger.debug("[Interceptor] 恢复 Bus 钩子失败:", e.message);
}
}
// 4. 清理全局钩子
delete window._tableFillerInterceptor;
originalNccsCall = null;
isHooked = false;
setInterceptorInstalled(false);
Logger.log("[Interceptor] API 拦截器已卸载");
if (window.toastr) {
window.toastr.info("并发填表拦截器已卸载", "Amily表格并发");
}
}
/**
* 获取拦截器状态
* @returns {Object}
*/
export function getInterceptorStatus() {
return {
hooked: isHooked,
fetchInterceptor: isFetchInterceptorInstalled(),
serviceInterceptor: isServiceInterceptorInstalled(),
globalHook: !!window._tableFillerInterceptor,
busHook: !!originalNccsCall
};
}

View File

@@ -0,0 +1,264 @@
/**
* 表格填表模式管理器
* 支持双模式:拦截模式和 Bus 联动模式
* @module table-filler/mode-manager
*/
import Logger from "@core/logger";
/**
* 调用模式枚举
*/
export const CallMode = {
AUTO: "auto", // 自动选择(优先 Busfallback 拦截)
BUS_ONLY: "bus_only", // 仅 Bus 模式(需 Amily2 支持)
INTERCEPT_ONLY: "intercept_only", // 仅拦截模式
};
/**
* 模式状态
*/
const modeState = {
busRegistered: false,
interceptorInstalled: false,
fetchInterceptorInstalled: false,
currentMode: null,
};
/**
* 设置 Bus 注册状态
* @param {boolean} registered
*/
export function setBusRegistered(registered) {
modeState.busRegistered = registered;
}
/**
* 设置拦截器安装状态
* @param {boolean} installed
*/
export function setInterceptorInstalled(installed) {
modeState.interceptorInstalled = installed;
}
/**
* 设置 Fetch 拦截器安装状态
* @param {boolean} installed
*/
export function setFetchInterceptorInstalled(installed) {
modeState.fetchInterceptorInstalled = installed;
}
/**
* 获取 Bus 注册状态
* @returns {boolean}
*/
export function isBusRegistered() {
return modeState.busRegistered;
}
/**
* 获取拦截器安装状态
* @returns {boolean}
*/
export function isInterceptorInstalled() {
return modeState.interceptorInstalled;
}
/**
* 获取 Fetch 拦截器安装状态
* @returns {boolean}
*/
export function isFetchInterceptorInstalled() {
return modeState.fetchInterceptorInstalled;
}
/**
* 检测当前可用的调用模式
* @returns {{bus: boolean, intercept: boolean, recommended: string}}
*/
export function detectAvailableMode() {
const busAvailable = checkBusMode();
const interceptAvailable = checkInterceptMode();
return {
bus: busAvailable,
intercept: interceptAvailable,
recommended: busAvailable
? "bus"
: interceptAvailable
? "intercept"
: "none",
};
}
/**
* 检查 Bus 模式是否可用
* @returns {boolean}
*/
function checkBusMode() {
// 1. Amily2Bus 存在
if (!window.Amily2Bus) return false;
// 2. 已成功注册
const proxy = window.Amily2Bus.query("TableFillerProxy");
if (!proxy) return false;
// 3. Amily2 支持 Bus 调用(检查 Amily2 版本或标记)
// 这个标记需要等 Amily2 更新后才会存在
const amilyApi = window.Amily2Bus.query("Amily2");
const amilySupport = amilyApi?.supportsBusTableFiller;
return !!amilySupport;
}
/**
* 检查拦截模式是否可用
* @returns {boolean}
*/
function checkInterceptMode() {
// 检查 Fetch 拦截器是否已安装(主要方式)
if (modeState.fetchInterceptorInstalled) {
return true;
}
// 检查拦截器是否已安装
if (modeState.interceptorInstalled) {
return true;
}
// 检查全局钩子是否存在
if (window._tableFillerInterceptor) {
return true;
}
// 检查 Amily2 模块是否加载(支持多种键名)
try {
const possibleKeys = [
"ST-Amily2-Chat-Optimisation",
"Amily2",
"amily2",
"Amily2-Chat-Optimisation"
];
for (const key of possibleKeys) {
if (window.extension_settings?.[key]) {
return true;
}
}
return false;
} catch {
return false;
}
}
/**
* 检查 Amily2 当前填表模式是否为分步模式
* @returns {boolean}
*/
export function isSecondaryApiMode() {
try {
// 尝试多种可能的设置键名
const possibleKeys = [
"Amily2",
"ST-Amily2-Chat-Optimisation",
"amily2",
"Amily2-Chat-Optimisation"
];
let amilySettings = null;
for (const key of possibleKeys) {
if (window.extension_settings?.[key]) {
amilySettings = window.extension_settings[key];
break;
}
}
if (!amilySettings) {
// 如果找不到设置默认返回true允许使用
return true;
}
// 检查多种可能的填表模式字段名
const fillingMode = amilySettings.filling_mode
|| amilySettings.fillingMode
|| amilySettings.tableFillingMode
|| amilySettings.batchFillerMode
|| "secondary-api"; // 默认假设是分步模式
return fillingMode === "secondary-api" || fillingMode === "secondary";
} catch {
return true; // 出错时默认允许
}
}
/**
* 获取 Amily2 当前填表模式名称
* @returns {string}
*/
export function getAmily2FillingModeName() {
try {
// 尝试多种可能的设置键名
const possibleKeys = [
"Amily2",
"ST-Amily2-Chat-Optimisation",
"amily2",
"Amily2-Chat-Optimisation"
];
let amilySettings = null;
for (const key of possibleKeys) {
if (window.extension_settings?.[key]) {
amilySettings = window.extension_settings[key];
break;
}
}
if (!amilySettings) {
return "未检测到Amily2";
}
const fillingMode = amilySettings.filling_mode
|| amilySettings.fillingMode
|| amilySettings.tableFillingMode
|| amilySettings.batchFillerMode
|| "unknown";
const modeNames = {
"main-api": "原始填表",
"secondary-api": "分步填表",
"secondary": "分步填表",
optimized: "优化中填表",
unknown: "未知模式",
};
return modeNames[fillingMode] || fillingMode;
} catch {
return "检测失败";
}
}
/**
* 获取当前模式状态详情
* @returns {Object}
*/
export function getModeStatus() {
const available = detectAvailableMode();
return {
busRegistered: modeState.busRegistered,
interceptorInstalled: modeState.interceptorInstalled,
fetchInterceptorInstalled: modeState.fetchInterceptorInstalled,
busAvailable: available.bus,
interceptAvailable: available.intercept,
recommended: available.recommended,
amily2Mode: getAmily2FillingModeName(),
isSecondaryApi: isSecondaryApiMode(),
};
}
/**
* 记录模式状态日志
*/
export function logModeStatus() {
const status = getModeStatus();
Logger.log("[TableFiller] 模式状态:", status);
}

View File

@@ -0,0 +1,371 @@
/**
* 并发执行器
* 并发调用多个 API 处理表格填充
* @module table-filler/parallel-executor
*/
import Logger from "@core/logger";
import { APIAdapter } from "@api/adapter";
import { isDebugModeEnabled, loadDefaultIndependentTemplates } from "@config/config-manager";
import { buildSingleTableMessages } from "./table-splitter";
import { buildPromptForTable } from "./prompt-handler";
import { showPreSendDebugModal, showPostMergeDebugModal, showRetryBanner, removeRetryBanner } from "./debug-modal";
/**
* 并发执行器类
*/
export class ParallelExecutor {
/**
* @param {Object} config 配置对象
*/
constructor(config) {
this.config = config;
this.abortControllers = new Map();
this.onProgress = null;
// 从配置读取重试次数,默认为 2
this.retryCount = config.retryCount ?? 2;
// 从配置读取重试延迟基数(毫秒),默认为 2000
this.retryDelay = config.retryDelay ?? 2000;
// 从配置读取调试模式
this.debugMode = config.debugMode ?? false;
}
/**
* 设置进度回调
* @param {Function} callback 进度回调函数
*/
setProgressCallback(callback) {
this.onProgress = callback;
}
/**
* 报告进度
* @param {string} tableName 表格名称
* @param {string} status 状态
* @param {string} message 消息
*/
reportProgress(tableName, status, message) {
if (this.onProgress) {
this.onProgress({
tableName,
status,
message,
timestamp: Date.now(),
});
}
}
/**
* 获取表格的 API 配置
* @param {string} tableName 表格名称
* @returns {Object}
*/
getApiConfigForTable(tableName) {
const tableApis = this.config.tableApiConfigs || {};
const tableConfig = tableApis[tableName];
// 如果表格有独立配置且不是使用默认
if (tableConfig && !tableConfig.useDefault) {
return tableConfig;
}
// 使用默认 API 配置
return this.config.defaultApi || {};
}
/**
* 并发填充所有表格
* @param {Array} tables 表格数组
* @param {Array|Object} originalMessagesOrBody 原始消息数组或请求体对象
* @param {Object} originalOptions 原始选项
* @returns {Promise<Array>}
*/
async fillAllTables(tables, originalMessagesOrBody, originalOptions = {}) {
Logger.log(
`[ParallelExecutor] 开始并发填表,共 ${tables.length} 个表格,重试次数: ${this.retryCount},重试延迟基数: ${this.retryDelay}ms`,
);
// 兼容处理:如果传入的是请求体对象,提取 messages
const originalMessages = Array.isArray(originalMessagesOrBody)
? originalMessagesOrBody
: originalMessagesOrBody?.messages || [];
// 预加载默认独立模板并合并到 config 中
if (this.config.promptMode === "independent") {
const defaultTemplates = await loadDefaultIndependentTemplates();
if (defaultTemplates?.templates) {
// 合并默认模板(持久化优先)
const mergedTemplates = { ...this.config.independentTemplates };
for (const [tableName, templateObj] of Object.entries(defaultTemplates.templates)) {
if (!mergedTemplates[tableName]) {
// 处理嵌套结构templateObj 可能是 { template: "..." } 或直接是字符串
const templateContent = typeof templateObj === 'string' ? templateObj : templateObj?.template;
if (templateContent) {
mergedTemplates[tableName] = { template: templateContent };
}
}
}
this.config.independentTemplates = mergedTemplates;
Logger.log(`[ParallelExecutor] 已合并默认独立模板,共 ${Object.keys(mergedTemplates).length}`);
}
}
// 【调试】先构建所有表格的提示词,显示调试弹窗
const tablePrompts = [];
for (const table of tables) {
try {
const messages = buildPromptForTable(table, originalMessages, this.config);
tablePrompts.push({
tableName: table.name,
messages: messages
});
} catch (buildError) {
Logger.error(`[ParallelExecutor] 构建表格「${table.name}」提示词失败:`, buildError);
if (window.toastr) {
window.toastr.error(
`构建「${table.name}」提示词失败: ${buildError.message}`,
"并发填表错误",
{ timeOut: 5000 }
);
}
throw buildError;
}
}
// 调试模式:显示发送前调试弹窗
if (isDebugModeEnabled()) {
const shouldContinue = await showPreSendDebugModal(tablePrompts);
if (!shouldContinue) {
Logger.log("[ParallelExecutor] 用户取消了发送");
// 返回空结果,让上层回退到原始请求
return tables.map(t => ({
tableName: t.name,
success: false,
response: null,
error: new Error("用户取消"),
retryAttempts: 0
}));
}
}
// 为每个表格创建任务
const tasks = tables.map((table, index) => ({
table,
apiConfig: this.getApiConfigForTable(table.name),
abortController: new AbortController(),
// 使用已构建的提示词
prebuiltMessages: tablePrompts[index].messages
}));
// 并发执行
const promises = tasks.map((task) =>
this.fillSingleTableWithRetry(task, originalMessages, originalOptions),
);
const settledResults = await Promise.allSettled(promises);
// 处理结果
const results = settledResults.map((result, index) => ({
tableName: tables[index].name,
success:
result.status === "fulfilled" && result.value?.success,
response:
result.status === "fulfilled" ? result.value?.response : null,
error:
result.status === "rejected"
? result.reason
: result.value?.error || null,
retryAttempts: result.status === "fulfilled" ? result.value?.retryAttempts : 0,
}));
// 统计结果
const successCount = results.filter((r) => r.success).length;
const failedCount = results.length - successCount;
Logger.log(
`[ParallelExecutor] 填表完成: ${successCount}/${results.length} 成功`,
);
// 详细记录失败的表格(仅日志,不弹通知)
if (failedCount > 0) {
const failedDetails = results
.filter((r) => !r.success)
.map((r) => {
const errorMsg = r.error?.message || '未知错误';
return `${r.tableName}: ${errorMsg}`;
});
Logger.warn(`[ParallelExecutor] 失败的表格详情:\n${failedDetails.join('\n')}`);
}
return results;
}
/**
* 带重试的单表格填充
* @param {Object} task 任务对象
* @param {Array} originalMessages 原始消息数组
* @param {Object} originalOptions 原始选项
* @returns {Promise<{success: boolean, response: string|null, error: Error|null, retryAttempts: number}>}
*/
async fillSingleTableWithRetry(task, originalMessages, originalOptions) {
const { table } = task;
let lastError = null;
let retryAttempts = 0;
Logger.log(`[ParallelExecutor] 开始处理表格 ${table.name},最大重试次数: ${this.retryCount}`);
for (let attempt = 0; attempt <= this.retryCount; attempt++) {
try {
if (attempt > 0) {
retryAttempts = attempt;
// 使用固定延迟时间
const delayMs = this.retryDelay;
this.reportProgress(table.name, "retrying", `重试第 ${attempt} 次(等待 ${delayMs / 1000} 秒)...`);
Logger.log(`[ParallelExecutor] 表格 ${table.name} 重试第 ${attempt} 次,延迟 ${delayMs}ms`);
// 重试前等待
await this.delay(delayMs);
// 创建新的 AbortController
task.abortController = new AbortController();
}
Logger.log(`[ParallelExecutor] 表格 ${table.name}${attempt} 次尝试调用 API`);
const result = await this.fillSingleTable(task, originalMessages, originalOptions);
if (result.success) {
if (retryAttempts > 0) {
Logger.log(`[ParallelExecutor] 表格 ${table.name} 在第 ${retryAttempts} 次重试后成功`);
}
return { ...result, retryAttempts };
}
// 调用返回了失败结果,记录错误
lastError = result.error;
const errorMsg = lastError?.message || '未知错误';
Logger.warn(`[ParallelExecutor] 表格 ${table.name}${attempt} 次尝试失败:`, errorMsg);
} catch (error) {
// 捕获异常,记录错误
lastError = error;
const errorMsg = error.message || '未知异常';
Logger.warn(`[ParallelExecutor] 表格 ${table.name}${attempt} 次尝试异常:`, errorMsg);
}
}
// 所有重试都失败
Logger.error(`[ParallelExecutor] 表格 ${table.name}${this.retryCount} 次重试后最终失败`);
this.reportProgress(table.name, "failed", `失败 (重试 ${retryAttempts} 次后): ${lastError?.message || '未知错误'}`);
return { success: false, response: null, error: lastError, retryAttempts };
}
/**
* 延迟函数
* @param {number} ms 毫秒
* @returns {Promise<void>}
*/
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 填充单个表格
* @param {Object} task 任务对象
* @param {Array} originalMessages 原始消息数组
* @param {Object} originalOptions 原始选项
* @returns {Promise<{success: boolean, response: string|null, error: Error|null}>}
*/
async fillSingleTable(task, originalMessages, originalOptions) {
const { table, apiConfig, abortController, prebuiltMessages } = task;
this.abortControllers.set(table.name, abortController);
this.reportProgress(table.name, "started", "开始处理");
try {
// 检查 API 配置
if (!apiConfig.apiUrl || !apiConfig.model) {
throw new Error(`表格 ${table.name} 未配置有效的 API`);
}
// 使用预构建的 messages如果有否则重新构建
const messages = prebuiltMessages || buildPromptForTable(
table,
originalMessages,
this.config,
);
this.reportProgress(table.name, "calling", "正在调用 API");
// 准备 API 配置
const finalConfig = {
...apiConfig,
apiFormat: apiConfig.apiFormat || "openai",
source: "table_filler",
taskId: `table_${table.name}`,
};
// 调用 API内部不再重试由外层 fillSingleTableWithRetry 控制)
const response = await APIAdapter.callWithMessages(
finalConfig,
null, // systemPrompt 已在 messages 中
messages,
`table_${table.name}`,
0, // 不在这里重试,由外层控制
abortController.signal,
);
this.reportProgress(table.name, "completed", "处理完成");
Logger.log(`[ParallelExecutor] 表格 ${table.name} 填充成功`);
return { success: true, response, error: null };
} catch (error) {
this.reportProgress(
table.name,
"failed",
`失败: ${error.message}`,
);
Logger.error(
`[ParallelExecutor] 表格 ${table.name} 填充失败:`,
error,
);
return { success: false, response: null, error };
} finally {
this.abortControllers.delete(table.name);
}
}
/**
* 取消所有任务
*/
abortAll() {
Logger.log("[ParallelExecutor] 取消所有任务");
this.abortControllers.forEach((controller, tableName) => {
controller.abort();
this.reportProgress(tableName, "aborted", "已取消");
});
this.abortControllers.clear();
}
/**
* 取消特定表格的任务
* @param {string} tableName 表格名称
*/
abortTable(tableName) {
const controller = this.abortControllers.get(tableName);
if (controller) {
controller.abort();
this.abortControllers.delete(tableName);
this.reportProgress(tableName, "aborted", "已取消");
Logger.log(`[ParallelExecutor] 取消表格 ${tableName} 的任务`);
}
}
/**
* 获取正在处理的表格列表
* @returns {Array<string>}
*/
getProcessingTables() {
return Array.from(this.abortControllers.keys());
}
}
export default ParallelExecutor;

View File

@@ -0,0 +1,335 @@
/**
* 提示词处理器
* 支持独立提示词和共享提示词两种模式
* @module table-filler/prompt-handler
*/
import Logger from "@core/logger";
/**
* 提示词处理模式
*/
export const PromptMode = {
INDEPENDENT: "independent", // 独立提示词:精准替换 ruleTemplate 和 flowTemplate
SHARED: "shared", // 共享提示词:保留原提示词,只替换表格数据 + 添加聚焦指令
};
/**
* 表格与思考阶段对应关系
*/
export const TABLE_PHASE_MAP = {
角色表: { phase: 2, name: "角色表检查" },
关系表: { phase: 3, name: "关系表检查" },
物品表: { phase: 4, name: "物品表检查" },
组织表: { phase: 5, name: "组织表检查" },
地点表: { phase: 6, name: "地点表检查" },
能力表: { phase: 7, name: "能力表检查" },
任务表: { phase: 8, name: "任务表检查" },
};
/**
* 为单个表格构建提示词
* @param {Object} table 表格对象
* @param {Array} originalMessages 原始消息数组
* @param {Object} config 配置对象
* @returns {Array}
*/
export function buildPromptForTable(table, originalMessages, config) {
if (config.promptMode === PromptMode.INDEPENDENT) {
// 独立模式:优先使用 V2按名称存储的模板回退到 V1导入的预设
if (config.independentTemplates?.[table.name]) {
return buildIndependentPromptV2(table, originalMessages, config);
}
// 回退到 V1兼容旧版导入的预设
return buildIndependentPrompt(table, originalMessages, config);
} else {
return buildSharedPrompt(table, originalMessages, config);
}
}
/**
* 独立提示词模式
* 精准替换 ruleTemplate 和 flowTemplate保留其他所有内容
* @param {Object} table 表格对象
* @param {Array} originalMessages 原始消息数组
* @param {Object} config 配置对象
* @returns {Array}
*/
function buildIndependentPrompt(table, originalMessages, config) {
const tableConfig = config.importedPreset?.tablePresets?.[table.name];
if (
!tableConfig?.batchFillerRuleTemplate ||
!tableConfig?.batchFillerFlowTemplate
) {
Logger.warn(
`[PromptHandler] 表格 ${table.name} 未配置独立提示词,回退到共享模式`,
);
return buildSharedPrompt(table, originalMessages, config);
}
// 复制原始 messages精准替换特定内容
return originalMessages.map((msg) => {
const content = msg.content;
// 识别并替换 ruleTemplate通过特征标记识别
if (isRuleTemplateMessage(content)) {
return {
...msg,
content: tableConfig.batchFillerRuleTemplate,
};
}
// 识别并替换 flowTemplate通过特征标记识别
if (isFlowTemplateMessage(content)) {
// 使用表格专属的 flowTemplate替换表格数据占位符
const newFlowContent =
tableConfig.batchFillerFlowTemplate.replace(
"{{{Amily2TableData}}}",
table.fullContent,
);
return {
...msg,
content: newFlowContent,
};
}
// 其他消息preset prompts、worldbook、coreContent保持不变
return msg;
});
}
/**
* 共享提示词模式
* 复用通用提示词,添加聚焦指令让 AI 只处理当前表格
* @param {Object} table 表格对象
* @param {Array} originalMessages 原始消息数组
* @param {Object} config 配置对象
* @returns {Array}
*/
function buildSharedPrompt(table, originalMessages, config) {
const phaseInfo = TABLE_PHASE_MAP[table.name];
// 表格名列表(用于移除其他表格)
const allTableNames = Object.keys(TABLE_PHASE_MAP);
// 标记是否已添加聚焦指令(只添加一次)
let focusInstructionAdded = false;
// 深拷贝原始 messages确保并发处理时不会相互影响
const messages = originalMessages.map((msg) => {
// 创建消息的深拷贝
const newMsg = { ...msg };
let content = newMsg.content;
if (!content) return newMsg;
// 移除其他表格的数据,只保留当前表格
for (const tableName of allTableNames) {
if (tableName === table.name) continue;
// 移除 "* N:表格名" 开头的完整块(包含【说明】【增加】【删除】【修改】和<表格名内容>
// 匹配从 "* 数字:表格名" 开始,到下一个 "* 数字:" 或 "</需要更新的旧表格>" 之前的所有内容
const fullBlockRegex = new RegExp(
`\\* \\d+:${tableName}[\\s\\S]*?(?=\\n\\* \\d+:|</需要更新的旧表格>)`,
'g'
);
content = content.replace(fullBlockRegex, '');
// 备用:移除独立的 <表格名内容>...</表格名内容> 格式(如果不在 * N: 块内)
const contentTagRegex = new RegExp(`<${tableName}内容>[\\s\\S]*?<\\/${tableName}内容>`, 'g');
content = content.replace(contentTagRegex, '');
// 备用:移除独立的 <表格名>...</表格名> 格式
const simpleTagRegex = new RegExp(`<${tableName}>[\\s\\S]*?<\\/${tableName}>`, 'g');
content = content.replace(simpleTagRegex, '');
}
// 清理多余的空行
content = content.replace(/\n{3,}/g, '\n\n');
// 在 flowTemplate 消息中添加聚焦指令(只添加一次)
if (!focusInstructionAdded && isFlowTemplateMessage(content)) {
content = addFocusInstruction(content, table.name, phaseInfo, table.index);
focusInstructionAdded = true;
}
newMsg.content = content;
return newMsg;
});
return messages;
}
/**
* 识别 ruleTemplate 消息
* 通过 Amily2 ruleTemplate 的特征标记识别
* @param {string} content 消息内容
* @returns {boolean}
*/
function isRuleTemplateMessage(content) {
if (!content) return false;
return (
content.includes("酒馆国家协议") ||
content.includes("酒馆国家的臣民") ||
content.includes("Amily需要严格遵守以下规则")
);
}
/**
* 识别 flowTemplate 消息
* 通过 Amily2 flowTemplate 的特征标记识别
* @param {string} content 消息内容
* @returns {boolean}
*/
function isFlowTemplateMessage(content) {
if (!content) return false;
return (
content.includes("# dataTable 说明") ||
content.includes("dataTable 说明") ||
content.includes("Amily2TableData") ||
content.includes("表格操作指南") ||
content.includes("insertRow(") ||
content.includes("updateRow(")
);
}
/**
* 添加聚焦指令
* 使用通用化格式,不依赖特定预设结构(如阶段号)
* @param {string} content 原始内容
* @param {string} tableName 表格名称
* @param {Object} phaseInfo 阶段信息(可选,不再强制使用)
* @param {number} tableIndex 表格索引
* @returns {string}
*/
function addFocusInstruction(content, tableName, phaseInfo, tableIndex) {
// 构建其他表格列表
const otherTables = Object.keys(TABLE_PHASE_MAP).filter(name => name !== tableName);
const otherPhases = otherTables.map(name => `阶段${TABLE_PHASE_MAP[name].phase}(${name})`).join('、');
const focusInstruction = `
##【并发模式-单表格聚焦指令】##
本次请求采用并发填表模式,你只需要处理「${tableName}」(索引: ${tableIndex})。
【重要】思考流程限制:
- 仅执行与「${tableName}」相关的思考步骤
- 完全跳过其他表格的思考步骤:${otherPhases}
- 严格按照预设中的操作函数格式和输出示例进行输出
【操作范围】
- 仅输出对「${tableName}」的操作指令
- 其他表格由并行任务处理,请勿跨表操作
##【聚焦指令结束】##
`;
// 在内容开头添加聚焦指令(确保 AI 优先看到)
return focusInstruction + "\n" + content;
}
/**
* 获取表格的阶段信息
* @param {string} tableName 表格名称
* @returns {Object|null}
*/
export function getTablePhaseInfo(tableName) {
return TABLE_PHASE_MAP[tableName] || null;
}
/**
* 独立模式 V2按名称查找模板 + 标签精准替换
*
* 处理逻辑:
* 1. 拦截 Amily 发送的内容
* 2. 从 <Instructions for filling out the form> 内的 <需要更新的旧表格> 提取表格数据并拆分(由 table-splitter.js 完成)
* 3. 把拦截内容的 <Instructions for filling out the form> 内全部清空
* 4. 用插件的独立提示词模板 + 占位符注入单个表格数据
* 5. 把第4步的结果放回第3步清空的 <Instructions for filling out the form> 标签内
*
* @param {Object} table 表格对象(含 fullContent 单表格数据)
* @param {Array} originalMessages 原始消息数组
* @param {Object} config 配置对象
* @returns {Array}
*/
export function buildIndependentPromptV2(table, originalMessages, config) {
// 按名称查找配置(而非索引)
const tableConfig = config.independentTemplates?.[table.name];
// 获取模板内容(处理可能的嵌套结构)
let templateContent = tableConfig?.template;
if (typeof templateContent === 'object' && templateContent !== null) {
// 处理嵌套结构:{ template: { template: "..." } }
templateContent = templateContent.template;
}
if (!templateContent || typeof templateContent !== 'string') {
// 无有效模板时回退到共享模式
Logger.warn(`[PromptHandler] 表格「${table.name}」模板无效或为空,回退到共享模式`);
return buildSharedPrompt(table, originalMessages, config);
}
// 标签名(支持用户自定义)
const tagName = config.independentTagName || "Instructions for filling out the form";
// 【步骤4】用插件的独立提示词模板 + 占位符注入单个表格数据
let userTemplate = templateContent;
userTemplate = userTemplate.split('{{tableData}}').join(table.fullContent || '');
userTemplate = userTemplate.split('{{tableName}}').join(table.name || '');
userTemplate = userTemplate.split('{{tableIndex}}').join(String(table.index));
// 用于跟踪是否已经注入过用户模板(只注入一次)
let templateInjected = false;
const openTag = `<${tagName}>`;
const closeTag = `</${tagName}>`;
const processedMessages = originalMessages.map((msg) => {
const content = msg.content;
if (!content) return msg;
// 使用 indexOf 查找标签位置(比正则更可靠)
const openIndex = content.indexOf(openTag);
const closeIndex = content.indexOf(closeTag);
if (openIndex !== -1 && closeIndex !== -1 && closeIndex > openIndex) {
// 找到标签,清空原内容并注入用户模板
const before = content.substring(0, openIndex + openTag.length);
const after = content.substring(closeIndex);
if (!templateInjected) {
templateInjected = true;
// 注入用户模板到标签内
const newContent = before + '\n' + userTemplate + '\n' + after;
return { ...msg, content: newContent };
} else {
// 已注入过,清空此标签内容
const newContent = before + '\n' + after;
return { ...msg, content: newContent };
}
}
// 没有找到标签,直接返回原消息
return msg;
});
// 如果模板未能生效,显示警告通知
if (!templateInjected) {
if (window.toastr) {
window.toastr.warning(
`未找到标签 <${tagName}>,模板可能未生效`,
`${table.name} 独立模式`,
{ timeOut: 5000 }
);
}
}
return processedMessages;
}
/**
* 转义正则表达式特殊字符
* @param {string} str 原始字符串
* @returns {string} 转义后的字符串
*/
function escapeRegExp(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

View File

@@ -0,0 +1,206 @@
/**
* Service 拦截器
* Hook SillyTavern 的 ConnectionManagerRequestService
* 用于拦截 sillytavern_preset 模式的 API 调用
*
* @module table-filler/service-interceptor
*/
import Logger from "@core/logger";
import { getTableFillerConfig, isTableFillerEnabled } from "@config/config-manager";
import { splitTablesFromMessages, mergeResults } from "./table-splitter";
import { ParallelExecutor } from "./parallel-executor";
import { isTableFillerRequest } from "./fetch-interceptor";
// 原始函数引用
let originalSendRequest = null;
// 安装状态
let isInstalled = false;
// 进度回调
let progressCallback = null;
/**
* 设置进度回调
* @param {Function} callback
*/
export function setServiceInterceptorProgressCallback(callback) {
progressCallback = callback;
}
/**
* 获取 SillyTavern 上下文
* @returns {Object|null}
*/
function getSTContext() {
// 尝试多种获取方式
if (window.SillyTavern?.getContext) {
return window.SillyTavern.getContext();
}
// 尝试从 extension 模块获取
try {
const extensions = window.extension_settings;
if (extensions) {
// 尝试从任一扩展获取 context
for (const key in extensions) {
const ext = extensions[key];
if (ext?.context?.ConnectionManagerRequestService) {
return ext.context;
}
}
}
} catch (e) {
Logger.debug("[ServiceInterceptor] 从 extensions 获取 context 失败:", e.message);
}
return null;
}
/**
* 安装 Service 拦截器
* @returns {boolean} 是否成功安装
*/
export function installServiceInterceptor() {
if (isInstalled) {
Logger.log("[ServiceInterceptor] 拦截器已安装,跳过");
return true;
}
const context = getSTContext();
if (!context?.ConnectionManagerRequestService) {
Logger.debug("[ServiceInterceptor] ConnectionManagerRequestService 不可用");
return false;
}
const service = context.ConnectionManagerRequestService;
if (typeof service.sendRequest !== 'function') {
Logger.debug("[ServiceInterceptor] sendRequest 方法不存在");
return false;
}
// 保存原始函数
originalSendRequest = service.sendRequest.bind(service);
// 替换为拦截版本
service.sendRequest = async function(profileId, messages, maxTokens) {
// 仅检测并记录日志,不做并发处理
if (isTableFillerEnabled() && isTableFillerRequest(messages)) {
Logger.log("[ServiceInterceptor] ✓ 检测到表格填充请求,但暂时不启用并发(调试中)");
}
// 透传给原始函数
return originalSendRequest(profileId, messages, maxTokens);
};
isInstalled = true;
Logger.log("[ServiceInterceptor] ✓ Service 拦截器已安装");
return true;
}
/**
* 卸载 Service 拦截器
*/
export function uninstallServiceInterceptor() {
if (!isInstalled || !originalSendRequest) {
return;
}
const context = getSTContext();
if (context?.ConnectionManagerRequestService) {
context.ConnectionManagerRequestService.sendRequest = originalSendRequest;
}
originalSendRequest = null;
isInstalled = false;
Logger.log("[ServiceInterceptor] Service 拦截器已卸载");
}
/**
* 获取安装状态
* @returns {boolean}
*/
export function isServiceInterceptorInstalled() {
return isInstalled;
}
/**
* 处理 Service 并发填表
* @param {string} profileId 配置文件 ID
* @param {Array} messages 消息数组
* @param {number} maxTokens 最大 token 数
* @returns {Promise<Object>}
*/
async function handleServiceParallelFill(profileId, messages, maxTokens) {
const config = getTableFillerConfig();
const executor = new ParallelExecutor(config);
if (progressCallback) {
executor.setProgressCallback(progressCallback);
}
try {
const tables = splitTablesFromMessages(messages);
if (tables.length === 0) {
Logger.warn("[ServiceInterceptor] 未检测到多表格,使用原始请求");
return originalSendRequest(profileId, messages, maxTokens);
}
Logger.log(`[ServiceInterceptor] 检测到 ${tables.length} 个表格,启用并发模式`);
if (window.toastr) {
window.toastr.info(
`🚀 正在并发处理 ${tables.length} 个表格...`,
"并发填表已启动",
{ timeOut: 3000 }
);
}
const results = await executor.fillAllTables(tables, { messages }, {
profileId,
maxTokens,
originalSendRequest
});
const successCount = results.filter(r => r.success).length;
const failedCount = results.length - successCount;
if (window.toastr) {
if (failedCount === 0) {
window.toastr.success(`${successCount} 个表格全部处理成功`, "并发填表完成");
} else if (successCount > 0) {
window.toastr.warning(`⚠️ ${successCount}/${results.length} 个表格成功`, "并发填表部分完成");
} else {
window.toastr.error("❌ 所有表格处理失败", "并发填表失败");
return originalSendRequest(profileId, messages, maxTokens);
}
}
const mergedContent = mergeResults(results);
// 返回模拟的响应对象
return {
choices: [{
message: {
role: "assistant",
content: mergedContent
},
finish_reason: "stop"
}]
};
} catch (error) {
Logger.error("[ServiceInterceptor] 并发填表失败:", error);
if (window.toastr) {
window.toastr.error(`❌ 并发填表出错: ${error.message}`, "并发填表错误");
}
// 回退到原始请求
return originalSendRequest(profileId, messages, maxTokens);
}
}

View File

@@ -0,0 +1,276 @@
/**
* 表格拆分器
* 从 messages 中提取并拆分表格数据
* @module table-filler/table-splitter
*/
import Logger from "@core/logger";
/**
* 表格名称列表(用于匹配)
*/
const TABLE_NAMES = [
"角色表", "关系表", "物品表", "组织表", "地点表", "能力表", "任务表",
"时空栏", "人物表", "道具表", "势力表", "场所表", "技能表", "事件表"
];
/**
* 从 messages 中提取并拆分表格数据
* @param {Array} messages 原始消息数组
* @returns {Array<{index: number, name: string, fullContent: string}>}
*/
export function splitTablesFromMessages(messages) {
if (!messages || !Array.isArray(messages)) {
Logger.debug("[TableSplitter] messages 不是有效数组");
return [];
}
// 合并所有消息内容进行搜索
const allContent = messages.map(m => m.content || '').join('\n');
// 方法1: 尝试匹配完整格式 "* 0:角色表\n【说明】..."
let tables = extractTablesFullFormat(allContent);
if (tables.length > 0) {
Logger.log(`[TableSplitter] 使用完整格式解析,找到 ${tables.length} 个表格:`, tables.map(t => t.name));
return tables;
}
// 方法2: 尝试匹配 <表格名内容>...</表格名内容> 格式
tables = extractTablesContentTagFormat(allContent);
if (tables.length > 0) {
Logger.log(`[TableSplitter] 使用内容标签格式解析,找到 ${tables.length} 个表格:`, tables.map(t => t.name));
return tables;
}
// 方法3: 尝试匹配 <表格名>...</表格名> 简化格式
tables = extractTablesSimpleTagFormat(allContent);
if (tables.length > 0) {
Logger.log(`[TableSplitter] 使用简化标签格式解析,找到 ${tables.length} 个表格:`, tables.map(t => t.name));
return tables;
}
Logger.debug("[TableSplitter] 未找到可解析的表格数据");
return [];
}
/**
* 提取完整格式表格
* 格式: * 0:角色表\n【说明】: ...\n<角色表内容>...\n【增加】: ...
* @param {string} content 内容
* @returns {Array}
*/
function extractTablesFullFormat(content) {
const tables = [];
// 匹配 "* 数字:表格名" 开头的块
// 使用更精确的边界:下一个表格块、结束标签
const tableRegex = /\* (\d+):([^\n]+)\n([\s\S]*?)(?=\* \d+:|<\/需要更新的旧表格>|$)/g;
let match;
while ((match = tableRegex.exec(content)) !== null) {
const tableName = match[2].trim();
// 验证是否是有效的表格名
if (TABLE_NAMES.some(name => tableName.includes(name) || name.includes(tableName))) {
tables.push({
index: parseInt(match[1]),
name: tableName,
fullContent: match[0].trim(),
});
}
}
return tables;
}
/**
* 提取内容标签格式表格
* 格式: <角色表内容>...</角色表内容>
* @param {string} content 内容
* @returns {Array}
*/
function extractTablesContentTagFormat(content) {
const tables = [];
// 匹配 <表格名内容>...</表格名内容>
for (let i = 0; i < TABLE_NAMES.length; i++) {
const tableName = TABLE_NAMES[i];
const regex = new RegExp(`<${tableName}内容>([\\s\\S]*?)<\\/${tableName}内容>`, 'g');
let match;
while ((match = regex.exec(content)) !== null) {
tables.push({
index: i,
name: tableName,
fullContent: match[0],
tableData: match[1].trim(),
});
}
}
return tables;
}
/**
* 提取简化标签格式表格
* 格式: <角色表>...</角色表>
* @param {string} content 内容
* @returns {Array}
*/
function extractTablesSimpleTagFormat(content) {
const tables = [];
// 匹配 <表格名>...</表格名>(排除 <表格名内容> 格式)
for (let i = 0; i < TABLE_NAMES.length; i++) {
const tableName = TABLE_NAMES[i];
// 使用负向先行断言排除 "内容>" 结尾
const regex = new RegExp(`<${tableName}>([\\s\\S]*?)<\\/${tableName}>`, 'g');
let match;
while ((match = regex.exec(content)) !== null) {
// 确保不是 <表格名内容> 格式
if (!match[0].includes(`<${tableName}内容>`)) {
tables.push({
index: i,
name: tableName,
fullContent: match[0],
tableData: match[1].trim(),
});
}
}
}
return tables;
}
/**
* 为单个表格构建独立的 messages
* 保留该表格的数据,移除其他表格的数据
* @param {Array} originalMessages 原始消息数组
* @param {Object} singleTable 单个表格对象
* @returns {Array}
*/
export function buildSingleTableMessages(originalMessages, singleTable) {
return originalMessages.map((msg) => {
let content = msg.content;
if (!content) return msg;
// 移除其他表格的数据,只保留当前表格
for (const tableName of TABLE_NAMES) {
if (tableName === singleTable.name) continue;
// 移除 <表格名内容>...</表格名内容> 格式
const contentTagRegex = new RegExp(`<${tableName}内容>[\\s\\S]*?<\\/${tableName}内容>`, 'g');
content = content.replace(contentTagRegex, '');
// 移除 <表格名>...</表格名> 格式
const simpleTagRegex = new RegExp(`<${tableName}>[\\s\\S]*?<\\/${tableName}>`, 'g');
content = content.replace(simpleTagRegex, '');
// 移除 "* N:表格名" 开头的完整块(包含【说明】【增加】【删除】【修改】和<表格名内容>
// 匹配从 "* 数字:表格名" 开始,到下一个 "* 数字:" 或 "</需要更新的旧表格>" 之前的所有内容
const fullBlockRegex = new RegExp(
`\\* \\d+:${tableName}[\\s\\S]*?(?=\\n\\* \\d+:|</需要更新的旧表格>)`,
'g'
);
content = content.replace(fullBlockRegex, '');
}
// 清理多余的空行
content = content.replace(/\n{3,}/g, '\n\n');
return { ...msg, content };
});
}
/**
* 从 AI 响应中提取 Amily2Edit 指令
* @param {string} response AI 响应文本
* @returns {string|null}
*/
export function extractCommands(response) {
if (!response) return null;
// 尝试匹配带注释的格式: <Amily2Edit><!--...--></Amily2Edit>
let match = response.match(
/<Amily2Edit>\s*<!--([\s\S]*?)-->\s*<\/Amily2Edit>/,
);
if (match) {
return match[1].trim();
}
// 尝试匹配不带注释的格式: <Amily2Edit>...</Amily2Edit>
match = response.match(
/<Amily2Edit>([\s\S]*?)<\/Amily2Edit>/,
);
if (match) {
// 如果内容被注释包裹,提取注释内的内容
const content = match[1].trim();
const commentMatch = content.match(/<!--([\s\S]*?)-->/);
if (commentMatch) {
return commentMatch[1].trim();
}
return content;
}
return null;
}
/**
* 合并所有表格的 AI 响应
* @param {Array} results 表格填充结果数组
* @returns {string}
*/
export function mergeResults(results) {
const successResults = results.filter((r) => r.success);
if (successResults.length === 0) {
throw new Error("所有表格填充均失败");
}
// 统计成功和失败
const successCount = successResults.length;
const failedTables = results
.filter((r) => !r.success)
.map((r) => r.tableName);
if (failedTables.length > 0) {
Logger.warn(
`[TableSplitter] 部分表格填充失败: ${failedTables.join(", ")}`,
);
}
Logger.log(
`[TableSplitter] 合并结果: ${successCount}/${results.length} 个表格成功`,
);
// 提取所有 <Amily2Edit> 块中的指令
const allCommands = [];
for (const r of successResults) {
const commands = extractCommands(r.response);
if (commands) {
allCommands.push(commands);
}
}
// 合并所有指令
const mergedCommands = allCommands.join("\n");
// 重新包装为 Amily2 期望的格式
return `<Amily2Edit>\n<!--\n${mergedCommands}\n-->\n</Amily2Edit>`;
}
/**
* 获取原始 messages 中的对话记录
* @param {Array} messages 消息数组
* @returns {string}
*/
export function extractDialogContent(messages) {
const userMsg = messages.find(
(m) => m.role === "user" && m.content?.includes("<对话记录>"),
);
if (!userMsg) return "";
const match = userMsg.content.match(/<对话记录>([\s\S]*?)<\/对话记录>/);
return match ? match[1].trim() : userMsg.content;
}

View File

@@ -4,14 +4,16 @@
*/
import Logger from '@core/logger';
import { getGlobalSettings, getGlobalConfig, getSummaryConfig } from '@config/config-manager';
import { getGlobalSettings, getGlobalConfig, getSummaryConfig, isSummaryAutoSplitEnabled, getSummaryAutoSplitConfig, getSummaryPartConfigs, getSummaryPartApiConfig, isSummaryMergeDeduplicateEnabled } from '@config/config-manager';
import { getImportedBookNames } from '@config/imported-books';
import { getImportedWorldBooks, classifyWorldBooks, isSummaryBook } from '@worldbook/api';
import { getSummaryContent } from '@worldbook/parser';
import { analyzeSummaryContent, needsSplit } from '@worldbook/summary-splitter';
import APIAdapter from '@api/adapter';
import { getHistoricalPromptTemplate } from '@utils/prompt-template';
import { buildDataInjection, injectDataToPrompt, replacePromptVariables, buildUserPrompt } from '@memory/prompt-builder';
import { getJailbreakPrefix } from '@memory/jailbreak';
import { isPartDebugEnabled, showPartDebugModal } from '@memory/part-debug-modal';
// 进度追踪器引用(将在初始化时设置)
let progressTracker = null;
@@ -382,7 +384,7 @@ export class MemorySearchPanel {
msg.innerHTML = `
<div class="mm-search-result-item" data-result-id="${resultId}" data-book-name="${this.escapeHtml(bookName)}">
<div class="mm-search-result-header">
<span class="mm-search-result-floor">${this.escapeHtml(floor)}楼】</span>
<span class="mm-search-result-floor">${this.escapeHtml(floor.startsWith('【') ? floor : `${floor}楼】`)}</span>
<div class="mm-search-result-actions">
<button class="mm-btn mm-btn-adopt mm-search-adopt-btn">
<i class="fa-solid fa-check"></i> 采纳
@@ -539,6 +541,17 @@ export class MemorySearchPanel {
booksContainer.style.height = `${newHeight}px`;
booksContainer.style.minHeight = `${newHeight}px`;
booksContainer.style.maxHeight = `${newHeight}px`;
// 同步更新内部内容区域的最大高度
const bookContents = booksContainer.querySelectorAll('.mm-search-book-content');
const headerHeight = 45; // 每个世界书头部的大约高度
const bookCount = bookContents.length || 1;
// 计算每个内容区域可用的高度(减去头部高度后平分)
const contentMaxHeight = Math.max(100, (newHeight - headerHeight * bookCount) / bookCount);
bookContents.forEach(content => {
content.style.maxHeight = `${contentMaxHeight}px`;
});
e.preventDefault();
};
@@ -716,7 +729,7 @@ export class MemorySearchPanel {
msg.innerHTML = `
<div class="mm-search-result-item" data-result-id="${resultId}">
<div class="mm-search-result-header">
<span class="mm-search-result-floor">【${floor}楼】</span>
<span class="mm-search-result-floor">${String(floor).startsWith('【') ? floor : `${floor}楼】`}</span>
<div class="mm-search-result-actions">
<button class="mm-btn mm-btn-adopt mm-search-adopt-btn">
<i class="fa-solid fa-check"></i> 采纳
@@ -901,7 +914,9 @@ export class MemorySearchPanel {
const floor = m.uid || m.key || "未知";
const content = m.content || "";
if (content.trim()) {
historicalLines.push(`${floor}楼】${content}`);
// 如果 floor 已经是完整标签格式,直接使用
const floorTag = String(floor).startsWith('【') ? floor : `${floor}楼】`;
historicalLines.push(`${floorTag}${content}`);
}
}
}
@@ -1229,45 +1244,169 @@ async function callHistoricalMemoryAI(panel, userMessage, context) {
}
/**
* 调用单个总结世界书的 AI
* 调用单个总结世界书的 AI(支持拆分模式)
*/
async function callSingleSummaryBookAI(panel, book, userMessage, context) {
const bookName = book.name;
try {
// 检查是否启用拆分
const splitEnabled = isSummaryAutoSplitEnabled();
const summaryContent = getSummaryContent(book);
if (splitEnabled) {
const splitConfig = getSummaryAutoSplitConfig();
const shouldSplit = needsSplit(summaryContent, splitConfig.targetChars);
if (shouldSplit) {
// 拆分模式:并发处理多个 Part
await callSummaryBookWithSplit(panel, book, userMessage, context, summaryContent, splitConfig);
return;
}
}
// 非拆分模式:单个 API 调用
await callSummaryBookSingle(panel, book, userMessage, context, summaryContent);
} catch (error) {
Logger.error(`[记忆搜索助手] 总结世界书 "${bookName}" 初始化失败:`, error.message);
panel.setBookStatus(bookName, "error", "失败");
panel.addBookSystemMessage(bookName, `初始化失败: ${error.message}`);
}
}
/**
* 单个 API 调用处理总结世界书(非拆分模式)
*/
async function callSummaryBookSingle(panel, book, userMessage, context, summaryContent) {
const bookName = book.name;
const taskId = `search_${bookName}`;
const abortController = new AbortController();
panel.setBookStatus(bookName, "loading", "调用AI中...");
panel.addBookAIMessage(bookName, "正在调用历史事件回忆AI...");
const aiConfig = getSummaryConfig(bookName);
const globalConfig = getGlobalConfig();
const dataInjection = buildDataInjection({
worldBookContent: summaryContent,
context: context || "",
userMessage: userMessage,
});
const template = await getHistoricalPromptTemplate();
const jailbreakPrefix = getJailbreakPrefix();
const prompt = injectDataToPrompt(template, dataInjection, {
flowType: "总结世界书",
jailbreakPrefix: jailbreakPrefix,
});
const finalSystemPrompt = replacePromptVariables(prompt.systemPrompt, aiConfig, globalConfig);
const finalUserMessage = buildUserPrompt(userMessage);
if (progressTracker) {
progressTracker.addTask(taskId, `搜索:${bookName}`, "search");
progressTracker.setTaskAbortController(taskId, abortController);
}
try {
panel.setBookStatus(bookName, "loading", "调用AI中...");
panel.addBookAIMessage(bookName, "正在调用历史事件回忆AI...");
const aiConfig = getSummaryConfig(bookName);
const globalConfig = getGlobalConfig();
const summaryContent = getSummaryContent(book);
const dataInjection = buildDataInjection({
worldBookContent: summaryContent,
context: context || "",
userMessage: userMessage,
});
const template = await getHistoricalPromptTemplate();
const prompt = injectDataToPrompt(template, dataInjection);
const baseSystemPrompt = replacePromptVariables(prompt.systemPrompt, aiConfig, globalConfig);
const finalSystemPrompt = getJailbreakPrefix() + "\n\n" + baseSystemPrompt;
const finalUserMessage = buildUserPrompt(userMessage);
const response = await APIAdapter.callWithRetry(
{
...aiConfig,
category: bookName,
source: bookName,
taskId: taskId,
},
finalSystemPrompt,
finalUserMessage,
taskId,
3,
abortController.signal
);
if (progressTracker) {
progressTracker.addTask(taskId, `搜索:${bookName}`, "search");
progressTracker.completeTask(taskId, true);
}
const events = parseHistoricalEvents(response);
displaySearchResults(panel, bookName, events);
} catch (error) {
handleSearchError(panel, bookName, taskId, error);
}
}
/**
* 拆分模式:并发处理多个 Part
*/
async function callSummaryBookWithSplit(panel, book, userMessage, context, summaryContent, splitConfig) {
const bookName = book.name;
// 分析拆分方案
const parts = analyzeSummaryContent(summaryContent, splitConfig);
if (parts.length <= 1) {
// 内容不足以拆分,使用单个 API
await callSummaryBookSingle(panel, book, userMessage, context, summaryContent);
return;
}
panel.setBookStatus(bookName, "loading", `并发处理 ${parts.length} 个Part...`);
panel.addBookAIMessage(bookName, `内容已拆分为 ${parts.length} 个Part正在并发调用AI...`);
// 获取配置
const partConfigs = getSummaryPartConfigs(bookName);
const originalConfig = getSummaryConfig(bookName);
const globalConfig = getGlobalConfig();
// 并发处理所有 Part
const partPromises = parts.map(async (part) => {
// Part 1index=0复用原配置其他 Part 使用各自的配置
let partConfig;
if (part.index === 0) {
partConfig = originalConfig;
} else {
partConfig = getSummaryPartApiConfig(bookName, part.id);
}
if (!partConfig || !partConfig.enabled) {
Logger.warn(`[记忆搜索助手] Part "${part.id}" 未配置,跳过`);
return { partId: part.id, success: false, error: "未配置", events: [] };
}
const taskId = `search_${bookName}_${part.id}`;
const abortController = new AbortController();
if (progressTracker) {
progressTracker.addTask(taskId, `搜索:${bookName} Part${part.index + 1}`, "search");
progressTracker.setTaskAbortController(taskId, abortController);
}
try {
const partContent = `=== Part ${part.id} (${part.startFloor}-${part.endFloor}楼) ===\n${part.content}`;
const dataInjection = buildDataInjection({
worldBookContent: partContent,
context: context || "",
userMessage: userMessage,
});
const template = await getHistoricalPromptTemplate();
const jailbreakPrefix = getJailbreakPrefix();
const prompt = injectDataToPrompt(template, dataInjection, {
flowType: "总结世界书",
jailbreakPrefix: jailbreakPrefix,
});
const finalSystemPrompt = replacePromptVariables(prompt.systemPrompt, partConfig, globalConfig);
const finalUserMessage = buildUserPrompt(userMessage);
const response = await APIAdapter.callWithRetry(
{
...aiConfig,
...partConfig,
category: bookName,
source: bookName,
source: `${bookName} Part${part.index + 1}`,
taskId: taskId,
},
finalSystemPrompt,
@@ -1282,39 +1421,163 @@ async function callSingleSummaryBookAI(panel, book, userMessage, context) {
}
const events = parseHistoricalEvents(response);
if (events.length === 0) {
panel.setBookStatus(bookName, "success", "无结果");
panel.addBookSystemMessage(bookName, "AI未返回历史事件请尝试自定义搜索");
} else {
panel.setBookStatus(bookName, "success", `${events.length}`);
panel.addBookAIMessage(bookName, `AI返回 ${events.length} 条历史事件:`);
for (const event of events) {
panel.addBookSearchResult(bookName, {
uid: event.floor,
content: event.content,
});
}
}
return {
partId: part.id,
partIndex: part.index,
success: true,
rawMemory: response,
events: events,
};
} catch (error) {
const isAborted = error.name === "AbortError";
if (progressTracker) {
progressTracker.completeTask(taskId, false, isAborted ? "已终止" : error.message);
}
if (isAborted) {
Logger.warn(`[记忆搜索助手] 总结世界书 "${bookName}" 已被终止`);
panel.setBookStatus(bookName, "error", "已终止");
panel.addBookSystemMessage(bookName, "搜索已被用户终止");
} else {
Logger.error(`[记忆搜索助手] 总结世界书 "${bookName}" AI调用失败:`, error.message);
panel.setBookStatus(bookName, "error", "失败");
panel.addBookSystemMessage(bookName, `AI调用失败: ${error.message}`);
return {
partId: part.id,
partIndex: part.index,
success: false,
error: isAborted ? "已终止" : error.message,
events: [],
};
}
});
const partResults = await Promise.all(partPromises);
// 合并结果
const mergedEvents = mergePartEventsForSearch(partResults);
// 显示调试弹窗(如果启用)
if (isPartDebugEnabled()) {
const debugResults = partResults.map(r => ({
partId: r.partId,
rawMemory: r.rawMemory || `(${r.error || '无返回'})`,
}));
const mergedResult = {
rawMemory: mergedEvents.map(e => {
const floorTag = String(e.floor).startsWith('【') ? e.floor : `${e.floor}楼】`;
return `${floorTag}${e.content}`;
}).join('\n'),
eventCount: mergedEvents.length,
};
showPartDebugModal(debugResults, bookName, mergedResult);
}
// 统计结果
const successCount = partResults.filter(r => r.success).length;
const failCount = partResults.length - successCount;
if (mergedEvents.length === 0) {
panel.setBookStatus(bookName, failCount > 0 ? "error" : "success", "无结果");
panel.addBookSystemMessage(bookName, `${successCount}/${parts.length} 个Part成功AI未返回历史事件`);
} else {
panel.setBookStatus(bookName, "success", `${mergedEvents.length}`);
panel.addBookAIMessage(bookName, `${successCount}/${parts.length} 个Part成功共返回 ${mergedEvents.length} 条历史事件:`);
for (const event of mergedEvents) {
panel.addBookSearchResult(bookName, {
uid: event.floor,
content: event.content,
});
}
}
}
/**
* 合并多个 Part 的搜索结果
*/
function mergePartEventsForSearch(partResults) {
const deduplicateEnabled = isSummaryMergeDeduplicateEnabled();
const allEvents = [];
// 收集所有事件(保持原始顺序)
for (const result of partResults) {
if (result.success && result.events) {
for (const event of result.events) {
allEvents.push({
floor: event.floor,
content: event.content,
sourcePartId: result.partId,
});
}
}
} catch (error) {
Logger.error(`[记忆搜索助手] 总结世界书 "${bookName}" 初始化失败:`, error.message);
}
if (deduplicateEnabled) {
// 去重模式:同一楼层只保留内容最长的
const floorBestEvent = new Map();
for (const event of allEvents) {
const existing = floorBestEvent.get(event.floor);
if (!existing || event.content.length > existing.content.length) {
floorBestEvent.set(event.floor, event);
}
}
// 按原始出现顺序输出(使用第一次出现的顺序)
const seenFloors = new Set();
const uniqueEvents = [];
for (const event of allEvents) {
if (!seenFloors.has(event.floor)) {
seenFloors.add(event.floor);
uniqueEvents.push(floorBestEvent.get(event.floor));
}
}
return uniqueEvents;
} else {
// 不去重模式:相同楼层的内容放在一起
const floorGroups = new Map();
const floorOrder = [];
for (const event of allEvents) {
if (!floorGroups.has(event.floor)) {
floorGroups.set(event.floor, []);
floorOrder.push(event.floor);
}
floorGroups.get(event.floor).push(event);
}
const finalEvents = [];
for (const floor of floorOrder) {
finalEvents.push(...floorGroups.get(floor));
}
return finalEvents;
}
}
/**
* 显示搜索结果
*/
function displaySearchResults(panel, bookName, events) {
if (events.length === 0) {
panel.setBookStatus(bookName, "success", "无结果");
panel.addBookSystemMessage(bookName, "AI未返回历史事件请尝试自定义搜索");
} else {
panel.setBookStatus(bookName, "success", `${events.length}`);
panel.addBookAIMessage(bookName, `AI返回 ${events.length} 条历史事件:`);
for (const event of events) {
panel.addBookSearchResult(bookName, {
uid: event.floor,
content: event.content,
});
}
}
}
/**
* 处理搜索错误
*/
function handleSearchError(panel, bookName, taskId, error) {
const isAborted = error.name === "AbortError";
if (progressTracker) {
progressTracker.completeTask(taskId, false, isAborted ? "已终止" : error.message);
}
if (isAborted) {
Logger.warn(`[记忆搜索助手] 总结世界书 "${bookName}" 已被终止`);
panel.setBookStatus(bookName, "error", "已终止");
panel.addBookSystemMessage(bookName, "搜索已被用户终止");
} else {
Logger.error(`[记忆搜索助手] 总结世界书 "${bookName}" AI调用失败:`, error.message);
panel.setBookStatus(bookName, "error", "失败");
panel.addBookSystemMessage(bookName, `初始化失败: ${error.message}`);
panel.addBookSystemMessage(bookName, `AI调用失败: ${error.message}`);
}
}
@@ -1334,11 +1597,16 @@ function parseHistoricalEvents(response) {
for (const line of lines) {
const trimmed = line.trim();
const floorMatch = trimmed.match(/^【(\d+)楼】(.*)$/);
// 兼容多种楼层格式【124楼】、【124至#125】、【124至125楼】
// 捕获完整的楼层标签和内容
const floorMatch = trimmed.match(/^(【\d+(?:楼|至#?\d+楼?)】)(.*)$/);
if (floorMatch) {
// 保留完整的楼层标签(如 【124至#125】
const floorTag = floorMatch[1];
const content = floorMatch[2] || '';
events.push({
floor: floorMatch[1],
content: floorMatch[2].trim(),
floor: floorTag,
content: content.trim(),
});
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -39,6 +39,13 @@ import {
bindWorldbookControlEvents,
} from './components/worldbook-control';
// 导入表格填表模块
import {
initTableFillerUI,
bindTableFillerEvents,
updateTableFillerBadge,
} from './components/table-filler';
// 导入配置弹窗模块中的函数(用于直接调用而非函数注入)
import {
saveConfig as saveConfigModal,
@@ -65,6 +72,12 @@ export {
toggleRecursionSetting,
};
export {
initTableFillerUI,
bindTableFillerEvents,
updateTableFillerBadge,
};
// 函数注入存储(用于在 index.js 中设置)
let togglePanelFn = null;
let showWorldBookSelectorFn = null;
@@ -106,6 +119,9 @@ let importPromptFileFn = null;
let exportPromptFileFn = null;
let switchPromptTypeFn = null;
// 总结世界书Part配置相关函数
let showSummaryPartConfigModalFn = null;
// 设置函数导出
export function setTogglePanelFunction(fn) { togglePanelFn = fn; }
export function setWorldBookSelectorFunction(fn) { showWorldBookSelectorFn = fn; }
@@ -155,6 +171,11 @@ export function setPromptEditorFunctions(show, hide, save, saveAs, del, restore,
switchPromptTypeFn = switchType;
}
// 总结世界书Part配置设置函数
export function setSummaryPartConfigModalFunction(fn) {
showSummaryPartConfigModalFn = fn;
}
// 兼容旧版导出名称
export function setSettingsFunctions(showFn, hideFn) {
// 设置面板直接通过 CSS 类切换,不需要回调
@@ -927,7 +948,21 @@ function bindWorldBookListEvents() {
if (editBtn) {
const category = editBtn.dataset.category;
const type = editBtn.dataset.type || "memory";
if (showConfigModalFn) showConfigModalFn(category, type);
// 检查是否有 Part 信息(总结世界书拆分模式)
let partInfo = null;
if (editBtn.dataset.partId) {
partInfo = {
partId: editBtn.dataset.partId,
partIndex: parseInt(editBtn.dataset.partIndex || "0", 10),
startFloor: parseInt(editBtn.dataset.startFloor || "0", 10),
endFloor: parseInt(editBtn.dataset.endFloor || "0", 10),
charCount: parseInt(editBtn.dataset.charCount || "0", 10),
bookName: editBtn.dataset.bookName || category,
};
}
if (showConfigModalFn) showConfigModalFn(category, type, partInfo);
return;
}
@@ -951,6 +986,20 @@ function bindWorldBookListEvents() {
}
return;
}
// 编辑Part配置
const editPartBtn = e.target.closest('[data-action="edit-part-config"]');
if (editPartBtn) {
const bookName = editPartBtn.dataset.book;
const partId = editPartBtn.dataset.partId;
Logger.log(`[Events] 点击Part配置: book=${bookName}, partId=${partId}, fn=${!!showSummaryPartConfigModalFn}`);
if (showSummaryPartConfigModalFn) {
showSummaryPartConfigModalFn(bookName, partId);
} else {
Logger.warn('[Events] showSummaryPartConfigModalFn 未设置');
}
return;
}
});
}
@@ -1570,6 +1619,9 @@ export function loadGlobalSettingsUI() {
// 初始化标签过滤 UI
initTagFilterUI(settings.contextTagFilter);
// 初始化表格填表 UI
initTableFillerUI();
}
/**
@@ -1796,6 +1848,57 @@ export function bindEvents() {
bindWorldbookControlEvents();
bindGameEvents();
bindMultiAIEvents();
bindTableFillerEvents();
bindSummaryAutoSplitEvents();
Logger.log("UI 事件绑定完成");
}
/**
* 绑定总结世界书自动拆分事件
*/
function bindSummaryAutoSplitEvents() {
// 使用事件委托处理动态创建的开关
document.addEventListener("change", (e) => {
if (e.target.id === "mm-summary-auto-split-toggle") {
const enabled = e.target.checked;
import('@config/config-manager').then(({ setSummaryAutoSplitEnabled }) => {
setSummaryAutoSplitEnabled(enabled);
// 刷新世界书列表以更新Part显示
refreshWorldBookList();
Logger.log(`[SummaryAutoSplit] 自动拆分已${enabled ? '启用' : '禁用'}`);
});
}
// Part 调试模式开关
if (e.target.id === "mm-summary-part-debug-toggle") {
const enabled = e.target.checked;
import('@memory/part-debug-modal').then(({ setPartDebugEnabled }) => {
setPartDebugEnabled(enabled);
if (typeof toastr !== "undefined") {
if (enabled) {
toastr.info("已启用调试模式处理完成后将显示各Part返回内容", "Part调试");
} else {
toastr.info("已关闭调试模式", "Part调试");
}
}
});
}
// 合并去重开关
if (e.target.id === "mm-summary-merge-deduplicate-toggle") {
const enabled = e.target.checked;
import('@config/config-manager').then(({ setSummaryMergeDeduplicateEnabled }) => {
setSummaryMergeDeduplicateEnabled(enabled);
if (typeof toastr !== "undefined") {
if (enabled) {
toastr.info("已启用去重,同一楼层只保留第一个", "合并去重");
} else {
toastr.info("已关闭去重,相同楼层内容会放在一起", "合并去重");
}
}
});
}
});
}

View File

@@ -88,6 +88,7 @@ export {
setRefreshAIConfigListFunction,
setFlowConfigFunctions,
setPromptEditorFunctions,
setSummaryPartConfigModalFunction,
// 标签过滤
initTagFilterUI,
updateTagFilterBadge,
@@ -179,6 +180,8 @@ export {
restoreDefaultPrompt,
switchPromptType,
bindPromptEditorEvents,
// 总结世界书Part配置弹窗
showSummaryPartConfigModal,
} from './modals';
// 剧情优化助手面板

View File

@@ -63,9 +63,11 @@ export function showClearDataConfirmModal() {
<ul style="margin: 0 0 16px 20px; padding: 0; line-height: 1.8; color: var(--mm-text-muted);">
<li><i class="fa-solid fa-robot" style="width: 16px; margin-right: 6px; color: #27ae60;"></i>记忆分类 API 配置</li>
<li><i class="fa-solid fa-scroll" style="width: 16px; margin-right: 6px; color: #27ae60;"></i>总结世界书 API 配置</li>
<li><i class="fa-solid fa-puzzle-piece" style="width: 16px; margin-right: 6px; color: #27ae60;"></i>总结世界书拆分 API 配置Part 配置)</li>
<li><i class="fa-solid fa-layer-group" style="width: 16px; margin-right: 6px; color: #27ae60;"></i>索引合并 API 配置</li>
<li><i class="fa-solid fa-wand-magic-sparkles" style="width: 16px; margin-right: 6px; color: #27ae60;"></i>剧情优化 API 配置</li>
<li><i class="fa-solid fa-users" style="width: 16px; margin-right: 6px; color: #27ae60;"></i>多AI生成的 API 配置(但会解除其提示词预设关联)</li>
<li><i class="fa-solid fa-table" style="width: 16px; margin-right: 6px; color: #27ae60;"></i>Amily表格并发 API 配置(但会清除导入的预设)</li>
</ul>
</div>

View File

@@ -12,10 +12,15 @@ import {
setMemoryConfig,
setSummaryConfig,
updateGlobalSettings,
setSummaryPartApiConfig,
isSummaryAutoSplitEnabled,
getSummaryAutoSplitConfig,
} from "@config/config-manager";
import Logger from "@core/logger";
import { refreshWorldBookList } from "@worldbook/refresh";
import { getWorldBookList, getWorldBookEntries } from "@worldbook/api";
import { analyzeSummaryContent, formatCharCount } from "@worldbook/summary-splitter";
import { getSummaryContent } from "@worldbook/parser";
// 更新显示回调函数(将在初始化时注入)
let updateIndexMergeModelDisplayFn = null;
@@ -34,6 +39,9 @@ export function setUpdateDisplayFunctions(indexMergeFn, plotOptimizeFn, refreshC
// 当前编辑状态
let currentEditingCategory = null;
let currentEditingType = null;
// Part 编辑状态(用于总结世界书拆分)
let currentEditingPartId = null;
let currentEditingPartInfo = null;
// 剧情优化配置中选中的世界书和条目(临时状态)
let plotConfigSelectedBooks = new Set();
@@ -42,6 +50,15 @@ let plotConfigSelectedEntries = {};
let configWorldBooksCache = [];
let configEntriesCache = {};
/**
* 根据名称获取世界书对象
* @param {string} bookName 世界书名称
* @returns {object|null} 世界书对象
*/
function getWorldBookByName(bookName) {
return configWorldBooksCache.find(book => book.name === bookName) || null;
}
/**
* 切换配置标签页
* @param {string} tabName 标签页名称 ('api' | 'context')
@@ -78,10 +95,13 @@ export function toggleCustomFormatOptions(show) {
* 显示配置弹窗
* @param {string} category 分类名称
* @param {string} type 类型 ('memory' | 'summary' | 'merge' | 'plot')
* @param {object} partInfo Part信息可选用于总结世界书拆分{ partId, partIndex, startFloor, endFloor, charCount, bookName }
*/
export function showConfigModal(category, type = "memory") {
export function showConfigModal(category, type = "memory", partInfo = null) {
currentEditingCategory = category;
currentEditingType = type;
currentEditingPartId = partInfo?.partId || null;
currentEditingPartInfo = partInfo || null;
const modal = document.getElementById("mm-ai-config-modal");
if (!modal) return;
@@ -99,15 +119,54 @@ export function showConfigModal(category, type = "memory") {
if (type === "memory") {
itemConfig = config?.memoryConfigs?.[category] || {};
} else if (type === "summary") {
itemConfig = config?.summaryConfigs?.[category] || {};
// 如果是 Part 配置且不是 Part 1index=0从 Part 配置中获取
if (partInfo && partInfo.partIndex > 0) {
const partConfigs = config?.summaryPartConfigs?.[partInfo.bookName];
const savedPart = partConfigs?.parts?.find(p => p.id === partInfo.partId);
itemConfig = savedPart?.apiConfig || {};
} else {
itemConfig = config?.summaryConfigs?.[category] || {};
}
} else if (type === "merge" || type === "indexMerge") {
itemConfig = globalSettings.indexMergeConfig || {};
} else if (type === "plot") {
itemConfig = globalSettings.plotOptimizeConfig || {};
}
// 设置标题
const categoryNameEl = document.getElementById("mm-config-category-name");
if (categoryNameEl) categoryNameEl.textContent = category;
if (categoryNameEl) {
if (partInfo) {
categoryNameEl.textContent = `Part ${partInfo.partIndex + 1}`;
} else {
categoryNameEl.textContent = category;
}
}
// 显示/隐藏楼层+字符信息横幅
const partInfoBanner = document.getElementById("mm-config-part-info");
const partInfoText = document.getElementById("mm-config-part-info-text");
if (partInfoBanner && partInfoText) {
if (type === "summary") {
partInfoBanner.style.display = "flex";
if (partInfo) {
// 拆分模式:显示楼层范围和字符数
partInfoText.textContent = `${partInfo.startFloor}-${partInfo.endFloor}${formatCharCount(partInfo.charCount)} 字符 | ${partInfo.bookName}`;
} else {
// 非拆分模式:显示总字符数
const book = getWorldBookByName(category);
if (book) {
const content = getSummaryContent(book);
const totalChars = content.length;
partInfoText.textContent = `${formatCharCount(totalChars)} 字符 | ${category}`;
} else {
partInfoText.textContent = category;
}
}
} else {
partInfoBanner.style.display = "none";
}
}
const enabledEl = document.getElementById("mm-config-enabled");
if (enabledEl) enabledEl.checked = itemConfig.enabled !== false;
@@ -298,7 +357,19 @@ export async function saveConfig() {
} else if (currentEditingType === "summary") {
const eventsInput = document.getElementById("mm-config-max-events");
aiConfig.maxHistoryEvents = parseInt(eventsInput?.value || "15", 10);
setSummaryConfig(currentEditingCategory, aiConfig);
// 如果是 Part 配置且不是 Part 1index > 0保存到 summaryPartConfigs
if (currentEditingPartInfo && currentEditingPartInfo.partIndex > 0) {
setSummaryPartApiConfig(
currentEditingPartInfo.bookName,
currentEditingPartId,
aiConfig
);
Logger.log(`已保存 Part ${currentEditingPartInfo.partIndex + 1} 配置`);
} else {
// Part 1 或非拆分模式,保存到 summaryConfigs
setSummaryConfig(currentEditingCategory, aiConfig);
}
} else if (
currentEditingType === "indexMerge" ||
currentEditingType === "merge"

View File

@@ -34,6 +34,14 @@ export const SOURCE_LABELS = {
plot_input: "[剧情优化] 面板用户输入 <最新用户消息>",
};
// 流程类型与调用功能的映射说明用于UI悬停提示
const FLOW_TYPE_DESCRIPTIONS = {
"记忆世界书": "调用功能:记忆世界书处理",
"总结世界书": "调用功能:总结世界书处理、记忆搜索助手",
"索引合并": "调用功能:索引合并处理",
"剧情优化": "调用功能:剧情优化助手",
};
/**
* 从配置文件加载流程配置
* @param {boolean} forceReload - 是否强制重新加载(从服务器重新加载)
@@ -272,11 +280,15 @@ export async function renderFlowConfigList(savedOrder = null) {
(source) => source !== "jailbreak",
);
// 获取流程类型的悬停提示说明
const flowTypeDescription = FLOW_TYPE_DESCRIPTIONS[category] || "";
card.innerHTML = `
<div class="mm-collapse-header mm-flow-group-header">
<div class="mm-collapse-title">
<i class="fa-solid fa-folder"></i>
<span>${category}</span>
<span title="${flowTypeDescription.replace(/"/g, '&quot;')}">${category}</span>
<i class="fa-solid fa-circle-question mm-flow-hint-icon" title="${flowTypeDescription.replace(/"/g, '&quot;')}" style="margin-left: 6px; font-size: 12px; opacity: 0.6; cursor: help;"></i>
<span class="mm-collapse-badge">${visibleSources.length} 项</span>
</div>
<i class="fa-solid fa-chevron-down mm-collapse-arrow"></i>

View File

@@ -0,0 +1,533 @@
/**
* 独立模式模板编辑弹窗
* @module ui/modals/independent-template-modal
*/
import Logger from "@core/logger";
import {
setIndependentTemplate,
deleteIndependentTemplate,
getAllIndependentTemplates,
getGlobalSettings,
getIndependentTagName,
setIndependentTagName,
loadDefaultIndependentTemplates,
getAllIndependentTemplatesWithDefault,
} from "@config/config-manager";
const log = Logger.createModuleLogger("独立模式模板");
// 缓存的表格名称
let cachedTableNames = [];
// 当前编辑中的模板数据(临时存储,保存时才写入配置)
let pendingTemplates = {};
// 是否有未保存的更改
let hasUnsavedChanges = false;
/**
* 从 Amily2 获取表格名称列表
* @returns {Promise<string[]>}
*/
async function getAmily2TableNames() {
try {
// 复用 table-filler.js 中的获取逻辑
const amilyExtName = "ST-Amily2-Chat-Optimisation";
const settings = window.extension_settings?.[amilyExtName];
if (settings?.global_table_preset?.tables) {
const tables = settings.global_table_preset.tables;
if (Array.isArray(tables) && tables.length > 0) {
const names = tables.map((t) => t.name).filter(Boolean);
if (names.length > 0) return names;
}
}
if (settings?.tables && Array.isArray(settings.tables)) {
const names = settings.tables.map((t) => t.name).filter(Boolean);
if (names.length > 0) return names;
}
// 尝试从 DOM 获取
const tableTabsContainer = document.querySelector(".amily2-table-tabs");
if (tableTabsContainer) {
const tabButtons =
tableTabsContainer.querySelectorAll("button.menu_button");
const names = [];
tabButtons.forEach((btn) => {
if (!btn.querySelector(".fa-plus")) {
const name = btn.textContent?.trim().replace(/•$/, "").trim();
if (name) names.push(name);
}
});
if (names.length > 0) return names;
}
log.warn("未能获取 Amily2 表格名称");
return [];
} catch (e) {
log.error("获取 Amily2 表格名称失败:", e);
return [];
}
}
/**
* 显示独立模式模板编辑弹窗
*/
export async function showIndependentTemplateModal() {
const modal = document.getElementById("mm-independent-template-modal");
if (!modal) {
log.error("找不到独立模式模板弹窗元素");
return;
}
// 应用主题
const settings = getGlobalSettings();
const theme = settings.theme || "default";
if (theme !== "default") {
modal.setAttribute("data-mm-theme", theme);
} else {
modal.removeAttribute("data-mm-theme");
}
// 重置状态
hasUnsavedChanges = false;
// 加载表格名称
cachedTableNames = await getAmily2TableNames();
// 加载已保存的模板和默认模板到临时存储
const allTemplates = await getAllIndependentTemplatesWithDefault();
pendingTemplates = {};
// 分离持久化模板和默认模板
for (const [tableName, data] of Object.entries(allTemplates)) {
// 确保模板内容是字符串(处理可能的嵌套结构)
let templateContent = data.template;
if (typeof templateContent === 'object' && templateContent !== null) {
templateContent = templateContent.template;
}
if (typeof templateContent !== 'string') {
templateContent = '';
}
if (data.isDefault) {
// 默认模板:标记为默认,但不算已配置
pendingTemplates[tableName] = { template: templateContent, isDefault: true };
} else {
// 持久化模板
pendingTemplates[tableName] = { template: templateContent };
}
}
// 渲染模板列表
renderTemplateList();
// 显示弹窗
modal.classList.add("mm-modal-visible");
}
/**
* 隐藏独立模式模板编辑弹窗
*/
export function hideIndependentTemplateModal() {
const modal = document.getElementById("mm-independent-template-modal");
if (modal) {
modal.classList.remove("mm-modal-visible");
}
}
/**
* 渲染模板列表
*/
function renderTemplateList() {
const listEl = document.getElementById("mm-independent-template-list");
if (!listEl) return;
if (cachedTableNames.length === 0) {
listEl.innerHTML = `
<div class="mm-template-loading">
<i class="fa-solid fa-triangle-exclamation"></i>
<span>未检测到 Amily2 表格<br><small>请确保已加载表格预设并开启聊天</small></span>
</div>
`;
return;
}
listEl.innerHTML = "";
cachedTableNames.forEach((tableName) => {
const templateData = pendingTemplates[tableName];
const isConfigured = !!templateData?.template && !templateData?.isDefault;
const isDefault = !!templateData?.isDefault;
const hasTemplate = !!templateData?.template;
const item = document.createElement("div");
item.className = `mm-template-item${isConfigured ? " configured" : ""}${isDefault ? " default" : ""}`;
item.dataset.tableName = tableName;
// 状态文字
let statusText = "未配置";
let statusClass = "";
if (isConfigured) {
statusText = "已配置";
statusClass = " configured";
} else if (isDefault) {
statusText = "内置默认";
statusClass = " default";
}
item.innerHTML = `
<div class="mm-template-item-header">
<div class="mm-template-item-left">
<i class="fa-solid fa-chevron-down mm-template-item-arrow"></i>
<span class="mm-template-item-name">${escapeHtml(tableName)}</span>
<span class="mm-template-item-status${statusClass}">${statusText}</span>
</div>
<div class="mm-template-item-right">
${isConfigured ? `<button type="button" class="mm-btn mm-btn-icon mm-btn-xs mm-btn-secondary mm-template-item-restore" title="恢复内置默认模板">
<i class="fa-solid fa-rotate-left"></i>
</button>` : ""}
${hasTemplate ? `<button type="button" class="mm-btn mm-btn-icon mm-btn-xs mm-btn-danger mm-template-item-clear" title="${isDefault ? "清空此模板" : "清空此模板"}">
<i class="fa-solid fa-trash"></i>
</button>` : ""}
</div>
</div>
<div class="mm-template-item-body">
<textarea class="mm-template-textarea" placeholder="输入提示词模板,可使用占位符:{{tableData}}、{{tableName}}、{{tableIndex}}">${escapeHtml(templateData?.template || "")}</textarea>
<div class="mm-template-preview-hint">
<i class="fa-solid fa-lightbulb"></i>
${isDefault ? "提示:这是内置默认模板,编辑后将保存为自定义模板" : "提示:未配置的表格将自动使用共享模式处理"}
</div>
</div>
`;
// 绑定折叠切换
const header = item.querySelector(".mm-template-item-header");
header.addEventListener("click", (e) => {
// 如果点击的是按钮,不切换折叠
if (e.target.closest(".mm-btn")) return;
item.classList.toggle("expanded");
});
// 绑定清空按钮
const clearBtn = item.querySelector(".mm-template-item-clear");
if (clearBtn) {
clearBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (confirm(`确定清空「${tableName}」的模板吗?`)) {
delete pendingTemplates[tableName];
hasUnsavedChanges = true;
renderTemplateList();
}
});
}
// 绑定恢复默认按钮
const restoreBtn = item.querySelector(".mm-template-item-restore");
if (restoreBtn) {
restoreBtn.addEventListener("click", async (e) => {
e.stopPropagation();
const defaultTemplates = await loadDefaultIndependentTemplates();
const defaultTemplate = defaultTemplates?.templates?.[tableName];
if (defaultTemplate) {
const templateContent = typeof defaultTemplate === 'string' ? defaultTemplate : defaultTemplate?.template;
if (templateContent) {
pendingTemplates[tableName] = { template: templateContent, isDefault: true };
hasUnsavedChanges = true;
renderTemplateList();
if (typeof toastr !== "undefined") {
toastr.success(`已恢复「${tableName}」的内置默认模板`, "独立模式");
}
}
} else {
if (typeof toastr !== "undefined") {
toastr.warning(`${tableName}」没有内置默认模板`, "独立模式");
}
}
});
}
// 绑定文本框变更
const textarea = item.querySelector(".mm-template-textarea");
textarea.addEventListener("input", () => {
const value = textarea.value.trim();
if (value) {
// 编辑后移除 isDefault 标记,变为自定义模板
pendingTemplates[tableName] = { template: value };
} else {
delete pendingTemplates[tableName];
}
hasUnsavedChanges = true;
updateItemStatus(item, !!value, false);
});
listEl.appendChild(item);
});
}
/**
* 更新单个项目的状态显示
* @param {HTMLElement} item
* @param {boolean} isConfigured
* @param {boolean} isDefault
*/
function updateItemStatus(item, isConfigured, isDefault = false) {
const statusEl = item.querySelector(".mm-template-item-status");
if (statusEl) {
if (isConfigured) {
statusEl.textContent = "已配置";
statusEl.className = "mm-template-item-status configured";
} else if (isDefault) {
statusEl.textContent = "内置默认";
statusEl.className = "mm-template-item-status default";
} else {
statusEl.textContent = "未配置";
statusEl.className = "mm-template-item-status";
}
}
item.classList.toggle("configured", isConfigured);
item.classList.toggle("default", isDefault && !isConfigured);
}
/**
* 保存所有模板
*/
export function saveAllTemplates() {
// 清除所有现有模板
const existingTemplates = getAllIndependentTemplates();
for (const tableName of Object.keys(existingTemplates)) {
deleteIndependentTemplate(tableName);
}
// 保存新模板(只保存非默认的,即用户自定义的)
for (const [tableName, data] of Object.entries(pendingTemplates)) {
if (data?.template && !data?.isDefault) {
setIndependentTemplate(tableName, data.template);
}
}
hasUnsavedChanges = false;
// 更新设置面板中的状态显示
updateTemplateStatusDisplay();
if (typeof toastr !== "undefined") {
toastr.success("模板配置已保存", "独立模式");
}
}
/**
* 导入模板配置
* @param {File} file
*/
export async function importTemplates(file) {
try {
const text = await file.text();
const data = JSON.parse(text);
if (!data.templates || typeof data.templates !== "object") {
throw new Error("无效的配置格式");
}
// 合并导入的模板
for (const [tableName, templateData] of Object.entries(data.templates)) {
if (templateData?.template) {
pendingTemplates[tableName] = { template: templateData.template };
}
}
// 如果有标签名配置,也导入
if (data.tagName) {
setIndependentTagName(data.tagName);
const tagInput = document.getElementById("mm-table-filler-tag-name");
if (tagInput) {
tagInput.value = data.tagName;
}
}
hasUnsavedChanges = true;
renderTemplateList();
if (typeof toastr !== "undefined") {
toastr.success("配置已导入,请点击保存", "独立模式");
}
} catch (e) {
log.error("导入配置失败:", e);
if (typeof toastr !== "undefined") {
toastr.error(`导入失败: ${e.message}`, "独立模式");
}
}
}
/**
* 导出模板配置
*/
export function exportTemplates() {
const data = {
version: "1.0",
templates: pendingTemplates,
tagName: getIndependentTagName(),
};
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "independent-templates.json";
a.click();
URL.revokeObjectURL(url);
}
/**
* 更新设置面板中的模板状态显示
*/
async function updateTemplateStatusDisplay() {
const statusEl = document.getElementById("mm-table-filler-template-status");
if (!statusEl) return;
const templates = getAllIndependentTemplates();
const customCount = Object.keys(templates).length;
// 加载默认模板统计
const defaultTemplates = await loadDefaultIndependentTemplates();
const defaultCount = defaultTemplates?.templates ? Object.keys(defaultTemplates.templates).length : 0;
if (customCount > 0) {
statusEl.textContent = `已配置 ${customCount}`;
statusEl.classList.add("configured");
statusEl.classList.remove("default");
} else if (defaultCount > 0) {
statusEl.textContent = `使用默认 ${defaultCount}`;
statusEl.classList.remove("configured");
statusEl.classList.add("default");
} else {
statusEl.textContent = "未配置";
statusEl.classList.remove("configured");
statusEl.classList.remove("default");
}
}
/**
* 绑定独立模式模板弹窗事件
*/
export function bindIndependentTemplateEvents() {
// 编辑按钮(设置面板中)- 先绑定,不依赖 modal 存在
document
.getElementById("mm-table-filler-edit-templates")
?.addEventListener("click", () => {
showIndependentTemplateModal();
});
const modal = document.getElementById("mm-independent-template-modal");
if (!modal) {
log.warn("独立模式模板弹窗元素未找到,部分事件未绑定");
return;
}
// 关闭按钮
modal.querySelector(".mm-modal-close")?.addEventListener("click", () => {
if (hasUnsavedChanges && !confirm("有未保存的更改,确定关闭吗?")) {
return;
}
hideIndependentTemplateModal();
});
// 取消按钮
document
.getElementById("mm-independent-template-cancel")
?.addEventListener("click", () => {
if (hasUnsavedChanges && !confirm("有未保存的更改,确定取消吗?")) {
return;
}
hideIndependentTemplateModal();
});
// 保存按钮
document
.getElementById("mm-independent-template-save")
?.addEventListener("click", () => {
saveAllTemplates();
hideIndependentTemplateModal();
});
// 导入按钮
document
.getElementById("mm-independent-template-import")
?.addEventListener("click", () => {
const fileInput = document.getElementById("mm-independent-template-file");
if (fileInput) {
fileInput.click();
}
});
// 文件选择
document
.getElementById("mm-independent-template-file")
?.addEventListener("change", async (e) => {
const file = e.target.files?.[0];
if (file) {
await importTemplates(file);
}
e.target.value = "";
});
// 导出按钮
document
.getElementById("mm-independent-template-export")
?.addEventListener("click", () => {
exportTemplates();
});
// 全部恢复默认按钮
document
.getElementById("mm-independent-template-restore-all")
?.addEventListener("click", async () => {
if (!confirm("确定将所有模板恢复为内置默认吗?自定义的模板将被覆盖。")) {
return;
}
const defaultTemplates = await loadDefaultIndependentTemplates();
if (defaultTemplates?.templates) {
pendingTemplates = {};
for (const [tableName, templateObj] of Object.entries(defaultTemplates.templates)) {
const templateContent = typeof templateObj === 'string' ? templateObj : templateObj?.template;
if (templateContent) {
pendingTemplates[tableName] = { template: templateContent, isDefault: true };
}
}
hasUnsavedChanges = true;
renderTemplateList();
if (typeof toastr !== "undefined") {
toastr.success("已恢复所有模板为内置默认", "独立模式");
}
} else {
if (typeof toastr !== "undefined") {
toastr.warning("未找到内置默认模板", "独立模式");
}
}
});
// 初始化状态显示
updateTemplateStatusDisplay();
}
/**
* 转义 HTML
* @param {string} text
* @returns {string}
*/
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
export default {
showIndependentTemplateModal,
hideIndependentTemplateModal,
bindIndependentTemplateEvents,
saveAllTemplates,
importTemplates,
exportTemplates,
};

View File

@@ -3,6 +3,84 @@
* @module ui/modals
*/
/**
* 为弹窗添加拖拽移动功能
* @param {HTMLElement} modal - 弹窗外层容器
* @param {HTMLElement} content - 弹窗内容区域(可拖拽移动的元素)
* @param {HTMLElement} header - 拖拽手柄(通常是弹窗头部)
*/
export function enableModalDrag(modal, content, header) {
if (!modal || !content || !header) return;
let isDragging = false;
let startX = 0;
let startY = 0;
let initialLeft = 0;
let initialTop = 0;
// 设置初始位置样式
content.style.position = "relative";
content.style.left = "0px";
content.style.top = "0px";
// 设置拖拽手柄样式
header.style.cursor = "move";
header.style.userSelect = "none";
const onMouseDown = (e) => {
// 忽略按钮点击
if (e.target.closest('button')) return;
isDragging = true;
startX = e.clientX || e.touches?.[0]?.clientX || 0;
startY = e.clientY || e.touches?.[0]?.clientY || 0;
initialLeft = parseInt(content.style.left) || 0;
initialTop = parseInt(content.style.top) || 0;
document.body.style.userSelect = "none";
e.preventDefault();
};
const onMouseMove = (e) => {
if (!isDragging) return;
const clientX = e.clientX || e.touches?.[0]?.clientX || 0;
const clientY = e.clientY || e.touches?.[0]?.clientY || 0;
const deltaX = clientX - startX;
const deltaY = clientY - startY;
content.style.left = `${initialLeft + deltaX}px`;
content.style.top = `${initialTop + deltaY}px`;
e.preventDefault();
};
const onMouseUp = () => {
if (isDragging) {
isDragging = false;
document.body.style.userSelect = "";
}
};
header.addEventListener("mousedown", onMouseDown);
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
header.addEventListener("touchstart", onMouseDown, { passive: false });
document.addEventListener("touchmove", onMouseMove, { passive: false });
document.addEventListener("touchend", onMouseUp);
// 返回清理函数
return () => {
header.removeEventListener("mousedown", onMouseDown);
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
header.removeEventListener("touchstart", onMouseDown);
document.removeEventListener("touchmove", onMouseMove);
document.removeEventListener("touchend", onMouseUp);
};
}
// 请求预览弹窗
export { showRequestPreview } from './request-preview';
@@ -84,3 +162,20 @@ export {
renderPromptPresetList,
} from './prompt-preset';
// 独立模式模板编辑弹窗
export {
showIndependentTemplateModal,
hideIndependentTemplateModal,
bindIndependentTemplateEvents,
saveAllTemplates,
importTemplates,
exportTemplates,
} from './independent-template-modal';
// 总结世界书Part配置弹窗
export {
showSummaryPartConfigModal,
hidePartConfigModal,
} from './summary-part-config';

View File

@@ -5,6 +5,7 @@
import Logger from '@core/logger';
import { getGlobalSettings, updateGlobalSettings } from '@config/config-manager';
import { enableModalDrag } from './index';
/**
* 显示请求预览弹窗
@@ -40,7 +41,6 @@ export function showRequestPreview(requests) {
content.className = "mm-modal-content mm-modal-large";
content.style.width = "100%";
content.style.maxWidth = "1000px";
content.style.height = "90vh";
content.style.maxHeight = "90vh";
content.style.overflow = "hidden";
content.style.display = "flex";
@@ -199,6 +199,9 @@ export function showRequestPreview(requests) {
header.appendChild(closeBtn);
content.appendChild(header);
// 启用弹窗拖拽移动
enableModalDrag(modal, content, header);
// 创建弹窗主体 - 可滚动区域
const body = document.createElement("div");
body.className = "mm-modal-body";

View File

@@ -4,6 +4,7 @@
*/
import { getGlobalSettings, isMultiAIAvailable } from '@config/config-manager';
import { enableModalDrag } from './index';
/**
* 显示汇总检查弹窗
@@ -40,7 +41,6 @@ export function showSummaryCheckModal(summaryContent, editorContent = "") {
content.className = "mm-modal-content mm-modal-large";
content.style.width = "100%";
content.style.maxWidth = "800px";
content.style.height = "80vh";
content.style.maxHeight = "80vh";
content.style.overflow = "hidden";
content.style.display = "flex";
@@ -76,12 +76,17 @@ export function showSummaryCheckModal(summaryContent, editorContent = "") {
header.appendChild(closeBtn);
content.appendChild(header);
// 启用弹窗拖拽移动
enableModalDrag(modal, content, header);
// 创建弹窗主体
const body = document.createElement("div");
body.className = "mm-modal-body";
body.style.flex = "1";
body.style.overflowY = "auto";
body.style.padding = "20px";
body.style.display = "flex";
body.style.flexDirection = "column";
// 提示信息
const hint = document.createElement("div");
@@ -126,7 +131,6 @@ export function showSummaryCheckModal(summaryContent, editorContent = "") {
summaryText.style.color = "var(--mm-text)";
summaryText.style.height = editorContent ? "200px" : "300px";
summaryText.style.minHeight = "100px";
summaryText.style.maxHeight = "none";
summaryText.style.overflowY = "auto";
summaryText.style.padding = "10px";
summaryText.style.background = "var(--mm-bg-secondary)";
@@ -146,30 +150,39 @@ export function showSummaryCheckModal(summaryContent, editorContent = "") {
let isResizing = false;
let startY = 0;
let startHeight = 0;
const maxScreenHeight = window.innerHeight * 0.8; // 最大高度为屏幕的80%
resizeHandle.addEventListener("mousedown", (e) => {
const onResizeMouseDown = (e) => {
isResizing = true;
startY = e.clientY;
startY = e.clientY || e.touches?.[0]?.clientY || 0;
startHeight = summaryText.offsetHeight;
document.body.style.cursor = "ns-resize";
document.body.style.userSelect = "none";
e.preventDefault();
});
};
document.addEventListener("mousemove", (e) => {
const onResizeMouseMove = (e) => {
if (!isResizing) return;
const deltaY = e.clientY - startY;
const newHeight = Math.max(100, startHeight + deltaY);
const clientY = e.clientY || e.touches?.[0]?.clientY || 0;
const deltaY = clientY - startY;
const newHeight = Math.max(100, Math.min(maxScreenHeight, startHeight + deltaY));
summaryText.style.height = newHeight + "px";
});
};
document.addEventListener("mouseup", () => {
const onResizeMouseUp = () => {
if (isResizing) {
isResizing = false;
document.body.style.cursor = "";
document.body.style.userSelect = "";
}
});
};
resizeHandle.addEventListener("mousedown", onResizeMouseDown);
document.addEventListener("mousemove", onResizeMouseMove);
document.addEventListener("mouseup", onResizeMouseUp);
resizeHandle.addEventListener("touchstart", onResizeMouseDown, { passive: false });
document.addEventListener("touchmove", onResizeMouseMove, { passive: false });
document.addEventListener("touchend", onResizeMouseUp);
summaryContainer.appendChild(resizableContainer);

View File

@@ -0,0 +1,501 @@
/**
* 总结世界书Part配置弹窗模块
* @module ui/modals/summary-part-config
*/
import Logger from "@core/logger";
import {
getGlobalSettings,
getSummaryPartApiConfig,
setSummaryPartApiConfig,
} from "@config/config-manager";
import { refreshWorldBookList, getSummaryParts } from "@worldbook/refresh";
import { formatCharCount } from "@worldbook/summary-splitter";
import APIAdapter from "@api/adapter";
/**
* 从API获取模型列表
* @param {string} apiUrl API地址
* @param {string} apiKey API密钥
* @param {string} format API格式
* @returns {Promise<string[]>} 模型列表
*/
async function fetchModelsFromApi(apiUrl, apiKey, format) {
let modelsUrl = apiUrl;
// 构建模型列表URL
if (format === 'openai') {
if (apiUrl.endsWith('/v1') || apiUrl.endsWith('/v1/')) {
modelsUrl = apiUrl.replace(/\/v1\/?$/, '/v1/models');
} else if (apiUrl.includes('/v1/chat/completions')) {
modelsUrl = apiUrl.replace('/v1/chat/completions', '/v1/models');
} else if (apiUrl.includes('/chat/completions')) {
modelsUrl = apiUrl.replace('/chat/completions', '/models');
} else if (!apiUrl.includes('/models')) {
modelsUrl = apiUrl.replace(/\/?$/, '/models');
}
} else if (format === 'anthropic') {
// Anthropic 不支持获取模型列表,返回常用模型
return [
'claude-3-5-sonnet-20241022',
'claude-3-5-haiku-20241022',
'claude-3-opus-20240229',
'claude-3-sonnet-20240229',
'claude-3-haiku-20240307',
];
} else if (format === 'google') {
// Google 不支持获取模型列表,返回常用模型
return [
'gemini-2.0-flash-exp',
'gemini-1.5-pro',
'gemini-1.5-flash',
'gemini-1.5-flash-8b',
'gemini-1.0-pro',
];
} else if (format === 'custom') {
throw new Error('Custom格式不支持获取模型列表请手动输入模型名称');
} else {
throw new Error('此API格式不支持获取模型列表请手动输入模型名称');
}
const headers = { 'Content-Type': 'application/json' };
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`;
}
const response = await fetch(modelsUrl, { headers });
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
let models = [];
if (data.data && Array.isArray(data.data)) {
// OpenAI 格式: { data: [{ id: "model-name" }, ...] }
models = data.data.map(m => m.id || m.name).filter(Boolean);
} else if (Array.isArray(data.models)) {
// 某些 API 格式: { models: ["model1", "model2"] }
models = data.models;
} else if (Array.isArray(data)) {
// 直接数组格式
models = data.map(m => typeof m === 'string' ? m : m.id || m.name).filter(Boolean);
}
return models.sort();
}
// 当前编辑状态
let currentBookName = null;
let currentPartId = null;
/**
* 获取当前主题
* @returns {string} 主题名称
*/
function getCurrentTheme() {
const settings = getGlobalSettings();
return settings.theme || 'default';
}
/**
* 应用主题到弹窗
* @param {HTMLElement} modal 弹窗元素
*/
function applyThemeToModal(modal) {
if (!modal) return;
const theme = getCurrentTheme();
if (theme === 'default') {
modal.removeAttribute('data-mm-theme');
} else {
modal.setAttribute('data-mm-theme', theme);
}
}
/**
* 转义HTML特殊字符
* @param {string} str 原始字符串
* @returns {string} 转义后的字符串
*/
function escapeHtml(str) {
if (!str) return '';
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/**
* 显示Part配置弹窗
* @param {string} bookName 世界书名称
* @param {string} partId Part ID
*/
export function showSummaryPartConfigModal(bookName, partId) {
Logger.log(`[SummaryPartConfig] showSummaryPartConfigModal called: book=${bookName}, partId=${partId}`);
currentBookName = bookName;
currentPartId = partId;
// 获取Part信息
const parts = getSummaryParts(bookName);
Logger.log(`[SummaryPartConfig] Parts for ${bookName}:`, parts?.length || 0);
const part = parts?.find(p => p.id === partId);
if (!part) {
Logger.warn(`[SummaryPartConfig] 未找到Part: ${bookName} - ${partId}`);
return;
}
// 获取已保存的配置
const savedConfig = getSummaryPartApiConfig(bookName, partId) || {};
const apiFormat = savedConfig.apiFormat || 'openai';
// 创建弹窗HTML - 复刻原有配置弹窗样式
const modalHtml = `
<div id="mm-part-config-modal" class="mm-modal">
<div class="mm-modal-content">
<div class="mm-modal-header">
<h4>配置 AI: <span id="mm-part-config-title">${part.startFloor}-${part.endFloor}楼</span></h4>
<button class="mm-modal-close mm-btn mm-btn-icon">
<i class="fa-solid fa-times"></i>
</button>
</div>
<div class="mm-modal-body">
<!-- Part信息横幅 -->
<div class="mm-part-info-banner">
<i class="fa-solid fa-layer-group"></i>
<div class="mm-part-info-details">
<div class="mm-part-info-title">${part.startFloor}-${part.endFloor}楼</div>
<div class="mm-part-info-meta">
${formatCharCount(part.charCount)} 字符 | ${escapeHtml(bookName)}
</div>
</div>
</div>
<!-- API格式 - 使用Radio按钮组 -->
<div class="mm-form-group">
<label>API 格式</label>
<div class="mm-radio-group">
<label><input type="radio" name="mm-part-api-format" value="openai" ${apiFormat === 'openai' ? 'checked' : ''} /> OpenAI 兼容</label>
<label><input type="radio" name="mm-part-api-format" value="anthropic" ${apiFormat === 'anthropic' ? 'checked' : ''} /> Anthropic</label>
<label><input type="radio" name="mm-part-api-format" value="google" ${apiFormat === 'google' ? 'checked' : ''} /> Google</label>
<label><input type="radio" name="mm-part-api-format" value="custom" ${apiFormat === 'custom' ? 'checked' : ''} /> Custom</label>
</div>
</div>
<!-- API URL -->
<div class="mm-form-group">
<label>API URL <span class="mm-required">*</span></label>
<input type="text" id="mm-part-api-url" placeholder="https://api.deepseek.com/v1" value="${escapeHtml(savedConfig.apiUrl || '')}">
<small class="mm-hint">填写到 /v1 即可,会自动补全完整路径</small>
</div>
<!-- API Key -->
<div class="mm-form-group">
<label>API Key</label>
<input type="password" id="mm-part-api-key" placeholder="sk-..." value="${escapeHtml(savedConfig.apiKey || '')}">
<small class="mm-hint">本地模型可留空</small>
</div>
<!-- 模型名称 -->
<div class="mm-form-group">
<label>模型名称 <span class="mm-required">*</span></label>
<div class="mm-model-input-row">
<select id="mm-part-model" class="mm-model-select">
<option value="" disabled ${!savedConfig.model ? 'selected' : ''}>--- 请获取模型 ---</option>
${savedConfig.model ? `<option value="${escapeHtml(savedConfig.model)}" selected>${escapeHtml(savedConfig.model)}</option>` : ''}
</select>
<button type="button" id="mm-part-fetch-models" class="mm-btn mm-btn-secondary" title="从API获取模型列表">
<i class="fa-solid fa-download"></i> 获取模型
</button>
</div>
</div>
<!-- Max Tokens 和 Temperature -->
<div class="mm-form-row">
<div class="mm-form-group">
<label>Max Tokens</label>
<input type="number" id="mm-part-max-tokens" value="${savedConfig.maxTokens || 2000}" min="100" max="128000">
</div>
<div class="mm-form-group">
<label>Temperature</label>
<input type="range" id="mm-part-temperature" value="${savedConfig.temperature || 0.5}" min="0" max="1" step="0.1">
<span id="mm-part-temperature-value">${savedConfig.temperature || 0.5}</span>
</div>
</div>
<!-- 关联性阈值 -->
<div class="mm-form-group">
<label>关联性阈值</label>
<div class="mm-form-row">
<input type="range" id="mm-part-relevance" value="${savedConfig.relevanceThreshold || 0.4}" min="0.1" max="1" step="0.1" style="flex: 1">
<span id="mm-part-relevance-value" style="min-width: 30px; text-align: center">${savedConfig.relevanceThreshold || 0.4}</span>
</div>
<small class="mm-hint">数值越小越严格,数值越大越宽松 (0.1-1.0)。占位符:<code>sulv1</code></small>
</div>
<!-- 历史事件数量 -->
<div class="mm-form-group">
<label>历史事件数量 (1-35)</label>
<input type="number" id="mm-part-max-events" value="${savedConfig.maxHistoryEvents || 10}" min="1" max="35">
<small class="mm-hint">AI 最多提取的历史事件数量。占位符:<code>sulv2</code></small>
</div>
<!-- Custom 格式选项(仅当选择 Custom 时显示) -->
<div id="mm-part-custom-format-options" class="${apiFormat === 'custom' ? '' : 'mm-hidden'}">
<div class="mm-form-group">
<label>自定义请求模板 (JSON)</label>
<textarea id="mm-part-custom-template" rows="5" placeholder='{"model": "{{model}}", "prompt": "{{system}}\n\n{{user}}"}'>${escapeHtml(savedConfig.customRequestTemplate || '')}</textarea>
<small class="mm-hint">可用变量: {{system}}, {{user}}, {{model}}, {{max_tokens}}, {{temperature}}</small>
</div>
<div class="mm-form-group">
<label>响应解析路径</label>
<input type="text" id="mm-part-response-path" placeholder="choices.0.message.content" value="${escapeHtml(savedConfig.customResponsePath || '')}">
<small class="mm-hint">用于从 API 响应中提取内容的路径choices.0.message.content</small>
</div>
</div>
</div>
<div class="mm-modal-footer">
<button type="button" id="mm-part-test-connection" class="mm-btn mm-btn-secondary">
<i class="fa-solid fa-plug"></i> 测试连接
</button>
<div class="mm-modal-footer-right">
<button type="button" id="mm-part-cancel" class="mm-btn">取消</button>
<button type="button" id="mm-part-save" class="mm-btn mm-btn-primary">
<i class="fa-solid fa-save"></i> 保存配置
</button>
</div>
</div>
</div>
</div>
`;
// 移除已存在的弹窗
document.getElementById('mm-part-config-modal')?.remove();
// 添加弹窗到页面
document.body.insertAdjacentHTML('beforeend', modalHtml);
// 应用主题
const modal = document.getElementById('mm-part-config-modal');
applyThemeToModal(modal);
// 绑定事件
bindPartConfigEvents();
// 显示弹窗
setTimeout(() => modal?.classList.add('mm-modal-visible'), 10);
}
/**
* 绑定Part配置弹窗事件
*/
function bindPartConfigEvents() {
const modal = document.getElementById('mm-part-config-modal');
if (!modal) return;
// 关闭按钮
modal.querySelector('.mm-modal-close')?.addEventListener('click', hidePartConfigModal);
modal.querySelector('#mm-part-cancel')?.addEventListener('click', hidePartConfigModal);
// Temperature 滑块
const tempSlider = modal.querySelector('#mm-part-temperature');
const tempValue = modal.querySelector('#mm-part-temperature-value');
if (tempSlider && tempValue) {
tempSlider.addEventListener('input', (e) => {
tempValue.textContent = e.target.value;
});
}
// 关联性阈值滑块
const relevanceSlider = modal.querySelector('#mm-part-relevance');
const relevanceValue = modal.querySelector('#mm-part-relevance-value');
if (relevanceSlider && relevanceValue) {
relevanceSlider.addEventListener('input', (e) => {
relevanceValue.textContent = e.target.value;
});
}
// API 格式切换事件(控制 Custom 选项显示)
const formatRadios = modal.querySelectorAll('input[name="mm-part-api-format"]');
formatRadios.forEach(radio => {
radio.addEventListener('change', (e) => {
const customOptions = document.getElementById('mm-part-custom-format-options');
if (customOptions) {
if (e.target.value === 'custom') {
customOptions.classList.remove('mm-hidden');
} else {
customOptions.classList.add('mm-hidden');
}
}
});
});
// 获取模型列表
modal.querySelector('#mm-part-fetch-models')?.addEventListener('click', async () => {
const apiUrl = document.getElementById('mm-part-api-url')?.value?.trim();
const apiKey = document.getElementById('mm-part-api-key')?.value?.trim();
const apiFormat = document.querySelector('input[name="mm-part-api-format"]:checked')?.value || 'openai';
if (!apiUrl) {
alert('请先填写API地址');
return;
}
const btn = modal.querySelector('#mm-part-fetch-models');
const originalHtml = btn.innerHTML;
try {
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> 获取中...';
btn.disabled = true;
const models = await fetchModelsFromApi(apiUrl, apiKey, apiFormat);
if (models && models.length > 0) {
const modelSelect = document.getElementById('mm-part-model');
if (modelSelect) {
const currentValue = modelSelect.value;
modelSelect.innerHTML = '<option value="" disabled>--- 请选择模型 ---</option>';
models.forEach(modelId => {
const option = document.createElement('option');
option.value = modelId;
option.textContent = modelId;
if (modelId === currentValue) {
option.selected = true;
}
modelSelect.appendChild(option);
});
// 如果之前没有选中的,选择第一个模型
if (!currentValue && models.length > 0) {
modelSelect.selectedIndex = 1;
}
}
if (window.toastr) {
window.toastr.success(`获取到 ${models.length} 个模型`, '成功');
}
} else {
alert('未获取到模型列表');
}
} catch (error) {
Logger.error('[SummaryPartConfig] 获取模型失败:', error);
alert('获取模型列表失败: ' + error.message);
} finally {
btn.innerHTML = originalHtml;
btn.disabled = false;
}
});
// 测试连接
modal.querySelector('#mm-part-test-connection')?.addEventListener('click', async () => {
const config = getFormConfig();
if (!config.apiUrl || !config.model) {
alert('请填写API地址和模型');
return;
}
const btn = modal.querySelector('#mm-part-test-connection');
const originalHtml = btn.innerHTML;
try {
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> 测试中...';
btn.disabled = true;
const result = await APIAdapter.testConnection(config);
if (result.success) {
if (window.toastr) {
window.toastr.success('API连接成功', '测试通过');
} else {
alert('连接成功!');
}
} else {
alert('连接失败: ' + (result.error || '未知错误'));
}
} catch (error) {
Logger.error('[SummaryPartConfig] 测试连接失败:', error);
alert('测试失败: ' + error.message);
} finally {
btn.innerHTML = originalHtml;
btn.disabled = false;
}
});
// 保存
modal.querySelector('#mm-part-save')?.addEventListener('click', () => {
const config = getFormConfig();
if (!config.apiUrl || !config.model) {
alert('请至少填写API地址和模型');
return;
}
// 添加enabled字段
config.enabled = true;
setSummaryPartApiConfig(currentBookName, currentPartId, config);
Logger.log(`[SummaryPartConfig] 已保存 ${currentBookName} - ${currentPartId} 的配置`);
hidePartConfigModal();
refreshWorldBookList();
if (window.toastr) {
window.toastr.success('Part配置已保存', '保存成功');
}
});
}
/**
* 从表单获取配置
* @returns {object} 配置对象
*/
function getFormConfig() {
const modelEl = document.getElementById('mm-part-model');
const modelValue = modelEl?.value || '';
const apiFormat = document.querySelector('input[name="mm-part-api-format"]:checked')?.value || 'openai';
const config = {
apiFormat,
apiUrl: document.getElementById('mm-part-api-url')?.value || '',
apiKey: document.getElementById('mm-part-api-key')?.value || '',
model: modelValue,
maxTokens: parseInt(document.getElementById('mm-part-max-tokens')?.value) || 2000,
temperature: parseFloat(document.getElementById('mm-part-temperature')?.value) || 0.5,
relevanceThreshold: parseFloat(document.getElementById('mm-part-relevance')?.value) || 0.4,
maxHistoryEvents: parseInt(document.getElementById('mm-part-max-events')?.value) || 10,
responsePath: 'choices.0.message.content',
};
// Custom 格式额外字段
if (apiFormat === 'custom') {
config.customRequestTemplate = document.getElementById('mm-part-custom-template')?.value || '';
config.customResponsePath = document.getElementById('mm-part-response-path')?.value || '';
// 如果有自定义响应路径,使用它
if (config.customResponsePath) {
config.responsePath = config.customResponsePath;
}
}
return config;
}
/**
* 隐藏Part配置弹窗
*/
export function hidePartConfigModal() {
const modal = document.getElementById('mm-part-config-modal');
if (modal) {
modal.classList.remove('mm-modal-visible');
setTimeout(() => modal.remove(), 300);
}
currentBookName = null;
currentPartId = null;
}
export default {
showSummaryPartConfigModal,
hidePartConfigModal,
};

View File

@@ -49,12 +49,20 @@ export async function loadSettingsTemplate() {
const plotOptimizeModal = container.querySelector("#mm-plot-optimize-modal");
const flowConfigModal = container.querySelector("#mm-flow-config-modal");
const multiAIConfigModal = container.querySelector("#mm-multi-ai-config-modal");
const tableFillerApiModal = container.querySelector("#mm-table-filler-api-modal");
const tableFillerSelectModal = container.querySelector("#mm-table-filler-select-modal");
const independentTemplateModal = container.querySelector("#mm-independent-template-modal");
const independentTemplateFile = container.querySelector("#mm-independent-template-file");
if (settingsPanel) document.body.appendChild(settingsPanel);
if (configModal) document.body.appendChild(configModal);
if (plotOptimizeModal) document.body.appendChild(plotOptimizeModal);
if (flowConfigModal) document.body.appendChild(flowConfigModal);
if (multiAIConfigModal) document.body.appendChild(multiAIConfigModal);
if (tableFillerApiModal) document.body.appendChild(tableFillerApiModal);
if (tableFillerSelectModal) document.body.appendChild(tableFillerSelectModal);
if (independentTemplateModal) document.body.appendChild(independentTemplateModal);
if (independentTemplateFile) document.body.appendChild(independentTemplateFile);
Logger.debug("设置模板已加载");
} catch (e) {

View File

@@ -28,8 +28,23 @@ export {
refreshWorldBookList,
getWorldBooksCache,
clearWorldBooksCache,
getSummaryParts,
clearSummaryPartsCache,
} from './refresh';
// 总结世界书拆分模块
export {
parseSegments,
analyzeSummaryContent,
calculateSplitPlan,
needsSplit,
getContentStats,
formatCharCount,
matchPartConfigs,
generatePartId,
getSummaryBookContent,
} from './summary-splitter';
// 更新列表模块
export {
addUpdates,

View File

@@ -4,12 +4,17 @@
*/
import Logger from '@core/logger';
import { loadConfig } from '@config/config-manager';
import { loadConfig, isSummaryAutoSplitEnabled, getSummaryAutoSplitConfig, getSummaryPartConfigs, getSummaryConfig, isSummaryMergeDeduplicateEnabled, setSummaryPartApiConfig } from '@config/config-manager';
import { getImportedWorldBooks, classifyWorldBooks } from './api';
import { analyzeSummaryContent, formatCharCount, needsSplit, matchPartConfigs } from './summary-splitter';
import { getSummaryContent } from './parser';
import { isPartDebugEnabled, setPartDebugEnabled } from '@memory/part-debug-modal';
// 世界书缓存
let worldBooksCache = [];
let worldBooksSnapshot = null;
// Part分析缓存避免重复计算
let summaryPartsCache = {};
/**
* 创建世界书快照(用于变化检测)
@@ -186,7 +191,31 @@ export async function refreshWorldBookList() {
if (summaryBooks.length > 0) {
html += '<div class="mm-book-group">';
html += '<div class="mm-book-group-title">总结世界书</div>';
// 总结世界书标题行 - 包含自动拆分开关、去重开关和调试开关
const splitEnabled = isSummaryAutoSplitEnabled();
const deduplicateEnabled = isSummaryMergeDeduplicateEnabled();
const debugEnabled = isPartDebugEnabled();
html += `
<div class="mm-book-group-header">
<div class="mm-book-group-title">总结世界书</div>
<div style="display: flex; align-items: center; gap: 12px;">
<label class="mm-switch mm-switch-sm" title="启用自动拆分超过5万字符时拆分为多个Part并发处理">
<input type="checkbox" id="mm-summary-auto-split-toggle" ${splitEnabled ? 'checked' : ''} />
<span class="mm-switch-slider"></span>
</label>
${splitEnabled ? `
<label class="mm-icon-toggle" title="合并时去重:同一楼层保留内容最长的。关闭时相同楼层的内容会放在一起。">
<input type="checkbox" id="mm-summary-merge-deduplicate-toggle" ${deduplicateEnabled ? 'checked' : ''} />
<i class="fa-solid fa-filter"></i>
</label>
<label class="mm-icon-toggle" title="启用调试模式处理完成后显示各Part返回内容">
<input type="checkbox" id="mm-summary-part-debug-toggle" ${debugEnabled ? 'checked' : ''} />
<i class="fa-solid fa-bug"></i>
</label>
` : ''}
</div>
</div>`;
for (const book of summaryBooks) {
const bookConfig = config?.summaryConfigs?.[book.name];
const hasConfig = !!bookConfig;
@@ -197,22 +226,40 @@ export async function refreshWorldBookList() {
const statusClass = hasConfig ? "mm-chip-ok" : "mm-chip-warning";
const safeBookName = escapeHtml(book.name);
// 检查是否需要拆分(启用拆分且内容足够多)
const content = getSummaryContent(book);
const splitConfig = getSummaryAutoSplitConfig();
const shouldSplit = splitEnabled && needsSplit(content, splitConfig.targetChars);
html += `
<div class="mm-book-card">
<div class="mm-book-card" data-book="${safeBookName}">
<div class="mm-book-title">
<span class="mm-book-name">${safeBookName}</span>
<span class="mm-chip-count" style="margin-left: 8px; margin-right: auto;">${entryCount}</span>
<button class="mm-btn mm-btn-xs mm-btn-danger" data-action="remove-book" data-book="${safeBookName}" title="移除">
<i class="fa-solid fa-times"></i>
</button>
</div>`;
// 如果启用了拆分功能且内容足够显示Part列表
if (shouldSplit) {
html += renderSummaryPartsUI(book, config);
} else {
// 不启用拆分或内容不足:显示单个可点击的配置芯片
html += `
<div class="mm-chips-container">
<div class="mm-chip ${statusClass}"
data-action="edit-config"
data-category="${safeBookName}"
data-type="summary"
title="条目: ${entryCount} | 事件: ${eventsCount} | 阈值: ${relevanceThreshold} | 模型: ${apiModel}">
<span class="mm-chip-name">${safeBookName}</span>
<span class="mm-chip-count">${entryCount}</span>
<span class="mm-chip-name">${formatCharCount(content.length)} 字符</span>
</div>
<button class="mm-btn mm-btn-xs mm-btn-danger" data-action="remove-book" data-book="${safeBookName}" title="移除">
<i class="fa-solid fa-times"></i>
</button>
</div>
</div>`;
</div>`;
}
html += `</div>`;
}
html += "</div>";
}
@@ -279,4 +326,404 @@ export function getWorldBooksCache() {
export function clearWorldBooksCache() {
worldBooksCache = [];
worldBooksSnapshot = null;
summaryPartsCache = {};
}
/**
* 渲染总结世界书的Part列表UI
* @param {Object} book 世界书对象
* @param {Object} config 配置对象
* @returns {string} HTML字符串
*/
function renderSummaryPartsUI(book, config) {
const splitConfig = getSummaryAutoSplitConfig();
const content = getSummaryContent(book);
const totalChars = content.length;
// 检查是否需要拆分
if (!needsSplit(content, splitConfig.targetChars)) {
return `
<div class="mm-summary-parts-info">
<span class="mm-parts-hint">
<i class="fa-solid fa-check-circle" style="color: var(--mm-success-color);"></i>
内容约 ${formatCharCount(totalChars)} 字符,无需拆分
</span>
</div>`;
}
// 每次都重新分析Part确保实时性避免世界书内容变化后显示旧数据
const parts = analyzeSummaryContent(content, splitConfig);
// 更新缓存(供其他地方使用)
summaryPartsCache[book.name] = parts;
if (parts.length <= 1) {
return `
<div class="mm-summary-parts-info">
<span class="mm-parts-hint">
<i class="fa-solid fa-check-circle" style="color: var(--mm-success-color);"></i>
内容约 ${formatCharCount(totalChars)} 字符,无需拆分
</span>
</div>`;
}
// 获取已保存的Part配置
const savedPartConfigs = getSummaryPartConfigs(book.name);
// 获取原总结世界书配置Part 1 复用)
const originalSummaryConfig = getSummaryConfig(book.name);
// 构建已保存配置的映射(用于模糊匹配)
const savedConfigsMap = {};
if (savedPartConfigs?.parts) {
for (const p of savedPartConfigs.parts) {
if (p.id && p.apiConfig) {
savedConfigsMap[p.id] = p.apiConfig;
}
}
}
// 使用模糊匹配来保留配置
const { matched, unmatched } = matchPartConfigs(parts.slice(1), savedConfigsMap); // 跳过 Part 1
// 如果有模糊匹配成功的,自动迁移配置到新的 partId
for (const matchedPart of matched) {
if (matchedPart.matchType === 'fuzzy' && matchedPart.apiConfig) {
// 将配置迁移到新的 partId
setSummaryPartApiConfig(book.name, matchedPart.id, matchedPart.apiConfig);
Logger.log(`[Refresh] 模糊匹配迁移配置: ${matchedPart.originalPartId} -> ${matchedPart.id}`);
}
}
// 检查是否有新的未配置的 Part用于提醒
const unconfiguredParts = [];
let html = '<div class="mm-chips-container">';
for (const part of parts) {
// Part 1index=0复用原总结世界书配置其他Part使用各自的配置
let hasConfig, modelName, dataAttrs;
if (part.index === 0) {
hasConfig = !!(originalSummaryConfig?.apiUrl && originalSummaryConfig?.model && originalSummaryConfig?.enabled);
modelName = hasConfig ? escapeHtml(originalSummaryConfig.model) : '未配置';
dataAttrs = `data-category="${escapeHtml(book.name)}" data-type="summary" data-part-index="0" data-part-id="${part.id}" data-start-floor="${part.startFloor}" data-end-floor="${part.endFloor}" data-char-count="${part.charCount}" data-book-name="${escapeHtml(book.name)}"`;
if (!hasConfig) {
unconfiguredParts.push({ ...part, floorRange: `${part.startFloor}-${part.endFloor}` });
}
} else {
// 先尝试精确匹配
let savedPart = savedPartConfigs?.parts?.find(p => p.id === part.id);
// 如果精确匹配失败,尝试从模糊匹配结果中获取
if (!savedPart) {
const matchedPart = matched.find(m => m.id === part.id);
if (matchedPart?.apiConfig) {
savedPart = { apiConfig: matchedPart.apiConfig };
}
}
hasConfig = !!(savedPart?.apiConfig?.apiUrl && savedPart?.apiConfig?.model);
modelName = hasConfig ? escapeHtml(savedPart.apiConfig.model) : '未配置';
dataAttrs = `data-category="${escapeHtml(book.name)}" data-type="summary" data-part-index="${part.index}" data-part-id="${part.id}" data-start-floor="${part.startFloor}" data-end-floor="${part.endFloor}" data-char-count="${part.charCount}" data-book-name="${escapeHtml(book.name)}"`;
if (!hasConfig) {
unconfiguredParts.push({ ...part, floorRange: `${part.startFloor}-${part.endFloor}` });
}
}
const statusClass = hasConfig ? 'mm-chip-ok' : 'mm-chip-warning';
const floorRange = part.startFloor && part.endFloor
? `${part.startFloor}-${part.endFloor}`
: `Part ${part.index + 1}`;
html += `
<div class="mm-chip ${statusClass}"
data-action="edit-config"
${dataAttrs}
title="点击配置API | 模型: ${modelName}">
<span class="mm-chip-name">${floorRange}</span>
<span class="mm-chip-count">${formatCharCount(part.charCount)}</span>
</div>`;
}
html += '</div>';
// 如果有未配置的 Part显示提醒通知
if (unconfiguredParts.length > 0) {
showUnconfiguredPartsNotification(book.name, unconfiguredParts);
}
return html;
}
// 用于防止重复通知的缓存
let lastNotificationKey = '';
let lastNotificationTime = 0;
/**
* 获取当前主题
* @returns {string} 主题名称
*/
function getCurrentTheme() {
const settings = loadConfig();
return settings?.global?.theme || 'default';
}
/**
* 显示未配置Part的通知自定义右下角卡片跟随插件主题
* @param {string} bookName 世界书名称
* @param {Array} unconfiguredParts 未配置的Part列表
*/
function showUnconfiguredPartsNotification(bookName, unconfiguredParts) {
// 防止短时间内重复通知5秒内同一世界书不重复提醒
const notificationKey = `${bookName}_${unconfiguredParts.length}`;
const now = Date.now();
if (notificationKey === lastNotificationKey && now - lastNotificationTime < 5000) {
return;
}
lastNotificationKey = notificationKey;
lastNotificationTime = now;
// 移除已存在的通知
const existingNotification = document.getElementById('mm-part-config-notification');
if (existingNotification) {
existingNotification.remove();
}
// 添加样式(如果不存在)
if (!document.getElementById('mm-part-notification-styles')) {
const style = document.createElement('style');
style.id = 'mm-part-notification-styles';
style.textContent = `
@keyframes mm-notification-slide-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes mm-notification-slide-out {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
#mm-part-config-notification {
position: fixed;
bottom: 20px;
right: 20px;
width: 320px;
max-width: calc(100vw - 40px);
background: rgba(15, 52, 96, 0.85);
border: 1px solid rgba(255, 255, 255, 0.1);
border-left: 3px solid #f0ad4e;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
z-index: 99998;
animation: mm-notification-slide-in 0.3s ease-out;
overflow: hidden;
cursor: pointer;
}
/* 暖灰棕主题 */
#mm-part-config-notification[data-mm-theme="warm-brown"] {
background: rgba(61, 53, 46, 0.85);
border-color: rgba(255, 255, 255, 0.1);
}
/* 淡紫薰衣草主题 */
#mm-part-config-notification[data-mm-theme="lavender"] {
background: rgba(45, 40, 56, 0.85);
border-color: rgba(255, 255, 255, 0.1);
}
/* 森林绿主题 */
#mm-part-config-notification[data-mm-theme="forest"] {
background: rgba(37, 53, 48, 0.85);
border-color: rgba(255, 255, 255, 0.1);
}
/* 玫瑰灰主题 */
#mm-part-config-notification[data-mm-theme="rose"] {
background: rgba(56, 40, 48, 0.85);
border-color: rgba(255, 255, 255, 0.1);
}
/* 静谧蓝灰主题 */
#mm-part-config-notification[data-mm-theme="slate"] {
background: rgba(40, 46, 53, 0.85);
border-color: rgba(255, 255, 255, 0.1);
}
/* 星空紫主题 */
#mm-part-config-notification[data-mm-theme="starry-purple"] {
background:
radial-gradient(1px 1px at 20px 30px, rgba(255,255,255,0.8), transparent),
radial-gradient(1px 1px at 40px 70px, rgba(255,255,255,0.6), transparent),
radial-gradient(1px 1px at 50px 160px, rgba(255,255,255,0.7), transparent),
radial-gradient(1.5px 1.5px at 100px 40px, rgba(255,255,255,0.9), transparent),
radial-gradient(1px 1px at 130px 80px, rgba(255,255,255,0.5), transparent),
radial-gradient(1.5px 1.5px at 160px 120px, rgba(255,255,255,0.8), transparent),
radial-gradient(1px 1px at 200px 50px, rgba(255,255,255,0.6), transparent),
radial-gradient(1px 1px at 250px 90px, rgba(255,255,255,0.7), transparent),
radial-gradient(1.5px 1.5px at 280px 140px, rgba(255,255,255,0.5), transparent),
rgba(26, 21, 37, 0.85);
border-color: rgba(138, 100, 200, 0.3);
}
/* 星空蓝主题 */
#mm-part-config-notification[data-mm-theme="starry-blue"] {
background:
radial-gradient(1px 1px at 15px 25px, rgba(255,255,255,0.8), transparent),
radial-gradient(1.5px 1.5px at 45px 65px, rgba(200,220,255,0.9), transparent),
radial-gradient(1px 1px at 75px 150px, rgba(255,255,255,0.6), transparent),
radial-gradient(1px 1px at 110px 35px, rgba(200,220,255,0.7), transparent),
radial-gradient(1.5px 1.5px at 140px 95px, rgba(255,255,255,0.8), transparent),
radial-gradient(1px 1px at 180px 55px, rgba(200,220,255,0.5), transparent),
radial-gradient(1px 1px at 220px 110px, rgba(255,255,255,0.7), transparent),
radial-gradient(1.5px 1.5px at 260px 70px, rgba(200,220,255,0.6), transparent),
radial-gradient(1px 1px at 290px 130px, rgba(255,255,255,0.5), transparent),
rgba(16, 24, 40, 0.85);
border-color: rgba(100, 150, 220, 0.3);
}
/* 星空黑主题 */
#mm-part-config-notification[data-mm-theme="starry-black"] {
background:
radial-gradient(1px 1px at 10px 20px, rgba(255,255,255,0.9), transparent),
radial-gradient(1.5px 1.5px at 35px 75px, rgba(255,255,255,0.7), transparent),
radial-gradient(1px 1px at 60px 140px, rgba(255,255,255,0.8), transparent),
radial-gradient(1px 1px at 95px 30px, rgba(255,255,255,0.6), transparent),
radial-gradient(1.5px 1.5px at 125px 100px, rgba(255,255,255,0.9), transparent),
radial-gradient(1px 1px at 165px 60px, rgba(255,255,255,0.5), transparent),
radial-gradient(1px 1px at 195px 120px, rgba(255,255,255,0.7), transparent),
radial-gradient(1.5px 1.5px at 235px 45px, rgba(255,255,255,0.6), transparent),
radial-gradient(1px 1px at 275px 85px, rgba(255,255,255,0.8), transparent),
rgba(12, 12, 16, 0.85);
border-color: rgba(255, 255, 255, 0.15);
}
#mm-part-config-notification .mm-notification-content {
padding: 12px 14px;
}
#mm-part-config-notification .mm-notification-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
#mm-part-config-notification .mm-notification-icon {
color: #f0ad4e;
font-size: 16px;
flex-shrink: 0;
}
#mm-part-config-notification .mm-notification-title {
color: #e4e4e4;
font-weight: 600;
font-size: 13px;
flex: 1;
}
#mm-part-config-notification .mm-notification-close {
color: #a0a0a0;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
transition: all 0.2s;
}
#mm-part-config-notification .mm-notification-close:hover {
color: #e4e4e4;
background: rgba(255, 255, 255, 0.1);
}
#mm-part-config-notification .mm-notification-body {
color: #c0c0c0;
font-size: 12px;
line-height: 1.5;
}
#mm-part-config-notification .mm-notification-parts {
color: #f0ad4e;
font-weight: 500;
margin: 4px 0;
}
#mm-part-config-notification .mm-notification-hint {
color: #888;
font-size: 11px;
margin-top: 8px;
}
#mm-part-config-notification:hover {
border-left-color: #ffc107;
}
#mm-part-config-notification.mm-notification-closing {
animation: mm-notification-slide-out 0.3s ease-in forwards;
}
/* 移动端适配 */
@media (max-width: 400px) {
#mm-part-config-notification {
bottom: 10px;
right: 10px;
width: calc(100vw - 20px);
}
}
`;
document.head.appendChild(style);
}
// 创建通知元素
const notification = document.createElement('div');
notification.id = 'mm-part-config-notification';
// 应用当前主题
const theme = getCurrentTheme();
if (theme && theme !== 'default') {
notification.setAttribute('data-mm-theme', theme);
}
const partsList = unconfiguredParts.map(p => p.floorRange).join('、');
notification.innerHTML = `
<div class="mm-notification-content">
<div class="mm-notification-header">
<span class="mm-notification-icon"><i class="fa-solid fa-exclamation-triangle"></i></span>
<span class="mm-notification-title">拆分配置提醒</span>
<span class="mm-notification-close"><i class="fa-solid fa-times"></i></span>
</div>
<div class="mm-notification-body">
<div>总结世界书「${escapeHtml(bookName)}」有 <strong>${unconfiguredParts.length}</strong> 个拆分未配置API</div>
<div class="mm-notification-parts">${escapeHtml(partsList)}</div>
<div class="mm-notification-hint">点击此通知打开设置进行配置</div>
</div>
</div>
`;
// 关闭按钮事件
notification.querySelector('.mm-notification-close').addEventListener('click', (e) => {
e.stopPropagation();
notification.classList.add('mm-notification-closing');
setTimeout(() => notification.remove(), 300);
});
// 点击通知打开设置
notification.addEventListener('click', () => {
const settingsBtn = document.querySelector('#mm-settings-toggle');
if (settingsBtn) {
settingsBtn.click();
}
notification.classList.add('mm-notification-closing');
setTimeout(() => notification.remove(), 300);
});
document.body.appendChild(notification);
// 10秒后自动关闭
setTimeout(() => {
if (notification.parentNode) {
notification.classList.add('mm-notification-closing');
setTimeout(() => notification.remove(), 300);
}
}, 10000);
}
/**
* 获取指定世界书的Part分析结果
* @param {string} bookName 世界书名称
* @returns {Array|null} Part数组或null
*/
export function getSummaryParts(bookName) {
return summaryPartsCache[bookName] || null;
}
/**
* 清除指定世界书的Part缓存
* @param {string} bookName 世界书名称
*/
export function clearSummaryPartsCache(bookName) {
if (bookName) {
delete summaryPartsCache[bookName];
} else {
summaryPartsCache = {};
}
}

View File

@@ -0,0 +1,425 @@
/**
* 总结世界书拆分模块
* 自动检测并拆分大型总结世界书内容
* @module worldbook/summary-splitter
*/
import Logger from "@core/logger";
/**
* 默认拆分选项
*/
const DEFAULT_SPLIT_OPTIONS = {
targetChars: 50000, // 目标拆分字符数
minChars: 40000, // 最小字符数
maxChars: 60000, // 最大字符数
};
/**
* 段落信息结构
* @typedef {Object} Segment
* @property {number} startFloor - 起始楼层
* @property {number} endFloor - 结束楼层
* @property {string} content - 段落内容
* @property {number} charCount - 字符数
*/
/**
* Part信息结构
* @typedef {Object} Part
* @property {string} id - Part ID基于楼层范围
* @property {number} index - Part索引从0开始
* @property {number} startFloor - 起始楼层
* @property {number} endFloor - 结束楼层
* @property {number} charCount - 字符数
* @property {Array<Segment>} segments - 包含的段落
* @property {string} content - Part完整内容
*/
/**
* 解析总结世界书内容中的段落
* 识别格式【X楼至Y楼详细总结记录】开头以<task completed>或本条勿动结尾
* @param {string} content 总结世界书完整内容
* @returns {Array<Segment>} 段落数组
*/
export function parseSegments(content) {
if (!content || typeof content !== 'string') {
Logger.debug("[SummarySplitter] 内容为空");
return [];
}
const segments = [];
// 匹配段落的正则表达式
// 格式【X楼至Y楼详细总结记录】...内容...<task completed>X-Y</task completed>
// 或者【X楼至Y楼详细总结记录】...内容...本条勿动【前X楼总结已完成】
const segmentRegex = /【(\d+)楼至(\d+)楼[^\n]*详细总结记录】([\s\S]*?)(?:<task completed>[\d-]+<\/task completed>|本条勿动【[^\]]+】)/g;
let match;
while ((match = segmentRegex.exec(content)) !== null) {
const startFloor = parseInt(match[1], 10);
const endFloor = parseInt(match[2], 10);
const segmentContent = match[0];
segments.push({
startFloor,
endFloor,
content: segmentContent,
charCount: segmentContent.length,
});
}
// 如果正则没有匹配到,尝试备用方案:按 --- 分隔符拆分
if (segments.length === 0) {
Logger.debug("[SummarySplitter] 主正则未匹配,尝试备用方案");
return parseSegmentsByDivider(content);
}
Logger.log(`[SummarySplitter] 解析到 ${segments.length} 个段落`);
return segments;
}
/**
* 备用方案:按 --- 分隔符拆分段落
* @param {string} content 内容
* @returns {Array<Segment>} 段落数组
*/
function parseSegmentsByDivider(content) {
const segments = [];
// 按 --- 分隔
const parts = content.split(/\n---+\n/);
// 从每个部分中提取楼层信息
const floorRegex = /【(\d+)楼至(\d+)楼/;
for (const part of parts) {
const trimmedPart = part.trim();
if (!trimmedPart) continue;
const floorMatch = trimmedPart.match(floorRegex);
if (floorMatch) {
segments.push({
startFloor: parseInt(floorMatch[1], 10),
endFloor: parseInt(floorMatch[2], 10),
content: trimmedPart,
charCount: trimmedPart.length,
});
} else {
// 无法识别楼层的部分,作为单独段落处理
// 尝试从内容中推断
segments.push({
startFloor: 0,
endFloor: 0,
content: trimmedPart,
charCount: trimmedPart.length,
});
}
}
Logger.log(`[SummarySplitter] 备用方案解析到 ${segments.length} 个段落`);
return segments;
}
/**
* 生成Part ID基于楼层范围
* @param {number} startFloor 起始楼层
* @param {number} endFloor 结束楼层
* @returns {string} Part ID
*/
export function generatePartId(startFloor, endFloor) {
return `floor_${startFloor}_${endFloor}`;
}
/**
* 计算拆分方案
* 使用贪心算法尽量接近targetChars不超过maxChars
* @param {Array<Segment>} segments 段落数组
* @param {Object} options 拆分选项
* @returns {Array<Part>} Part数组
*/
export function calculateSplitPlan(segments, options = {}) {
const { targetChars, minChars, maxChars } = { ...DEFAULT_SPLIT_OPTIONS, ...options };
if (segments.length === 0) {
return [];
}
const parts = [];
let currentPart = {
segments: [],
charCount: 0,
startFloor: 0,
endFloor: 0,
};
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
const newCharCount = currentPart.charCount + segment.charCount;
// 如果当前Part为空直接添加
if (currentPart.segments.length === 0) {
currentPart.segments.push(segment);
currentPart.charCount = segment.charCount;
currentPart.startFloor = segment.startFloor;
currentPart.endFloor = segment.endFloor;
continue;
}
// 判断是否应该开始新的Part
const shouldStartNewPart =
// 添加后超过最大限制
newCharCount > maxChars ||
// 当前已达到目标且下一个段落会让它远离目标
(currentPart.charCount >= targetChars && newCharCount > maxChars);
if (shouldStartNewPart && currentPart.charCount >= minChars) {
// 保存当前Part并开始新的
parts.push(finalizePart(currentPart, parts.length));
currentPart = {
segments: [segment],
charCount: segment.charCount,
startFloor: segment.startFloor,
endFloor: segment.endFloor,
};
} else {
// 继续添加到当前Part
currentPart.segments.push(segment);
currentPart.charCount = newCharCount;
currentPart.endFloor = segment.endFloor;
}
}
// 处理最后一个Part
if (currentPart.segments.length > 0) {
parts.push(finalizePart(currentPart, parts.length));
}
Logger.log(`[SummarySplitter] 计算出 ${parts.length} 个Part`);
return parts;
}
/**
* 完成Part对象的构建
* @param {Object} partData Part临时数据
* @param {number} index Part索引
* @returns {Part} 完整的Part对象
*/
function finalizePart(partData, index) {
const content = partData.segments.map(s => s.content).join('\n\n---\n\n');
return {
id: generatePartId(partData.startFloor, partData.endFloor),
index,
startFloor: partData.startFloor,
endFloor: partData.endFloor,
charCount: partData.charCount,
segments: partData.segments,
content,
};
}
/**
* 分析总结世界书内容,返回拆分方案
* @param {string} content 总结世界书完整内容
* @param {Object} options 拆分选项
* @returns {Array<Part>} Part数组
*/
export function analyzeSummaryContent(content, options = {}) {
const mergedOptions = { ...DEFAULT_SPLIT_OPTIONS, ...options };
Logger.log(`[SummarySplitter] 开始分析内容,总长度: ${content?.length || 0}`);
// 1. 解析所有段落
const segments = parseSegments(content);
if (segments.length === 0) {
Logger.warn("[SummarySplitter] 未找到可识别的段落");
return [];
}
// 2. 计算总字符数
const totalChars = segments.reduce((sum, s) => sum + s.charCount, 0);
Logger.log(`[SummarySplitter] 总字符数: ${totalChars}, 段落数: ${segments.length}`);
// 3. 如果总内容小于目标字符数,不需要拆分
if (totalChars < mergedOptions.targetChars) {
Logger.log("[SummarySplitter] 内容少于目标字符数,不需要拆分");
// 返回单个Part
return [{
id: generatePartId(
segments[0]?.startFloor || 0,
segments[segments.length - 1]?.endFloor || 0
),
index: 0,
startFloor: segments[0]?.startFloor || 0,
endFloor: segments[segments.length - 1]?.endFloor || 0,
charCount: totalChars,
segments,
content: content,
needsSplit: false,
}];
}
// 4. 计算拆分方案
const parts = calculateSplitPlan(segments, mergedOptions);
// 标记需要拆分
parts.forEach(part => {
part.needsSplit = parts.length > 1;
});
return parts;
}
/**
* 判断内容是否需要拆分
* @param {string} content 总结世界书内容
* @param {number} threshold 阈值默认5万字符
* @returns {boolean} 是否需要拆分
*/
export function needsSplit(content, threshold = 50000) {
if (!content) return false;
return content.length >= threshold;
}
/**
* 获取内容的简要统计信息
* @param {string} content 总结世界书内容
* @returns {Object} 统计信息
*/
export function getContentStats(content) {
if (!content) {
return {
totalChars: 0,
segmentCount: 0,
estimatedParts: 0,
needsSplit: false,
};
}
const totalChars = content.length;
const segments = parseSegments(content);
const estimatedParts = Math.ceil(totalChars / DEFAULT_SPLIT_OPTIONS.targetChars);
return {
totalChars,
segmentCount: segments.length,
estimatedParts: Math.max(1, estimatedParts),
needsSplit: totalChars >= DEFAULT_SPLIT_OPTIONS.targetChars,
};
}
/**
* 格式化字符数显示
* @param {number} charCount 字符数
* @returns {string} 格式化后的字符串
*/
export function formatCharCount(charCount) {
if (charCount >= 10000) {
return `${(charCount / 10000).toFixed(1)}`;
}
return `${charCount}`;
}
/**
* 匹配已保存的Part配置
* @param {Array<Part>} newParts 新的Part列表
* @param {Object} savedConfigs 已保存的配置 { partId: apiConfig }
* @returns {Object} 匹配结果 { matched: [], unmatched: [] }
*/
export function matchPartConfigs(newParts, savedConfigs = {}) {
const matched = [];
const unmatched = [];
for (const part of newParts) {
const savedConfig = savedConfigs[part.id];
if (savedConfig) {
// 完全匹配
matched.push({
...part,
apiConfig: savedConfig,
matchType: 'exact',
});
} else {
// 尝试模糊匹配(楼层范围有重叠)
const fuzzyMatch = findFuzzyMatch(part, savedConfigs);
if (fuzzyMatch) {
matched.push({
...part,
apiConfig: fuzzyMatch.config,
matchType: 'fuzzy',
originalPartId: fuzzyMatch.partId,
});
} else {
unmatched.push(part);
}
}
}
return { matched, unmatched };
}
/**
* 模糊匹配Part配置
* @param {Part} part Part对象
* @param {Object} savedConfigs 已保存的配置
* @returns {Object|null} 匹配结果
*/
function findFuzzyMatch(part, savedConfigs) {
for (const [partId, config] of Object.entries(savedConfigs)) {
// 解析 partId 获取楼层范围
const match = partId.match(/^floor_(\d+)_(\d+)$/);
if (!match) continue;
const savedStart = parseInt(match[1], 10);
const savedEnd = parseInt(match[2], 10);
// 计算重叠度
const overlapStart = Math.max(part.startFloor, savedStart);
const overlapEnd = Math.min(part.endFloor, savedEnd);
if (overlapStart <= overlapEnd) {
const overlapRange = overlapEnd - overlapStart + 1;
const partRange = part.endFloor - part.startFloor + 1;
const savedRange = savedEnd - savedStart + 1;
// 重叠超过80%认为匹配
const overlapRatio = overlapRange / Math.min(partRange, savedRange);
if (overlapRatio >= 0.8) {
return { partId, config };
}
}
}
return null;
}
/**
* 获取总结世界书的完整内容
* @param {Object} book 世界书对象
* @returns {string} 完整内容
*/
export function getSummaryBookContent(book) {
if (!book || !book.entries) return '';
// 按条目顺序合并内容
const entries = Object.values(book.entries)
.filter(e => e.disable !== true)
.sort((a, b) => (a.order || 0) - (b.order || 0));
return entries.map(e => e.content || '').join('\n\n---\n\n');
}
export default {
parseSegments,
analyzeSummaryContent,
calculateSplitPlan,
needsSplit,
getContentStats,
formatCharCount,
matchPartConfigs,
generatePartId,
getSummaryBookContent,
};