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

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

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

View File

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

View File

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

View File

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