Files
Cola/summary.js
2025-12-30 15:08:26 +08:00

1054 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 总结功能
*/
import { requestSave } from './save-manager.js';
import { getContext } from '../../../extensions.js';
import { loadWorldInfo, saveWorldInfo, createNewWorldInfo, world_names } from '../../../world-info.js';
import { getSettings, getCupName, SUMMARY_MARKER_PREFIX, LOREBOOK_NAME_PREFIX, LOREBOOK_NAME_SUFFIX } from './config.js';
import { sleep, escapeHtml } from './utils.js';
import { addErrorLog } from './history-logs.js';
// 替换占位符 {{user}} 和 {{char}}
function replacePlaceholders(content, userName, charName) {
if (!content) return content;
return content
.replace(/\{\{user\}\}/gi, userName)
.replace(/\{\{char\}\}/gi, charName);
}
// 获取指定聊天的下一杯编号
export function getNextCupNumber(lorebookName = null) {
const settings = getSettings();
const selectedLorebooks = settings.selectedLorebooks || [];
if (!lorebookName) return 1;
const lorebook = selectedLorebooks.find(lb => lb.name === lorebookName);
if (lorebook && lorebook.entries) {
return lorebook.entries.length + 1;
}
return 1;
}
// 刷新总结聊天列表
export function refreshSummaryChatList() {
const settings = getSettings();
const contacts = settings.contacts || [];
const groupChats = settings.groupChats || [];
const listEl = document.getElementById('wechat-summary-chat-list');
if (!listEl) return;
let html = '';
// 单聊
contacts.forEach((contact, idx) => {
const chatHistory = contact.chatHistory || [];
// 计算未总结的消息数量
let lastMarkerIndex = -1;
for (let i = chatHistory.length - 1; i >= 0; i--) {
if (chatHistory[i].content?.startsWith(SUMMARY_MARKER_PREFIX)) {
lastMarkerIndex = i;
break;
}
}
const newMsgCount = chatHistory.slice(lastMarkerIndex + 1).filter(m => !m.content?.startsWith(SUMMARY_MARKER_PREFIX)).length;
if (newMsgCount > 0) {
html += `
<div class="wechat-summary-chat-item" style="display: flex; align-items: center; padding: 6px 4px; cursor: pointer; border-radius: 4px; margin-bottom: 4px;">
<input type="checkbox" class="wechat-summary-chat-check" data-type="contact" data-index="${idx}" checked style="margin-right: 8px; cursor: pointer;">
<span style="flex: 1; font-size: 13px;">${escapeHtml(contact.name)}</span>
<span style="font-size: 11px; color: var(--wechat-text-secondary);">${newMsgCount}条新消息</span>
</div>
`;
}
});
// 群聊
groupChats.forEach((group, idx) => {
const chatHistory = group.chatHistory || [];
// 计算未总结的消息数量
let lastMarkerIndex = -1;
for (let i = chatHistory.length - 1; i >= 0; i--) {
if (chatHistory[i].content?.startsWith(SUMMARY_MARKER_PREFIX)) {
lastMarkerIndex = i;
break;
}
}
const newMsgCount = chatHistory.slice(lastMarkerIndex + 1).filter(m => !m.content?.startsWith(SUMMARY_MARKER_PREFIX)).length;
if (newMsgCount > 0) {
html += `
<div class="wechat-summary-chat-item" style="display: flex; align-items: center; padding: 6px 4px; cursor: pointer; border-radius: 4px; margin-bottom: 4px;">
<input type="checkbox" class="wechat-summary-chat-check" data-type="group" data-index="${idx}" checked style="margin-right: 8px; cursor: pointer;">
<span style="flex: 1; font-size: 13px;">👥 ${escapeHtml(group.name)}</span>
<span style="font-size: 11px; color: var(--wechat-text-secondary);">${newMsgCount}条新消息</span>
</div>
`;
}
});
if (!html) {
html = '<div style="text-align: center; color: var(--wechat-text-secondary); padding: 20px; font-size: 13px;">暂无新的聊天记录</div>';
}
listEl.innerHTML = html;
// 点击行也能切换checkbox
listEl.querySelectorAll('.wechat-summary-chat-item').forEach(item => {
item.addEventListener('click', (e) => {
if (e.target.type !== 'checkbox') {
const checkbox = item.querySelector('input[type="checkbox"]');
if (checkbox) checkbox.checked = !checkbox.checked;
}
});
});
}
// 全选/取消全选
export function selectAllSummaryChats(select) {
const checkboxes = document.querySelectorAll('.wechat-summary-chat-check');
checkboxes.forEach(cb => cb.checked = select);
}
// 获取选中的聊天
export function getSelectedChats() {
const checkboxes = document.querySelectorAll('.wechat-summary-chat-check:checked');
const selected = {
contacts: [],
groups: []
};
checkboxes.forEach(cb => {
const type = cb.dataset.type;
const index = parseInt(cb.dataset.index);
if (type === 'contact') {
selected.contacts.push(index);
} else if (type === 'group') {
selected.groups.push(index);
}
});
return selected;
}
// 收集所有联系人的聊天记录(只收集最后一个标记之后的内容)
export function collectAllChatHistory(selectedFilter = null) {
const settings = getSettings();
const contacts = settings.contacts || [];
const groupChats = settings.groupChats || [];
const allChats = [];
// 收集单聊
contacts.forEach((contact, idx) => {
// 如果有过滤器,检查是否被选中
if (selectedFilter && !selectedFilter.contacts.includes(idx)) return;
const chatHistory = contact.chatHistory || [];
if (chatHistory.length === 0) return;
let lastMarkerIndex = -1;
for (let i = chatHistory.length - 1; i >= 0; i--) {
if (chatHistory[i].content?.startsWith(SUMMARY_MARKER_PREFIX)) {
lastMarkerIndex = i;
break;
}
}
const startIndex = lastMarkerIndex + 1;
const newMessages = chatHistory.slice(startIndex);
const realMessages = newMessages.filter(msg =>
!msg.content?.startsWith(SUMMARY_MARKER_PREFIX)
);
if (realMessages.length > 0) {
allChats.push({
type: 'contact',
index: idx,
contact: contact, // 添加完整的联系人对象,用于获取通话/一起听/心动瞬间记录
contactName: `【可乐】和${contact.name}的聊天`,
contactDescription: contact.description || '',
messages: realMessages.map(msg => ({
role: msg.role,
content: msg.content,
time: msg.time || '',
isVoice: msg.isVoice || false,
isSticker: msg.isSticker || false,
isPhoto: msg.isPhoto || false,
musicInfo: msg.musicInfo || null
}))
});
}
});
// 收集群聊
groupChats.forEach((group, idx) => {
// 如果有过滤器,检查是否被选中
if (selectedFilter && !selectedFilter.groups.includes(idx)) return;
const chatHistory = group.chatHistory || [];
if (chatHistory.length === 0) return;
let lastMarkerIndex = -1;
for (let i = chatHistory.length - 1; i >= 0; i--) {
if (chatHistory[i].content?.startsWith(SUMMARY_MARKER_PREFIX)) {
lastMarkerIndex = i;
break;
}
}
const startIndex = lastMarkerIndex + 1;
const newMessages = chatHistory.slice(startIndex);
const realMessages = newMessages.filter(msg =>
!msg.content?.startsWith(SUMMARY_MARKER_PREFIX)
);
if (realMessages.length > 0) {
// 获取群成员名称列表
const memberNames = (group.memberIds || []).map(id => {
const contact = settings.contacts.find(c => c.id === id);
return contact?.name || '未知';
});
const memberNamesStr = memberNames.join(',');
// 收集群聊消息,包含发言者信息
allChats.push({
type: 'group',
index: idx,
contactName: `【可乐】和${memberNamesStr}的聊天`,
contactDescription: `成员:${Math.min((group.memberIds?.length || 0), 3) + 1}`,
messages: realMessages.map(msg => ({
role: msg.role,
content: msg.content,
characterName: msg.characterName || '',
time: msg.time || '',
isVoice: msg.isVoice || false,
isSticker: msg.isSticker || false,
isPhoto: msg.isPhoto || false,
musicInfo: msg.musicInfo || null
}))
});
}
});
return allChats;
}
// 在所有联系人的聊天记录中插入标记
export function insertSummaryMarker(cupNumber, selectedFilter = null) {
const settings = getSettings();
const contacts = settings.contacts || [];
const groupChats = settings.groupChats || [];
const marker = `${SUMMARY_MARKER_PREFIX}${cupNumber}`;
const now = new Date();
const timeStr = `${now.getFullYear()}-${(now.getMonth()+1).toString().padStart(2,'0')}-${now.getDate().toString().padStart(2,'0')} ${now.getHours().toString().padStart(2,'0')}:${now.getMinutes().toString().padStart(2,'0')}`;
// 单聊
contacts.forEach((contact, idx) => {
if (selectedFilter && !selectedFilter.contacts.includes(idx)) return;
if (!contact.chatHistory) contact.chatHistory = [];
let hasNewMessages = false;
for (let i = contact.chatHistory.length - 1; i >= 0; i--) {
const msg = contact.chatHistory[i];
if (msg.content?.startsWith(SUMMARY_MARKER_PREFIX)) break;
if (!msg.content?.startsWith(SUMMARY_MARKER_PREFIX)) {
hasNewMessages = true;
break;
}
}
if (hasNewMessages || contact.chatHistory.length === 0) {
const lastMsg = contact.chatHistory[contact.chatHistory.length - 1];
if (!lastMsg?.content?.startsWith(SUMMARY_MARKER_PREFIX)) {
contact.chatHistory.push({
role: 'system',
content: marker,
time: timeStr,
timestamp: Date.now(),
isMarker: true
});
}
}
});
// 群聊
groupChats.forEach((group, idx) => {
if (selectedFilter && !selectedFilter.groups.includes(idx)) return;
if (!group.chatHistory) group.chatHistory = [];
let hasNewMessages = false;
for (let i = group.chatHistory.length - 1; i >= 0; i--) {
const msg = group.chatHistory[i];
if (msg.content?.startsWith(SUMMARY_MARKER_PREFIX)) break;
if (!msg.content?.startsWith(SUMMARY_MARKER_PREFIX)) {
hasNewMessages = true;
break;
}
}
if (hasNewMessages || group.chatHistory.length === 0) {
const lastMsg = group.chatHistory[group.chatHistory.length - 1];
if (!lastMsg?.content?.startsWith(SUMMARY_MARKER_PREFIX)) {
group.chatHistory.push({
role: 'system',
content: marker,
time: timeStr,
timestamp: Date.now(),
isMarker: true
});
}
}
});
requestSave();
}
// 生成总结提示词
export function generateSummaryPrompt(allChats, cupNumber) {
const settings = getSettings();
// 如果有自定义模板,使用自定义模板
let prompt;
if (settings.customSummaryTemplate && settings.customSummaryTemplate.trim()) {
prompt = settings.customSummaryTemplate.trim() + '\n\n';
} else {
// 使用默认模板(纯对话记录模式)
prompt = `你的任务是将这段【线上聊天记录】原样整理成JSON格式。
【核心原则】
- 原样保留:完整复制每一条对话,不做任何修改、润色或总结
- 格式统一:按"发言者: 内容"格式逐行记录
- 仅提取关键词从对话中提取3-5个核心关键词用于检索触发
【输出格式要求】
- 只输出一个JSON对象
- 不要使用markdown代码块
- 直接以 { 开头,以 } 结尾
- keys: 3-5个能代表本次聊天核心内容的关键词人名、地点、事件等
- content: 以"以下是线上聊天内容:"开头,然后原样复制对话记录,每条一行,格式为"发言者: 内容"
- comment: "${getCupName(cupNumber)}"
【JSON示例】
{"keys":["公园","约会","周末"],"content":"以下是线上聊天内容:\\n{{user}}: 今天去哪玩?\\n{{char}}: 去公园吧\\n{{user}}: 好呀\\n{{char}}: 那我们下午2点见","comment":"${getCupName(cupNumber)}"}
`;
}
allChats.forEach(chat => {
// 从联系人名字中提取角色名
const match = chat.contactName.match(/【可乐】和(.+)的聊天/);
const charName = match ? match[1] : '{{char}}';
// 开头加说明
prompt += `以下是用户{{user}}和角色${charName}的线上聊天内容:\n\n`;
// 获取联系人的历史记录(用于匹配通话详情)
const contact = chat.contact;
const callHistory = contact?.callHistory || [];
const listenHistory = contact?.listenHistory || [];
const toyHistory = contact?.toyHistory || [];
// 用于追踪已处理的历史记录
let callIndex = 0;
let listenIndex = 0;
let toyIndex = 0;
chat.messages.slice(-300).forEach(msg => {
let speaker;
if (msg.role === 'user') {
speaker = '{{user}}';
} else if (chat.type === 'group' && msg.characterName) {
speaker = msg.characterName;
} else {
speaker = charName;
}
// 检查是否是通话记录
const callRecordMatch = (msg.content || '').match(/^\[通话记录[:](.+?)\]$/);
const videoCallRecordMatch = (msg.content || '').match(/^\[视频通话[:](.+?)\]$/);
const listenRecordMatch = (msg.content || '').match(/^\[一起听[:](.+?)\]$/);
const toyRecordMatch = (msg.content || '').match(/^\[心动瞬间[:](.+?)\]$/);
if (callRecordMatch) {
// 语音通话记录
const duration = callRecordMatch[1];
const voiceRecords = callHistory.filter(r => r.type === 'voice');
const record = voiceRecords[callIndex];
callIndex++;
prompt += `\n--- 以下是本次语音通话 ---\n`;
if (record && record.messages && record.messages.length > 0) {
record.messages.forEach(m => {
const s = m.role === 'user' ? '{{user}}' : charName;
prompt += `${s}: ${m.content}\n`;
});
} else {
prompt += `(通话时长:${duration}\n`;
}
prompt += `--- 本次语音通话结束 ---\n\n`;
} else if (videoCallRecordMatch) {
// 视频通话记录
const duration = videoCallRecordMatch[1];
const videoRecords = callHistory.filter(r => r.type === 'video');
const record = videoRecords[callIndex];
prompt += `\n--- 以下是本次视频通话 ---\n`;
if (record && record.messages && record.messages.length > 0) {
record.messages.forEach(m => {
const s = m.role === 'user' ? '{{user}}' : charName;
prompt += `${s}: ${m.content}\n`;
});
} else {
prompt += `(通话时长:${duration}\n`;
}
prompt += `--- 本次视频通话结束 ---\n\n`;
} else if (listenRecordMatch) {
// 一起听记录
const info = listenRecordMatch[1];
const record = listenHistory[listenIndex];
listenIndex++;
prompt += `\n--- 以下是本次一起听 ---\n`;
if (record) {
if (record.songName) {
prompt += `正在听:${record.songName}${record.artist ? ' - ' + record.artist : ''}\n`;
}
if (record.messages && record.messages.length > 0) {
record.messages.forEach(m => {
const s = m.role === 'user' ? '{{user}}' : charName;
prompt += `${s}: ${m.content}\n`;
});
}
} else {
prompt += `${info}\n`;
}
prompt += `--- 本次一起听结束 ---\n\n`;
} else if (toyRecordMatch) {
// 心动瞬间记录
const info = toyRecordMatch[1];
const record = toyHistory[toyIndex];
toyIndex++;
prompt += `\n--- 以下是本次心动瞬间 ---\n`;
if (record) {
if (record.gift) {
const targetText = record.target === 'character' ? `${charName}在用` : '{{user}}在用';
prompt += `使用道具:${record.gift.name}${targetText}\n`;
}
if (record.messages && record.messages.length > 0) {
record.messages.forEach(m => {
const s = m.role === 'user' ? '{{user}}' : charName;
prompt += `${s}: ${m.content}\n`;
});
}
} else {
prompt += `${info}\n`;
}
prompt += `--- 本次心动瞬间结束 ---\n\n`;
} else {
// 普通消息(不加时间戳)
let messageContent;
if (msg.musicInfo) {
const musicName = msg.musicInfo.name || '未知歌曲';
const musicArtist = msg.musicInfo.artist || '未知歌手';
messageContent = `[分享歌曲] ${musicName} - ${musicArtist}`;
} else if (msg.isVoice) {
messageContent = `[语音] ${msg.content}`;
} else if (msg.isSticker) {
messageContent = '[发送了一个表情包]';
} else if (msg.isPhoto) {
messageContent = '[发送了一张图片]';
} else {
messageContent = msg.content;
}
prompt += `${speaker}: ${messageContent}\n`;
}
});
});
prompt += `\n请将以上聊天记录原样整理成${getCupName(cupNumber)}的JSON`;
return prompt;
}
// 调用总结API
export async function callSummaryAPI(prompt) {
const settings = getSettings();
const apiUrl = settings.summaryApiUrl;
const apiKey = settings.summaryApiKey;
const model = settings.summarySelectedModel;
if (!apiUrl || !apiKey || !model) {
throw new Error('请先配置总结APIURL、密钥和模型');
}
const chatUrl = apiUrl.replace(/\/$/, '') + '/chat/completions';
const response = await fetch(chatUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: model,
messages: [
{ role: 'system', content: '你是一个专业的内容分析师,擅长从对话中提取关键信息并生成结构化的世界书条目。' },
{ role: 'user', content: prompt }
],
temperature: 1,
max_tokens: 50000
})
});
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
throw new Error(errData.error?.message || `HTTP ${response.status}`);
}
const data = await response.json();
const content = data.choices?.[0]?.message?.content || '';
// 解析JSON
const parsed = parseJSONResponse(content);
if (parsed) return parsed;
throw new Error('AI返回内容为空或无法解析');
}
// 解析JSON响应
function parseJSONResponse(content) {
// 方法1: 直接解析
try {
const result = JSON.parse(content);
if (result.keys && result.content) return result;
if (result.entries?.[0]) return result.entries[0];
} catch (e) {}
// 方法2: 移除markdown代码块
try {
const cleaned = content.replace(/```json\n?/gi, '').replace(/```\n?/g, '').trim();
const result = JSON.parse(cleaned);
if (result.keys && result.content) return result;
} catch (e) {}
// 方法3: 提取JSON部分
try {
const firstBrace = content.indexOf('{');
const lastBrace = content.lastIndexOf('}');
if (firstBrace !== -1 && lastBrace > firstBrace) {
const result = JSON.parse(content.substring(firstBrace, lastBrace + 1));
if (result.keys && result.content) return result;
}
} catch (e) {}
// 降级方案
if (content && content.trim().length > 20) {
const words = content.match(/[\u4e00-\u9fa5]{2,}/g) || ['聊天', '记录'];
return {
keys: [...new Set(words)].slice(0, 5),
content: content.substring(0, 30000).replace(/```[\s\S]*?```/g, '').trim(),
comment: '感情记录'
};
}
return null;
}
// 保存条目到收藏
export function saveEntryToFavorites(entry, cupNumber, lorebookName) {
const settings = getSettings();
if (!settings.selectedLorebooks) settings.selectedLorebooks = [];
const now = new Date();
const timeStr = `${now.getFullYear()}-${(now.getMonth()+1).toString().padStart(2,'0')}-${now.getDate().toString().padStart(2,'0')} ${now.getHours().toString().padStart(2,'0')}:${now.getMinutes().toString().padStart(2,'0')}`;
// 获取用户名和角色名用于替换占位符
const context = getContext();
const userName = context?.name1 || 'User';
// 从世界书名称中提取角色名格式【可乐】和xxx的聊天
let charName = lorebookName;
if (lorebookName.startsWith(LOREBOOK_NAME_PREFIX) && lorebookName.endsWith(LOREBOOK_NAME_SUFFIX)) {
charName = lorebookName.slice(LOREBOOK_NAME_PREFIX.length, -LOREBOOK_NAME_SUFFIX.length);
}
let lorebook = settings.selectedLorebooks.find(lb => lb.name === lorebookName);
if (!lorebook) {
lorebook = {
name: lorebookName,
addedTime: timeStr,
entries: [],
enabled: true,
fromSummary: true
};
settings.selectedLorebooks.push(lorebook);
}
// 替换 {{user}} 和 {{char}} 占位符
const processedContent = replacePlaceholders(entry.content || '', userName, charName);
const processedKeys = (entry.keys || []).map(key => replacePlaceholders(key, userName, charName));
const newEntry = {
uid: cupNumber - 1,
keys: processedKeys,
content: processedContent,
comment: entry.comment || getCupName(cupNumber),
enabled: true,
case_sensitive: false,
priority: 10,
id: cupNumber - 1,
addedTime: timeStr
};
lorebook.entries.push(newEntry);
lorebook.lastUpdated = timeStr;
requestSave();
return lorebook;
}
// 同步条目到酒馆世界书
export async function syncEntryToSillyTavern(entry, cupNumber, lorebookName) {
try {
const name = lorebookName;
// 获取用户名和角色名用于替换占位符
const context = getContext();
const userName = context?.name1 || 'User';
let charName = lorebookName;
if (lorebookName.startsWith(LOREBOOK_NAME_PREFIX) && lorebookName.endsWith(LOREBOOK_NAME_SUFFIX)) {
charName = lorebookName.slice(LOREBOOK_NAME_PREFIX.length, -LOREBOOK_NAME_SUFFIX.length);
}
// 替换占位符
const processedContent = replacePlaceholders(entry.content || '', userName, charName);
const processedKeys = (entry.keys || []).map(key => replacePlaceholders(key, userName, charName));
const newEntry = {
uid: cupNumber - 1,
key: processedKeys,
keysecondary: [],
comment: entry.comment || getCupName(cupNumber),
content: processedContent,
constant: false,
vectorized: false,
selective: true,
selectiveLogic: 0,
addMemo: true,
order: 100,
position: 0,
disable: false,
excludeRecursion: false,
preventRecursion: false,
delayUntilRecursion: false,
probability: 100,
useProbability: true,
depth: 4,
group: '',
caseSensitive: false,
role: 0
};
const worldExists = typeof world_names !== 'undefined' &&
Array.isArray(world_names) &&
world_names.includes(name);
if (!worldExists) {
if (typeof createNewWorldInfo === 'function') {
await createNewWorldInfo(name);
await sleep(500);
}
}
let worldInfo = { entries: {} };
if (typeof loadWorldInfo === 'function') {
const existingData = await loadWorldInfo(name);
if (existingData?.entries) worldInfo = existingData;
}
worldInfo.entries[cupNumber - 1] = newEntry;
if (typeof saveWorldInfo === 'function') {
await saveWorldInfo(name, worldInfo);
return true;
}
throw new Error('saveWorldInfo 函数不可用');
} catch (err) {
console.error('[可乐不加冰] 同步到酒馆失败:', err);
throw err;
}
}
// 执行总结主函数(按聊天分别处理,每个聊天有自己的世界书)
export async function executeSummary() {
const progressEl = document.getElementById('wechat-summary-progress');
const executeBtn = document.getElementById('wechat-summary-execute');
const updateProgress = (msg) => {
if (progressEl) progressEl.textContent = msg;
};
if (executeBtn) {
executeBtn.disabled = true;
executeBtn.textContent = '⏳ 处理中...';
}
try {
// 获取选中的聊天
const selectedFilter = getSelectedChats();
// 检查是否有选中项
if (selectedFilter.contacts.length === 0 && selectedFilter.groups.length === 0) {
throw new Error('请至少选择一个聊天进行总结');
}
updateProgress('📋 收集聊天记录...');
const allChats = collectAllChatHistory(selectedFilter);
if (allChats.length === 0) {
throw new Error('没有新的聊天记录需要总结');
}
const totalMessages = allChats.reduce((sum, chat) => sum + chat.messages.length, 0);
updateProgress('📋 收集到 ' + allChats.length + ' 个对话,共 ' + totalMessages + ' 条消息');
await sleep(500);
// 逐个处理每个聊天
let successCount = 0;
for (let i = 0; i < allChats.length; i++) {
const chat = allChats[i];
const lorebookName = chat.contactName; // 已经是【可乐】和xxx的聊天格式
const cupNumber = getNextCupNumber(lorebookName);
updateProgress('🍵 正在处理 ' + chat.contactName + ' (' + (i + 1) + '/' + allChats.length + ')...');
await sleep(300);
try {
// 为单个聊天生成总结
updateProgress('🤖 分析 ' + chat.contactName + ' 的' + getCupName(cupNumber) + '...');
const prompt = generateSummaryPrompt([chat], cupNumber);
const entry = await callSummaryAPI(prompt);
// 保存到收藏
saveEntryToFavorites(entry, cupNumber, lorebookName);
// 尝试同步到酒馆
try {
await syncEntryToSillyTavern(entry, cupNumber, lorebookName);
} catch (syncErr) {
console.error('[可乐] 同步 ' + lorebookName + ' 到酒馆失败:', syncErr);
addErrorLog(syncErr, '同步到酒馆');
}
// 为该聊天插入标记
const singleFilter = {
contacts: chat.type === 'contact' ? [chat.index] : [],
groups: chat.type === 'group' ? [chat.index] : []
};
insertSummaryMarker(cupNumber, singleFilter);
successCount++;
} catch (chatErr) {
console.error('[可乐] 处理 ' + chat.contactName + ' 失败:', chatErr);
addErrorLog(chatErr, '总结处理: ' + chat.contactName);
updateProgress('⚠️ ' + chat.contactName + ' 处理失败: ' + chatErr.message);
await sleep(1000);
}
}
if (successCount === allChats.length) {
updateProgress('✅ 完成!已为 ' + successCount + ' 个聊天生成总结');
} else {
updateProgress('✅ 完成 ' + successCount + '/' + allChats.length + ' 个聊天总结');
}
// 刷新收藏列表和聊天选择列表
import('./favorites.js').then(m => m.refreshFavoritesList());
refreshSummaryChatList();
} catch (err) {
console.error('[可乐] 执行总结失败:', err);
addErrorLog(err, '执行总结');
updateProgress('❌ 失败: ' + err.message);
} finally {
if (executeBtn) {
executeBtn.disabled = false;
executeBtn.textContent = '执行总结';
}
}
}
// 回退总结(从历史回顾中选择要回退的世界书)
export async function rollbackSummary() {
const settings = getSettings();
const progressEl = document.getElementById('wechat-summary-progress');
const rollbackBtn = document.getElementById('wechat-summary-rollback');
const updateProgress = (msg) => {
if (progressEl) progressEl.textContent = msg;
};
// 找到所有总结生成的世界书
const selectedLorebooks = settings.selectedLorebooks || [];
const summaryBooks = selectedLorebooks.filter(lb =>
lb.fromSummary === true ||
(lb.name && lb.name.startsWith('【可乐】和') && lb.name.endsWith('的聊天'))
);
if (summaryBooks.length === 0) {
updateProgress('🧊 没有可回退的总结');
return;
}
// 构建选择列表
const options = summaryBooks.map((lb, idx) => {
const entriesCount = lb.entries?.length || 0;
return (idx + 1) + '. ' + lb.name + ' (' + entriesCount + '杯)';
}).join('\n');
const choice = prompt('选择要回退的世界书(输入序号):\n\n' + options + '\n\n输入序号:');
if (!choice) return;
const choiceIdx = parseInt(choice) - 1;
if (isNaN(choiceIdx) || choiceIdx < 0 || choiceIdx >= summaryBooks.length) {
updateProgress('🧊 无效的选择');
return;
}
const targetBook = summaryBooks[choiceIdx];
const lorebookIdx = selectedLorebooks.findIndex(lb => lb.name === targetBook.name);
if (lorebookIdx < 0 || !targetBook.entries?.length) {
updateProgress('🧊 该世界书没有可回退的条目');
return;
}
const cupNumber = targetBook.entries.length;
if (!confirm(
'确定要回退「' + targetBook.name + '」的' + getCupName(cupNumber) + '总结吗?\n\n' +
'这将删除:\n1. 世界书中的' + getCupName(cupNumber) + '条目\n' +
'2. 相关聊天记录中的"' + SUMMARY_MARKER_PREFIX + cupNumber + '"标记'
)) {
return;
}
if (rollbackBtn) {
rollbackBtn.disabled = true;
rollbackBtn.textContent = '⏳ 回退中...';
}
try {
// 1) 从收藏中删除最后一个条目
targetBook.entries.pop();
updateProgress('✅ 已删除收藏中的条目...');
// 2) 从相关聊天记录中删除对应标记
const markerToRemove = SUMMARY_MARKER_PREFIX + cupNumber;
const contacts = settings.contacts || [];
const groupChats = settings.groupChats || [];
let removedCount = 0;
// 从单聊中移除
contacts.forEach(contact => {
if (!contact.chatHistory) return;
for (let i = contact.chatHistory.length - 1; i >= 0; i--) {
const msg = contact.chatHistory[i];
if (msg.content === markerToRemove ||
(msg.isMarker && msg.content?.startsWith(SUMMARY_MARKER_PREFIX + cupNumber))) {
contact.chatHistory.splice(i, 1);
removedCount++;
}
}
});
// 从群聊中移除
groupChats.forEach(group => {
if (!group.chatHistory) return;
for (let i = group.chatHistory.length - 1; i >= 0; i--) {
const msg = group.chatHistory[i];
if (msg.content === markerToRemove ||
(msg.isMarker && msg.content?.startsWith(SUMMARY_MARKER_PREFIX + cupNumber))) {
group.chatHistory.splice(i, 1);
removedCount++;
}
}
});
updateProgress('✅ 已移除 ' + removedCount + ' 个标记...');
// 如果世界书条目已清空从selectedLorebooks中移除整个世界书
if (targetBook.entries.length === 0) {
selectedLorebooks.splice(lorebookIdx, 1);
updateProgress('✅ 世界书已清空,已删除...');
}
requestSave();
// 3) 尝试同步删除酒馆世界书条目(或整个世界书)
try {
const name = targetBook.name;
const worldExists = typeof world_names !== 'undefined' &&
Array.isArray(world_names) &&
world_names.includes(name);
if (worldExists && typeof loadWorldInfo === 'function' && typeof saveWorldInfo === 'function') {
const worldInfo = await loadWorldInfo(name);
if (worldInfo?.entries && worldInfo.entries[cupNumber - 1]) {
delete worldInfo.entries[cupNumber - 1];
// 检查酒馆世界书是否还有条目
const remainingEntries = Object.keys(worldInfo.entries).length;
if (remainingEntries === 0) {
// 如果没有条目了,尝试删除整个世界书
try {
const { deleteWorldInfo } = await import('../../../world-info.js');
if (typeof deleteWorldInfo === 'function') {
await deleteWorldInfo(name);
updateProgress('✅ 已删除酒馆世界书');
} else {
await saveWorldInfo(name, worldInfo);
updateProgress('✅ 已同步回退到酒馆(世界书已清空)');
}
} catch (delErr) {
await saveWorldInfo(name, worldInfo);
updateProgress('✅ 已同步回退到酒馆');
}
} else {
await saveWorldInfo(name, worldInfo);
updateProgress('✅ 已同步回退到酒馆');
}
} else {
updateProgress('✅ 本地回退完成(酒馆无需同步)');
}
} else {
updateProgress('✅ 本地回退完成(酒馆同步不可用)');
}
} catch (syncErr) {
console.error('[可乐] 回退同步到酒馆失败:', syncErr);
addErrorLog(syncErr, '回退同步');
updateProgress('✅ 本地回退完成(酒馆同步失败)');
}
import('./favorites.js').then(m => m.refreshFavoritesList());
refreshSummaryChatList();
} catch (err) {
console.error('[可乐] 回退总结失败:', err);
addErrorLog(err, '回退总结');
updateProgress('⚠️ 回退失败: ' + err.message);
} finally {
if (rollbackBtn) {
rollbackBtn.disabled = false;
rollbackBtn.textContent = '回退总结';
}
}
}
/**
* 从酒馆世界书恢复总结数据
* 当插件的 selectedLorebooks 条目丢失但酒馆世界书还在时使用
*/
export async function recoverFromTavernWorldbook() {
const settings = getSettings();
const selectedLorebooks = settings.selectedLorebooks || [];
// 找到所有总结生成的世界书(条目为空的)
const emptyBooks = selectedLorebooks.filter(lb =>
(lb.fromSummary === true || (lb.name && lb.name.startsWith(LOREBOOK_NAME_PREFIX))) &&
(!lb.entries || lb.entries.length === 0)
);
if (emptyBooks.length === 0) {
alert('没有需要恢复的世界书(所有世界书都有条目,或没有总结类世界书)');
return;
}
const options = emptyBooks.map((lb, idx) => `${idx + 1}. ${lb.name}`).join('\n');
const choice = prompt(`以下世界书条目为空,可尝试从酒馆恢复:\n\n${options}\n\n输入序号(或输入 all 恢复全部):`);
if (!choice) return;
const booksToRecover = choice.toLowerCase() === 'all'
? emptyBooks
: [emptyBooks[parseInt(choice) - 1]].filter(Boolean);
if (booksToRecover.length === 0) {
alert('无效的选择');
return;
}
let recoveredCount = 0;
let totalEntries = 0;
for (const book of booksToRecover) {
try {
const name = book.name;
// 检查酒馆世界书是否存在
const worldExists = typeof world_names !== 'undefined' &&
Array.isArray(world_names) &&
world_names.includes(name);
if (!worldExists) {
console.log(`[可乐] 酒馆中不存在世界书: ${name}`);
continue;
}
// 加载酒馆世界书
if (typeof loadWorldInfo !== 'function') {
console.error('[可乐] loadWorldInfo 函数不可用');
continue;
}
const worldInfo = await loadWorldInfo(name);
if (!worldInfo?.entries || Object.keys(worldInfo.entries).length === 0) {
console.log(`[可乐] 酒馆世界书 ${name} 没有条目`);
continue;
}
// 将酒馆条目转换为插件格式
const entries = [];
const sortedKeys = Object.keys(worldInfo.entries).sort((a, b) => parseInt(a) - parseInt(b));
for (const key of sortedKeys) {
const tavernEntry = worldInfo.entries[key];
if (!tavernEntry) continue;
entries.push({
content: tavernEntry.content || '',
comment: tavernEntry.comment || getCupName(entries.length + 1),
keys: tavernEntry.key || [],
enabled: !tavernEntry.disable,
addedTime: new Date().toISOString()
});
}
if (entries.length > 0) {
// 更新插件的 selectedLorebooks
const bookIndex = selectedLorebooks.findIndex(lb => lb.name === name);
if (bookIndex >= 0) {
selectedLorebooks[bookIndex].entries = entries;
selectedLorebooks[bookIndex].lastUpdated = new Date().toISOString();
recoveredCount++;
totalEntries += entries.length;
console.log(`[可乐] 已恢复 ${name}: ${entries.length}`);
}
}
} catch (err) {
console.error(`[可乐] 恢复 ${book.name} 失败:`, err);
}
}
if (recoveredCount > 0) {
requestSave();
alert(`恢复完成!\n\n已恢复 ${recoveredCount} 个世界书,共 ${totalEntries} 条总结。\n\n请刷新页面查看。`);
} else {
alert('恢复失败:酒馆中没有找到对应的世界书数据。\n\n数据可能已彻底丢失。');
}
}