mirror of
https://github.com/Cola-Echo/memory-manager-concurrent.git
synced 2026-06-06 05:25:53 +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:
@@ -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