mirror of
https://github.com/Cola-Echo/Cola.git
synced 2026-06-06 03:35:50 +00:00
855 lines
30 KiB
JavaScript
855 lines
30 KiB
JavaScript
/**
|
||
* 总结功能
|
||
*/
|
||
|
||
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,
|
||
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以下是线上聊天记录:\n\n【线上聊天记录】\n';
|
||
} else {
|
||
// 使用默认模板(纯对话记录模式)
|
||
prompt = `你的任务是将这段【线上聊天记录】原样整理成JSON格式。
|
||
|
||
【核心原则】
|
||
- 原样保留:完整复制每一条对话,不做任何修改、润色或总结
|
||
- 格式统一:按"发言者: 内容"格式逐行记录
|
||
- 仅提取关键词:从对话中提取3-5个核心关键词用于检索触发
|
||
|
||
【输出格式要求】
|
||
- 只输出一个JSON对象
|
||
- 不要使用markdown代码块
|
||
- 直接以 { 开头,以 } 结尾
|
||
- keys: 3-5个能代表本次聊天核心内容的关键词(人名、地点、事件等)
|
||
- content: 原样复制的对话记录,每条一行,格式为"发言者: 内容"
|
||
- comment: "${getCupName(cupNumber)}"
|
||
|
||
【JSON示例】
|
||
{"keys":["公园","约会","周末"],"content":"{{user}}: 今天去哪玩?\\n{{char}}: 去公园吧\\n{{user}}: 好呀\\n{{char}}: 那我们下午2点见","comment":"${getCupName(cupNumber)}"}
|
||
|
||
以下是线上聊天记录:
|
||
|
||
【线上聊天记录】
|
||
`;
|
||
}
|
||
|
||
allChats.forEach(chat => {
|
||
prompt += `\n--- ${chat.contactName} ---\n`;
|
||
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 {
|
||
// 从"【可乐】和xxx的聊天"格式中提取联系人名字
|
||
const match = chat.contactName.match(/【可乐】和(.+)的聊天/);
|
||
speaker = match ? match[1] : '{{char}}';
|
||
}
|
||
const timeStr = msg.time ? `[${msg.time}] ` : '';
|
||
|
||
// 根据消息类型生成不同的内容描述
|
||
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 += `${timeStr}${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('请先配置总结API(URL、密钥和模型)');
|
||
}
|
||
|
||
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: 8196
|
||
})
|
||
});
|
||
|
||
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, 800).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 = '回退总结';
|
||
}
|
||
}
|
||
}
|