mirror of
https://github.com/Cola-Echo/memory-manager-concurrent.git
synced 2026-06-06 04:15:52 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {}, // 提示词文件存储(跨浏览器同步)
|
||||
});
|
||||
|
||||
22
src/index.js
22
src/index.js
@@ -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);
|
||||
|
||||
203
src/memory/part-debug-modal.js
Normal file
203
src/memory/part-debug-modal.js
Normal 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;
|
||||
}
|
||||
@@ -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 1(index=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 1(index=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 1(index=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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 1(index=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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
157
src/table-filler/bus-integration.js
Normal file
157
src/table-filler/bus-integration.js
Normal 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;
|
||||
}
|
||||
761
src/table-filler/debug-modal.js
Normal file
761
src/table-filler/debug-modal.js
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
668
src/table-filler/fetch-interceptor.js
Normal file
668
src/table-filler/fetch-interceptor.js
Normal 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
155
src/table-filler/index.js
Normal 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);
|
||||
}
|
||||
328
src/table-filler/interceptor.js
Normal file
328
src/table-filler/interceptor.js
Normal 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
|
||||
};
|
||||
}
|
||||
264
src/table-filler/mode-manager.js
Normal file
264
src/table-filler/mode-manager.js
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* 表格填表模式管理器
|
||||
* 支持双模式:拦截模式和 Bus 联动模式
|
||||
* @module table-filler/mode-manager
|
||||
*/
|
||||
|
||||
import Logger from "@core/logger";
|
||||
|
||||
/**
|
||||
* 调用模式枚举
|
||||
*/
|
||||
export const CallMode = {
|
||||
AUTO: "auto", // 自动选择(优先 Bus,fallback 拦截)
|
||||
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);
|
||||
}
|
||||
371
src/table-filler/parallel-executor.js
Normal file
371
src/table-filler/parallel-executor.js
Normal 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;
|
||||
335
src/table-filler/prompt-handler.js
Normal file
335
src/table-filler/prompt-handler.js
Normal 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, '\\$&');
|
||||
}
|
||||
206
src/table-filler/service-interceptor.js
Normal file
206
src/table-filler/service-interceptor.js
Normal 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);
|
||||
}
|
||||
}
|
||||
276
src/table-filler/table-splitter.js
Normal file
276
src/table-filler/table-splitter.js
Normal 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;
|
||||
}
|
||||
@@ -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 1(index=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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
1162
src/ui/components/table-filler.js
Normal file
1162
src/ui/components/table-filler.js
Normal file
File diff suppressed because it is too large
Load Diff
105
src/ui/events.js
105
src/ui/events.js
@@ -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("已关闭去重,相同楼层内容会放在一起", "合并去重");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ export {
|
||||
setRefreshAIConfigListFunction,
|
||||
setFlowConfigFunctions,
|
||||
setPromptEditorFunctions,
|
||||
setSummaryPartConfigModalFunction,
|
||||
// 标签过滤
|
||||
initTagFilterUI,
|
||||
updateTagFilterBadge,
|
||||
@@ -179,6 +180,8 @@ export {
|
||||
restoreDefaultPrompt,
|
||||
switchPromptType,
|
||||
bindPromptEditorEvents,
|
||||
// 总结世界书Part配置弹窗
|
||||
showSummaryPartConfigModal,
|
||||
} from './modals';
|
||||
|
||||
// 剧情优化助手面板
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 1(index=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 1(index > 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"
|
||||
|
||||
@@ -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, '"')}">${category}</span>
|
||||
<i class="fa-solid fa-circle-question mm-flow-hint-icon" title="${flowTypeDescription.replace(/"/g, '"')}" 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>
|
||||
|
||||
533
src/ui/modals/independent-template-modal.js
Normal file
533
src/ui/modals/independent-template-modal.js
Normal 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,
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
501
src/ui/modals/summary-part-config.js
Normal file
501
src/ui/modals/summary-part-config.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示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,
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 1(index=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 = {};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
425
src/worldbook/summary-splitter.js
Normal file
425
src/worldbook/summary-splitter.js
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user